@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
@@ -5,15 +5,17 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
6
  import { JsStore } from '@animalabs/chronicle';
7
7
  import { MembraneError } from '@animalabs/membrane';
8
- import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
8
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks } from '@animalabs/context-manager';
9
9
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
10
10
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
11
+ import { bodyGroupRun, bodyRangeIn, locateMessageIndex, locateMessageIndices, removeStoredMessage, removeStoredMessages, } from './stored-removal.js';
11
12
  import { ProcessQueueImpl } from './queue.js';
12
- import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
13
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
13
14
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
14
15
  import { Agent } from './agent.js';
15
16
  import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
16
17
  import { McplServerRegistry } from './mcpl/server-registry.js';
18
+ import { logValue } from './mcpl/log-value.js';
17
19
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
18
20
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
19
21
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
@@ -291,6 +293,7 @@ import { CheckpointManager } from './mcpl/checkpoint-manager.js';
291
293
  import { isToolAllowed } from './mcpl/tool-policy.js';
292
294
  import { EventGate, formatShadowWarning } from './gate/event-gate.js';
293
295
  import { UsageTracker } from './usage/usage-tracker.js';
296
+ import { stderrTailOf, withStderr } from './mcpl/server-connection.js';
294
297
  import { isWebSocketTransport } from './mcpl/transport.js';
295
298
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
296
299
  import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
@@ -338,16 +341,20 @@ function splitExplicitEnvelopes(rawText) {
338
341
  return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
339
342
  }
340
343
  /**
341
- * Whether a stream resumes its logical turn rather than starting one: a budget
342
- * restart, or a tool-result-guard retry. Such a stream keeps the turn's
343
- * routing state (the sticky destination, the turn's destination) and queues
344
- * its deliveries behind the turn's earlier ones.
344
+ * Whether a stream resumes its turn's request rather than starting one: a
345
+ * budget restart, or a tool-result-guard retry. Such a start takes in no new
346
+ * input, and keeps the turn's locus, trigger channel and tool-call count. Its
347
+ * routing state is the logical turn's, as for every start that continues the
348
+ * turn (TurnRouting).
345
349
  */
346
350
  function resumesLogicalTurn(reason) {
347
351
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
348
352
  }
349
353
  function freshTurnRouting() {
350
- return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
354
+ return {
355
+ engaged: new Set(), destination: null, hybridHold: false,
356
+ continuation: false, deliveries: [], suppressed: 0, arrivals: [],
357
+ };
351
358
  }
352
359
  function isTurnContinuation(reason) {
353
360
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
@@ -383,6 +390,110 @@ function normalizeDiscordAwarenessDeadline(value) {
383
390
  }
384
391
  return Math.max(MIN_DISCORD_AWARENESS_DEADLINE_MS, Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)));
385
392
  }
393
+ /** Messages copied into another conversation (a fork's seed) without their
394
+ * `thinking` and `redacted_thinking` blocks: a fork inherits the template's
395
+ * conversation, not its reasoning. A signed block was minted under the
396
+ * template's prefix, which the fork's doesn't promise to match; unsigned
397
+ * reasoning (text-only providers, XML mode) goes by the same rule. On the
398
+ * OpenAI Responses path a removed block also carried the reasoning item
399
+ * (`rawItem`) the message's other items were paired with by id, so in a
400
+ * message that lost its thinking those items lose their `rawItem` and render
401
+ * from their fields, without ids; an opaque carrier (a text block whose raw
402
+ * item isn't a `message`, such as a compaction) keeps it, since the raw item
403
+ * is its whole content. An Anthropic or XML-mode message's thinking carries
404
+ * no raw item, and its only raw items are such carriers (membrane keeps a
405
+ * block it has no type for, such as a `server_tool_use`, that way), so it
406
+ * loses only its thinking. A message with no content left, whether
407
+ * the filter emptied it or it was already empty, is left out, since a
408
+ * request can't carry an empty message; any other message without thinking
409
+ * is kept as it was. */
410
+ function withoutThinking(messages) {
411
+ const out = [];
412
+ for (const message of messages) {
413
+ const content = message.content.filter((b) => !isThinkingBlock(b));
414
+ if (content.length === 0)
415
+ continue;
416
+ out.push(content.length === message.content.length
417
+ ? message
418
+ : { ...message, content: content.map(releasePairedRawItem) });
419
+ }
420
+ return out;
421
+ }
422
+ function isThinkingBlock(block) {
423
+ return block.type === 'thinking' || block.type === 'redacted_thinking';
424
+ }
425
+ function rawItemOf(block) {
426
+ return block.rawItem;
427
+ }
428
+ /** A block without the provider-native item it was parsed from, unless that
429
+ * item is the block's whole content: an opaque carrier, a text block whose
430
+ * raw item isn't a `message` item (membrane keeps compaction and other items
431
+ * it has no block for that way). */
432
+ function releasePairedRawItem(block) {
433
+ const raw = rawItemOf(block);
434
+ if (raw === undefined)
435
+ return block;
436
+ const rawType = typeof raw === 'object' && raw !== null ? raw.type : undefined;
437
+ if (block.type === 'text' && rawType !== 'message')
438
+ return block;
439
+ const { rawItem: _released, ...rest } = block;
440
+ return rest;
441
+ }
442
+ /** One log line for a fork's seed that left something out, so an operator who
443
+ * sees a fork answer differently from its template has something to check:
444
+ * the template's thinking blocks, and the tool blocks the pairing pass
445
+ * dropped (calls with no result in the seed, results whose call isn't in
446
+ * it). `withoutReasoning` is the seed before that pass, `seed` after it. */
447
+ function logSeedOmissions(fork, template, compiled, withoutReasoning, seed) {
448
+ const count = (messages, test) => messages.reduce((n, m) => n + m.content.filter(test).length, 0);
449
+ const thinking = count(compiled, isThinkingBlock);
450
+ const calls = count(withoutReasoning, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
451
+ const results = count(withoutReasoning, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
452
+ if (thinking + calls + results === 0)
453
+ return;
454
+ console.log(`[conversation] ${fork}: seeded from ${template} without ${thinking} thinking block(s), ` +
455
+ `${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`);
456
+ }
457
+ /**
458
+ * A maintenance provider call withdrawn because a surgery holds the store, or
459
+ * because stop() has closed provider admission: either is the framework's own
460
+ * doing, and no failure. It sent nothing: it was refused at the provider
461
+ * gate, or woken there from its wait. For a call outside context-manager
462
+ * 0.14's compression fallback ladder, a merge's or a chunk's canonical call
463
+ * with its retries after a tool_use stop, context-manager counts no attempt.
464
+ * A merge keeps its queue entry, so the next tick after the hold, or after
465
+ * the next start, does it again. A chunk's compression has already left the
466
+ * queue: it logs "Failed to compress chunk (hierarchical)" with this error
467
+ * and records the call, and the chunk, still uncompressed, is queued again at
468
+ * that manager's next rebuild (its next initialization, message, sent compile
469
+ * or released hold). A call inside the ladder, after a chunk's canonical call
470
+ * was refused or cut short, is caught by the rung that made it and recorded
471
+ * as a provider error, since 0.14 rethrows only its own
472
+ * CompressionBranchDiscard there; once every rung has failed, the chunk's
473
+ * request family is quarantined, with its alert, and the tick returns.
474
+ */
475
+ class MaintenanceWithdrawnError extends Error {
476
+ constructor(reason) {
477
+ super(`maintenance call withdrawn: ${reason}`);
478
+ this.name = 'MaintenanceWithdrawnError';
479
+ }
480
+ }
481
+ /**
482
+ * A runtime-settings preview's reason, from what stopped it: the strategy's
483
+ * known throws as the codes `RuntimeSettingsPreview.reason` documents, and
484
+ * anything else verbatim. Message matching is fragile by nature, so an
485
+ * unknown message surfaces as the reason rather than being swallowed.
486
+ */
487
+ function previewStoppedReason(error) {
488
+ const message = error instanceof Error ? error.message : String(error);
489
+ if (message.includes('requires reinitialization for the current branch generation'))
490
+ return 'branch_generation_changed';
491
+ if (message.includes('already running; previews must not overlap'))
492
+ return 'preview_in_flight';
493
+ if (message.includes('requires adaptiveResolution'))
494
+ return 'no_adaptive_resolution';
495
+ return message;
496
+ }
386
497
  class DiscordAwarenessAccountingError extends Error {
387
498
  constructor(operation, cause) {
388
499
  const detail = cause instanceof Error ? cause.message : String(cause);
@@ -422,58 +533,32 @@ export class ResumeBlockedError extends Error {
422
533
  }
423
534
  }
424
535
  /**
425
- * Default inference policy - infer if any request exists for the agent.
536
+ * How `runEphemeralToCompletion` rejects when a retryable provider failure
537
+ * states a wait the run won't sleep: one longer than an ephemeral run sleeps
538
+ * in its turn (600 s), an infinite one, or one that is `NaN`. The run ends at
539
+ * once, without retrying inside the wait, and every other ending of the run
540
+ * rejects with a plain `Error`. `retryAfterMs` is the wait as the provider
541
+ * stated it, in milliseconds, so `Infinity` or `NaN` in those two cases: a
542
+ * host that schedules a rerun from it handles a value that isn't finite, and
543
+ * one past the 2,147,483,647 ms a timer can hold. The message says the same
544
+ * in words. A host can test `instanceof` or the `name`, which also holds for
545
+ * an error from another copy of this package.
426
546
  */
427
- class DefaultInferencePolicy {
428
- shouldInfer(agentName, requests, _state) {
429
- return requests.some((r) => r.agentName === agentName);
547
+ export class ProviderWaitNotSleptError extends Error {
548
+ retryAfterMs;
549
+ constructor(message, retryAfterMs) {
550
+ super(message);
551
+ this.retryAfterMs = retryAfterMs;
552
+ this.name = 'ProviderWaitNotSleptError';
430
553
  }
431
554
  }
432
- const SURGERY_SCAN_WINDOW = 500;
433
555
  /**
434
- * Slot index of `messageId` on the active branch, or -1. Scans from the tail
435
- * in blob-free windows: operator targets are almost always recent, and a
436
- * full `getAllMessages()` re-inflates every attachment on the branch.
556
+ * Default inference policy - infer if any request exists for the agent.
437
557
  */
438
- function locateMessageIndex(cm, messageId) {
439
- const hit = locateMessageIndices(cm, new Set([messageId])).get(messageId);
440
- return hit ? hit.index : -1;
441
- }
442
- /** Tail-first scan resolving many ids at once; stops as soon as all are found. */
443
- function locateMessageIndices(cm, ids) {
444
- const found = new Map();
445
- const total = cm.getMessageCount();
446
- for (let end = total; end > 0 && found.size < ids.size; end -= SURGERY_SCAN_WINDOW) {
447
- const start = Math.max(0, end - SURGERY_SCAN_WINDOW);
448
- const { messages, startIndex } = cm.getMessageWindow(start, end - start, { resolveBlobs: false });
449
- for (let i = messages.length - 1; i >= 0; i--) {
450
- const id = String(messages[i].id);
451
- if (ids.has(id) && !found.has(id)) {
452
- found.set(id, {
453
- index: startIndex + i,
454
- ...(messages[i].bodyGroupId ? { bodyGroupId: messages[i].bodyGroupId } : {}),
455
- });
456
- }
457
- }
558
+ class DefaultInferencePolicy {
559
+ shouldInfer(agentName, requests, _state) {
560
+ return requests.some((r) => r.agentName === agentName);
458
561
  }
459
- return found;
460
- }
461
- /**
462
- * Inclusive slot range of the body-group run containing `index` (the slot
463
- * itself when it is not sharded). Delegates to the store's own
464
- * `alignToBodyGroups` edge walk — one contiguity assumption, not two.
465
- */
466
- function bodyGroupRun(cm, index) {
467
- const win = cm.getMessageWindow(index, 1, { alignToBodyGroups: true, resolveBlobs: false });
468
- const from = win.startIndex;
469
- const to = win.startIndex + win.messages.length - 1;
470
- return {
471
- from,
472
- to,
473
- fromId: String(win.messages[0].id),
474
- toId: String(win.messages[win.messages.length - 1].id),
475
- group: win.messages[0].bodyGroupId !== undefined,
476
- };
477
562
  }
478
563
  function describeMarkers(m) {
479
564
  if (m.status === 'queued')
@@ -834,18 +919,19 @@ function boundFailureFields(record) {
834
919
  *
835
920
  * 400 and 422 are how a provider answers a request that fails its
836
921
  * validation, as a history it can't accept does, but other refusals come
837
- * back the same way, such as Anthropic's 400 for a credit balance that's too
838
- * low. A 413 is the request's size. When the newest exchange made it too
839
- * large, as an oversized attachment does, shedding that exchange may bring
840
- * the request back under the limit, and nothing else in the framework
841
- * recovers a 413; when the history as a whole is too large, the shed doesn't
842
- * help, and the breaker stops at its rewind cap. The 413 arm takes both
843
- * labels a 413 arrives under, invalid_request and context_length, because
844
- * membrane's label for a 413 differs by version and adapter. Other statuses
845
- * keep the history, since shed history can't be got back and the status
846
- * gives no sign that a shed would help: a 404 is a model or an endpoint that
847
- * doesn't exist, and 405, 409, 410 and 415 are about the HTTP request or the
848
- * resource, as a rule, rather than the conversation.
922
+ * back the same way, such as a refusal of the account that membrane doesn't
923
+ * recognize by its text. A 413 is the request's size. When the newest
924
+ * exchange made it too large, as an oversized attachment does, shedding that
925
+ * exchange may bring the request back under the limit, and nothing else in
926
+ * the framework recovers a 413; when the history as a whole is too large,
927
+ * the shed doesn't help, and the breaker stops at its rewind cap. The 413 arm
928
+ * takes both labels membranes have given a 413, invalid_request and
929
+ * context_length (see the notes below). Other statuses keep the history,
930
+ * since a shed takes the exchange out of the agent's context, which only an
931
+ * operator's undo brings back, and the status gives no sign that a shed
932
+ * would help: a 404 is a model or an endpoint that doesn't exist, and 405,
933
+ * 409, 410 and 415 are about the HTTP request or the resource, as a rule,
934
+ * rather than the conversation.
849
935
  *
850
936
  * The table test in test/inference-exhaustion-rewind.test.ts is this
851
937
  * predicate's contract. The notes below on which membrane version gives which
@@ -860,13 +946,24 @@ function boundFailureFields(record) {
860
946
  * adapter files every status it doesn't list (404, 405, 409, 410, 413,
861
947
  * 415, 422 …) as invalid_request, so it alone produces an invalid_request
862
948
  * at 413 or 422, and no 0.5.86 adapter produces a context_length at 413.
863
- * - From membrane #56 (merged, not yet in a release), one table serves every
864
- * adapter. Auth codes and context overflow are checked on a 400 or 422
865
- * first, a 404 is invalid_request, a 413 is context_length, and other 4xx
866
- * are invalid_request. So a 413 still sheds, an Anthropic one included,
867
- * while a context_length at 400 or 422, a token overflow, keeps the
868
- * history. The 422 arm then also covers an OpenAI-compatible server, such
869
- * as a FastAPI one, that answers a validation failure with 422.
949
+ * - From membrane 0.5.87 (membrane #56 and #107), this package's floor, every
950
+ * adapter sends an HTTP failure through one status table
951
+ * (errorFromProviderStatus). On a 400 or 422, auth codes and reasons and
952
+ * the two account refusals it recognizes by their text (a balance too low,
953
+ * usage limits reached) are auth, and a context overflow, by its code or
954
+ * text, is context_length. A 401, 402 or 403 is auth, a 408 timeout, a 429
955
+ * rate_limit, a 404 invalid_request and a 413 context_length; other 4xx
956
+ * are invalid_request. So a 413 the table types sheds, an Anthropic one
957
+ * included, while a context_length at 400 or 422, a token overflow, keeps
958
+ * the history, and so does an account refusal, which is auth. An adapter
959
+ * can type a failure before the table does, as Anthropic's does for a
960
+ * gateway's aggregate error (server). A failure with no HTTP status takes
961
+ * the status its provider code implies when the table knows the code, as
962
+ * for Anthropic's mid-stream errors, and is otherwise typed from its
963
+ * message, with no status. The 422 arm also covers an
964
+ * OpenAI-compatible server, such as a FastAPI one, that answers a
965
+ * validation failure with 422. The test of provider answers through the
966
+ * table, beside the table test, pins how the two meet at the floor.
870
967
  *
871
968
  * An error with no status, from a membrane that reports none, keeps the rule
872
969
  * the breaker had before: the type alone.
@@ -960,6 +1057,10 @@ export class AgentFramework {
960
1057
  stopPromise = null;
961
1058
  stopCompleted = false;
962
1059
  ownedStoreClosed = false;
1060
+ /** An operator's operations in flight, and the config materializations they
1061
+ * start. stop() waits until this set is empty before it tears down, unless
1062
+ * its wait budget runs out. */
1063
+ operatorOperations = new Set();
963
1064
  membrane;
964
1065
  queue;
965
1066
  agents = new Map();
@@ -967,16 +1068,6 @@ export class AgentFramework {
967
1068
  inferencePolicy;
968
1069
  errorPolicy;
969
1070
  pendingRequests = [];
970
- /**
971
- * Per-agent channel that triggered the agent's CURRENT inference turn, if any
972
- * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
973
- * route a single-trunk agent's plain-text speech back to the channel it is
974
- * answering, instead of the process-global most-recent-inbound locus that a
975
- * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
976
- * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
977
- * turn. Never read between turns (a given agent runs one turn at a time).
978
- */
979
- activeTriggerChannels = new Map();
980
1071
  running = false;
981
1072
  loopPromise = null;
982
1073
  /** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from
@@ -999,6 +1090,14 @@ export class AgentFramework {
999
1090
  maintenanceRunId = 0;
1000
1091
  currentMaintenanceRun = null;
1001
1092
  maintenanceHistory = [];
1093
+ /** Each agent's last reported maintenance failure, its whole text and when
1094
+ * it was alerted, until its maintenance goes without one
1095
+ * (reportMaintenanceFailure). */
1096
+ maintenanceFailureReported = new Map();
1097
+ /** Run records whose failure repeated one already reported, and whose report was suppressed. */
1098
+ maintenanceRepeats = new WeakSet();
1099
+ /** 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. */
1100
+ maintenancePassRequested = false;
1002
1101
  providerGates = new Map();
1003
1102
  providerAccelerationCooldowns = new Map();
1004
1103
  providerAccelerationRecoveries = new Map();
@@ -1020,32 +1119,6 @@ export class AgentFramework {
1020
1119
  processLoggingPersist;
1021
1120
  processLoggingBroadcast;
1022
1121
  activeStreams = new Map();
1023
- /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
1024
- * eagerly in startAgentStream (home → addressed trigger → global default)
1025
- * and never moved until the next turn: mid-turn injected messages — ambient
1026
- * chatter, reactions, system markers — must not hijack where the agent's
1027
- * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
1028
- * driveStream locals — so the pin survives a context-budget stream restart,
1029
- * which continues the same logical turn in a fresh driveStream. Cleared or
1030
- * re-set at the start of every non-restart turn. Since 2026-07-31 the pin
1031
- * is no longer fully frozen: an ADDRESSED conversational injection — or a
1032
- * conversational follow-up in a channel the agent itself explicitly sent
1033
- * into this turn (turnEngagedChannels) — re-pins it at the boundary (see
1034
- * the addressed re-pin block in the tool-result handler). Ambient chatter
1035
- * elsewhere, reactions, and system markers still cannot move it. */
1036
- turnLocusPins = new Map();
1037
- /** Channels the agent EXPLICITLY sent into during the CURRENT turn
1038
- * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
1039
- * signal: a conversational reply in a channel the agent just engaged is
1040
- * addressed by context even without a mention (2026-07-31 n=7: q's
1041
- * #portables follow-up — no @mention — was answered in trailing prose
1042
- * that followed the stale pin into repligate's DM). No author-kind
1043
- * filter: agent-residents are full participants, and the bot flag
1044
- * tracks nothing that matters (antra). Reactions/system markers are
1045
- * excluded by isConversationalInjection; the engaged-this-turn scope
1046
- * keeps unrelated channels from moving the pin. Cleared at every fresh
1047
- * turn's start; budget restarts keep it. */
1048
- turnEngagedChannels = new Map();
1049
1122
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
1050
1123
  * not a reaction or a system marker) into the live stream. Tells
1051
1124
  * driveStream to clear sticky explicit-send suppression before handling
@@ -1060,12 +1133,13 @@ export class AgentFramework {
1060
1133
  /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
1061
1134
  proseClipboards = new Map();
1062
1135
  /**
1063
- * Each agent's routing state for its current turn (TurnRouting): the
1064
- * sticky destination, the turn's destination for a resumed stream,
1065
- * hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
1066
- * instead of clearing the old one, and a stream that resumes the turn
1067
- * (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
1068
- * turn that queued it, so prose a turn leaves queued when it ends (a
1136
+ * Each agent's routing state for its current logical turn (TurnRouting):
1137
+ * its trigger channel, locus and engaged channels, the sticky destination,
1138
+ * the turn's destination, hybrid's hold, a ` !` continuation and receipt
1139
+ * tallies. The start that opens a turn makes a
1140
+ * new object instead of clearing the old one, and every later stream of
1141
+ * the turn shares it (openLogical). A queued delivery holds the object of
1142
+ * the turn that queued it, so prose a turn leaves queued when it ends (a
1069
1143
  * skip_reply ends it before its posts have run) still follows that turn's
1070
1144
  * destination, and its late writes can't reach the next turn.
1071
1145
  */
@@ -1077,23 +1151,25 @@ export class AgentFramework {
1077
1151
  */
1078
1152
  proseSpeechChains = new Map();
1079
1153
  /**
1080
- * Each agent's open logical turn (LogicalTurn). It stays open from the
1081
- * fresh start that opens it until the turn settles (settleAgent: it
1082
- * completes, a tool ends it, or it fails or is abandoned), and every fresh
1083
- * start that routes prose while it is open continues it: a framework
1084
- * retry, the start that consumes tool results the non-streaming fallback
1085
- * left (whichever wake the scheduler starts it with), the later compile a
1086
- * provider-capacity hold defers a turn to. A silent start neither
1087
- * continues nor opens one. A stream that ends without settling needs to do
1088
- * nothing to carry its turn. Settling only detaches the turn from future
1089
- * starts: the settling stream keeps its own object, which its remaining
1090
- * deliveries, its receipt and its ` !` check go on using (a completion
1091
- * settles before it awaits its chain and writes its receipt). So every
1092
- * logical end must close inheritance once, before the next fresh start:
1093
- * a turn left open would hand its object, late writes and all, to an
1094
- * unrelated turn. A tool-ended turn settles before the next turn can
1095
- * start. Only the stream that holds the open turn closes it; disposal
1096
- * drops it, and stop() clears every agent's.
1154
+ * Each agent's open logical turn: its routing state and receipt tallies
1155
+ * (TurnRouting). It stays open from the start that opens it until the
1156
+ * turn settles (settleAgent: it completes, a tool ends it, or it fails or
1157
+ * is abandoned), and every start that routes prose while it is open
1158
+ * continues it with that object: a framework retry, the start that
1159
+ * consumes tool results the non-streaming fallback left (whichever wake
1160
+ * the scheduler starts it with), the later compile a provider-capacity
1161
+ * hold defers a turn to. A silent start neither continues nor opens one,
1162
+ * and leaves the open turn's object untouched (its own is the live routing
1163
+ * while it runs: turnRouting). A stream that ends without
1164
+ * settling needs to do nothing to carry its turn. Settling only detaches
1165
+ * the turn from future starts: the settling stream keeps its own object,
1166
+ * which its remaining deliveries, its receipt and its ` !` check go on
1167
+ * using (a completion settles before it awaits its chain and writes its
1168
+ * receipt). So every logical end must close inheritance once, before the
1169
+ * next fresh start: a turn left open would hand its object, late writes
1170
+ * and all, to an unrelated turn. A tool-ended turn settles before the
1171
+ * next turn can start. Only the stream that holds the open turn closes
1172
+ * it; disposal drops it, and stop() clears every agent's.
1097
1173
  */
1098
1174
  openLogical = new Map();
1099
1175
  /** Consecutive bounce-wakes per agent; capped so an agent that keeps
@@ -1156,6 +1232,8 @@ export class AgentFramework {
1156
1232
  * tick counts in the success log stop meaning anything. One kick per agent
1157
1233
  * at a time; cleared when the kick settles (success or failure). */
1158
1234
  overBudgetDrainInFlight = new Set();
1235
+ /** The over-budget drain kicks running, which a branch move waits for as it waits for a maintenance pass. */
1236
+ overBudgetDrains = new Set();
1159
1237
  /** Per-agent current rewind episode: the single consolidated marker's id and
1160
1238
  * how many turns have been shed so far. One marker per episode, updated in
1161
1239
  * place (see updateRewindMarker); cleared when the episode ends. */
@@ -1854,11 +1932,16 @@ export class AgentFramework {
1854
1932
  }
1855
1933
  }
1856
1934
  /**
1857
- * Stop the event loop. Failed module/MCPL cleanup can be retried with its
1858
- * original context; storage stays open until that cleanup succeeds.
1859
- * @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
1860
- * until cleanup settles, as required by existing module/child contracts. This
1861
- * does not bound earlier stream or event-loop quiescence, or cancel cleanup.
1935
+ * Stop the event loop. After a failed or timed-out wait on operator, module
1936
+ * or MCPL cleanup, storage stays open and stop() can be called again. A
1937
+ * timed-out wait lets teardown go on while that work may still act on what
1938
+ * is being torn down.
1939
+ * @param teardownTimeoutMs Optional budget for each wait on cleanup: an
1940
+ * operator's operations already running, then modules and MCPL together.
1941
+ * Omit to wait until cleanup settles, as required by existing module/child
1942
+ * contracts. This does not bound earlier stream, event-loop or maintenance
1943
+ * quiescence (a maintenance pass or over-budget drain kick in flight), or
1944
+ * cancel cleanup.
1862
1945
  */
1863
1946
  stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
1864
1947
  try {
@@ -1869,7 +1952,12 @@ export class AgentFramework {
1869
1952
  }
1870
1953
  if (this.stopPromise)
1871
1954
  return this.stopPromise;
1872
- const attempt = this.stopOwned(teardownTimeoutMs);
1955
+ // Claim the attempt before any of its body runs: stopOwned's synchronous
1956
+ // start can emit traces (the push coalescer's suspend() does), and a
1957
+ // listener that calls stop() there must get this attempt, not start a
1958
+ // second teardown beside it.
1959
+ let settle;
1960
+ const attempt = new Promise((resolve, reject) => { settle = { resolve, reject }; });
1873
1961
  this.stopPromise = attempt;
1874
1962
  void attempt.then(() => { this.stopCompleted = true; }, () => { });
1875
1963
  // Failed cleanup can be retried while its original store is open. A
@@ -1878,6 +1966,9 @@ export class AgentFramework {
1878
1966
  if (!this.ownedStoreClosed && this.stopPromise === attempt)
1879
1967
  this.stopPromise = null;
1880
1968
  });
1969
+ // The body still starts in this call, so its synchronous steps (closing
1970
+ // the queue among them) happen before stop() returns.
1971
+ this.stopOwned(teardownTimeoutMs).then(settle.resolve, settle.reject);
1881
1972
  return attempt;
1882
1973
  }
1883
1974
  resetCompletedStop() {
@@ -1888,6 +1979,59 @@ export class AgentFramework {
1888
1979
  this.stopCompleted = false;
1889
1980
  }
1890
1981
  }
1982
+ /** Register before invoking the body, so the set holds the operation for
1983
+ * its whole life: its synchronous trace callbacks can reenter stop(), and
1984
+ * that stop() must find it. Invocation stays in this stack, preserving
1985
+ * immediate mode changes; the returned promise carries its result to the
1986
+ * caller. */
1987
+ trackOperatorOperation(label, run) {
1988
+ let resolve;
1989
+ let reject;
1990
+ const operation = new Promise((yes, no) => { resolve = yes; reject = no; });
1991
+ const entry = { label, promise: operation.then(() => { }, () => { }) };
1992
+ this.operatorOperations.add(entry);
1993
+ void entry.promise.then(() => { this.operatorOperations.delete(entry); });
1994
+ try {
1995
+ resolve(run());
1996
+ }
1997
+ catch (error) {
1998
+ reject(error);
1999
+ }
2000
+ return operation;
2001
+ }
2002
+ /** Whether stop() has begun: an attempt in flight (stopPromise is set before
2003
+ * its body runs, which emits traces before the queue closes) or a closed
2004
+ * queue (after a failed or completed stop()). The admission rule for every
2005
+ * operator entry point, the host/command listener included. */
2006
+ stopHasBegun() {
2007
+ return !!this.stopPromise || !!this.queue?.isClosed;
2008
+ }
2009
+ refuseOperatorAfterStop(verb) {
2010
+ if (this.stopHasBegun())
2011
+ throw new Error(verb + ' refused: the framework is stopping');
2012
+ }
2013
+ /** Wait in rounds until the set is empty: admitted work can still start work
2014
+ * of its own. All rounds share the caller's deadline rather than renewing
2015
+ * its budget. */
2016
+ async waitForOperatorOperations(timeoutMs) {
2017
+ const deadline = timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
2018
+ while (this.operatorOperations.size > 0) {
2019
+ const remaining = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
2020
+ const timedOut = () => new ShutdownTimeoutError([...this.operatorOperations].map(({ label }) => label), timeoutMs, []);
2021
+ if (remaining !== undefined && remaining < 1)
2022
+ throw timedOut();
2023
+ try {
2024
+ await waitForShutdown([...this.operatorOperations], remaining);
2025
+ }
2026
+ catch (error) {
2027
+ // Report the original budget and everything still pending, including
2028
+ // child work added after this round's snapshot.
2029
+ if (error instanceof ShutdownTimeoutError)
2030
+ throw timedOut();
2031
+ throw error;
2032
+ }
2033
+ }
2034
+ }
1891
2035
  async stopOwned(teardownTimeoutMs) {
1892
2036
  this.pushCoalescer?.suspend();
1893
2037
  this.flushCoalescingSnapshot();
@@ -1928,8 +2072,10 @@ export class AgentFramework {
1928
2072
  // A stopped host must never hang behind its own cooldown.
1929
2073
  this.cancelProviderAdmission();
1930
2074
  // An open logical turn doesn't survive a stop: a start after a restart
1931
- // on this instance opens a new one rather than carrying its ` !` and
1932
- // receipt tallies across (openLogical).
2075
+ // on this instance opens a new one rather than continuing the old turn's
2076
+ // routing (openLogical). A resume still continues its turn, with its
2077
+ // routing state (turnRouting), since it carries that turn's own request
2078
+ // on: beginAgentTurn opens the turn again for it.
1933
2079
  this.openLogical.clear();
1934
2080
  // Cancel all active streams
1935
2081
  for (const agent of this.agents.values()) {
@@ -1951,12 +2097,25 @@ export class AgentFramework {
1951
2097
  clearInterval(this.maintenanceTimer);
1952
2098
  this.maintenanceTimer = null;
1953
2099
  }
1954
- if (this.maintenancePass) {
1955
- await this.maintenancePass;
1956
- }
2100
+ // The pass and any over-budget drain kick: a compression in flight lands
2101
+ // before the store closes.
2102
+ await this.settleMaintenancePass();
1957
2103
  if (this.loopPromise) {
1958
2104
  await this.loopPromise;
1959
2105
  }
2106
+ // Admission closed when stop() began. What admitted operations start now,
2107
+ // such as a surgery's config materialization, joins the set, so wait until
2108
+ // the set is empty. This comes after the streams and the event loop, which
2109
+ // a quiesce or a surgery may be waiting on. If the budget runs out,
2110
+ // teardown goes on while the operation may still act on what it tears
2111
+ // down, and the store stays open for another stop().
2112
+ let operatorOperationsTimedOut;
2113
+ try {
2114
+ await this.waitForOperatorOperations(teardownTimeoutMs);
2115
+ }
2116
+ catch (error) {
2117
+ operatorOperationsTimedOut = error;
2118
+ }
1960
2119
  // Stop typing indicators
1961
2120
  this.channelRegistry?.stopAll();
1962
2121
  // Dispose EventGate (clear debounce timers)
@@ -1981,7 +2140,7 @@ export class AgentFramework {
1981
2140
  }
1982
2141
  // Timed-out or failed teardown retains its context and may still write.
1983
2142
  // Sync available state best-effort, but leave its store open for a retry.
1984
- const shutdownErrors = [];
2143
+ const shutdownErrors = operatorOperationsTimedOut === undefined ? [] : [operatorOperationsTimedOut];
1985
2144
  for (const result of await Promise.allSettled(shutdownPromises)) {
1986
2145
  if (result.status === 'rejected')
1987
2146
  shutdownErrors.push(result.reason);
@@ -2082,8 +2241,15 @@ export class AgentFramework {
2082
2241
  }
2083
2242
  async withAuxiliaryAdmission(agentName, run, model) {
2084
2243
  const gate = this.providerGate(agentName);
2244
+ // A surgery's hold or a stop withdraws the call before a provider wait
2245
+ // can refuse it: either is the framework's own doing, and no failure.
2246
+ this.withdrawMaintenanceUnderSurgery();
2247
+ this.withdrawMaintenanceWhenStopping(agentName);
2085
2248
  this.refuseHeldProviderModel(agentName, model);
2086
- if (this.providerGateBlocked(agentName)) {
2249
+ // A parked call is woken when the gate opens, when stop() closes
2250
+ // admission, or when a surgery takes the store; one woken while the gate
2251
+ // is still closed parks again.
2252
+ while (this.providerGateBlocked(agentName)) {
2087
2253
  gate.deferredAuxiliary++;
2088
2254
  try {
2089
2255
  await new Promise((resolve) => gate.auxiliaryWaiters.push(resolve));
@@ -2091,10 +2257,12 @@ export class AgentFramework {
2091
2257
  finally {
2092
2258
  gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
2093
2259
  }
2260
+ if (this.providerAdmissionClosed)
2261
+ break;
2262
+ // A surgery that took the store while this call was parked withdraws it.
2263
+ this.withdrawMaintenanceUnderSurgery();
2094
2264
  }
2095
- if (this.providerAdmissionClosed) {
2096
- throw new Error(`Provider admission closed while stopping (${agentName})`);
2097
- }
2265
+ this.withdrawMaintenanceWhenStopping(agentName);
2098
2266
  // A wait recorded while this call was parked binds it too.
2099
2267
  this.refuseHeldProviderModel(agentName, model);
2100
2268
  gate.auxiliaryInFlight++;
@@ -2121,6 +2289,28 @@ export class AgentFramework {
2121
2289
  resolve();
2122
2290
  }
2123
2291
  }
2292
+ /**
2293
+ * Maintenance makes no provider call while a surgery holds the store. The
2294
+ * surgery waits for the maintenance work that is running before it moves
2295
+ * the branch, so a call that parked at the gate would make it wait on
2296
+ * whatever reopens the gate: the end of a cooldown, or the retry turn a
2297
+ * cooldown queues, which the hold itself keeps from starting.
2298
+ */
2299
+ withdrawMaintenanceUnderSurgery() {
2300
+ if (this.surgeryHold) {
2301
+ throw new MaintenanceWithdrawnError(`a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store`);
2302
+ }
2303
+ }
2304
+ /**
2305
+ * Nor once stop() has closed provider admission: a stop that catches a
2306
+ * tick is the framework stopping on purpose, so the call is withdrawn, as
2307
+ * under a surgery's hold, and no failure is reported for it.
2308
+ */
2309
+ withdrawMaintenanceWhenStopping(agentName) {
2310
+ if (this.providerAdmissionClosed) {
2311
+ throw new MaintenanceWithdrawnError(`provider admission closed while stopping (${agentName})`);
2312
+ }
2313
+ }
2124
2314
  auxiliaryMembraneFor(agentName) {
2125
2315
  return this.membraneAdmitting((run, model) => this.withAuxiliaryAdmission(agentName, run, model));
2126
2316
  }
@@ -2132,10 +2322,16 @@ export class AgentFramework {
2132
2322
  * as a resident's are, so they bind its auxiliary calls too: a held model
2133
2323
  * is refused without a call, and an auxiliary call's own stated wait is
2134
2324
  * recorded for it. (An ephemeral run records nothing, its name being
2135
- * single-use, so its context manager keeps the plain membrane.)
2325
+ * single-use, so its context manager keeps the plain membrane.) Its
2326
+ * compression is the background work a resident's is, whether a
2327
+ * maintenance tick or a drain after a message starts it, so it is withdrawn
2328
+ * as a resident's is: a call made while a surgery holds the store, or once
2329
+ * stop() has closed provider admission, is withdrawn without being sent.
2136
2330
  */
2137
2331
  conversationAuxiliaryMembraneFor(agentName) {
2138
2332
  return this.membraneAdmitting(async (run, model) => {
2333
+ this.withdrawMaintenanceUnderSurgery();
2334
+ this.withdrawMaintenanceWhenStopping(agentName);
2139
2335
  this.refuseHeldProviderModel(agentName, model);
2140
2336
  try {
2141
2337
  return await run();
@@ -2311,17 +2507,26 @@ export class AgentFramework {
2311
2507
  return undefined;
2312
2508
  }
2313
2509
  /** Why an ephemeral run ended instead of sleeping a provider's stated wait,
2314
- * for its caller. */
2510
+ * for its caller: the text, and the wait itself, which its rejection
2511
+ * carries (ProviderWaitNotSleptError). */
2315
2512
  statedWaitNotSlept(error) {
2316
- const stated = isMembraneError(error) ? error.retryAfterMs : undefined;
2513
+ // Called only after nonOwningProviderWait answered 'fail', which takes a
2514
+ // numeric stated wait.
2515
+ const stated = isMembraneError(error) && typeof error.retryAfterMs === 'number' ? error.retryAfterMs : Number.NaN;
2317
2516
  const cap = `${PROVIDER_ACCELERATION_MAX_COOLDOWN_MS / 1000} s`;
2318
- if (typeof stated !== 'number' || Number.isNaN(stated)) {
2319
- return `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
2320
- `stated wait of ${cap} or less, so it ends now)`;
2517
+ if (Number.isNaN(stated)) {
2518
+ return {
2519
+ error: `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
2520
+ `stated wait of ${cap} or less, so it ends now)`,
2521
+ statedWaitMs: Number.NaN,
2522
+ };
2321
2523
  }
2322
2524
  const asked = Number.isFinite(stated) ? `${Math.ceil(stated / 1000)} s` : 'longer than can be represented';
2323
- return `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
2324
- `longer than ${cap}, so it ends now)`;
2525
+ return {
2526
+ error: `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
2527
+ `longer than ${cap}, so it ends now)`,
2528
+ statedWaitMs: stated,
2529
+ };
2325
2530
  }
2326
2531
  /** When a hold's timer next fires: at its deadline, stepped past what one
2327
2532
  * timer can hold. A hold with no deadline (a wait held until released, or
@@ -2627,7 +2832,28 @@ export class AgentFramework {
2627
2832
  this.maintenancePass = null;
2628
2833
  });
2629
2834
  }
2835
+ /** resume()'s refusal while a surgery holds the store. */
2836
+ refuseResumeUnderSurgery() {
2837
+ if (!this.surgeryHold)
2838
+ return;
2839
+ throw new OperatorActionError('agent-busy', `Cannot resume: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`);
2840
+ }
2841
+ /** Whether maintenance work is running: a pass, or an over-budget drain kick. */
2842
+ maintenanceRunning() {
2843
+ return this.maintenancePass !== null || this.overBudgetDrains.size > 0;
2844
+ }
2845
+ /** Wait for the maintenance work that is running, whatever its outcome. */
2846
+ async settleMaintenancePass() {
2847
+ await Promise.allSettled([...(this.maintenancePass ? [this.maintenancePass] : []), ...this.overBudgetDrains]);
2848
+ }
2630
2849
  async runQueuedMaintenance() {
2850
+ // A surgery holds the store while it switches or forks a branch. No pass
2851
+ // starts while it's held (the timer's next pass after it runs as usual),
2852
+ // and a running pass starts no new tick. The surgery waits for that pass
2853
+ // to finish before it moves the branch, so no maintenance work overlaps
2854
+ // the move, and a failure a pass meets is never the surgery's doing.
2855
+ if (this.surgeryHold)
2856
+ return;
2631
2857
  // A guard whose agent has left the map gets no activation to retry its
2632
2858
  // queue; retry it here. It leaves busyGuards once nothing of it is left.
2633
2859
  const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
@@ -2641,6 +2867,10 @@ export class AgentFramework {
2641
2867
  console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
2642
2868
  }
2643
2869
  }
2870
+ for (const name of this.maintenanceFailureReported.keys()) {
2871
+ if (!this.agents.has(name))
2872
+ this.maintenanceFailureReported.delete(name);
2873
+ }
2644
2874
  const queued = [...this.agents.values()].flatMap((agent) => {
2645
2875
  try {
2646
2876
  agent.toolResultGuard.flushUnrecorded();
@@ -2648,28 +2878,53 @@ export class AgentFramework {
2648
2878
  catch (error) {
2649
2879
  console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
2650
2880
  }
2651
- const cm = agent.getContextManager();
2652
- const tools = this.compressionToolsForAgent(agent.name);
2653
- cm.setToolDefinitions(tools);
2654
- if (this.providerGateBlocked(agent.name))
2655
- return [];
2656
- if (cm.isReady())
2881
+ // Whatever throws while one agent's maintenance is prepared is that
2882
+ // agent's failure, reported for it alone, while the others run.
2883
+ const failedAgent = (error) => {
2884
+ const record = {
2885
+ agentName: agent.name,
2886
+ startedAt: Date.now(),
2887
+ ticks: 0,
2888
+ readyBefore: false,
2889
+ };
2890
+ record.finishedAt = record.startedAt;
2891
+ this.reportMaintenanceFailure(agent.name, record, error instanceof Error ? String(error.message) : failureDescription(error));
2892
+ return [{ agent, record, failed: true }];
2893
+ };
2894
+ let cm;
2895
+ let state;
2896
+ try {
2897
+ cm = agent.getContextManager();
2898
+ const tools = this.compressionToolsForAgent(agent.name);
2899
+ cm.setToolDefinitions(tools);
2900
+ if (this.providerGateBlocked(agent.name))
2901
+ return [];
2902
+ state = this.contextReadiness(cm);
2903
+ }
2904
+ catch (error) {
2905
+ return failedAgent(error);
2906
+ }
2907
+ if ('ready' in state && state.ready) {
2908
+ this.maintenanceFailureReported.delete(agent.name);
2657
2909
  return [];
2658
- const pending = cm.getPendingWork()?.description;
2910
+ }
2659
2911
  const progress = this.contextProgress(cm);
2660
2912
  const record = {
2661
2913
  agentName: agent.name,
2662
2914
  startedAt: Date.now(),
2663
2915
  ticks: 0,
2664
2916
  readyBefore: false,
2665
- ...(pending ? { pendingBefore: pending } : {}),
2917
+ ...('pending' in state && state.pending ? { pendingBefore: state.pending } : {}),
2666
2918
  ...(progress ? { progressBefore: progress } : {}),
2667
2919
  };
2668
- return [{
2669
- agent,
2670
- cm,
2671
- record,
2672
- }];
2920
+ if ('error' in state) {
2921
+ // Its readiness can't be read: that agent's failure, reported for it
2922
+ // alone, while the others run.
2923
+ record.finishedAt = Date.now();
2924
+ this.reportMaintenanceFailure(agent.name, record, state.error);
2925
+ return [{ agent, record, failed: true }];
2926
+ }
2927
+ return [{ agent, cm, record, failed: false }];
2673
2928
  });
2674
2929
  if (queued.length === 0)
2675
2930
  return;
@@ -2680,54 +2935,141 @@ export class AgentFramework {
2680
2935
  };
2681
2936
  this.currentMaintenanceRun = run;
2682
2937
  try {
2683
- await Promise.all(queued.map(async ({ agent, cm, record }) => {
2938
+ const running = queued.filter((item) => !item.failed);
2939
+ // Settled, not all: the pass ends only when every agent's work has.
2940
+ const outcomes = await Promise.allSettled(running.map(async ({ agent, cm, record }) => {
2941
+ // A tick whose provider call a surgery or stop() withdrew ends this
2942
+ // agent's work for the pass, as the hold or the stop would have before
2943
+ // its next tick: it is no failure, and what the failure reports
2944
+ // remember stays as it is.
2945
+ let withdrawn = false;
2684
2946
  try {
2685
- for (let i = 0; i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady(); i++) {
2947
+ for (let i = 0; i < MAINTENANCE_TICKS_PER_PASS && this.running && !this.surgeryHold && !cm.isReady(); i++) {
2686
2948
  await cm.tick();
2687
2949
  record.ticks++;
2688
2950
  }
2689
2951
  }
2690
2952
  catch (error) {
2691
- const wholeError = error instanceof Error ? String(error.message) : failureDescription(error);
2692
- record.error = boundFailureText(wholeError);
2693
- console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
2694
- this.opsAlert('context-maintenance-failed', agent.name, wholeError, {
2695
- data: {
2696
- scope: 'agent',
2697
- ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
2698
- ...(record.progressBefore ? { progress: record.progressBefore } : {}),
2699
- },
2700
- });
2953
+ if (error instanceof MaintenanceWithdrawnError) {
2954
+ withdrawn = true;
2955
+ console.error(`[context-maintenance] agent=${agent.name} stopped: ${error.message}`);
2956
+ }
2957
+ else {
2958
+ this.reportMaintenanceFailure(agent.name, record, error instanceof Error ? String(error.message) : failureDescription(error));
2959
+ }
2701
2960
  }
2702
2961
  finally {
2703
- const pending = cm.getPendingWork()?.description;
2704
2962
  record.finishedAt = Date.now();
2705
- record.readyAfter = cm.isReady();
2706
- if (pending)
2707
- record.pendingAfter = pending;
2963
+ const state = this.contextReadiness(cm);
2964
+ if ('error' in state) {
2965
+ // A run records an agent's first failure: a readiness refusal
2966
+ // after a tick that failed is read again by the next pass.
2967
+ if (record.error === undefined && !withdrawn)
2968
+ this.reportMaintenanceFailure(agent.name, record, state.error);
2969
+ }
2970
+ else {
2971
+ record.readyAfter = state.ready;
2972
+ if (state.pending)
2973
+ record.pendingAfter = state.pending;
2974
+ if (record.error === undefined && !withdrawn)
2975
+ this.maintenanceFailureReported.delete(agent.name);
2976
+ }
2708
2977
  const progress = this.contextProgress(cm);
2709
2978
  if (progress)
2710
2979
  record.progressAfter = progress;
2711
2980
  }
2712
2981
  }));
2982
+ for (const outcome of outcomes) {
2983
+ if (outcome.status === 'rejected') {
2984
+ console.error('[context-maintenance] reporting an agent\'s failure failed:', describeFailureWithFrames(outcome.reason));
2985
+ }
2986
+ }
2713
2987
  }
2714
2988
  finally {
2715
2989
  run.finishedAt = Date.now();
2716
2990
  run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
2717
2991
  this.currentMaintenanceRun = null;
2718
- this.maintenanceHistory.unshift(run);
2992
+ // A pass that only met failures already reported, and took no tick,
2993
+ // adds nothing the history doesn't hold, and a lasting failure would
2994
+ // otherwise fill it in minutes, pushing out the runs that did work. One
2995
+ // an operator asked for is kept: it's the pass their maintain reports.
2996
+ const requested = this.maintenancePassRequested;
2997
+ this.maintenancePassRequested = false;
2998
+ if (requested || !run.agents.every((record) => record.ticks === 0 && this.maintenanceRepeats.has(record)))
2999
+ this.maintenanceHistory.unshift(run);
2719
3000
  if (this.maintenanceHistory.length > 50)
2720
3001
  this.maintenanceHistory.length = 50;
2721
3002
  }
2722
3003
  }
3004
+ /**
3005
+ * One agent's maintenance failure: recorded on its run, bounded as every
3006
+ * failure sink is (`record.error`), and logged and alerted for that agent
3007
+ * with the whole text. A failure that lasts, such as a manager whose
3008
+ * strategy failed to initialize, meets every pass, a few seconds apart. It
3009
+ * is logged and alerted again only when its whole text changes, once
3010
+ * opsAlert's cooldown for it has passed since the webhook last posted it
3011
+ * (so the webhook, which posts a kind at most once per cooldown, hears a
3012
+ * failure that lasts), or after a pass in which that agent's maintenance
3013
+ * went without a failure (maintenanceFailureReported). A record whose
3014
+ * failure isn't reported again is marked as a repeat, and a pass whose
3015
+ * every record is a repeat that took no tick stays out of the history.
3016
+ */
3017
+ reportMaintenanceFailure(agentName, record, wholeError) {
3018
+ record.error = boundFailureText(wholeError);
3019
+ const reported = this.maintenanceFailureReported.get(agentName);
3020
+ const now = Date.now();
3021
+ if (reported && reported.text === wholeError) {
3022
+ // The same failure again is alerted once the webhook's cooldown for it
3023
+ // has passed, counted from the webhook's last post of it: a report of
3024
+ // changed text that the webhook didn't post doesn't push the reminder
3025
+ // back. With no webhook, it's counted from this failure's last report.
3026
+ const since = this.opsAlertLastSent.get(`${agentName}:context-maintenance-failed`) ?? reported.at;
3027
+ if (now - since < this.opsAlertCooldownMs) {
3028
+ this.maintenanceRepeats.add(record);
3029
+ return;
3030
+ }
3031
+ }
3032
+ this.maintenanceFailureReported.set(agentName, { text: wholeError, at: now });
3033
+ console.error(`[context-maintenance] agent=${agentName} failed:`, record.error);
3034
+ this.opsAlert('context-maintenance-failed', agentName, wholeError, {
3035
+ data: {
3036
+ scope: 'agent',
3037
+ ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
3038
+ ...(record.progressBefore ? { progress: record.progressBefore } : {}),
3039
+ },
3040
+ });
3041
+ }
3042
+ /**
3043
+ * An agent's context readiness, or the error that reading it threw. The
3044
+ * context manager refuses while its strategy's initialization is pending
3045
+ * or has failed (context-manager 0.14), and a strategy can refuse on its
3046
+ * own, as the autobiographical one does after a store-level branch switch
3047
+ * it hasn't reloaded for. Every such throw is that agent's failure.
3048
+ */
3049
+ contextReadiness(cm) {
3050
+ try {
3051
+ const ready = cm.isReady();
3052
+ const pending = ready ? undefined : cm.getPendingWork()?.description;
3053
+ return pending ? { ready, pending } : { ready };
3054
+ }
3055
+ catch (error) {
3056
+ return { error: error instanceof Error ? String(error.message) : failureDescription(error) };
3057
+ }
3058
+ }
3059
+ /** The strategy's progress snapshot, for diagnostics: one it can't give reads as none. */
2723
3060
  contextProgress(cm) {
2724
- const strategy = cm.getStrategy();
2725
- if (typeof strategy.getProgressSnapshot !== 'function')
3061
+ try {
3062
+ const strategy = cm.getStrategy();
3063
+ if (typeof strategy.getProgressSnapshot !== 'function')
3064
+ return undefined;
3065
+ const progress = strategy.getProgressSnapshot();
3066
+ return progress && typeof progress === 'object'
3067
+ ? progress
3068
+ : undefined;
3069
+ }
3070
+ catch {
2726
3071
  return undefined;
2727
- const progress = strategy.getProgressSnapshot();
2728
- return progress && typeof progress === 'object'
2729
- ? progress
2730
- : undefined;
3072
+ }
2731
3073
  }
2732
3074
  /**
2733
3075
  * A server's request or notification the host couldn't handle, answered
@@ -3431,14 +3773,15 @@ export class AgentFramework {
3431
3773
  `advance merges to go lower). Applying; the live compile budget is unchanged.`);
3432
3774
  }
3433
3775
  }
3434
- else if (preview.reason) {
3776
+ else if (preview.reason && agent.contextManagerAnswers()) {
3777
+ // A manager that refuses its queries refuses the update below too.
3435
3778
  console.warn(`[budget-preflight] preview unavailable for ${agentName} (${preview.reason}); ` +
3436
3779
  `applying without preflight`);
3437
3780
  }
3438
3781
  }
3439
3782
  const result = agent.updateRuntimeSettings(patch);
3440
3783
  if (opts?.persist !== false) {
3441
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
3784
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
3442
3785
  }
3443
3786
  this.recordOperatorAction({
3444
3787
  kind: 'settings-update',
@@ -3455,7 +3798,7 @@ export class AgentFramework {
3455
3798
  throw new Error(`Unknown agent: ${agentName}`);
3456
3799
  const result = agent.resetRuntimeSettings(keys);
3457
3800
  if (opts?.persist !== false) {
3458
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
3801
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
3459
3802
  }
3460
3803
  this.recordOperatorAction({
3461
3804
  kind: 'settings-reset',
@@ -3471,7 +3814,7 @@ export class AgentFramework {
3471
3814
  if (!agent)
3472
3815
  throw new Error(`Unknown agent: ${agentName}`);
3473
3816
  const result = agent.cancelRuntimeSettingsTransition();
3474
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
3817
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
3475
3818
  this.recordOperatorAction({
3476
3819
  kind: 'settings-cancel-transition',
3477
3820
  agent: agentName,
@@ -3547,6 +3890,10 @@ export class AgentFramework {
3547
3890
  * inference-failure accounting.
3548
3891
  */
3549
3892
  async quiesce(opts) {
3893
+ this.refuseOperatorAfterStop('quiesce');
3894
+ return this.trackOperatorOperation('host:quiesce', () => this.runQuiesce(opts));
3895
+ }
3896
+ async runQuiesce(opts) {
3550
3897
  if (this.quiesced) {
3551
3898
  // Idempotent — EXCEPT abandon escalation: a first quiesce that timed
3552
3899
  // out undrained must be escalatable with a second quiesce({abandon})
@@ -3594,6 +3941,7 @@ export class AgentFramework {
3594
3941
  // latter becomes a turn the moment its auxiliary settles unless the
3595
3942
  // continuation's quiesce recheck requeues it — wait for that to happen).
3596
3943
  while (this.quiesced &&
3944
+ !this.queue?.isClosed &&
3597
3945
  (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
3598
3946
  Date.now() < deadline) {
3599
3947
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -3606,6 +3954,13 @@ export class AgentFramework {
3606
3954
  console.error('[host-mode] quiesce superseded by a concurrent resume during drain');
3607
3955
  return this.getHostModeStatus();
3608
3956
  }
3957
+ // Stop cuts this drain short, including any requested abandon. It cancels
3958
+ // streams and waits for the event loop; a token with no stream settles
3959
+ // on its own. Return the status as it stands and keep the durable quiesce.
3960
+ if (this.queue?.isClosed) {
3961
+ console.error('[host-mode] quiesce stopped waiting for its drain: the framework is stopping');
3962
+ return this.getHostModeStatus();
3963
+ }
3609
3964
  let abandoned = false;
3610
3965
  let unabandonable = [];
3611
3966
  if (this.activeTurnTokens.size > 0 && opts?.abandon) {
@@ -3657,9 +4012,11 @@ export class AgentFramework {
3657
4012
  `(status=${state?.status ?? 'unknown'}) — it must settle on its own`);
3658
4013
  }
3659
4014
  }
3660
- // Bounded grace for the cancelled streams' teardown to settle.
4015
+ // Bounded grace for cancelled streams. Stop cancels streams too; a turn
4016
+ // with no stream settles on its own. Report the abandon that already ran,
4017
+ // even if stop cuts its grace short.
3661
4018
  const grace = Date.now() + 10_000;
3662
- while (this.quiesced && this.activeTurnTokens.size > 0 && Date.now() < grace) {
4019
+ while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
3663
4020
  await new Promise((resolve) => setTimeout(resolve, 250));
3664
4021
  }
3665
4022
  // Only turns still alive after the grace are worth reporting.
@@ -3675,18 +4032,32 @@ export class AgentFramework {
3675
4032
  * strategy cannot preview would hold hosts hostage to a diagnostic.
3676
4033
  */
3677
4034
  async resume(opts) {
4035
+ this.refuseOperatorAfterStop('resume');
4036
+ return this.trackOperatorOperation('host:resume', () => this.runResume(opts));
4037
+ }
4038
+ async runResume(opts) {
3678
4039
  if (!this.quiesced)
3679
4040
  return this.getHostModeStatus();
4041
+ // A surgery switches or forks the branch while it holds the store, and a
4042
+ // manager whose initialization that left pending refuses its settings
4043
+ // read: resume once it's done, as the other operator calls do. Checked
4044
+ // again after the waits below, since a surgery can take the store while
4045
+ // resume waits for a pass or a preview.
4046
+ this.refuseResumeUnderSurgery();
3680
4047
  // The canonical operator flow is `maintain; resume`, and a timer-driven
3681
4048
  // pass may still be mid-flight: previewContext refuses to overlap one
3682
4049
  // ("already running"), which used to degrade the gate to "no opinion".
3683
4050
  // Wait for the pass first, then retry a few times for the case where a
3684
4051
  // fresh pass started in between.
3685
- if (this.maintenancePass) {
3686
- await this.maintenancePass.catch(() => { });
3687
- }
4052
+ if (this.maintenanceRunning())
4053
+ await this.settleMaintenancePass();
3688
4054
  const failing = [];
3689
4055
  for (const agentName of this.agents.keys()) {
4056
+ // A preview that isn't available gives no verdict, and resume goes on
4057
+ // for the others. That includes an agent whose context manager refuses
4058
+ // its queries, as when its strategy failed to initialize: that manager
4059
+ // refuses its compiles and writes until a switchBranch or setStrategy
4060
+ // succeeds, so the agent can't run over budget, and maintenance reports it.
3690
4061
  let preview = this.previewAgentRuntimeSettings(agentName);
3691
4062
  for (let attempt = 0; attempt < 8 && !preview.available && preview.reason === 'preview_in_flight'; attempt++) {
3692
4063
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -3702,6 +4073,13 @@ export class AgentFramework {
3702
4073
  `(${preview.reason}) — proceeding without a verdict`);
3703
4074
  }
3704
4075
  }
4076
+ this.refuseResumeUnderSurgery();
4077
+ // stop() closes the queue first. If it has begun, before this call or
4078
+ // during the waits above, leave the host quiesced, as it will boot,
4079
+ // rather than reopen what stop() is closing.
4080
+ if (this.queue?.isClosed) {
4081
+ throw new Error('resume refused: the framework is stopping, so the host stays quiesced');
4082
+ }
3705
4083
  if (failing.length > 0 && !opts?.force) {
3706
4084
  const detail = failing.map(({ agentName, preview }) => `${agentName}: folded floor ${preview.effective.finalTokens} > hard budget ` +
3707
4085
  `${preview.effective.budgetTokens}` +
@@ -3749,9 +4127,10 @@ export class AgentFramework {
3749
4127
  this.completeMcplDataPlaneGate(barrier);
3750
4128
  }
3751
4129
  catch (error) {
3752
- // The host IS resumed — don't rethrow. failMcplDataPlaneGate
3753
- // recycles the connections, and their reconnect flows re-run the
3754
- // funnel with quiesced=false, self-healing the data planes.
4130
+ // The host has resumed, so report the failed barrier without undoing
4131
+ // that. While serving, failMcplDataPlaneGate recycles the
4132
+ // connections and their reconnect flows reopen the data planes;
4133
+ // while the host stops, it releases the barrier instead.
3755
4134
  await this.failMcplDataPlaneGate(barrier, 'quiesce resume', error);
3756
4135
  }
3757
4136
  }
@@ -3764,7 +4143,9 @@ export class AgentFramework {
3764
4143
  // mid-quiesce boots quiesced but with these queues empty (sources like
3765
4144
  // heartbeats re-deliver on their own cadence; one-shot wakes are lost).
3766
4145
  // Deferred context WRITES are persisted while quiesced and survive.
3767
- console.error(`[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
4146
+ console.error(this.queue?.isClosed
4147
+ ? `[host-mode] resumed — ${releasedRequests} parked wake(s) won't run because the framework is stopping`
4148
+ : `[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
3768
4149
  }
3769
4150
  return this.getHostModeStatus();
3770
4151
  }
@@ -4057,26 +4438,38 @@ export class AgentFramework {
4057
4438
  * true in quiesce mode precisely so this machinery stays hot.
4058
4439
  */
4059
4440
  async maintenanceTick() {
4441
+ // A pass already running is stop()'s to wait for; a new one isn't started.
4442
+ this.refuseOperatorAfterStop('maintain');
4060
4443
  if (!this.running) {
4061
- // startQueuedMaintenance no-ops on a stopped/never-started framework —
4062
- // a bare snapshot would masquerade as a completed pass, so say so.
4444
+ // startQueuedMaintenance no-ops on a framework that was never started
4445
+ // (a stopping one is refused above): a bare snapshot would masquerade
4446
+ // as a completed pass, so say so.
4063
4447
  console.warn('[host-mode] maintenanceTick: framework is not running — no pass executed');
4064
4448
  return { ...this.getContextMaintenanceSnapshot(), ran: false };
4065
4449
  }
4450
+ if (this.surgeryHold) {
4451
+ // No pass starts while a surgery holds the store (runQueuedMaintenance).
4452
+ console.warn(`[host-mode] maintenanceTick: a ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — no pass executed`);
4453
+ return { ...this.getContextMaintenanceSnapshot(), ran: false };
4454
+ }
4455
+ this.maintenancePassRequested = true;
4066
4456
  this.startQueuedMaintenance();
4067
4457
  await this.maintenancePass;
4458
+ // A pass with nothing queued records no run, and the next pass wasn't asked for.
4459
+ this.maintenancePassRequested = false;
4068
4460
  return { ...this.getContextMaintenanceSnapshot(), ran: true };
4069
4461
  }
4070
4462
  /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
4071
4463
  getContextMaintenanceSnapshot() {
4072
4464
  const agents = [...this.agents.values()].map((agent) => {
4073
4465
  const cm = agent.getContextManager();
4074
- const pending = cm.getPendingWork()?.description;
4466
+ const state = this.contextReadiness(cm);
4075
4467
  const progress = this.contextProgress(cm);
4076
4468
  return {
4077
4469
  agentName: agent.name,
4078
- ready: cm.isReady(),
4079
- ...(pending ? { pending } : {}),
4470
+ ...('error' in state
4471
+ ? { ready: false, error: boundFailureText(state.error) }
4472
+ : { ready: state.ready, ...(state.pending ? { pending: state.pending } : {}) }),
4080
4473
  ...(progress ? { progress } : {}),
4081
4474
  };
4082
4475
  });
@@ -4093,13 +4486,28 @@ export class AgentFramework {
4093
4486
  * were activated right now — WITHOUT running inference, opening a stream, or
4094
4487
  * mutating agent state. Intended for debug/preview tooling.
4095
4488
  *
4096
- * Transparency contract (default): the preview is side-effect-free and
4097
- * leaves no trace on the system. It does only read-only work —
4098
- * `ContextManager.compile` (which never triggers compression itself;
4099
- * compression runs in the background out-of-band), tool filtering, and
4100
- * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
4101
- * No tokens are spent, nothing is written to Chronicle, and no external
4102
- * MCPL server is contacted.
4489
+ * What a preview commits (default): nothing of its own. It compiles as a
4490
+ * dry run (`dryRun`), filters tools and assembles the system prompt, then
4491
+ * delegates to `Agent.buildActivationRequest`. No tokens are spent, no
4492
+ * external MCPL server is contacted, no fold resolution is persisted and
4493
+ * none of the compression its fold plan calls for is queued; the consumed
4494
+ * watermark and any prepared budget change are left alone; and once
4495
+ * context-manager's thinking binding (context-manager#160, not yet
4496
+ * released) ships, no stamps are committed and no compile awaits
4497
+ * acceptance. In context-manager 0.14 its dry run leaves nothing else
4498
+ * behind but the release of an expired compression hold, as any compile
4499
+ * does (see `Agent.compileWithInjections`). The preview keeps in-memory
4500
+ * bookkeeping of its own, such as handing the context manager the
4501
+ * compression tool definitions, as every activation request does.
4502
+ * Under the autobiographical strategy, a budget the context can't fit
4503
+ * still throws, as for a committing compile: a dry run skips only
4504
+ * context-manager's post-pick check, and an adaptive fold plan that
4505
+ * doesn't fit then overruns at the middle's emission, with a refusal that
4506
+ * names the tokens and the limit. Windowed passthrough's newest message
4507
+ * alone is the one window that comes back larger than the budget. For a
4508
+ * verdict instead of the refusal, `previewContextSettings` returns
4509
+ * context-manager's own at a budget, with the agent's `maxTokens` reserved
4510
+ * for the response.
4103
4511
  *
4104
4512
  * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
4105
4513
  * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
@@ -4126,13 +4534,13 @@ export class AgentFramework {
4126
4534
  return request;
4127
4535
  };
4128
4536
  // An explicit budget compiles against a HYPOTHETICAL window instead of the
4129
- // agent's live one. That also suppresses transition-settling in
4130
- // compileWithInjections (which only settles when no budget is passed), so
4131
- // previewing a smaller window cannot advance a converging descent.
4132
- // Default: no dynamic injection gathering → fully transparent (no
4133
- // inference, no Chronicle writes, no external RPC). Opt in explicitly.
4537
+ // agent's live one. Every preview is a dry run, so none of them settles
4538
+ // a converging descent or commits what a sent compile commits (see the
4539
+ // doc comment above).
4540
+ // Default: no dynamic injection gathering → no inference, no Chronicle
4541
+ // writes of its own, no external RPC. Opt in explicitly.
4134
4542
  if (!opts?.injections) {
4135
- return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
4543
+ return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true }));
4136
4544
  }
4137
4545
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
4138
4546
  // NOT transparent — see the doc comment above.
@@ -4162,7 +4570,7 @@ export class AgentFramework {
4162
4570
  console.error('beforeInference hook error (preview):', error);
4163
4571
  }
4164
4572
  }
4165
- return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
4573
+ return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools, { dryRun: true }));
4166
4574
  }
4167
4575
  /**
4168
4576
  * Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
@@ -4171,9 +4579,11 @@ export class AgentFramework {
4171
4579
  *
4172
4580
  * Unlike `previewActivation` (which renders a request), this returns the
4173
4581
  * picker's plan: resulting tokens, whether it fits, how many summaries would
4174
- * have to be produced first, and the per-chunk fold levels. Commits nothing:
4175
- * no resolution persistence, no compression enqueue, no transition
4176
- * bookkeeping — see `AutobiographicalStrategy.previewContext`.
4582
+ * have to be produced first, and the per-chunk fold levels. It commits
4583
+ * nothing: its select is a dry run, which in context-manager 0.14 leaves
4584
+ * what a select leaves behind as the last committing compile left it —
4585
+ * see `AutobiographicalStrategy.previewContext` and
4586
+ * `Agent.compileWithInjections`.
4177
4587
  *
4178
4588
  * Returns null when the agent's strategy has no fold plan (non-adaptive).
4179
4589
  */
@@ -4181,6 +4591,9 @@ export class AgentFramework {
4181
4591
  const agent = this.agents.get(agentName);
4182
4592
  if (!agent)
4183
4593
  throw new Error(`Unknown agent: ${agentName}`);
4594
+ return this.previewContextFor(agent, budgetTokens, overrides, opts);
4595
+ }
4596
+ previewContextFor(agent, budgetTokens, overrides, opts) {
4184
4597
  const cm = agent.getContextManager();
4185
4598
  if (typeof cm.previewContext !== 'function')
4186
4599
  return null;
@@ -4204,16 +4617,42 @@ export class AgentFramework {
4204
4617
  * the compile budget and rejected safe-by-construction paced descents.
4205
4618
  *
4206
4619
  * Never throws for preview-layer reasons: strategy without previewContext,
4207
- * preview overlap, stale branch generation, and the hierarchical (non-
4208
- * adaptive) path all come back as `{available: false, reason}`. Unknown
4209
- * agent still throws, matching the sibling accessors.
4620
+ * preview overlap, stale branch generation, the hierarchical (non-
4621
+ * adaptive) path, and a context manager that refuses its queries (its
4622
+ * strategy's initialization pending or failed) all come back as
4623
+ * `{available: false, reason}`. Unknown agent still throws, matching the
4624
+ * sibling accessors, and so does a patch whose values the agent refuses,
4625
+ * whatever its context manager's state. Whether the strategy takes live
4626
+ * tail and pace settings is the manager's to say, so while it refuses, a
4627
+ * patch that sets them gets no verdict either.
4210
4628
  */
4211
4629
  previewAgentRuntimeSettings(agentName, patch) {
4212
4630
  const agent = this.agents.get(agentName);
4213
4631
  if (!agent)
4214
4632
  throw new Error(`Unknown agent: ${agentName}`);
4215
- const plan = agent.planRuntimeSettings(patch);
4216
- const settings = agent.getRuntimeSettings();
4633
+ return this.previewRuntimeSettingsFor(agent, patch);
4634
+ }
4635
+ /** previewAgentRuntimeSettings for an agent in hand: boot restore previews one not yet registered. */
4636
+ previewRuntimeSettingsFor(agent, patch) {
4637
+ // A patch whose values the agent refuses throws, whatever its context
4638
+ // manager's state.
4639
+ if (patch && Object.keys(patch).length > 0)
4640
+ agent.checkRuntimeSettingsPatch(patch);
4641
+ let plan;
4642
+ let settings;
4643
+ try {
4644
+ plan = agent.planRuntimeSettings(patch);
4645
+ settings = agent.getRuntimeSettings();
4646
+ }
4647
+ catch (error) {
4648
+ // A manager that refuses its queries gives no verdict, as a preview
4649
+ // that can't be computed gives none: the agent's own state still says
4650
+ // its budget and the patch's path, and whatever stopped the preview is
4651
+ // its reason. When the manager answers, anything else throws as before.
4652
+ if (agent.contextManagerAnswers())
4653
+ throw error;
4654
+ return { available: false, reason: previewStoppedReason(error), ...agent.runtimeBudgetPath(patch) };
4655
+ }
4217
4656
  const base = {
4218
4657
  available: false,
4219
4658
  path: plan.path,
@@ -4223,27 +4662,14 @@ export class AgentFramework {
4223
4662
  };
4224
4663
  const runPreview = (budgetTokens) => {
4225
4664
  try {
4226
- const result = this.previewContextSettings(agentName, budgetTokens, Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined);
4665
+ const result = this.previewContextFor(agent, budgetTokens, Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined);
4227
4666
  if (result === null || typeof result !== 'object') {
4228
4667
  return { reason: 'no_preview_support' };
4229
4668
  }
4230
4669
  return { result: result };
4231
4670
  }
4232
4671
  catch (error) {
4233
- const message = error instanceof Error ? error.message : String(error);
4234
- // Structured mapping of the strategy's known throw paths. Message
4235
- // matching is fragile by nature, so unknown messages surface verbatim
4236
- // as the reason rather than being swallowed or rethrown.
4237
- if (message.includes('requires reinitialization for the current branch generation')) {
4238
- return { reason: 'branch_generation_changed' };
4239
- }
4240
- if (message.includes('already running; previews must not overlap')) {
4241
- return { reason: 'preview_in_flight' };
4242
- }
4243
- if (message.includes('requires adaptiveResolution')) {
4244
- return { reason: 'no_adaptive_resolution' };
4245
- }
4246
- return { reason: message };
4672
+ return { reason: previewStoppedReason(error) };
4247
4673
  }
4248
4674
  };
4249
4675
  const effective = runPreview(plan.effectiveBudgetTokens);
@@ -4364,17 +4790,36 @@ export class AgentFramework {
4364
4790
  // Non-fatal — usage tracking is best-effort
4365
4791
  }
4366
4792
  }
4793
+ /** An agent's persisted record, or null when it has none; throws when the store can't be read. */
4367
4794
  readAgentRuntimeSettings(agentName) {
4368
- try {
4369
- const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
4370
- const stored = data?.agentRuntimeSettings?.[agentName];
4371
- return stored ? { ...stored } : null;
4372
- }
4373
- catch {
4374
- return null;
4375
- }
4795
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
4796
+ const stored = data?.agentRuntimeSettings?.[agentName];
4797
+ return stored ? { ...stored } : null;
4376
4798
  }
4377
4799
  validatePersistedAgentRuntimeSettings(agentName, overrides) {
4800
+ // A malformed number is dropped, with a line that names the value's kind,
4801
+ // never its content. It could never apply: a budget, tail or pace kept
4802
+ // unapplied would be reported and written back as it is, and a marker
4803
+ // that can't be read isn't trusted, so the budget restores paced.
4804
+ const numbers = [
4805
+ ['contextBudgetReachedTokens', 1],
4806
+ ['contextBudgetTokens', 1],
4807
+ ['tailTokens', 0],
4808
+ ['transitionPaceTokens', 1],
4809
+ ];
4810
+ for (const [key, least] of numbers) {
4811
+ const value = overrides[key];
4812
+ if (value === undefined || (Number.isSafeInteger(value) && value >= least))
4813
+ continue;
4814
+ const kind = typeof value === 'number' ? `a number that isn't a ${least > 0 ? 'positive' : 'non-negative'} safe integer`
4815
+ : value === null ? 'null'
4816
+ : Array.isArray(value) ? 'an array'
4817
+ : typeof value === 'object' ? 'an object'
4818
+ : `a ${typeof value}`;
4819
+ console.error(`[agent-settings] ignoring an invalid persisted ${key} for agent "${agentName}": ${kind}`);
4820
+ const { [key]: _ignored, ...rest } = overrides;
4821
+ overrides = rest;
4822
+ }
4378
4823
  if (overrides.sameRoundThinkTextPolicy !== undefined &&
4379
4824
  overrides.sameRoundThinkTextPolicy !== 'public' &&
4380
4825
  overrides.sameRoundThinkTextPolicy !== 'private') {
@@ -4420,6 +4865,142 @@ export class AgentFramework {
4420
4865
  state.toolResultInlineCaps = all;
4421
4866
  this.store.setStateJson(FRAMEWORK_STATE_ID, state);
4422
4867
  }
4868
+ /**
4869
+ * Apply an agent's persisted settings at boot, each on its own, the budget
4870
+ * last. A persisted setting never stops the agent booting, except a
4871
+ * think-text policy, which fails closed: an invalid one is refused before
4872
+ * this (validatePersistedAgentRuntimeSettings), and a valid one always
4873
+ * applies. A tail, pace or budget that can't be applied now (a tail or pace
4874
+ * on a strategy without live settings, a budget no longer above maxTokens,
4875
+ * a lower one restored paced on a strategy that can't prepare a smaller
4876
+ * window) is kept set but unapplied (Agent.keepRuntimeSettings), so a later
4877
+ * persisting write keeps it in the record.
4878
+ *
4879
+ * A budget below the recipe's restores as a paced descent from it, unless
4880
+ * the record carries its marker (`contextBudgetReachedTokens`, written when
4881
+ * a compile fit at it) and the preflight says it fits now: then it is
4882
+ * applied at once. The preflight plans the stored context as the context
4883
+ * manager will, with the restored tail and pace, so it sees what a restart
4884
+ * can change there: a new maxTokens (the response reserve) or strategy
4885
+ * configuration. It also sees a context that grew while the agent ran at
4886
+ * another budget after the record was written, through a persist: false
4887
+ * change. The system prompt, tools and injections are outside the context
4888
+ * manager's budget, so they don't enter it (context-manager #162). If a
4889
+ * compile at a budget applied at once this way is refused, the agent
4890
+ * restores it paced then (armed by Agent.restoreRuntimeSettings, run in
4891
+ * Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is
4892
+ * applied at once with or without the marker, as an increase always is.
4893
+ *
4894
+ * When a marked budget isn't applied at once or a setting can't be
4895
+ * applied, one line says why, what couldn't be applied, and the budget set
4896
+ * beside the one compiles plan at.
4897
+ */
4898
+ restorePersistedRuntimeSettings(agentName, agent, overrides) {
4899
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
4900
+ const { contextBudgetReachedTokens: reached, sameRoundThinkTextPolicy, ...settings } = overrides;
4901
+ if (sameRoundThinkTextPolicy !== undefined)
4902
+ agent.restoreRuntimeSettings({ sameRoundThinkTextPolicy });
4903
+ const unapplied = [];
4904
+ /** Restore one setting, or keep it set but unapplied; true if it applied. */
4905
+ const restore = (part) => {
4906
+ try {
4907
+ agent.restoreRuntimeSettings(part);
4908
+ return true;
4909
+ }
4910
+ catch (error) {
4911
+ agent.keepRuntimeSettings(part);
4912
+ unapplied.push(Object.entries(part).map(([key, value]) => `${key}=${value}`).join(', ') + ` (${messageOf(error)})`);
4913
+ return false;
4914
+ }
4915
+ };
4916
+ if (settings.tailTokens !== undefined)
4917
+ restore({ tailTokens: settings.tailTokens });
4918
+ if (settings.transitionPaceTokens !== undefined)
4919
+ restore({ transitionPaceTokens: settings.transitionPaceTokens });
4920
+ const budget = settings.contextBudgetTokens;
4921
+ let atOnce;
4922
+ let appliedAtOnce = false;
4923
+ if (budget !== undefined && reached === budget) {
4924
+ const patch = { contextBudgetTokens: budget, immediate: true };
4925
+ let why;
4926
+ try {
4927
+ const preview = this.previewRuntimeSettingsFor(agent, patch);
4928
+ if (!preview.available)
4929
+ why = `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`;
4930
+ else if (preview.effective?.fits !== true)
4931
+ why = `the folded floor renders ${preview.effective?.finalTokens} tokens against it now`;
4932
+ }
4933
+ catch (error) {
4934
+ why = `whether it fits now can't be checked (${messageOf(error)})`;
4935
+ }
4936
+ if (why === undefined) {
4937
+ try {
4938
+ agent.restoreRuntimeSettings(patch, { provenBudget: budget });
4939
+ appliedAtOnce = true;
4940
+ }
4941
+ catch (error) {
4942
+ why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`;
4943
+ }
4944
+ }
4945
+ if (why !== undefined)
4946
+ atOnce = `its record says a compile fit at contextBudgetTokens=${budget}, but ${why}`;
4947
+ }
4948
+ const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget });
4949
+ if (atOnce === undefined && unapplied.length === 0)
4950
+ return;
4951
+ const now = agent.getRuntimeSettings();
4952
+ const said = [];
4953
+ if (atOnce !== undefined) {
4954
+ said.push(budgetKept ? atOnce : `${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`);
4955
+ }
4956
+ if (unapplied.length > 0) {
4957
+ said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`);
4958
+ }
4959
+ console.error(`[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` +
4960
+ `and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`);
4961
+ }
4962
+ /**
4963
+ * What persists for an agent: its overrides, and `contextBudgetReachedTokens`
4964
+ * when the agent's proof stands for the overridden budget: a compile fit at
4965
+ * it, or the boot applied it at once on the record's proof, and the live
4966
+ * budget hasn't changed since (context-manager #162). A
4967
+ * change of budget, immediate or not, carries none until a compile at it
4968
+ * fits; arming or cancelling a descent leaves the proof as it was.
4969
+ */
4970
+ persistedRuntimeSettingsOf(agent) {
4971
+ const overrides = agent.getRuntimeSettingsOverrides();
4972
+ const proven = agent.getProvenContextBudgetTokens();
4973
+ return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens
4974
+ ? { ...overrides, contextBudgetReachedTokens: proven }
4975
+ : overrides;
4976
+ }
4977
+ /**
4978
+ * A compile at the agent's live budget came out differently from the last
4979
+ * (Agent.onContextBudgetCompiled). Keep the persisted record's
4980
+ * `contextBudgetReachedTokens` true to it: written when a compile fits at
4981
+ * the record's own budget, withdrawn when the context manager refuses that
4982
+ * budget. Nothing else in the record changes, and a budget other than the
4983
+ * record's (one applied with `persist: false`, or a descent's starting
4984
+ * budget) writes nothing. A record that can't be read, or written, is
4985
+ * logged, and the next compile at that budget tells it again.
4986
+ */
4987
+ recordCompiledContextBudget(agentName, budget, fit) {
4988
+ try {
4989
+ const persisted = this.readAgentRuntimeSettings(agentName);
4990
+ if (!persisted || persisted.contextBudgetTokens !== budget)
4991
+ return true;
4992
+ if ((persisted.contextBudgetReachedTokens === budget) === fit)
4993
+ return true;
4994
+ const { contextBudgetReachedTokens: _previous, ...settings } = persisted;
4995
+ this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings);
4996
+ return true;
4997
+ }
4998
+ catch (error) {
4999
+ // The next compile at this budget tells it again.
5000
+ console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error);
5001
+ return false;
5002
+ }
5003
+ }
4423
5004
  persistAgentRuntimeSettings(agentName, overrides) {
4424
5005
  const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
4425
5006
  const state = (data && typeof data === 'object' ? data : {});
@@ -4591,18 +5172,32 @@ export class AgentFramework {
4591
5172
  run.toolCallsCount += count;
4592
5173
  run.lastActivity = Date.now();
4593
5174
  }
5175
+ /**
5176
+ * A start dropped before its stream sends anything (its agent was replaced,
5177
+ * or stop() closed admission or ended the lifecycle the start began in)
5178
+ * ends the logical turn it was running. stop() ends every open turn
5179
+ * (openLogical), but a fresh start that awaited its batches' assembly
5180
+ * across a stop() reaches its routing after it and opens its turn. That
5181
+ * turn would then stay open, and the agent's first start after a restart
5182
+ * would continue the dropped turn's routing. The identity check leaves any
5183
+ * other turn open.
5184
+ */
5185
+ endDroppedTurn(agentName, routing) {
5186
+ if (this.openLogical.get(agentName) === routing)
5187
+ this.openLogical.delete(agentName);
5188
+ }
4594
5189
  /** A logical turn's end, for every agent: it closes the agent's open
4595
5190
  * logical turn when it is the settling stream's own (openLogical), and
4596
5191
  * settles an active ephemeral run. The run's tool-call count comes from
4597
5192
  * its own counter — the single source of truth, which unlike a
4598
5193
  * per-stream local survives budget restarts. */
4599
5194
  settleAgent(agentName, outcome,
4600
- /** The settling stream's logical turn, when that stream still owns the
4601
- * agent: it ends here, so the agent's next fresh start opens a new one.
4602
- * A settle from a stream a newer one replaced closes nothing. */
5195
+ /** The settling stream's logical turn (TurnRouting), when that stream
5196
+ * still owns the agent: it ends here, so the agent's next fresh start
5197
+ * opens a new one. A stream that no longer owns the agent passes none. */
4603
5198
  closes) {
4604
- // This identity check is the only guard against a stale settle closing
4605
- // a newer turn (no test reaches one); keep it when editing call sites.
5199
+ // The identity check keeps a settle that carries an earlier turn's
5200
+ // object from closing a newer turn; keep it when editing call sites.
4606
5201
  if (closes && this.openLogical.get(agentName) === closes)
4607
5202
  this.openLogical.delete(agentName);
4608
5203
  const run = this.ephemeralRuns.get(agentName);
@@ -4611,7 +5206,10 @@ export class AgentFramework {
4611
5206
  run.lastActivity = Date.now();
4612
5207
  if (outcome.stopReason === 'exhausted') {
4613
5208
  // The caller may log or relay the message, which can echo the whole request.
4614
- run.settle.reject(new Error(boundFailureText(outcome.error ?? 'Unknown error')));
5209
+ const message = boundFailureText(outcome.error ?? 'Unknown error');
5210
+ run.settle.reject(outcome.statedWaitMs === undefined
5211
+ ? new Error(message)
5212
+ : new ProviderWaitNotSleptError(message, outcome.statedWaitMs));
4615
5213
  return;
4616
5214
  }
4617
5215
  run.settle.resolve({
@@ -4626,6 +5224,9 @@ export class AgentFramework {
4626
5224
  * The agent is temporarily registered, inference is triggered, and the
4627
5225
  * framework drives the stream (emitting traces, logging, dispatching tools).
4628
5226
  * Returns the agent's speech output when it finishes (no more tool calls).
5227
+ * Rejects with ProviderWaitNotSleptError when the run ends rather than sleep
5228
+ * a retryable failure's stated wait, and with a plain Error for any other
5229
+ * ending.
4629
5230
  *
4630
5231
  * The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
4631
5232
  * The task message should already be in the context manager.
@@ -5150,7 +5751,7 @@ export class AgentFramework {
5150
5751
  const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
5151
5752
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
5152
5753
  if (!server) {
5153
- console.error(`[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`);
5754
+ console.error(`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`);
5154
5755
  return;
5155
5756
  }
5156
5757
  await server.sendToolsCall('add_reaction', {
@@ -5200,23 +5801,37 @@ export class AgentFramework {
5200
5801
  }
5201
5802
  if (idx < 0)
5202
5803
  return null;
5804
+ // The newest message can be the last shard of a body (a document stored
5805
+ // in shards). context-manager removes a shard only with the rest of its
5806
+ // body, so the rewind takes the whole body.
5807
+ const first = bodyRangeIn(all, idx).from;
5808
+ const members = all.slice(first, idx + 1);
5203
5809
  const msg = all[idx];
5204
5810
  const md = (msg.metadata ?? {});
5205
- const content = Array.isArray(msg.content) ? msg.content : [];
5811
+ const content = members.flatMap((m) => (Array.isArray(m.content) ? m.content : []));
5206
5812
  const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
5207
5813
  const images = content.filter((b) => typeOf(b) === 'image').length;
5208
5814
  const textLen = content
5209
5815
  .filter((b) => typeOf(b) === 'text')
5210
5816
  .reduce((n, b) => n + String(b.text ?? '').length, 0);
5211
- // Determine the complete exchange to remove.
5212
- const removedIds = [msg.id];
5817
+ // Determine the complete exchange to remove, newest first.
5818
+ const removedIds = members.map((m) => m.id).reverse();
5819
+ // Where the exchange starts: this body, or the tool_use turn paired with it.
5820
+ let from = first;
5213
5821
  let kind;
5214
5822
  let descriptor;
5215
5823
  if (toolBlocks.length > 0) {
5216
5824
  kind = 'tool';
5217
- // Pair the tool_result with the tool_use assistant turn right before it.
5218
- if (idx - 1 >= 0 && hasBlock(all[idx - 1], 'tool_use')) {
5219
- removedIds.push(all[idx - 1].id);
5825
+ // Pair the tool_result with the tool_use assistant turn right before
5826
+ // it, the whole of it: a turn stored as a body carries its tool_use on
5827
+ // its first shard, not its last.
5828
+ if (first - 1 >= 0) {
5829
+ const turn = bodyRangeIn(all, first - 1);
5830
+ const turnMembers = all.slice(turn.from, turn.to + 1);
5831
+ if (turnMembers.some((m) => hasBlock(m, 'tool_use'))) {
5832
+ from = turn.from;
5833
+ removedIds.push(...turnMembers.map((m) => m.id).reverse());
5834
+ }
5220
5835
  }
5221
5836
  const sz = toolBlocks.reduce((n, b) => n + String(b.content ?? '').length, 0);
5222
5837
  descriptor = `a tool exchange (~${Math.max(1, Math.round(sz / 1024))}KB` +
@@ -5235,8 +5850,8 @@ export class AgentFramework {
5235
5850
  const discordRef = md.messageId && md.channelId
5236
5851
  ? { channelId: String(md.channelId), messageId: String(md.messageId) }
5237
5852
  : undefined;
5238
- for (const id of removedIds)
5239
- cm.removeMessage(id);
5853
+ // One removal for the whole exchange, so it is never half applied.
5854
+ removeStoredMessages(cm, all[from], msg);
5240
5855
  return { kind, descriptor, removedIds, discordRef };
5241
5856
  }
5242
5857
  /**
@@ -5408,7 +6023,11 @@ export class AgentFramework {
5408
6023
  * stated retry-after, kept across restarts) for one `model` or all, as an
5409
6024
  * explicit, recorded operator override.
5410
6025
  */
5411
- async handleHostCommand(serverId, params) {
6026
+ handleHostCommand(serverId, params) {
6027
+ return this.trackOperatorOperation(`host/command:${String(params.command)}`, () => this.runHostCommand(serverId, params));
6028
+ }
6029
+ /** handleHostCommand's work, run where stop() can wait for it. */
6030
+ async runHostCommand(serverId, params) {
5412
6031
  if (params.command !== 'undo' &&
5413
6032
  params.command !== 'hide' &&
5414
6033
  params.command !== 'unstick' &&
@@ -5531,26 +6150,80 @@ export class AgentFramework {
5531
6150
  if (agent.state.status !== 'idle') {
5532
6151
  return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
5533
6152
  }
6153
+ // A surgery switches or forks the branch while it holds the store, so
6154
+ // a removal now would land on the branch it's leaving, or meet a
6155
+ // manager mid-switch: refused as busy, as the other operator calls are.
6156
+ if (this.surgeryHold) {
6157
+ return {
6158
+ ok: false,
6159
+ error: `Cannot hide: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
6160
+ };
6161
+ }
5534
6162
  if (!params.fromMessageId) {
5535
6163
  return { ok: false, error: 'hide: fromMessageId is required' };
5536
6164
  }
5537
6165
  const cm = agent.getContextManager();
5538
6166
  const all = cm.getAllMessages();
5539
- const byDiscordId = (did) => all.findIndex((m) => String(m.metadata?.messageId) === String(did));
5540
- // Collect Discord refs of every message in [lo, hi] that carries one,
6167
+ // Only a message that carries a Discord id matches: a missing one
6168
+ // would otherwise read as the text "undefined".
6169
+ const discordIdOf = (m) => {
6170
+ const id = m.metadata?.messageId;
6171
+ return id === undefined || id === null || id === '' ? undefined : String(id);
6172
+ };
6173
+ const byDiscordId = (did) => all.findIndex((m) => discordIdOf(m) === String(did));
6174
+ /** Runs in stored order, those that overlap or touch made one. */
6175
+ const merged = (runs) => {
6176
+ const out = [];
6177
+ for (const { from, to } of [...runs].sort((a, b) => a.from - b.from)) {
6178
+ const last = out[out.length - 1];
6179
+ if (last && from <= last.to + 1)
6180
+ last.to = Math.max(last.to, to);
6181
+ else
6182
+ out.push({ from, to });
6183
+ }
6184
+ return out;
6185
+ };
6186
+ // A message stored as a body (a long one, in shards) goes whole, and so
6187
+ // does every copy of it: a write cut short leaves a partial body before
6188
+ // its retry, both carrying the Discord id.
6189
+ const copiesOf = (did) => {
6190
+ const runs = [];
6191
+ all.forEach((m, i) => { if (discordIdOf(m) === String(did))
6192
+ runs.push(bodyRangeIn(all, i)); });
6193
+ return runs;
6194
+ };
6195
+ // Collect Discord refs of every message in the runs that carries one,
5541
6196
  // so the surface can mark them. channelId here is whatever the ingest
5542
6197
  // path stored (raw id or "discord:guild:channel"); the surface
5543
6198
  // normalizes it.
5544
- const refsIn = (lo, hi) => {
6199
+ // Each Discord message once, though every shard of a body carries it.
6200
+ const refsIn = (runs) => {
5545
6201
  const refs = [];
5546
- for (let i = lo; i <= hi; i++) {
5547
- const md = all[i].metadata;
5548
- if (md?.messageId && md?.channelId) {
6202
+ const seen = new Set();
6203
+ for (const { from, to } of runs) {
6204
+ for (let i = from; i <= to; i++) {
6205
+ const md = all[i].metadata;
6206
+ if (!md?.messageId || !md?.channelId)
6207
+ continue;
6208
+ const key = `${String(md.channelId)}\u0000${String(md.messageId)}`;
6209
+ if (seen.has(key))
6210
+ continue;
6211
+ seen.add(key);
5549
6212
  refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
5550
6213
  }
5551
6214
  }
5552
6215
  return refs;
5553
6216
  };
6217
+ // How many stored messages the runs hold, a body (a long message, in
6218
+ // shards) counting once.
6219
+ const messagesIn = (runs) => {
6220
+ let count = 0;
6221
+ for (const { from, to } of runs) {
6222
+ for (let i = from; i <= to; i = bodyRangeIn(all, i).to + 1)
6223
+ count++;
6224
+ }
6225
+ return count;
6226
+ };
5554
6227
  const fromIdx = byDiscordId(params.fromMessageId);
5555
6228
  if (fromIdx < 0) {
5556
6229
  return {
@@ -5564,11 +6237,24 @@ export class AgentFramework {
5564
6237
  if (toIdx < 0) {
5565
6238
  return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
5566
6239
  }
5567
- const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
5568
- const refs = refsIn(lo, hi);
5569
- const rangeIds = all.slice(lo, hi + 1).map((m) => String(m.id));
5570
- cm.removeMessages(all[lo].id, all[hi].id);
5571
- console.error(`[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
6240
+ // What's between the two messages, after the earlier one began and
6241
+ // before the later one began: from the earlier one's first stored
6242
+ // copy through the later one's first, each whole. Each end is every
6243
+ // copy of it besides, as the single form takes, so a copy stored
6244
+ // outside that span (a write cut short and retried later) is taken
6245
+ // on its own, and nothing else stored after the later one began is
6246
+ // taken. Offline recovery's range is the same.
6247
+ const span = {
6248
+ from: bodyRangeIn(all, Math.min(fromIdx, toIdx)).from,
6249
+ to: bodyRangeIn(all, Math.max(fromIdx, toIdx)).to,
6250
+ };
6251
+ const runs = merged([span, ...copiesOf(params.fromMessageId), ...copiesOf(params.toMessageId)]);
6252
+ const refs = refsIn(runs);
6253
+ const hidden = messagesIn(runs);
6254
+ const rangeIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
6255
+ for (const { from, to } of runs)
6256
+ removeStoredMessages(cm, all[from], all[to]);
6257
+ console.error(`[host-command] hide agent=${agentName} range removed=${hidden} ` +
5572
6258
  `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
5573
6259
  const loggedRange = capIds(rangeIds);
5574
6260
  this.recordOperatorAction({
@@ -5578,33 +6264,44 @@ export class AgentFramework {
5578
6264
  params: { fromMessageId: params.fromMessageId, toMessageId: params.toMessageId },
5579
6265
  result: {
5580
6266
  branch: this.store.currentBranch().name,
5581
- hidden: hi - lo + 1,
6267
+ hidden,
5582
6268
  removedIds: loggedRange.ids,
5583
6269
  ...(loggedRange.truncated ? { removedIdsTruncated: true } : {}),
5584
6270
  },
5585
6271
  });
5586
6272
  return {
5587
6273
  ok: true,
5588
- hidden: hi - lo + 1,
6274
+ hidden,
5589
6275
  hiddenRefs: refs,
5590
6276
  lastVisible: await this.lastVisiblePreview(agentName),
5591
6277
  };
5592
6278
  }
5593
- const refs = refsIn(fromIdx, fromIdx);
5594
- const hiddenId = String(all[fromIdx].id);
5595
- cm.removeMessage(all[fromIdx].id);
5596
- console.error(`[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
6279
+ const runs = merged(copiesOf(params.fromMessageId));
6280
+ const refs = refsIn(runs);
6281
+ const hidden = messagesIn(runs);
6282
+ const hiddenIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
6283
+ for (const { from, to } of runs)
6284
+ removeStoredMessages(cm, all[from], all[to]);
6285
+ console.error(`[host-command] hide agent=${agentName} removed=${hidden} (${params.fromMessageId}) ` +
5597
6286
  `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
6287
+ // Every shard of every copy goes, so the ids are bounded as the
6288
+ // range form's are.
6289
+ const loggedIds = capIds(hiddenIds);
5598
6290
  this.recordOperatorAction({
5599
6291
  kind: 'hide',
5600
6292
  agent: agentName,
5601
6293
  requester: hostCommandRequester(serverId, params),
5602
6294
  params: { fromMessageId: params.fromMessageId },
5603
- result: { branch: this.store.currentBranch().name, hidden: 1, removedIds: [hiddenId] },
6295
+ result: {
6296
+ branch: this.store.currentBranch().name,
6297
+ hidden,
6298
+ removedIds: loggedIds.ids,
6299
+ ...(loggedIds.truncated ? { removedIdsTruncated: true } : {}),
6300
+ },
5604
6301
  });
5605
6302
  return {
5606
6303
  ok: true,
5607
- hidden: 1,
6304
+ hidden,
5608
6305
  hiddenRefs: refs,
5609
6306
  lastVisible: await this.lastVisiblePreview(agentName),
5610
6307
  };
@@ -5658,7 +6355,7 @@ export class AgentFramework {
5658
6355
  let undone = 0;
5659
6356
  try {
5660
6357
  for (let i = 0; i < requested; i++) {
5661
- const r = this.undoLastTurn(agentName, hostCommandRequester(serverId, params));
6358
+ const r = this.runUndoLastTurn(agentName, hostCommandRequester(serverId, params));
5662
6359
  if (!r.undone)
5663
6360
  break;
5664
6361
  undone++;
@@ -5669,7 +6366,7 @@ export class AgentFramework {
5669
6366
  // including any turns already undone before the failure.
5670
6367
  const msg = error instanceof Error ? error.message : String(error);
5671
6368
  if (undone === 0)
5672
- return { ok: false, error: msg };
6369
+ return { ok: false, error: msg, ...(error instanceof OperatorActionError ? { code: error.code } : {}) };
5673
6370
  console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
5674
6371
  }
5675
6372
  console.error(`[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
@@ -5938,8 +6635,45 @@ export class AgentFramework {
5938
6635
  return result.entries;
5939
6636
  }
5940
6637
  // ==========================================================================
5941
- // Undo / Redo
6638
+ // Branch switch, Undo / Redo
5942
6639
  // ==========================================================================
6640
+ /**
6641
+ * Switch the store to an existing branch, start the delivery of the
6642
+ * Discord awareness marks the move changes (startDiscordAwarenessDelivery),
6643
+ * and re-materialize the config mount from it. Resolves once the mount has
6644
+ * run; if it fails, it rejects with the store already moved. The marks'
6645
+ * delivery isn't awaited, and its failure doesn't fail the switch. The
6646
+ * store has moved by the time the call returns, before its promise
6647
+ * settles, and a refusal moves nothing. An operator's operation: refused
6648
+ * once stop() has begun, and waited for by stop() while it runs.
6649
+ *
6650
+ * The branch moves under every agent sharing the store, so the switch
6651
+ * reserves the store as a surgery does: every agent must be idle, or it is
6652
+ * refused (OperatorActionError 'agent-busy'), and no turn starts until the
6653
+ * switch is done.
6654
+ */
6655
+ async switchBranch(name) {
6656
+ this.refuseOperatorAfterStop('switch branch');
6657
+ const release = this.reserveStoreForSurgery('switch branch', 'every agent');
6658
+ try {
6659
+ // A maintenance tick already running finishes before the switch.
6660
+ if (this.maintenanceRunning())
6661
+ await this.settleMaintenancePass();
6662
+ this.store.switchBranch(name);
6663
+ // Reversible Discord awareness markers are part of the branch
6664
+ // projection. Their delivery starts behind the data-plane gate, which
6665
+ // goes up before the store is released, and the switch doesn't wait
6666
+ // for Discord.
6667
+ this.startDiscordAwarenessDelivery('branch switch');
6668
+ const ws = this.moduleRegistry.getModule('workspace');
6669
+ if (ws && 'materializeMount' in ws) {
6670
+ await ws.materializeMount('_config');
6671
+ }
6672
+ }
6673
+ finally {
6674
+ release();
6675
+ }
6676
+ }
5943
6677
  /**
5944
6678
  * Undo the last inference turn for an agent.
5945
6679
  *
@@ -5951,6 +6685,12 @@ export class AgentFramework {
5951
6685
  * Returns the checkpoint that was undone, or null if nothing to undo.
5952
6686
  */
5953
6687
  undoLastTurn(agentName, requester) {
6688
+ this.refuseOperatorAfterStop('undo');
6689
+ return this.runUndoLastTurn(agentName, requester);
6690
+ }
6691
+ /** undoLastTurn's work, for a host/command undo already admitted: a stop()
6692
+ * begun by a trace of one of its turns doesn't refuse the next. */
6693
+ runUndoLastTurn(agentName, requester) {
5954
6694
  const agent = this.agents.get(agentName);
5955
6695
  if (!agent) {
5956
6696
  throw new Error(`Unknown agent: ${agentName}`);
@@ -5958,6 +6698,10 @@ export class AgentFramework {
5958
6698
  if (agent.state.status !== 'idle') {
5959
6699
  throw new Error(`Cannot undo while agent is ${agent.state.status}`);
5960
6700
  }
6701
+ // A surgery or a branch switch holds the store while it awaits its steps.
6702
+ if (this.surgeryHold) {
6703
+ throw new OperatorActionError('agent-busy', `Cannot undo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
6704
+ }
5961
6705
  const checkpoints = this.getTurnCheckpoints(agentName);
5962
6706
  if (checkpoints.length === 0) {
5963
6707
  return { undone: false };
@@ -5968,11 +6712,7 @@ export class AgentFramework {
5968
6712
  const undoBranchName = `undo/${agentName}/${checkpoint.turnIndex}-${Date.now()}`;
5969
6713
  this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
5970
6714
  this.store.switchBranch(undoBranchName);
5971
- // Materialize config files from the new branch (fire-and-forget; gate picks up via mtime)
5972
- const wsUndo = this.moduleRegistry.getModule('workspace');
5973
- if (wsUndo && 'materializeMount' in wsUndo) {
5974
- wsUndo.materializeMount('_config').catch(() => { });
5975
- }
6715
+ this.materializeConfigMountAfterBranchSwitch();
5976
6716
  // Push onto redo stack
5977
6717
  let redoStack = this.redoStacks.get(agentName);
5978
6718
  if (!redoStack) {
@@ -6008,6 +6748,7 @@ export class AgentFramework {
6008
6748
  * Returns false if there's nothing to redo.
6009
6749
  */
6010
6750
  redo(agentName, requester) {
6751
+ this.refuseOperatorAfterStop('redo');
6011
6752
  const agent = this.agents.get(agentName);
6012
6753
  if (!agent) {
6013
6754
  throw new Error(`Unknown agent: ${agentName}`);
@@ -6015,6 +6756,10 @@ export class AgentFramework {
6015
6756
  if (agent.state.status !== 'idle') {
6016
6757
  throw new Error(`Cannot redo while agent is ${agent.state.status}`);
6017
6758
  }
6759
+ // A surgery or a branch switch holds the store while it awaits its steps.
6760
+ if (this.surgeryHold) {
6761
+ throw new OperatorActionError('agent-busy', `Cannot redo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
6762
+ }
6018
6763
  const redoStack = this.redoStacks.get(agentName);
6019
6764
  if (!redoStack || redoStack.length === 0) {
6020
6765
  return { redone: false };
@@ -6022,11 +6767,7 @@ export class AgentFramework {
6022
6767
  const { branchName, checkpoint } = redoStack.pop();
6023
6768
  const currentBranch = this.store.currentBranch();
6024
6769
  this.store.switchBranch(branchName);
6025
- // Materialize config files from the restored branch (fire-and-forget; gate picks up via mtime)
6026
- const wsRedo = this.moduleRegistry.getModule('workspace');
6027
- if (wsRedo && 'materializeMount' in wsRedo) {
6028
- wsRedo.materializeMount('_config').catch(() => { });
6029
- }
6770
+ this.materializeConfigMountAfterBranchSwitch();
6030
6771
  // Restore the checkpoint
6031
6772
  const checkpoints = this.getTurnCheckpoints(agentName);
6032
6773
  checkpoints.push(checkpoint);
@@ -6138,11 +6879,22 @@ export class AgentFramework {
6138
6879
  // arrivals during the awaited switch (see surgeryHold).
6139
6880
  const hold = { verb, agentName, since: Date.now() };
6140
6881
  this.surgeryHold = hold;
6882
+ // Maintenance calls parked at a provider gate wake, find the hold and are
6883
+ // withdrawn, so the wait for running maintenance can't wait on them.
6884
+ for (const gate of this.providerGates.values())
6885
+ for (const resolve of gate.auxiliaryWaiters.splice(0))
6886
+ resolve();
6887
+ // stop() waits for the release (see stopOwned): a surgery admitted before
6888
+ // stop() began finishes, flush included, before the store closes.
6889
+ let settle;
6890
+ void this.trackOperatorOperation(`surgery:${verb}`, () => new Promise((resolve) => { settle = resolve; }));
6141
6891
  let released = false;
6142
6892
  return () => {
6143
6893
  if (released)
6144
6894
  return;
6145
6895
  released = true;
6896
+ // The release runs to its end before stop() can go on.
6897
+ settle();
6146
6898
  if (this.surgeryHold === hold)
6147
6899
  this.surgeryHold = null;
6148
6900
  for (const [name, token] of reserved) {
@@ -6205,6 +6957,7 @@ export class AgentFramework {
6205
6957
  * agent must be idle; nothing is queued.
6206
6958
  */
6207
6959
  async rollbackToMessage(agentName, opts) {
6960
+ this.refuseOperatorAfterStop('roll back');
6208
6961
  const logBase = {
6209
6962
  kind: 'rollback',
6210
6963
  agent: agentName,
@@ -6247,6 +7000,9 @@ export class AgentFramework {
6247
7000
  let branchName;
6248
7001
  let markers;
6249
7002
  try {
7003
+ // A maintenance tick already running finishes before the switch.
7004
+ if (this.maintenanceRunning())
7005
+ await this.settleMaintenancePass();
6250
7006
  // Prepare the external side effect before switching Chronicle. If the
6251
7007
  // process dies after the switch but before activate(), startup
6252
7008
  // promotes this batch by matching targetBranch to the active branch.
@@ -6333,6 +7089,7 @@ export class AgentFramework {
6333
7089
  * entered if that matters.
6334
7090
  */
6335
7091
  async suppressMessages(agentName, opts) {
7092
+ this.refuseOperatorAfterStop('suppress');
6336
7093
  const requestedIds = [...new Set(opts.messageIds.map(String))];
6337
7094
  const logBase = {
6338
7095
  kind: 'suppress',
@@ -6384,6 +7141,9 @@ export class AgentFramework {
6384
7141
  let createdBranch;
6385
7142
  let markers;
6386
7143
  try {
7144
+ // A maintenance tick already running finishes before the fork.
7145
+ if (this.maintenanceRunning())
7146
+ await this.settleMaintenancePass();
6387
7147
  const markerBatch = this.discordAwarenessOutbox?.prepare({
6388
7148
  agentName,
6389
7149
  sourceBranch,
@@ -6539,9 +7299,9 @@ export class AgentFramework {
6539
7299
  * undo or redo) without the caller awaiting it. No operator response waits
6540
7300
  * for Discord to accept marks: the MCPL data-plane gate alone holds channel
6541
7301
  * traffic that arrives after it until they are attempted.
6542
- * `syncDiscordAwarenessMarkers` installs that gate synchronously, before
6543
- * its first await, so calling this while a surgery still holds the store
6544
- * puts the gate up before the store is released. With no drain running, the
7302
+ * The sync's body installs that gate synchronously, before its first
7303
+ * await, so calling this while a surgery still holds the store puts the
7304
+ * gate up before the store is released. With no drain running, the
6545
7305
  * drain starts in the same synchronous run (the first reaction request can
6546
7306
  * be written before this returns); with one running, this generation's pass
6547
7307
  * follows it (drainForGeneration). Either way delivery continues
@@ -6552,10 +7312,20 @@ export class AgentFramework {
6552
7312
  * (queued when a surgery began, or arriving during its switch or fork): the
6553
7313
  * surgery's store reservation, which used to hold such a turn through the
6554
7314
  * drain, now ends once the gate is up. A failure is accounted by the gate
6555
- * itself: the current generation's failure recycles the connections, and a
6556
- * superseded one leaves that to the newer generation, whose own pass meets
6557
- * the same ledger. Here it is logged and raised as an ops alert, and the
6558
- * next sync, reconnect or start delivers what is still pending.
7315
+ * itself: the current generation's failure recycles the connections (or,
7316
+ * once the host is stopping, releases the gate instead), and a superseded
7317
+ * one leaves that to the newer generation, whose own pass meets the same
7318
+ * ledger. Here it is logged and raised as an ops alert, and the next sync,
7319
+ * reconnect or start delivers what is still pending.
7320
+ *
7321
+ * Like a server's drain, the delivery runs apart from the operation that
7322
+ * started it: it isn't an operator's operation, so stop() doesn't wait for
7323
+ * it. A drain already running when stop() begins runs on while the
7324
+ * connections close, and what it can't deliver stays pending in the
7325
+ * durable outbox. Once stop() has begun, none is started, since there's
7326
+ * no point opening a drain that the teardown is about to cut off: the
7327
+ * marks stay pending, and the next start's barrier reconciles the outbox
7328
+ * with the branch and delivers them.
6559
7329
  *
6560
7330
  * @param after What moved the branch, for the log and the alert
6561
7331
  * ("rollback", "branch switch", "undo" …).
@@ -6565,20 +7335,25 @@ export class AgentFramework {
6565
7335
  startDiscordAwarenessDelivery(after, agentName = 'framework') {
6566
7336
  if (!this.discordAwarenessOutbox)
6567
7337
  return;
6568
- this.syncDiscordAwarenessMarkers().catch((error) => {
7338
+ if (this.stopHasBegun()) {
7339
+ console.error(`[discord-awareness] delivery after ${after} agent=${agentName} not started: ` +
7340
+ 'the framework is stopping; the next start delivers what is pending');
7341
+ return;
7342
+ }
7343
+ this.runDiscordAwarenessSync().catch((error) => {
6569
7344
  const detail = error instanceof Error ? error.message : String(error);
6570
7345
  console.error(`[discord-awareness] delivery after ${after} agent=${agentName} failed: ${detail}`);
6571
7346
  this.opsAlert('discord-awareness-delivery', agentName, `Awareness mark delivery after ${after} failed: ${detail}`);
6572
7347
  });
6573
7348
  }
6574
- /** Re-materialize config files from the (new) active branch — fire and
6575
- * forget; the gate picks changes up via mtime. Mirrors undoLastTurn. */
7349
+ /** Start config file IO without holding the caller or a surgery's store
7350
+ * reservation. The gate picks it up by mtime; stop() still waits for it. */
6576
7351
  materializeConfigMountAfterBranchSwitch() {
6577
7352
  const ws = this.moduleRegistry.getModule('workspace');
6578
7353
  if (ws && 'materializeMount' in ws) {
6579
- ws
6580
- .materializeMount('_config')
6581
- .catch(() => { });
7354
+ void this.trackOperatorOperation('config:materialize', () => ws.materializeMount('_config')).catch((error) => {
7355
+ console.error('[workspace] re-materializing the config mount after a branch switch failed:', error instanceof Error ? error.message : error);
7356
+ });
6582
7357
  }
6583
7358
  }
6584
7359
  /**
@@ -6737,10 +7512,18 @@ export class AgentFramework {
6737
7512
  this.adoptToolResultGuard(agent);
6738
7513
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
6739
7514
  this.sharedSlotAgents.add(agent);
6740
- const restoredSettings = this.readAgentRuntimeSettings(config.name);
7515
+ let restoredSettings = null;
7516
+ try {
7517
+ restoredSettings = this.readAgentRuntimeSettings(config.name);
7518
+ }
7519
+ catch (error) {
7520
+ console.error(`[agent-settings] ${config.name}: its persisted settings can't be read ` +
7521
+ `(${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`);
7522
+ }
6741
7523
  if (restoredSettings) {
6742
- agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
7524
+ this.restorePersistedRuntimeSettings(config.name, agent, this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
6743
7525
  }
7526
+ agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit));
6744
7527
  this.agents.set(config.name, agent);
6745
7528
  this.agentConfigs.set(config.name, config);
6746
7529
  // First non-ephemeral agent becomes the primary for message routing
@@ -6979,11 +7762,17 @@ export class AgentFramework {
6979
7762
  // queued, they flush at the target's own next boundary — with
6980
7763
  // injection — or in driveStream's finally when its turn ends.
6981
7764
  const midTurnInjections = [];
7765
+ // The injected messages' ids, for a boundary that leaves them to the
7766
+ // turn's next start as arrivals.
7767
+ const injectedIds = new Map();
6982
7768
  {
6983
7769
  const deferred = this.drainDeferredFor(agent.name);
6984
7770
  if (deferred.length > 0) {
6985
7771
  for (const msg of deferred) {
6986
- agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
7772
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
7773
+ // Every other resident reads what this boundary stores; for its
7774
+ // own agent, the boundary weighs what it injects below.
7775
+ this.noteForOtherResidents(agent, msg.metadata, id);
6987
7776
  // Injection guards: tool blocks would corrupt the tool-cycle
6988
7777
  // structure the membrane enforces, and a message named as the
6989
7778
  // agent itself would render as an ASSISTANT turn on the wire
@@ -6991,11 +7780,13 @@ export class AgentFramework {
6991
7780
  // messages stay window-only — visible next turn.
6992
7781
  const hasToolBlocks = msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
6993
7782
  if (!hasToolBlocks && msg.participant !== agent.name) {
6994
- midTurnInjections.push({
7783
+ const injection = {
6995
7784
  participant: msg.participant,
6996
7785
  content: msg.content,
6997
7786
  ...(msg.metadata ? { metadata: msg.metadata } : {}),
6998
- });
7787
+ };
7788
+ midTurnInjections.push(injection);
7789
+ injectedIds.set(injection, id);
6999
7790
  }
7000
7791
  }
7001
7792
  }
@@ -7033,13 +7824,18 @@ export class AgentFramework {
7033
7824
  // under budget instead of dispatching a doomed request.
7034
7825
  // Projection terms: prior round's real input + prior round's
7035
7826
  // OUTPUT (the just-generated thinking/text/tool_use joins the next
7036
- // round's input — omitting it is a systematic underestimate) +
7037
- // blocks about to be appended + reserve for the next response.
7827
+ // round's input — omitting it is a systematic underestimate) + the
7828
+ // growth since that round, when calls after it reported no prompt
7829
+ // (lastStreamUnreportedTokens) + blocks about to be appended +
7830
+ // reserve for the next response. It runs once a call on this
7831
+ // stream has reported a prompt: before then there is no figure to
7832
+ // project from.
7038
7833
  const projectedRealTokens = currentState.stream
7039
7834
  && agent.physicalWindowTokens !== undefined
7040
7835
  && agent.lastStreamRealInputTokens > 0
7041
7836
  ? agent.lastStreamRealInputTokens
7042
7837
  + agent.lastStreamOutputTokens
7838
+ + agent.lastStreamUnreportedTokens
7043
7839
  + estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
7044
7840
  + agent.maxTokens
7045
7841
  : 0;
@@ -7068,13 +7864,18 @@ export class AgentFramework {
7068
7864
  // classifier-safe D-class from the 2026-07-24 ablation). It also
7069
7865
  // updates lastAnnouncedLocus so the next turn's announce-on-change
7070
7866
  // diffs against what the agent was actually last told.
7071
- if ((agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
7072
- !shouldEndTurn && !overBudget && currentState.stream) {
7867
+ // The turn's own mode: a silent turn routes no prose, whatever the
7868
+ // agent's mode, so its boundary neither re-pins nor notes. The
7869
+ // trigger is this stream's: its start holds the agent's turn token
7870
+ // while the agent waits on this round's tools.
7871
+ const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
7872
+ && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
7873
+ if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
7073
7874
  // Two signals qualify an injection to move the pin (n=6 + n=7):
7074
7875
  // - chat:addressed — someone explicitly spoke TO the agent
7075
7876
  // (mention / reply / DM);
7076
7877
  // - a conversational follow-up in a channel the agent itself
7077
- // explicitly sent into THIS turn (turnEngagedChannels) —
7878
+ // explicitly sent into THIS turn (TurnRouting.engaged) —
7078
7879
  // conversation the agent just engaged continues without
7079
7880
  // re-mentioning it (2026-07-31: q's #portables reply, no @,
7080
7881
  // answered in prose that followed the stale pin into
@@ -7085,46 +7886,28 @@ export class AgentFramework {
7085
7886
  // Reactions and system markers are already excluded by
7086
7887
  // isConversationalInjection, and the engaged-this-turn scope
7087
7888
  // keeps unrelated channels from moving the pin.
7088
- const engaged = this.turnEngagedChannels.get(agent.name);
7089
- const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
7090
- const m = inj.metadata;
7091
- if (!isConversationalInjection(inj.metadata))
7092
- return false;
7093
- if (typeof m?.channelId !== 'string')
7094
- return false;
7095
- const tags = m.tags;
7096
- if (isAddressedMessage(tags, m))
7097
- return true;
7098
- return engaged?.has(m.channelId) === true;
7099
- });
7100
- const newLocus = lastQualifying?.metadata
7101
- ?.channelId;
7102
- if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
7103
- const prevPin = this.turnLocusPins.get(agent.name) ?? null;
7104
- this.turnLocusPins.set(agent.name, newLocus);
7105
- this.lastAnnouncedLocus.set(agent.name, newLocus);
7106
- const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
7107
- const shown = label && label !== newLocus
7108
- ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
7109
- : newLocus;
7110
- const noticeContent = [{
7111
- type: 'text',
7112
- text: `[routing] The conversation moved to ${shown} — your plain ` +
7113
- 'speech now lands there for the rest of this turn. Other ' +
7114
- 'channels need an explicit send tool.',
7115
- }];
7116
- const noticeMeta = { system: true, kind: 'routing-notice' };
7117
- try {
7118
- const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
7119
- this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
7120
- midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
7121
- console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
7122
- }
7123
- catch (err) {
7124
- console.error('mid-turn locus re-pin: failed to record routing notice:', err);
7125
- }
7889
+ // The last injection that qualifies wins (repinChannel).
7890
+ const routing = this.routingOf(agent.name);
7891
+ let newLocus;
7892
+ for (const inj of midTurnInjections)
7893
+ newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
7894
+ if (newLocus) {
7895
+ // What this boundary just stored is newer than any arrival the
7896
+ // turn holds, so the arrivals can't move the locus back.
7897
+ routing.arrivals = [];
7898
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
7899
+ if (notice)
7900
+ midTurnInjections.push(notice);
7126
7901
  }
7127
7902
  }
7903
+ else if (routesLocus && !shouldEndTurn) {
7904
+ // A boundary that can't re-pin now (over budget, or no live
7905
+ // stream to tell) leaves its injections to the turn's next start,
7906
+ // as arrivals: a budget restart, or a re-inference on these tool
7907
+ // results.
7908
+ for (const inj of midTurnInjections)
7909
+ this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj));
7910
+ }
7128
7911
  if (shouldEndTurn) {
7129
7912
  // endTurn: messages already stored above, cancel stream, reset to idle.
7130
7913
  // The batch is never submitted, so nothing can refuse it: admit
@@ -7140,7 +7923,7 @@ export class AgentFramework {
7140
7923
  // agent must be wakeable the moment it goes idle. (Idempotent;
7141
7924
  // driveStream's finally is the backstop.)
7142
7925
  this.eventGate?.onInferenceEnded(agent.name);
7143
- this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
7926
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name));
7144
7927
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
7145
7928
  }
7146
7929
  else if (overBudget || overPhysical) {
@@ -7186,6 +7969,12 @@ export class AgentFramework {
7186
7969
  });
7187
7970
  }
7188
7971
  else if (currentState.stream) {
7972
+ // What this round appends is in the next call's prompt. If that
7973
+ // call reports no prompt, the next boundary projects it from the
7974
+ // growth since the last call that did.
7975
+ if (agent.physicalWindowTokens !== undefined) {
7976
+ agent.lastStreamUnreportedTokens += estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections);
7977
+ }
7189
7978
  // Streaming path: convert results and resume the stream.
7190
7979
  // Mid-turn messages collected above ride along as injected user
7191
7980
  // messages (membrane ≥0.5.72) — appended after the tool_result
@@ -7325,7 +8114,7 @@ export class AgentFramework {
7325
8114
  // Same backstop as the push lane: no row, no wake, no fork spawned for a
7326
8115
  // message with nothing visible in it.
7327
8116
  if (isVisiblyEmptyContent(event.content)) {
7328
- console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
8117
+ console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${logValue(event.channelId)} messageId=${logValue(event.messageId)} reason=empty-content`);
7329
8118
  this.emitTrace({
7330
8119
  type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
7331
8120
  channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
@@ -7490,7 +8279,7 @@ export class AgentFramework {
7490
8279
  }
7491
8280
  catch (error) {
7492
8281
  const err = error instanceof Error ? error : new Error(String(error));
7493
- console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
8282
+ console.error(`Failed to spawn conversation agent for ${logValue(event.channelId)}:`, err);
7494
8283
  this.emitTrace({
7495
8284
  type: 'mcpl:conversation-spawn-failed',
7496
8285
  channelId: event.channelId,
@@ -7540,9 +8329,10 @@ export class AgentFramework {
7540
8329
  /**
7541
8330
  * Spawn a persistent per-channel conversation agent ("fork") from the
7542
8331
  * template agent: own ContextManager under `conversations/{name}`, seeded
7543
- * with a copy of the template's compiled context (the SubagentModule
7544
- * wholesale-copy pattern), registered in the event loop like any agent —
7545
- * but never primary.
8332
+ * with the template's compiled context without its thinking or its unpaired
8333
+ * tool blocks (the SubagentModule wholesale-copy pattern; see the seeding
8334
+ * comment below), registered in the event loop like any agent — but never
8335
+ * primary.
7546
8336
  */
7547
8337
  async createConversationAgent(name, channelId) {
7548
8338
  const router = this.conversationRouter;
@@ -7574,13 +8364,53 @@ export class AgentFramework {
7574
8364
  // persisted precisely so names aren't reused, but if this namespace has
7575
8365
  // history anyway (counter state lost, crash between spawn and persist),
7576
8366
  // seeding again would stack another template copy on top of it.
7577
- const { messages: existing } = await contextManager.compile();
8367
+ //
8368
+ // Both compiles are dry runs: neither is sent, and the template's runs
8369
+ // while the template may be mid-stream, where a committing compile
8370
+ // would commit fold resolutions and queue the compression its fold plan
8371
+ // calls for (and, once context-manager's thinking binding ships, fence
8372
+ // its stream's replies). In context-manager 0.14 a dry run leaves
8373
+ // nothing else behind but the release of an expired compression hold
8374
+ // (see Agent.compileWithInjections), so the template's request in
8375
+ // flight keeps its calibration estimate.
8376
+ // The template's compile runs at context-manager's default budget (none
8377
+ // is passed), and a dry run still refuses a budget it can't fit: it
8378
+ // skips only the post-pick check, and an adaptive fold plan that
8379
+ // doesn't fit then overruns at the middle's emission. So a template
8380
+ // whose fold plan doesn't fit that budget fails the spawn with
8381
+ // context-manager's refusal, which names the tokens and the limit, as
8382
+ // a committing compile failed it. Only a windowed-passthrough template
8383
+ // whose newest message alone exceeds it seeds that message, larger than
8384
+ // the budget. The fork's own compiles meet its window by its strategy's
8385
+ // rule: passthrough, the fork's default, keeps the newest messages that
8386
+ // fit; an adaptive strategy folds to fit or throws over_budget for the
8387
+ // drain breaker.
8388
+ // The seed leaves out the template's thinking (withoutThinking): a
8389
+ // signed block verifies only while everything before it is what it was
8390
+ // minted under, and a fork's system, tools and scoped injections don't
8391
+ // promise that, while its own namespace holds no stamps that could
8392
+ // vouch for the copies. On models that don't bind thinking to its
8393
+ // prefix a copy would have been kept, so there the fork loses the
8394
+ // template's reasoning.
8395
+ // Then context-manager's stripUnpairedToolBlocks drops each call whose
8396
+ // result isn't in the seed and each result whose call isn't, keeping
8397
+ // the rest of their messages (a message left with nothing becomes its
8398
+ // "[tool call omitted]" placeholder). A fork spawned while the template
8399
+ // is between a round's call and its results (the coalescer can spawn
8400
+ // one outside the event loop) would otherwise give the fork's first
8401
+ // request an unanswered call: the passthrough strategies have no
8402
+ // pairing pass, and an adaptive template's compile has been through its
8403
+ // own.
8404
+ const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true });
7578
8405
  if (existing.length === 0) {
7579
8406
  // The copy is the fork's own, and the template's annotation edits only
7580
8407
  // the template's message: seed only once each of the template's
7581
8408
  // annotations has finished.
7582
- const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile());
7583
- for (const msg of compiled) {
8409
+ const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile(undefined, undefined, { dryRun: true }));
8410
+ const withoutReasoning = withoutThinking(compiled);
8411
+ const seed = stripUnpairedToolBlocks(withoutReasoning);
8412
+ logSeedOmissions(name, template.name, compiled, withoutReasoning, seed);
8413
+ for (const msg of seed) {
7584
8414
  const participant = msg.participant === template.name ? name : msg.participant;
7585
8415
  contextManager.addMessage(participant, msg.content);
7586
8416
  }
@@ -7833,7 +8663,19 @@ export class AgentFramework {
7833
8663
  throw new Error('server disconnected');
7834
8664
  return server.sendPushRender(params);
7835
8665
  },
7836
- audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
8666
+ audit: (record) => {
8667
+ // A render result the host refused falls back to the occurrence's own
8668
+ // content; say so on the host, with the server, the subject and the
8669
+ // failing field, not only in the trace. The error is a field like the
8670
+ // rest (logValue, spaces escaped), not a host-message tail: a server
8671
+ // that answers push/render with an error chooses its message.
8672
+ if (record.kind === 'render-failed') {
8673
+ console.error(`[push-render-failed] server=${logValue(record.serverId ?? '')} subject=${logValue(record.subject ?? '')} ` +
8674
+ `eventId=${logValue(record.eventId ?? '')}${record.field !== undefined ? ` field=${logValue(record.field)}` : ''}: ` +
8675
+ `${logValue(record.error ?? '')} (the occurrence's own content is delivered instead)`);
8676
+ }
8677
+ this.emitTrace({ type: 'mcpl:coalescing', ...record });
8678
+ },
7837
8679
  save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
7838
8680
  recordReceipt: (record) => {
7839
8681
  // Written now, not at the throttled flush: an acknowledged acceptance
@@ -7951,7 +8793,7 @@ export class AgentFramework {
7951
8793
  }
7952
8794
  if (typeof message.timestamp !== 'string')
7953
8795
  throw new CoalesceError('timestamp', 'timestamp is required');
7954
- validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
8796
+ validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true });
7955
8797
  const c = message.coalesce;
7956
8798
  const result = await this.pushCoalescer.accept({
7957
8799
  serverId,
@@ -7980,7 +8822,7 @@ export class AgentFramework {
7980
8822
  throw new CoalesceError('eventId', 'eventId is required');
7981
8823
  if (typeof params.timestamp !== 'string')
7982
8824
  throw new CoalesceError('timestamp', 'timestamp is required');
7983
- validateCoalescedContent(params.payload?.content, undefined, {
8825
+ validateCoalescedContent(params.payload?.content, {
7984
8826
  allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
7985
8827
  serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
7986
8828
  }),
@@ -8082,7 +8924,7 @@ export class AgentFramework {
8082
8924
  return [decision.agentName];
8083
8925
  }
8084
8926
  catch (err) {
8085
- console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
8927
+ console.error(`[coalescing] could not spawn a conversation agent for ${logValue(occ.scope.id)}:`, err);
8086
8928
  return [];
8087
8929
  }
8088
8930
  }
@@ -8280,7 +9122,7 @@ export class AgentFramework {
8280
9122
  if (!agent)
8281
9123
  return false;
8282
9124
  try {
8283
- agent.getContextManager().removeMessage(p.messageId);
9125
+ removeStoredMessage(agent.getContextManager(), p.messageId);
8284
9126
  this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
8285
9127
  return true;
8286
9128
  }
@@ -8299,7 +9141,7 @@ export class AgentFramework {
8299
9141
  // model would wake to `[Continue]` or an older message re-presented as
8300
9142
  // the newest, a wake with no visible cause.
8301
9143
  if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
8302
- console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
9144
+ console.error(`[push-event-dropped] server=${event.serverId} eventId=${logValue(event.eventId ?? '')} reason=empty-content`);
8303
9145
  this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
8304
9146
  return undefined;
8305
9147
  }
@@ -8355,7 +9197,7 @@ export class AgentFramework {
8355
9197
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
8356
9198
  }
8357
9199
  else {
8358
- console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
9200
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
8359
9201
  }
8360
9202
  if (event.triggerInference) {
8361
9203
  // Default broadcast excludes conversation forks (channel-driven).
@@ -8742,13 +9584,13 @@ export class AgentFramework {
8742
9584
  recordProseSuppression(routing, count) {
8743
9585
  if (count <= 0)
8744
9586
  return;
8745
- routing.logical.suppressed += count;
9587
+ routing.suppressed += count;
8746
9588
  }
8747
9589
  /** Record a successful plain-prose delivery for this turn's receipt. */
8748
9590
  recordProseDelivery(routing, outcome) {
8749
9591
  if (!outcome?.delivered)
8750
9592
  return;
8751
- routing.logical.deliveries.push(outcome.channelId);
9593
+ routing.deliveries.push(outcome.channelId);
8752
9594
  }
8753
9595
  /**
8754
9596
  * Append the turn's `[delivered]` receipt: one compact system message
@@ -8764,12 +9606,12 @@ export class AgentFramework {
8764
9606
  * ([discord-send-failed]); this is the success half.
8765
9607
  */
8766
9608
  appendProseDeliveryReceipt(agent, routing) {
8767
- const list = routing.logical.deliveries;
8768
- const suppressed = routing.logical.suppressed;
9609
+ const list = routing.deliveries;
9610
+ const suppressed = routing.suppressed;
8769
9611
  if (list.length === 0 && suppressed === 0)
8770
9612
  return;
8771
- routing.logical.deliveries = [];
8772
- routing.logical.suppressed = 0;
9613
+ routing.deliveries = [];
9614
+ routing.suppressed = 0;
8773
9615
  const seen = new Set();
8774
9616
  const shown = [];
8775
9617
  for (const id of list ?? []) {
@@ -8815,7 +9657,6 @@ export class AgentFramework {
8815
9657
  this.lastAnnouncedLocus.set(agentName, locus);
8816
9658
  return;
8817
9659
  }
8818
- this.lastAnnouncedLocus.set(agentName, locus);
8819
9660
  const agent = this.agents.get(agentName);
8820
9661
  if (!agent)
8821
9662
  return;
@@ -8836,6 +9677,9 @@ export class AgentFramework {
8836
9677
  }
8837
9678
  try {
8838
9679
  const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
9680
+ // The baseline is what the agent was told: it moves once the notice is
9681
+ // stored, so a notice that fails is announced again at the next turn.
9682
+ this.lastAnnouncedLocus.set(agentName, locus);
8839
9683
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
8840
9684
  console.error(`[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`);
8841
9685
  }
@@ -8843,6 +9687,125 @@ export class AgentFramework {
8843
9687
  console.error('announceLocusIfChanged: failed to record routing notice:', err);
8844
9688
  }
8845
9689
  }
9690
+ /**
9691
+ * The channel a message moves its turn's locus to, by the addressed
9692
+ * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a
9693
+ * reaction or a system marker) that addressed the agent, or that follows
9694
+ * up in a channel the turn sent into (TurnRouting.engaged). Undefined for
9695
+ * any other message. A tool boundary weighs the messages it injects by it,
9696
+ * and noteArrival the ones no boundary of the turn weighs.
9697
+ */
9698
+ repinChannel(metadata, routing) {
9699
+ if (!isConversationalInjection(metadata))
9700
+ return undefined;
9701
+ const m = metadata;
9702
+ if (typeof m?.channelId !== 'string')
9703
+ return undefined;
9704
+ if (isAddressedMessage(m.tags, m))
9705
+ return m.channelId;
9706
+ return routing.engaged.has(m.channelId) ? m.channelId : undefined;
9707
+ }
9708
+ /**
9709
+ * Weigh a message that no tool boundary of the agent's open logical turn
9710
+ * will weigh: one that would re-pin the locus becomes one of the turn's
9711
+ * arrivals (TurnRouting.arrivals). A message tune-out diverted
9712
+ * (metadata.tuneOut) never enters a resident's compiled view, so the agent
9713
+ * won't see it, and it moves nothing.
9714
+ */
9715
+ noteArrival(agentName, metadata, messageId) {
9716
+ const open = this.openLogical.get(agentName);
9717
+ if (!open)
9718
+ return;
9719
+ if (metadata?.tuneOut)
9720
+ return;
9721
+ const channelId = this.repinChannel(metadata, open);
9722
+ if (channelId)
9723
+ open.arrivals.push({ messageId, channelId });
9724
+ }
9725
+ /**
9726
+ * Whether `agent`'s store is the residents' one message slot, which every
9727
+ * ordinary resident reads (sharedSlotAgents). The subconscious reads that
9728
+ * slot through a merged view, but its own store is isolated, as a fork's
9729
+ * or an ephemeral agent's is.
9730
+ */
9731
+ isOrdinaryResident(agent) {
9732
+ return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
9733
+ }
9734
+ /**
9735
+ * Weigh a message just stored in an ordinary resident's store for every
9736
+ * other ordinary resident's open turn, running or not: residents read one
9737
+ * message slot, and a tool boundary weighs only the messages drained for
9738
+ * its own agent, so no boundary of another resident's turn weighs it.
9739
+ */
9740
+ noteForOtherResidents(agent, metadata, messageId) {
9741
+ if (!this.isOrdinaryResident(agent))
9742
+ return;
9743
+ for (const reader of this.sharedSlotAgents) {
9744
+ if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
9745
+ this.noteArrival(reader.name, metadata, messageId);
9746
+ }
9747
+ }
9748
+ }
9749
+ /**
9750
+ * The channel of the turn's newest arrival whose message is still in the
9751
+ * agent's store (TurnRouting.arrivals). A message removed since it was
9752
+ * stored, such as an unread coalesced message its server retracted, moves
9753
+ * nothing, and an earlier one that remains still does. Compression keeps a
9754
+ * message in the store; the last compile can't be the test, since an
9755
+ * arrival between the turn's streams isn't in it yet.
9756
+ */
9757
+ remainingArrival(agent, routing) {
9758
+ const cm = agent.getContextManager();
9759
+ for (let i = routing.arrivals.length - 1; i >= 0; i--) {
9760
+ const { messageId, channelId } = routing.arrivals[i];
9761
+ // The store answers null for a message that isn't there. A store that
9762
+ // can't be read throws here, as anywhere else in the start, rather
9763
+ // than have its candidates read as withdrawn.
9764
+ if (cm.getMessage(messageId))
9765
+ return channelId;
9766
+ }
9767
+ return undefined;
9768
+ }
9769
+ /**
9770
+ * Move a turn's locus to the channel its conversation moved to: an
9771
+ * addressed message, or a follow-up in a channel the turn engaged (the
9772
+ * addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves
9773
+ * a durable `[routing]` notice in the window and updates
9774
+ * lastAnnouncedLocus once it's stored, so the next turn's announce-on-change
9775
+ * diffs against what the agent was last told. Returns the notice as stored, for a live
9776
+ * stream to inject with its boundary's other messages. Returns undefined
9777
+ * when the locus is already there, and when the notice couldn't be
9778
+ * stored. The locus has moved then too, but the announce baseline hasn't,
9779
+ * so the agent's next turn announces where its speech lands.
9780
+ */
9781
+ repinTurnLocus(agent, routing, channelId, cause) {
9782
+ if (routing.locus === channelId)
9783
+ return undefined;
9784
+ const prevPin = routing.locus ?? null;
9785
+ routing.locus = channelId;
9786
+ const label = this.channelRegistry?.getDescriptor(channelId)?.label;
9787
+ const shown = label && label !== channelId
9788
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
9789
+ : channelId;
9790
+ const content = [{
9791
+ type: 'text',
9792
+ text: `[routing] The conversation moved to ${shown} — your plain ` +
9793
+ 'speech now lands there for the rest of this turn. Other ' +
9794
+ 'channels need an explicit send tool.',
9795
+ }];
9796
+ const metadata = { system: true, kind: 'routing-notice' };
9797
+ try {
9798
+ const id = agent.getContextManager().addMessage('user', content, metadata);
9799
+ this.lastAnnouncedLocus.set(agent.name, channelId);
9800
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
9801
+ console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`);
9802
+ return { participant: 'user', content, metadata };
9803
+ }
9804
+ catch (err) {
9805
+ console.error('turn locus re-pin: failed to record routing notice:', err);
9806
+ return undefined;
9807
+ }
9808
+ }
8846
9809
  /**
8847
9810
  * Durable window notice for a channel the agent did not ask to open —
8848
9811
  * subscription-policy admission, or an open forced by the agent's own
@@ -8889,11 +9852,11 @@ export class AgentFramework {
8889
9852
  */
8890
9853
  requestProseContinuation(agent, routing) {
8891
9854
  // The flag may be shared with an earlier stream of the same logical
8892
- // turn (LogicalTurn); its deliveries are on the agent's chain ahead of
9855
+ // turn (TurnRouting); its deliveries are on the agent's chain ahead of
8893
9856
  // this stream's, so by the time this check runs, they have run.
8894
- if (!routing.logical.continuation)
9857
+ if (!routing.continuation)
8895
9858
  return;
8896
- routing.logical.continuation = false;
9859
+ routing.continuation = false;
8897
9860
  // An explicit turn's check can run after its stream has returned. A
8898
9861
  // stopped framework, or an agent no longer registered under its name (a
8899
9862
  // disposed fork or ephemeral run), takes no new turn from it.
@@ -8997,7 +9960,7 @@ export class AgentFramework {
8997
9960
  const attempted = normalized.startsWith('>>>');
8998
9961
  const parsed = parseHybridProsePrefix(normalized);
8999
9962
  if (parsed.continueTurn)
9000
- routing.logical.continuation = true;
9963
+ routing.continuation = true;
9001
9964
  if (parsed.kind === 'private') {
9002
9965
  delete routing.pin;
9003
9966
  routing.hybridHold = true;
@@ -9076,7 +10039,7 @@ export class AgentFramework {
9076
10039
  // A private round (one that called skip_reply) asks for no publication
9077
10040
  // or continuation, even when delivery runs after that turn has ended.
9078
10041
  if (parsed.continueTurn && !round.keepPrivate)
9079
- routing.logical.continuation = true;
10042
+ routing.continuation = true;
9080
10043
  if (parsed.kind === 'private') {
9081
10044
  // The text already lives in the assistant message (window/chronicle);
9082
10045
  // "delivery" is deliberately a no-op. Consumes the retained text if the
@@ -9349,6 +10312,22 @@ export class AgentFramework {
9349
10312
  try {
9350
10313
  tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
9351
10314
  }
10315
+ catch (error) {
10316
+ // A throw outside the stream's own failure handling (the checkpoint,
10317
+ // locus resolution, the typing indicator) drops this start's trigger,
10318
+ // and nothing retries or holds it. The agent's open logical turn is the
10319
+ // one this start was opening, continuing or resuming, so it ends here,
10320
+ // as a terminal failure in beginAgentTurn's own catch ends its turn:
10321
+ // the next start opens a turn of its own rather than continuing this
10322
+ // one's routing, and an ephemeral run fails with the error rather than
10323
+ // waiting out its watchdog. A silent start has no part in an open turn.
10324
+ if (!trigger?.suppressProse) {
10325
+ this.settleAgent(agent.name, {
10326
+ stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
10327
+ }, this.openLogical.get(agent.name));
10328
+ }
10329
+ throw error;
10330
+ }
9352
10331
  finally {
9353
10332
  if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
9354
10333
  this.activeTurnTokens.delete(agent.name);
@@ -9356,8 +10335,52 @@ export class AgentFramework {
9356
10335
  }
9357
10336
  if (!tokenHandedOff && ownsProviderGate)
9358
10337
  this.releasePrimaryProviderGate(agent.name);
10338
+ // A start that ends before its stream exists lands what was deferred
10339
+ // during it, as a stream's teardown does. Left queued, those messages
10340
+ // would land only at the agent's next start, after any stored meanwhile.
10341
+ // Per message, as the turn-start flush does: one store failure must
10342
+ // neither drop the messages behind it nor replace the start's own error.
10343
+ if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name)
10344
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
10345
+ for (const msg of this.drainDeferredFor(agent.name)) {
10346
+ try {
10347
+ this.addMessage(msg.participant, msg.content, msg.metadata, {
10348
+ deferredWriteId: msg.id,
10349
+ ...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
10350
+ });
10351
+ }
10352
+ catch (err) {
10353
+ console.error(`[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` +
10354
+ `(participant=${msg.participant}, id=${msg.id}):`, err);
10355
+ }
10356
+ }
10357
+ this.ackDeferredWrites();
10358
+ }
9359
10359
  }
9360
10360
  }
10361
+ /**
10362
+ * End a turn that beginAgentTurn drops before it sends anything. The turn
10363
+ * emitted inference:started, and listeners that bracket a turn, such as a
10364
+ * typing loop or an activity indicator, end it on a terminal event, so it
10365
+ * gets inference:aborted with `reason`, and lastInference records its end
10366
+ * as driveStream's would, when the record is still this turn's. Nothing of
10367
+ * the kind when another turn of this agent's name holds the turn token (one
10368
+ * begun after a stop and start, or by a replacement agent object): the
10369
+ * event and the record carry only the name, so this frame's would end that
10370
+ * newer turn instead.
10371
+ */
10372
+ abortDroppedTurn(agentName, turnToken, reason, startedAt) {
10373
+ const owner = this.activeTurnTokens.get(agentName);
10374
+ if (owner !== undefined && owner !== turnToken)
10375
+ return;
10376
+ const now = Date.now();
10377
+ // Only this turn's own record ends: a name whose run was disposed meanwhile
10378
+ // (runEphemeralToCompletion deletes its record) gets none back.
10379
+ const last = this.lastInferenceAt.get(agentName);
10380
+ if (last?.startedAt === startedAt)
10381
+ this.lastInferenceAt.set(agentName, { ...last, endedAt: now });
10382
+ this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: now - startedAt });
10383
+ }
9361
10384
  /**
9362
10385
  * Body of startAgentStream, split out so the caller's try/finally owns the
9363
10386
  * turn token unconditionally. Returns true iff the token was handed off to
@@ -9365,7 +10388,7 @@ export class AgentFramework {
9365
10388
  */
9366
10389
  async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
9367
10390
  const lifecycle = this.lifecycleGeneration;
9368
- const continuingTurn = resumesLogicalTurn(trigger?.reason);
10391
+ const resumesTurn = resumesLogicalTurn(trigger?.reason);
9369
10392
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
9370
10393
  // Flush messages deferred during the PREVIOUS turn — before the
9371
10394
  // checkpoint, the locus announcement, and the compile — so a turn started
@@ -9382,7 +10405,7 @@ export class AgentFramework {
9382
10405
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
9383
10406
  // products — undoing the turn must not destroy them.
9384
10407
  if (attempt === 0 &&
9385
- !continuingTurn &&
10408
+ !resumesTurn &&
9386
10409
  this.deferredMessages.length > 0) {
9387
10410
  {
9388
10411
  const deferred = this.drainDeferredFor(agent.name);
@@ -9392,6 +10415,8 @@ export class AgentFramework {
9392
10415
  // abort the turn or drop the messages behind it in the queue.
9393
10416
  try {
9394
10417
  const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
10418
+ this.noteArrival(agent.name, msg.metadata, id);
10419
+ this.noteForOtherResidents(agent, msg.metadata, id);
9395
10420
  this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
9396
10421
  }
9397
10422
  catch (err) {
@@ -9407,7 +10432,7 @@ export class AgentFramework {
9407
10432
  // not yet run) and before the checkpoint (they are the turn's inputs).
9408
10433
  // Not on a context-budget restart: that continues the same logical turn.
9409
10434
  let assembly;
9410
- if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
10435
+ if (attempt === 0 && !resumesTurn && this.pushCoalescer?.pendingBatches()) {
9411
10436
  try {
9412
10437
  assembly = await this.pushCoalescer.assemble(agent.name);
9413
10438
  }
@@ -9452,44 +10477,61 @@ export class AgentFramework {
9452
10477
  this.recordTurnCheckpoint(agent.name);
9453
10478
  this.redoStacks.delete(agent.name); // new work invalidates redo
9454
10479
  }
9455
- // Establish this turn's outbound routing locus (item-3 redux). Set it to the
9456
- // triggering channel for a channel/DM-triggered turn; clear it otherwise so
9457
- // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
9458
- // instead falls back to the global default. A given agent runs one turn at a
9459
- // time, so a set here is only read during THIS turn's routeSpeech /
9460
- // buildChannelContext; retries re-run with the same trigger, re-setting it.
9461
- // A context-budget restart continues the SAME logical turn in a fresh
9462
- // stream — its trigger carries no channelId, and deleting the trigger
9463
- // channel here mid-logical-turn is exactly the hole that sent Sol's DM
9464
- // reply to a stale guild channel (2026-07-22): any live locus resolution
9465
- // after the restart fell through to the hours-old defaultPublishChannel.
9466
- // Keep the turn's trigger channel across restarts; only real new turns
9467
- // reset it.
9468
- if (!continuingTurn) {
9469
- if (trigger?.channelId) {
9470
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
10480
+ // This start's routing: its logical turn's (TurnRouting), which holds
10481
+ // everything the turn has decided about where its speech goes.
10482
+ // - A resume (a budget restart, a tool-result-guard retry) carries its
10483
+ // turn's request on and keeps the turn's object as it is, and the
10484
+ // turn stays, or after a stop() becomes again, the agent's open turn.
10485
+ // - A silent start routes no prose. It gets an object of its own, with
10486
+ // no locus, so it neither takes nor disturbs an open turn's. Its
10487
+ // object is the agent's live routing while it runs, and the open
10488
+ // turn's next start makes the turn's object live again. (No silent
10489
+ // start runs while a prose turn is open in production: a capacity
10490
+ // hold holds silent wakes with the turn's, and a re-inference's wake
10491
+ // is queued before its stream ends, so it batches with them.)
10492
+ // - Any other start continues the agent's open logical turn, if one is
10493
+ // open (openLogical): a framework retry, the start that consumes tool
10494
+ // results the non-streaming fallback left (whichever wake the
10495
+ // scheduler starts it with), the later compile a provider-capacity
10496
+ // hold defers a turn to. It takes the turn's object: the sticky
10497
+ // destination, the destination and the hold, the ` !` and receipt
10498
+ // tallies, the trigger channel, the locus with any re-pin, and the
10499
+ // engaged channels. A delivery still queued from an earlier stream
10500
+ // runs ahead of this one's on the agent's chain, so what it sets
10501
+ // applies to this stream's prose, in the order the turn wrote them.
10502
+ // With no turn open, the start opens one.
10503
+ // A new object doesn't clear the previous turn's: prose that turn left
10504
+ // queued still reads and writes its own.
10505
+ let routing;
10506
+ let opensTurn = false;
10507
+ if (resumesTurn) {
10508
+ routing = this.routingOf(agent.name);
10509
+ // The turn a resume continues is open, unless a stop() ended every
10510
+ // open turn since: it opens again, so its later starts continue it.
10511
+ if (!trigger?.suppressProse && !this.openLogical.has(agent.name))
10512
+ this.openLogical.set(agent.name, routing);
10513
+ }
10514
+ else if (trigger?.suppressProse) {
10515
+ routing = freshTurnRouting();
10516
+ opensTurn = true;
10517
+ }
10518
+ else {
10519
+ const open = this.openLogical.get(agent.name);
10520
+ if (open) {
10521
+ routing = open;
9471
10522
  }
9472
10523
  else {
9473
- this.activeTriggerChannels.delete(agent.name);
9474
- }
9475
- }
9476
- // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
9477
- // once — home channel for forks, else the triggering channel, else the
9478
- // global default — and never moved for the rest of the turn: mid-turn
9479
- // injections must not redirect the agent's plain prose (ambient chatter /
9480
- // reactions hijacked it before, 2026-07-21). A context-budget restart
9481
- // continues the same logical turn, so it keeps the existing pin. The
9482
- // eager snapshot also protects heartbeat turns from a moving
9483
- // defaultPublishChannel mid-turn. If the effective locus differs from the
9484
- // last one announced, drop a durable `[routing]` notice into the window
9485
- // BEFORE this turn compiles, so the agent always knows where its voice
9486
- // goes (announce-on-change only — no per-turn chatter, append-only for
9487
- // KV stability).
9488
- if (continuingTurn) {
10524
+ routing = freshTurnRouting();
10525
+ this.openLogical.set(agent.name, routing);
10526
+ opensTurn = true;
10527
+ }
10528
+ }
10529
+ this.turnRouting.set(agent.name, routing);
10530
+ if (resumesTurn) {
9489
10531
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
9490
10532
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
9491
10533
  }
9492
- if (!continuingTurn) {
10534
+ else {
9493
10535
  if (attempt === 0)
9494
10536
  this.maybePrimeProseMode(agent);
9495
10537
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
@@ -9501,77 +10543,65 @@ export class AgentFramework {
9501
10543
  // A framework retry is a new physical stream in the same logical turn.
9502
10544
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
9503
10545
  }
9504
- // Fresh turn: forget the previous turn's explicit-send engagements.
9505
- // A restart continues the same logical turn and keeps them.
9506
- this.turnEngagedChannels.delete(agent.name);
9507
- // A new routing state for the new turn, in every mode: the sticky
9508
- // destination, the turn's destination, hybrid's hold and the receipt
9509
- // tallies start empty. The previous turn's object isn't cleared: prose
9510
- // it left queued still reads and writes its own.
9511
- const fresh = freshTurnRouting();
9512
- // A start while the agent's logical turn is open continues it: it
9513
- // shares the earlier stream's ` !` and receipt tallies, which a
9514
- // delivery still queued there can write after this stream begins
9515
- // (openLogical). Otherwise this start opens one. A silent start does
9516
- // neither: it routes no prose, so it keeps a logical turn of its own,
9517
- // and an open turn waits for the next start that routes prose.
9518
- if (!trigger?.suppressProse) {
9519
- const open = this.openLogical.get(agent.name);
9520
- if (open)
9521
- fresh.logical = open;
9522
- else
9523
- this.openLogical.set(agent.name, fresh.logical);
9524
- }
9525
- this.turnRouting.set(agent.name, fresh);
9526
- if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
9527
- // Explicit and disabled prose routing have no inferred locus.
9528
- // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
9529
- // prose target at all. Neither mode freezes or announces a locus.
9530
- this.midTurnInputSignals.delete(agent.name);
9531
- this.turnLocusPins.delete(agent.name);
9532
- }
9533
- else {
10546
+ this.midTurnInputSignals.delete(agent.name);
10547
+ }
10548
+ if (opensTurn) {
10549
+ // The turn's trigger channel (item-3 redux): the channel of a
10550
+ // channel/DM-triggered turn, and none for a heartbeat / no-trigger
10551
+ // turn, which falls back to the global default rather than inheriting
10552
+ // a previous turn's channel.
10553
+ routing.triggerChannel = trigger?.channelId;
10554
+ // FREEZE the turn's outbound locus now (turn-frozen routing): the home
10555
+ // channel for forks, else the trigger channel, else the global default.
10556
+ // Mid-turn injections must not redirect the agent's plain prose
10557
+ // (ambient chatter and reactions hijacked it before, 2026-07-21), and
10558
+ // the eager snapshot protects heartbeat turns from a moving
10559
+ // defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
10560
+ // and disabled mode has no prose target, so neither freezes or
10561
+ // announces a locus. If the effective locus differs from the last one
10562
+ // announced, a durable `[routing]` notice goes into the window BEFORE
10563
+ // this turn compiles, so the agent always knows where its voice goes
10564
+ // (announce-on-change only — no per-turn chatter, append-only for KV
10565
+ // stability).
10566
+ if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
9534
10567
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
9535
10568
  if (locus !== null)
9536
- this.turnLocusPins.set(agent.name, locus);
9537
- else
9538
- this.turnLocusPins.delete(agent.name);
9539
- this.midTurnInputSignals.delete(agent.name);
10569
+ routing.locus = locus;
9540
10570
  if (attempt === 0)
9541
10571
  this.announceLocusIfChanged(agent.name, locus);
9542
10572
  }
9543
10573
  }
9544
- // A context-budget restart normally preserves the turn locus, but a
9545
- // silent control-plane wake must remain locusless even if an explicit tool
9546
- // opened/pinned a channel mid-turn.
9547
- if (trigger?.suppressProse) {
9548
- this.turnLocusPins.delete(agent.name);
9549
- // Silent: no routing state. A continuing silent start keeps its turn's
9550
- // logical turn; a fresh one keeps the one of its own it was given.
9551
- const silent = freshTurnRouting();
9552
- const logical = this.turnRouting.get(agent.name)?.logical;
9553
- if (logical)
9554
- silent.logical = logical;
9555
- this.turnRouting.set(agent.name, silent);
9556
- }
9557
- // This start's own logical turn: a failure before its stream exists
9558
- // settles it, and no other start's.
9559
- const startLogical = this.turnRouting.get(agent.name)?.logical;
10574
+ else {
10575
+ // Every other start of the turn keeps its locus: a resume, or a start
10576
+ // that continues the open turn. A message no tool boundary of the turn
10577
+ // weighed can still move it, as it would have at a boundary
10578
+ // (TurnRouting.arrivals).
10579
+ // Only a turn that has a locus reads its arrivals' messages.
10580
+ const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
10581
+ ? this.remainingArrival(agent, routing)
10582
+ : undefined;
10583
+ routing.arrivals = [];
10584
+ if (arrival)
10585
+ this.repinTurnLocus(agent, routing, arrival, 'arrival');
10586
+ }
10587
+ // A silent start, or a disabled agent's, has no locus to show, whatever a
10588
+ // channel_open in a silent turn left on its object.
10589
+ const turnLocus = turnProseRouting === 'disabled' ? undefined : routing.locus;
9560
10590
  this.touchEphemeralRun(agent.name, true);
10591
+ const turnStartedAt = Date.now();
9561
10592
  this.emitTrace({
9562
10593
  type: 'inference:started',
9563
10594
  agentName: agent.name,
9564
- // The turn-frozen locus was pinned just above (kept across a
9565
- // context-budget restart, which skips the re-pin but re-emits this).
9566
- channelId: this.turnLocusPins.get(agent.name),
10595
+ // The turn's locus, kept across a start that continues the turn.
10596
+ channelId: turnLocus,
9567
10597
  ...(trigger?.suppressProse ? { silent: true } : {}),
9568
10598
  });
9569
10599
  // A budget restart continues the same logical inference window. The
9570
10600
  // predecessor keeps EventGate liveness until its successor terminates.
9571
- if (!continuingTurn) {
10601
+ if (!resumesTurn) {
9572
10602
  this.eventGate?.onInferenceStarted(agent.name);
9573
10603
  }
9574
- this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
10604
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: turnStartedAt });
9575
10605
  // Typing indicator, started at TURN START. It says "attending", which is
9576
10606
  // true from the moment the turn is accepted — but it used to start inside
9577
10607
  // driveStream, i.e. only after module gatherContext + MCPL beforeInference
@@ -9583,7 +10613,7 @@ export class AgentFramework {
9583
10613
  // it on the no-driveStream failure paths (e.g. a compile refusal).
9584
10614
  const earlyTypingChannel = turnProseRouting === 'explicit'
9585
10615
  ? trigger?.channelId ?? null
9586
- : this.turnLocusPins.get(agent.name) ?? null;
10616
+ : turnLocus ?? null;
9587
10617
  if (earlyTypingChannel)
9588
10618
  this.channelRegistry?.startTyping(earlyTypingChannel);
9589
10619
  try {
@@ -9635,8 +10665,13 @@ export class AgentFramework {
9635
10665
  // and stop() may close provider admission. Do not create a provider
9636
10666
  // stream after its generation lost ownership, or once admission closed.
9637
10667
  if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
10668
+ const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed' : 'the framework was stopping';
10669
+ const reason = `a turn assembled while ${why} is dropped before it sends anything`;
10670
+ console.error(`[turn] ${agent.name}: ${reason}`);
10671
+ this.endDroppedTurn(agent.name, routing);
9638
10672
  this.channelRegistry?.stopTyping();
9639
10673
  this.eventGate?.onInferenceEnded(agent.name);
10674
+ this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
9640
10675
  return false;
9641
10676
  }
9642
10677
  // The subconscious's standing dispositions ride a fixed system-position
@@ -9658,9 +10693,11 @@ export class AgentFramework {
9658
10693
  // Membrane's yielding stream starts its request when it is first
9659
10694
  // iterated, so cancelling it here, before driveStream, sends nothing.
9660
10695
  if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
9661
- console.error(`[turn] ${agent.name}: a turn compiled while ${this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
10696
+ const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
9662
10697
  : this.providerAdmissionClosed ? 'the framework was stopping'
9663
- : 'the framework was stopped and started again'} is dropped before it sends anything`);
10698
+ : 'the framework was stopped and started again';
10699
+ const reason = `a turn compiled while ${why} is dropped before it sends anything`;
10700
+ console.error(`[turn] ${agent.name}: ${reason}`);
9664
10701
  stream.cancel();
9665
10702
  agent.cancelStream();
9666
10703
  if (this.agents.get(agent.name) === agent) {
@@ -9670,8 +10707,10 @@ export class AgentFramework {
9670
10707
  this.activeStreams.delete(agent.name);
9671
10708
  this.pendingAssistantBlocks.delete(agent.name);
9672
10709
  }
10710
+ this.endDroppedTurn(agent.name, routing);
9673
10711
  this.channelRegistry?.stopTyping();
9674
10712
  this.eventGate?.onInferenceEnded(agent.name);
10713
+ this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
9675
10714
  return false;
9676
10715
  }
9677
10716
  // A conversation agent takes no provider gate, so its own compression
@@ -9756,11 +10795,13 @@ export class AgentFramework {
9756
10795
  this.activeTurnTokens.delete(agent.name);
9757
10796
  this.activeTurnTriggers.delete(agent.name);
9758
10797
  }
10798
+ // This start's own logical turn: a failure before its stream exists
10799
+ // settles it, and no other start's.
9759
10800
  this.settleAgent(agent.name, {
9760
10801
  stopReason: 'exhausted',
9761
10802
  speech: '',
9762
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
9763
- }, startLogical);
10803
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
10804
+ }, routing);
9764
10805
  this.emitTrace({
9765
10806
  type: 'inference:exhausted',
9766
10807
  agentName: agent.name,
@@ -9796,8 +10837,9 @@ export class AgentFramework {
9796
10837
  const myStreamId = agent.streamId;
9797
10838
  // Membrane usage events are cumulative across the native/XML tool loop.
9798
10839
  // Keep the previous cumulative sample so consumers that operate at the
9799
- // physical provider-call boundary (estimator calibration and kv receipt
9800
- // acceptance) see this call only, not an ever-growing turn total.
10840
+ // physical provider-call boundary (estimator calibration and the
10841
+ // physical-window projection) see this call only, not an ever-growing
10842
+ // turn total.
9801
10843
  let previousUsage = {
9802
10844
  inputTokens: 0,
9803
10845
  outputTokens: 0,
@@ -9820,14 +10862,14 @@ export class AgentFramework {
9820
10862
  let hadToolCalls = false;
9821
10863
  let generationLost = false;
9822
10864
  // ---- Present-while-acting turn state ---------------------------------
9823
- // Output locus for the WHOLE logical turn: frozen in startAgentStream
9824
- // (home → addressed trigger → global default) before this stream began,
9825
- // and never moved until the next turn. Mid-turn injections do not touch
9826
- // it — an ambient message or a reaction arriving at a tool boundary must
9827
- // not redirect the agent's task narration (2026-07-21 Cairn misroute).
9828
- // The pin lives in `turnLocusPins` so a context-budget restart (same
9829
- // logical turn, fresh driveStream) keeps it.
9830
- const resolveTurnLocus = () => this.turnLocusPins.get(agent.name) ?? null;
10865
+ // Output locus for the WHOLE logical turn: frozen when the turn opened
10866
+ // (home → addressed trigger → global default), and moved only by a
10867
+ // re-pin (an addressed message, or a follow-up in a channel the turn
10868
+ // engaged) or the agent's own channel_open. Ambient messages and
10869
+ // reactions arriving at a tool boundary must not redirect the agent's
10870
+ // task narration (2026-07-21 Cairn misroute). The locus lives in the
10871
+ // turn's routing state, so every stream of the turn shares it.
10872
+ const resolveTurnLocus = () => streamRouting.locus ?? null;
9831
10873
  // Ordered delivery chain for live-routed prose. Links are enqueued
9832
10874
  // WITHOUT awaiting in the stream-event loop — an awaited network post
9833
10875
  // here would stall consumption of the next round's events by
@@ -9838,11 +10880,13 @@ export class AgentFramework {
9838
10880
  //
9839
10881
  // Every stream queues its deliveries behind the agent's latest link, so
9840
10882
  // the agent's posts go out in the order they were written, across a
9841
- // turn's streams (a budget restart, a tool-result-guard retry) and across
9842
- // turns. A turn that a tool ends (skip_reply) settles before its queued
9843
- // prose has posted; that prose keeps its own turn's routing state
9844
- // (turnRouting), and the next turn's posts wait behind it.
9845
- const resumesTurn = resumesLogicalTurn(trigger?.reason);
10883
+ // turn's streams and across turns. So an earlier stream's deliveries,
10884
+ // and the sticky destination or hold they set, come before this stream's
10885
+ // own. A stream queues only while it is the agent's current stream: the
10886
+ // event loop stops at the first event after a replacement. A turn that a
10887
+ // tool ends (skip_reply) settles before its queued prose has posted; that
10888
+ // prose keeps its own turn's routing state (turnRouting), and the next
10889
+ // turn's posts wait behind it.
9846
10890
  let turnSpeechChain = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
9847
10891
  this.proseSpeechChains.set(agent.name, turnSpeechChain);
9848
10892
  const chainSpeech = (link, failure) => {
@@ -9929,14 +10973,14 @@ export class AgentFramework {
9929
10973
  ? new ProseStreamRouter({
9930
10974
  mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
9931
10975
  // Explicit text has no destination until a `>>` prefix names one,
9932
- // so a fresh turn's stream starts suppressed. One that resumes the
9933
- // turn (a tool-result-guard retry, a budget restart) starts at the
9934
- // destination the turn's delivered rounds have set: a private
9935
- // round's prefixes don't count, and the gateway's queued
9936
- // deliveries needn't have run. The trigger channel is only where
9937
- // explicit mode's typing indicator shows.
10976
+ // so a fresh turn's stream starts suppressed. A later stream of the
10977
+ // turn (a resume, a retry, a re-inference, a capacity-deferred
10978
+ // compile) starts at the destination the turn's delivered rounds
10979
+ // have set: a private round's prefixes don't count, and the
10980
+ // gateway's queued deliveries needn't have run. The trigger channel
10981
+ // is only where explicit mode's typing indicator shows.
9938
10982
  initialTarget: turnProseRouting === 'explicit'
9939
- ? (resumesTurn ? streamRouting.destination : null)
10983
+ ? streamRouting.destination
9940
10984
  : typingChannel,
9941
10985
  resolve: (spec) => {
9942
10986
  const r = this.channelRegistry.resolveProseTarget(spec);
@@ -10476,7 +11520,7 @@ export class AgentFramework {
10476
11520
  .filter((block) => block.type === 'text')
10477
11521
  .map((block) => block.text)
10478
11522
  .join('\n'),
10479
- }, streamRouting.logical);
11523
+ }, streamRouting);
10480
11524
  this.emitTrace({
10481
11525
  type: 'inference:completed',
10482
11526
  agentName: agent.name,
@@ -10753,13 +11797,11 @@ export class AgentFramework {
10753
11797
  }
10754
11798
  }
10755
11799
  else {
10756
- // Reuse the locus pinned at the turn's first live-routed
10757
- // segment (or resolve it now for a turn whose only prose is
10758
- // trailing). This dispatch runs AFTER the agent is idle (see
10759
- // PR #32 note below), so a queued inbound from another channel
10760
- // could otherwise overwrite the per-agent triggering channel
10761
- // between segments — and a turn that narrated live into one
10762
- // channel must not land its postscript in another.
11800
+ // The turn's own locus, read from this stream's routing
11801
+ // state. This dispatch runs AFTER the agent is idle (see PR
11802
+ // #32 note below), so a next turn may already have started
11803
+ // with routing of its own, and a turn that narrated live into
11804
+ // one channel must not land its postscript in another.
10763
11805
  const locus = resolveTurnLocus();
10764
11806
  console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`);
10765
11807
  // Deliver sequentially (await each) so the segments land in order.
@@ -10838,7 +11880,7 @@ export class AgentFramework {
10838
11880
  stopReason: 'exhausted',
10839
11881
  speech: '',
10840
11882
  error: 'Turn abandoned by operator quiesce',
10841
- }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
11883
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
10842
11884
  this.emitTrace({
10843
11885
  type: 'inference:aborted',
10844
11886
  agentName: agent.name,
@@ -10877,8 +11919,8 @@ export class AgentFramework {
10877
11919
  this.settleAgent(agent.name, {
10878
11920
  stopReason: 'exhausted',
10879
11921
  speech: '',
10880
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
10881
- }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
11922
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
11923
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
10882
11924
  this.emitTrace({
10883
11925
  type: 'inference:exhausted',
10884
11926
  agentName: agent.name,
@@ -10936,7 +11978,7 @@ export class AgentFramework {
10936
11978
  stopReason: 'exhausted',
10937
11979
  speech: '',
10938
11980
  error: 'Turn abandoned by operator quiesce',
10939
- }, streamRouting.logical);
11981
+ }, streamRouting);
10940
11982
  }
10941
11983
  this.emitTrace({
10942
11984
  type: 'inference:aborted',
@@ -10981,7 +12023,7 @@ export class AgentFramework {
10981
12023
  stopReason: 'exhausted',
10982
12024
  speech: '',
10983
12025
  error: `Stream aborted: ${reason}`,
10984
- }, streamRouting.logical);
12026
+ }, streamRouting);
10985
12027
  this.emitTrace({
10986
12028
  type: 'inference:exhausted',
10987
12029
  agentName: agent.name,
@@ -11008,12 +12050,9 @@ export class AgentFramework {
11008
12050
  break;
11009
12051
  }
11010
12052
  case 'usage': {
12053
+ // The stream's accumulated fresh input, which is what the
12054
+ // maxStreamTokens budget checks.
11011
12055
  agent.lastStreamInputTokens = event.usage.inputTokens;
11012
- agent.lastStreamRealInputTokens =
11013
- (event.usage.inputTokens ?? 0) +
11014
- (event.usage.cacheCreationTokens ?? 0) +
11015
- (event.usage.cacheReadTokens ?? 0);
11016
- agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
11017
12056
  // Closed-loop estimator calibration (2026-07-12). Sample the REAL
11018
12057
  // prefix size of THIS API call (fresh + cache write + cache read)
11019
12058
  // and hand it to the context strategy, which accepts exactly one
@@ -11037,11 +12076,36 @@ export class AgentFramework {
11037
12076
  };
11038
12077
  previousUsage = cumulativeUsage;
11039
12078
  outputTokensSinceStamp += perCallUsage.outputTokens;
12079
+ const realTotal = perCallUsage.inputTokens +
12080
+ perCallUsage.cacheCreationTokens +
12081
+ perCallUsage.cacheReadTokens;
12082
+ // The physical-window projection adds the next two fields as the
12083
+ // prior round's real input and output, so they take this call's
12084
+ // share, never the event's own figures: the event is the stream's
12085
+ // running total, which after k calls holds k prompts and would
12086
+ // restart a stream whose next request still fits. Every real call
12087
+ // has a prompt, and a call that ends at a tool boundary wrote
12088
+ // output (its tool_use), so a 0 in either is a count the adapter
12089
+ // left at 0, not an empty call. A call that reported no prompt is
12090
+ // unreported: the fields stay the last call that reported one,
12091
+ // which is what the provider billed, and its own output joins the
12092
+ // growth since that call (lastStreamUnreportedTokens). An output
12093
+ // of 0 counts as maxTokens, the bound on it, in either case, so
12094
+ // the projection errs toward a restart. That holds once a call on
12095
+ // the stream has reported a prompt: the projection runs only then.
12096
+ const callOutputTokens = perCallUsage.outputTokens > 0
12097
+ ? perCallUsage.outputTokens
12098
+ : agent.maxTokens;
12099
+ if (realTotal > 0) {
12100
+ agent.lastStreamRealInputTokens = realTotal;
12101
+ agent.lastStreamOutputTokens = callOutputTokens;
12102
+ agent.lastStreamUnreportedTokens = 0;
12103
+ }
12104
+ else {
12105
+ agent.lastStreamUnreportedTokens += callOutputTokens;
12106
+ }
11040
12107
  const strat = agent.getContextManager?.()?.getStrategy?.();
11041
12108
  try {
11042
- const realTotal = perCallUsage.inputTokens +
11043
- perCallUsage.cacheCreationTokens +
11044
- perCallUsage.cacheReadTokens;
11045
12109
  strat?.reportRealInputTokens?.(realTotal);
11046
12110
  }
11047
12111
  catch { /* calibration is best-effort */ }
@@ -11130,8 +12194,8 @@ export class AgentFramework {
11130
12194
  this.settleAgent(agent.name, {
11131
12195
  stopReason: 'exhausted',
11132
12196
  speech: '',
11133
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
11134
- }, streamRouting.logical);
12197
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
12198
+ }, streamRouting);
11135
12199
  this.emitTrace({
11136
12200
  type: 'inference:exhausted',
11137
12201
  agentName: agent.name,
@@ -11336,6 +12400,11 @@ export class AgentFramework {
11336
12400
  * the operator's call; the princess precedent was disclosed first.
11337
12401
  */
11338
12402
  async puppetToolCall(agentName, toolName, input) {
12403
+ this.refuseOperatorAfterStop('puppet');
12404
+ return this.trackOperatorOperation('puppet:tool-call', () => this.runPuppetToolCall(agentName, toolName, input));
12405
+ }
12406
+ /** puppetToolCall's work, run where stop() can wait for it. */
12407
+ async runPuppetToolCall(agentName, toolName, input) {
11339
12408
  const agent = this.agents.get(agentName);
11340
12409
  if (!agent)
11341
12410
  throw new Error(`Unknown agent: ${agentName}`);
@@ -12869,6 +13938,12 @@ export class AgentFramework {
12869
13938
  opts.placement.agent = agent.name;
12870
13939
  opts.placement.messageId = stored;
12871
13940
  }
13941
+ // Stored while none of the agent's turns is alive, or assembled for the
13942
+ // start now beginning: no tool boundary will weigh it.
13943
+ if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) {
13944
+ this.noteArrival(agent.name, metadata, stored);
13945
+ }
13946
+ this.noteForOtherResidents(agent, metadata, stored);
12872
13947
  return stored;
12873
13948
  }
12874
13949
  /**
@@ -12910,7 +13985,7 @@ export class AgentFramework {
12910
13985
  if (!agent) {
12911
13986
  throw new Error('No agents configured');
12912
13987
  }
12913
- agent.getContextManager().removeMessage(id);
13988
+ removeStoredMessage(agent.getContextManager(), id);
12914
13989
  }
12915
13990
  getMessage(id) {
12916
13991
  const agent = this.primaryAgentName
@@ -13322,31 +14397,56 @@ export class AgentFramework {
13322
14397
  // this turn — surface it on the ops-alert pipeline (ops:alert trace +
13323
14398
  // webhook → fleet-watch) immediately, not only at the hard-down streak.
13324
14399
  // The per-(agent,kind) cooldown throttles repeats; failures.log already
13325
- // has the per-exhaustion record from logFailure above.
13326
- if (overBudget) {
13327
- this.opsAlert('context-refusal', agentName, `compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
13328
- `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
13329
- `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`, { skipLog: true });
13330
- }
13331
- if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
14400
+ // has the per-exhaustion record from logFailure above. It says whether
14401
+ // the drain breaker kicks, decided where the kick is. What ends drains is
14402
+ // stop(), not a framework that was never started: one run with
14403
+ // runUntilIdle() has no maintenance timer, so a drain is its recovery.
14404
+ const drainState = !overBudget ? undefined
14405
+ : !agent ? 'unavailable (no agent handle)'
14406
+ : this.providerAdmissionClosed ? 'not kicked: the framework is stopping'
14407
+ : this.surgeryHold ? 'not kicked: a surgery holds the store'
14408
+ : this.overBudgetDrainInFlight.has(agentName) ? 'already draining'
14409
+ : 'kicking';
14410
+ if (agent && drainState === 'kicking') {
13332
14411
  this.overBudgetDrainInFlight.add(agentName);
13333
- void (async () => {
14412
+ // Like a maintenance pass: none starts once stop() has begun, it starts
14413
+ // no tick while a surgery holds the store, and a surgery, a
14414
+ // switchBranch or stop() waits for the tick in flight. It joins the
14415
+ // drains they wait for before the alert is raised and before its body
14416
+ // runs, so the alert's listeners find it: one that begins stop() or a
14417
+ // surgery waits for it, and its body then ticks none and says why. So
14418
+ // does a stop() made from inside its first tick, before that tick's
14419
+ // first await.
14420
+ const drain = Promise.resolve().then(async () => {
13334
14421
  let ticks = 0;
13335
14422
  try {
13336
14423
  const cm = agent.getContextManager();
13337
- while (ticks < 8) {
14424
+ while (ticks < 8 && !this.providerAdmissionClosed && !this.surgeryHold) {
13338
14425
  await cm.tick();
13339
14426
  ticks++;
13340
14427
  }
13341
- console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
14428
+ const stopped = ticks >= 8 ? '' : this.providerAdmissionClosed ? ', stopped as the framework stops' : ', stopped while a surgery holds the store';
14429
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks${stopped})`);
13342
14430
  }
13343
14431
  catch (err) {
13344
- console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
14432
+ if (err instanceof MaintenanceWithdrawnError) {
14433
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks, stopped: ${err.message})`);
14434
+ }
14435
+ else {
14436
+ console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
14437
+ }
13345
14438
  }
13346
14439
  finally {
13347
14440
  this.overBudgetDrainInFlight.delete(agentName);
13348
14441
  }
13349
- })();
14442
+ });
14443
+ this.overBudgetDrains.add(drain);
14444
+ void drain.finally(() => { this.overBudgetDrains.delete(drain); });
14445
+ }
14446
+ if (overBudget) {
14447
+ this.opsAlert('context-refusal', agentName, `compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
14448
+ `drain breaker ${drainState}; ` +
14449
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`, { skipLog: true });
13350
14450
  }
13351
14451
  // (3) Hard-down escalation on repeated identical failure.
13352
14452
  if (streak >= this.inferenceFailureEscalationThreshold) {
@@ -13473,13 +14573,22 @@ export class AgentFramework {
13473
14573
  // belt-and-suspenders fallback.
13474
14574
  homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName)
13475
14575
  ?? this.conversationRouter?.channelForAgent(agentName),
13476
- // Route a single TRUNK agent's plain-text speech to the channel that
13477
- // triggered its CURRENT turn (item-3 redux). connectome-host runs every
13478
- // agent as a trunk (it never exposes conversation forks), so without this
13479
- // a reply falls back to the process-global most-recent-inbound locus and
13480
- // a concurrent message in another channel hijacks it. Empty for
13481
- // heartbeat / no-trigger turns → correct global fallback.
13482
- activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
14576
+ // A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
14577
+ // its locus once the turn has one, a re-pin's or a channel_open's
14578
+ // included, else the channel that triggered the turn. connectome-host
14579
+ // runs every agent as a trunk (it never exposes conversation forks), so
14580
+ // without this a reply falls back to the process-global
14581
+ // most-recent-inbound locus and a concurrent message in another
14582
+ // channel hijacks it. An opening start resolves the turn's locus from
14583
+ // it (the trigger channel then), and the channel context names it, so
14584
+ // a later start of the turn is told where its unprefixed speech lands
14585
+ // unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
14586
+ // no-trigger turn until its locus is resolved, so that resolution
14587
+ // takes the global fallback; the turn then keeps that channel.
14588
+ activeChannelResolver: (agentName) => {
14589
+ const routing = this.turnRouting.get(agentName);
14590
+ return routing?.locus ?? routing?.triggerChannel;
14591
+ },
13483
14592
  // A text-only turn whose speech couldn't be delivered must not vanish
13484
14593
  // silently: record a `[discord-send-failed]` marker in chronicle so the
13485
14594
  // agent sees, on her next turn, that her reply never reached the human.
@@ -13570,15 +14679,17 @@ export class AgentFramework {
13570
14679
  }
13571
14680
  // Fail-open: log and continue with remaining servers
13572
14681
  const err = error instanceof Error ? error : new Error(String(error));
13573
- console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
14682
+ console.error(`Failed to connect MCPL server "${config.id}": ${err.message}${withStderr(err)}`);
13574
14683
  // Reaching this catch means no stub was created, so nothing will
13575
14684
  // retry — except in the edge case where addServer succeeded (with a
13576
14685
  // reconnecting stub) and a later setup step threw; config.reconnect
13577
14686
  // is the best cheap approximation of that.
14687
+ const stderrTail = stderrTailOf(err);
13578
14688
  this.emitTrace({
13579
14689
  type: 'mcpl:server-connect-failed',
13580
14690
  serverId: config.id,
13581
14691
  error: err.message,
14692
+ ...(stderrTail === undefined ? {} : { stderrTail }),
13582
14693
  attempt: 0,
13583
14694
  willRetry: config.reconnect === true,
13584
14695
  });
@@ -13603,9 +14714,19 @@ export class AgentFramework {
13603
14714
  /**
13604
14715
  * Reconcile the durable ledger with Chronicle, then deliver every server's
13605
14716
  * work. A drain still running is waited for and then followed by a fresh
13606
- * pass (drainForGeneration).
14717
+ * pass (drainForGeneration). Like the other operator entry points, it's
14718
+ * refused once stop() has begun, and stop() waits for one already running.
14719
+ * The framework's own branch moves start their delivery without awaiting
14720
+ * it (startDiscordAwarenessDelivery).
13607
14721
  */
13608
- async syncDiscordAwarenessMarkers(_onlyServerId) {
14722
+ async syncDiscordAwarenessMarkers(onlyServerId) {
14723
+ this.refuseOperatorAfterStop('awareness sync');
14724
+ return this.trackOperatorOperation('awareness:sync', () => this.runDiscordAwarenessSync(onlyServerId));
14725
+ }
14726
+ /** The sync's work. It installs the MCPL data-plane gate before its first
14727
+ * await, so a caller that starts it while holding the store puts the gate
14728
+ * up before releasing the store. */
14729
+ async runDiscordAwarenessSync(_onlyServerId) {
13609
14730
  if (!this.discordAwarenessOutbox)
13610
14731
  return;
13611
14732
  // A targeted retry may discover pending work for other Discord servers
@@ -13641,10 +14762,7 @@ export class AgentFramework {
13641
14762
  throw new Error(`Cannot safely resume Discord suppression ${batch.id}: interval ` +
13642
14763
  `${interval.fromId}..${interval.toId} is only partially present`);
13643
14764
  }
13644
- if (interval.fromId === interval.toId)
13645
- cm.removeMessage(from.id);
13646
- else
13647
- cm.removeMessages(from.id, to.id);
14765
+ removeStoredMessages(cm, from, to);
13648
14766
  }
13649
14767
  this.discordAwarenessOutbox.activate(batch.id);
13650
14768
  console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
@@ -13903,6 +15021,15 @@ export class AgentFramework {
13903
15021
  error: `Discord awareness accounting unhealthy: ${err.message}`,
13904
15022
  });
13905
15023
  }
15024
+ // A stopping host closes these connections itself, so recycling them now
15025
+ // would start reconnects only for stop() to close them again. Release the
15026
+ // barrier instead, as a completed one is released: nothing waits on a
15027
+ // rejected barrier, and unless the host is quiesced, what the pause held
15028
+ // back reaches the listeners' stopping refusal.
15029
+ if (this.queue?.isClosed) {
15030
+ this.completeMcplDataPlaneGate(barrier);
15031
+ return;
15032
+ }
13906
15033
  await Promise.all(connections.map(async (connection) => {
13907
15034
  await connection.reconnectAfterFailure().catch((closeError) => {
13908
15035
  console.error(`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError);
@@ -14374,6 +15501,10 @@ export class AgentFramework {
14374
15501
  * connection/handshake fails.
14375
15502
  */
14376
15503
  async connectMcplServer(config) {
15504
+ this.refuseOperatorAfterStop('connect MCPL server');
15505
+ return this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config));
15506
+ }
15507
+ async runConnectMcplServer(config) {
14377
15508
  // Lazily bring up the MCPL subsystem — a framework that started with zero
14378
15509
  // configured servers can still deploy its first one at runtime.
14379
15510
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
@@ -14402,6 +15533,10 @@ export class AgentFramework {
14402
15533
  * No-op-ish if the server is not connected (still clears routing state).
14403
15534
  */
14404
15535
  async disconnectMcplServer(id) {
15536
+ this.refuseOperatorAfterStop('disconnect MCPL server');
15537
+ return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
15538
+ }
15539
+ async runDisconnectMcplServer(id) {
14405
15540
  if (!this.mcplServerRegistry) {
14406
15541
  throw new Error('MCPL subsystem is not initialized');
14407
15542
  }
@@ -14428,6 +15563,10 @@ export class AgentFramework {
14428
15563
  * failures. Throws if the server has no stored config and none is given.
14429
15564
  */
14430
15565
  async restartMcplServer(id, newConfig) {
15566
+ this.refuseOperatorAfterStop('restart MCPL server');
15567
+ return this.trackOperatorOperation('mcpl:restart', () => this.runRestartMcplServer(id, newConfig));
15568
+ }
15569
+ async runRestartMcplServer(id, newConfig) {
14431
15570
  const config = newConfig ?? this.mcplServerConfigs.get(id);
14432
15571
  if (!config) {
14433
15572
  throw new Error(`MCPL server "${id}" is not configured`);
@@ -14438,8 +15577,8 @@ export class AgentFramework {
14438
15577
  // refuses the restart and leaves the running child up, whatever the
14439
15578
  // server's baseline or transport. (No outbox configured: nothing to read.)
14440
15579
  this.getPlacedReactionBaseline();
14441
- await this.disconnectMcplServer(id);
14442
- await this.connectMcplServer(config);
15580
+ await this.runDisconnectMcplServer(id);
15581
+ await this.runConnectMcplServer(config);
14443
15582
  }
14444
15583
  /**
14445
15584
  * List configured MCPL servers with live connection status and the number
@@ -14650,8 +15789,8 @@ export class AgentFramework {
14650
15789
  if (this.discordAwarenessBarrier)
14651
15790
  await this.awaitDiscordAwarenessBarriers();
14652
15791
  // A stopping host runs no host command: refused like the requests
14653
- // above, before its handler runs.
14654
- if (this.queue?.isClosed) {
15792
+ // above, before its handler runs, from the moment stop() has begun.
15793
+ if (this.stopHasBegun()) {
14655
15794
  this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
14656
15795
  return;
14657
15796
  }
@@ -14729,6 +15868,7 @@ export class AgentFramework {
14729
15868
  type: 'mcpl:server-connect-failed',
14730
15869
  serverId: connection.id,
14731
15870
  error: params.error,
15871
+ ...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
14732
15872
  attempt: params.attempt,
14733
15873
  willRetry: connection.willReconnect,
14734
15874
  });
@@ -14738,6 +15878,7 @@ export class AgentFramework {
14738
15878
  type: 'mcpl:server-connect-failed',
14739
15879
  serverId: connection.id,
14740
15880
  error: params.error,
15881
+ ...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
14741
15882
  attempt: params.attempt,
14742
15883
  willRetry: connection.willReconnect,
14743
15884
  });
@@ -14746,7 +15887,12 @@ export class AgentFramework {
14746
15887
  // 5 failed attempts ≈ a few minutes of outage. Throttled per
14747
15888
  // (serverId, kind), so a long outage re-posts every ~15 min.
14748
15889
  if (params.attempt >= 5) {
14749
- this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`);
15890
+ this.opsAlert('mcpl-down', connection.id,
15891
+ // Not the server's stderr (params.stderrTail): the webhook posts
15892
+ // outside the process, and a server's stderr can hold a credential
15893
+ // its configuration gives it, such as a token, which nothing here
15894
+ // redacts.
15895
+ `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`);
14750
15896
  }
14751
15897
  });
14752
15898
  // A late response to a tools/call that already timed out, carrying
@@ -14782,7 +15928,7 @@ export class AgentFramework {
14782
15928
  // on it would erase every checkpoint tree on an ordinary host restart while
14783
15929
  // a SIGKILL preserves them. Permanent removal is owned solely by
14784
15930
  // disconnectMcplServer, which deletes the trees explicitly.
14785
- connection.on('close', (code, signal) => {
15931
+ connection.on('close', (code, signal, info) => {
14786
15932
  this.featureSetManager?.removeServer(connection.id);
14787
15933
  // RFC-008: a closed provider's class hints are stale — on reconnect it
14788
15934
  // may class its tools differently. Until the next tools/list its tools
@@ -14793,6 +15939,9 @@ export class AgentFramework {
14793
15939
  serverId: connection.id,
14794
15940
  code: code ?? null,
14795
15941
  signal: signal ?? null,
15942
+ // The transport's own words only: a WebSocket server's close text
15943
+ // stays off the trace.
15944
+ reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
14796
15945
  willReconnect: connection.willReconnect,
14797
15946
  });
14798
15947
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -15035,6 +16184,9 @@ export class AgentFramework {
15035
16184
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
15036
16185
  const startTime = Date.now();
15037
16186
  const args = (call.input && typeof call.input === 'object') ? call.input : {};
16187
+ // The turn that sends: a send's engaged channel is recorded on it, even
16188
+ // when the send's result, or the open that follows, comes after the turn.
16189
+ const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined;
15038
16190
  // Build state params for stateful tools (Step 8).
15039
16191
  // A tools/list attribution selects that exact stateful set. Untagged tools
15040
16192
  // retain the host-managed or unique server-managed fallback, but never guess
@@ -15122,12 +16274,7 @@ export class AgentFramework {
15122
16274
  // the microtask queue, i.e. before the tool-result event
15123
16275
  // that evaluates injections.
15124
16276
  if (status !== 'unknown-channel' && status !== 'ambiguous') {
15125
- let engaged = this.turnEngagedChannels.get(agentName);
15126
- if (!engaged) {
15127
- engaged = new Set();
15128
- this.turnEngagedChannels.set(agentName, engaged);
15129
- }
15130
- engaged.add(channelId ?? target);
16277
+ sendingTurn?.engaged.add(channelId ?? target);
15131
16278
  }
15132
16279
  if (status === 'opened') {
15133
16280
  console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
@@ -15233,6 +16380,15 @@ export class AgentFramework {
15233
16380
  }
15234
16381
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
15235
16382
  const startTime = Date.now();
16383
+ // The turn that called: a channel_open's result records its trigger
16384
+ // channel there, however late it arrives, and moves its locus while the
16385
+ // turn is still open, even once another start's routing is the agent's
16386
+ // live one. A silent turn's open moves no locus: no silent start or
16387
+ // resume registers its routing state as the open turn (openLogical), and
16388
+ // a non-silent resume that does, a budget restart batched with an
16389
+ // ordinary wake, starts only once its over-budget boundary has every
16390
+ // result of the round.
16391
+ const callingTurn = this.routingOf(agentName);
15236
16392
  this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
15237
16393
  .then((result) => {
15238
16394
  const durationMs = Date.now() - startTime;
@@ -15240,10 +16396,10 @@ export class AgentFramework {
15240
16396
  // A successful channel_open plants the agent's feet in the opened
15241
16397
  // channel. The agent's own deliberate open is the strongest "my next
15242
16398
  // words go here" signal in the system — stronger than any injection —
15243
- // so it MOVES the current turn's prose pin (turnLocusPins), not just
15244
- // the next turn's trigger state. (The original 2026-07-21 Aria fix
15245
- // set only activeTriggerChannels, which resolveLocus reads at the
15246
- // NEXT turn's freeze — the turn-frozen refactor had silently
16399
+ // so it MOVES the current turn's locus (TurnRouting.locus), not just
16400
+ // its trigger channel. (The original 2026-07-21 Aria fix set only
16401
+ // the trigger channel, which resolveLocus reads at the NEXT turn's
16402
+ // freeze — the turn-frozen refactor had silently
15247
16403
  // regressed the "reply right after opening" case; found while
15248
16404
  // mapping the routing logic 2026-07-31, antra-ratified fix.) The
15249
16405
  // announcement rides THIS TOOL RESULT — model-requested content,
@@ -15254,10 +16410,19 @@ export class AgentFramework {
15254
16410
  const opened = result.data?.channelId
15255
16411
  ?? call.input?.channelId;
15256
16412
  if (opened) {
15257
- this.activeTriggerChannels.set(agentName, opened);
16413
+ callingTurn.triggerChannel = opened;
15258
16414
  const openerAgent = this.agents.get(agentName);
15259
- if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
15260
- this.turnLocusPins.set(agentName, opened);
16415
+ // The move is one this result tells the agent, so it needs the
16416
+ // turn that called still open. A result that lands after the turn
16417
+ // settled is dropped, so the agent never reads it, whether or not
16418
+ // a later start has replaced the turn's routing state yet: the
16419
+ // turn's locus stays where the agent was last told, for a resume
16420
+ // that opens the turn again, and so does the announce baseline,
16421
+ // which is the agent's across turns.
16422
+ if (openerAgent
16423
+ && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
16424
+ && this.openLogical.get(agentName) === callingTurn) {
16425
+ callingTurn.locus = opened;
15261
16426
  this.lastAnnouncedLocus.set(agentName, opened);
15262
16427
  result = {
15263
16428
  ...result,
@@ -15521,7 +16686,7 @@ export class AgentFramework {
15521
16686
  }
15522
16687
  }
15523
16688
  else {
15524
- const locus = this.turnLocusPins.get(agentName) ?? null;
16689
+ const locus = this.turnRouting.get(agentName)?.locus ?? null;
15525
16690
  this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
15526
16691
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
15527
16692
  });
@@ -16355,9 +17520,10 @@ export class AgentFramework {
16355
17520
  // Conversation identity = the agent (a trunk agent IS its own conversation;
16356
17521
  // forks get their own agent). The turn's channel LOCUS — the "proper
16357
17522
  // conversation tracking" this once flagged as a TODO — is now tracked
16358
- // per-agent in `activeTriggerChannels` and surfaced to the agent via
16359
- // buildChannelContext (channels.defaultOutgoing) below, so the agent is
16360
- // told the same channel its speech will route to (item-3 redux).
17523
+ // on the turn's routing state (TurnRouting.locus, from its trigger
17524
+ // channel) and surfaced to the agent via buildChannelContext
17525
+ // (channels.defaultOutgoing) below, so the agent is told the same
17526
+ // channel its speech will route to (item-3 redux).
16361
17527
  conversationId: agent.name,
16362
17528
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
16363
17529
  userMessage: null, // Could extract from trigger context