@animalabs/agent-framework 0.22.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (997) hide show
  1. package/README.md +108 -4
  2. package/dist/src/agent.d.ts +108 -17
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +207 -67
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +1 -1
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +5 -1
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/api/types.d.ts +1 -1
  12. package/dist/src/api/types.d.ts.map +1 -1
  13. package/dist/src/branch-names.d.ts +14 -0
  14. package/dist/src/branch-names.d.ts.map +1 -0
  15. package/dist/src/branch-names.js +22 -0
  16. package/dist/src/branch-names.js.map +1 -0
  17. package/dist/src/closest-names.d.ts +28 -0
  18. package/dist/src/closest-names.d.ts.map +1 -0
  19. package/dist/src/closest-names.js +98 -0
  20. package/dist/src/closest-names.js.map +1 -0
  21. package/dist/src/code-execution/index.d.ts +1 -1
  22. package/dist/src/code-execution/index.d.ts.map +1 -1
  23. package/dist/src/code-execution/index.js.map +1 -1
  24. package/dist/src/code-execution/py-runner.d.ts +50 -7
  25. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/py-runner.js +258 -90
  27. package/dist/src/code-execution/py-runner.js.map +1 -1
  28. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  29. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  30. package/dist/src/code-execution/runtime-py.js +94 -8
  31. package/dist/src/code-execution/runtime-py.js.map +1 -1
  32. package/dist/src/code-execution/tool-definition.d.ts +17 -1
  33. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  34. package/dist/src/code-execution/tool-definition.js +72 -6
  35. package/dist/src/code-execution/tool-definition.js.map +1 -1
  36. package/dist/src/config-checks.d.ts +28 -0
  37. package/dist/src/config-checks.d.ts.map +1 -0
  38. package/dist/src/config-checks.js +56 -0
  39. package/dist/src/config-checks.js.map +1 -0
  40. package/dist/src/deadline-timer.d.ts +21 -0
  41. package/dist/src/deadline-timer.d.ts.map +1 -0
  42. package/dist/src/deadline-timer.js +43 -0
  43. package/dist/src/deadline-timer.js.map +1 -0
  44. package/dist/src/error-message.d.ts +30 -0
  45. package/dist/src/error-message.d.ts.map +1 -0
  46. package/dist/src/error-message.js +50 -0
  47. package/dist/src/error-message.js.map +1 -0
  48. package/dist/src/framework.d.ts +1529 -165
  49. package/dist/src/framework.d.ts.map +1 -1
  50. package/dist/src/framework.js +8264 -1939
  51. package/dist/src/framework.js.map +1 -1
  52. package/dist/src/gate/event-gate.d.ts +8 -0
  53. package/dist/src/gate/event-gate.d.ts.map +1 -1
  54. package/dist/src/gate/event-gate.js +36 -5
  55. package/dist/src/gate/event-gate.js.map +1 -1
  56. package/dist/src/held-writes.d.ts +16 -0
  57. package/dist/src/held-writes.d.ts.map +1 -0
  58. package/dist/src/held-writes.js +56 -0
  59. package/dist/src/held-writes.js.map +1 -0
  60. package/dist/src/index.d.ts +10 -4
  61. package/dist/src/index.d.ts.map +1 -1
  62. package/dist/src/index.js +11 -5
  63. package/dist/src/index.js.map +1 -1
  64. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  65. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  66. package/dist/src/mcpl/capability-grant.js +1 -1
  67. package/dist/src/mcpl/capability-grant.js.map +1 -1
  68. package/dist/src/mcpl/channel-registry.d.ts +233 -18
  69. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/channel-registry.js +975 -386
  71. package/dist/src/mcpl/channel-registry.js.map +1 -1
  72. package/dist/src/mcpl/checkpoint-manager.d.ts +21 -1
  73. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  74. package/dist/src/mcpl/checkpoint-manager.js +91 -30
  75. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  76. package/dist/src/mcpl/conversation-router.d.ts +19 -3
  77. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/conversation-router.js +23 -8
  79. package/dist/src/mcpl/conversation-router.js.map +1 -1
  80. package/dist/src/mcpl/convert-block.d.ts +20 -0
  81. package/dist/src/mcpl/convert-block.d.ts.map +1 -0
  82. package/dist/src/mcpl/convert-block.js +88 -0
  83. package/dist/src/mcpl/convert-block.js.map +1 -0
  84. package/dist/src/mcpl/errors.d.ts +13 -0
  85. package/dist/src/mcpl/errors.d.ts.map +1 -1
  86. package/dist/src/mcpl/errors.js +28 -0
  87. package/dist/src/mcpl/errors.js.map +1 -1
  88. package/dist/src/mcpl/hook-orchestrator.d.ts +23 -0
  89. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.js +83 -72
  91. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  92. package/dist/src/mcpl/inbound-source.d.ts +114 -0
  93. package/dist/src/mcpl/inbound-source.d.ts.map +1 -0
  94. package/dist/src/mcpl/inbound-source.js +83 -0
  95. package/dist/src/mcpl/inbound-source.js.map +1 -0
  96. package/dist/src/mcpl/index.d.ts +7 -1
  97. package/dist/src/mcpl/index.d.ts.map +1 -1
  98. package/dist/src/mcpl/index.js +5 -1
  99. package/dist/src/mcpl/index.js.map +1 -1
  100. package/dist/src/mcpl/inference-router.d.ts +19 -13
  101. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  102. package/dist/src/mcpl/inference-router.js +155 -87
  103. package/dist/src/mcpl/inference-router.js.map +1 -1
  104. package/dist/src/mcpl/log-value.d.ts +7 -0
  105. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  106. package/dist/src/mcpl/log-value.js +9 -0
  107. package/dist/src/mcpl/log-value.js.map +1 -1
  108. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  109. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  110. package/dist/src/mcpl/modern-connection.js +1088 -0
  111. package/dist/src/mcpl/modern-connection.js.map +1 -0
  112. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  113. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  114. package/dist/src/mcpl/prefix-rule.js +25 -0
  115. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  116. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  117. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  118. package/dist/src/mcpl/protocol-family.js +697 -0
  119. package/dist/src/mcpl/protocol-family.js.map +1 -0
  120. package/dist/src/mcpl/push-coalescer.d.ts +58 -17
  121. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  122. package/dist/src/mcpl/push-coalescer.js +93 -32
  123. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  124. package/dist/src/mcpl/push-handler.d.ts +16 -9
  125. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  126. package/dist/src/mcpl/push-handler.js +18 -83
  127. package/dist/src/mcpl/push-handler.js.map +1 -1
  128. package/dist/src/mcpl/reference-fetcher.d.ts +76 -13
  129. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  130. package/dist/src/mcpl/reference-fetcher.js +275 -66
  131. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  132. package/dist/src/mcpl/references.d.ts +98 -18
  133. package/dist/src/mcpl/references.d.ts.map +1 -1
  134. package/dist/src/mcpl/references.js +170 -32
  135. package/dist/src/mcpl/references.js.map +1 -1
  136. package/dist/src/mcpl/server-connection.d.ts +406 -31
  137. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  138. package/dist/src/mcpl/server-connection.js +1331 -272
  139. package/dist/src/mcpl/server-connection.js.map +1 -1
  140. package/dist/src/mcpl/server-registry.d.ts +35 -6
  141. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  142. package/dist/src/mcpl/server-registry.js +63 -13
  143. package/dist/src/mcpl/server-registry.js.map +1 -1
  144. package/dist/src/mcpl/skip-reply-wake.d.ts +36 -0
  145. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -0
  146. package/dist/src/mcpl/skip-reply-wake.js +55 -0
  147. package/dist/src/mcpl/skip-reply-wake.js.map +1 -0
  148. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  149. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  150. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  151. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  152. package/dist/src/mcpl/transport.d.ts +104 -12
  153. package/dist/src/mcpl/transport.d.ts.map +1 -1
  154. package/dist/src/mcpl/transport.js +315 -85
  155. package/dist/src/mcpl/transport.js.map +1 -1
  156. package/dist/src/mcpl/types.d.ts +76 -19
  157. package/dist/src/mcpl/types.d.ts.map +1 -1
  158. package/dist/src/mcpl/types.js.map +1 -1
  159. package/dist/src/mcpl/visible-content.d.ts +16 -0
  160. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  161. package/dist/src/mcpl/visible-content.js +26 -0
  162. package/dist/src/mcpl/visible-content.js.map +1 -1
  163. package/dist/src/module-registry.d.ts +34 -6
  164. package/dist/src/module-registry.d.ts.map +1 -1
  165. package/dist/src/module-registry.js +119 -31
  166. package/dist/src/module-registry.js.map +1 -1
  167. package/dist/src/modules/api/index.d.ts +1 -1
  168. package/dist/src/modules/api/index.d.ts.map +1 -1
  169. package/dist/src/modules/api/index.js +3 -2
  170. package/dist/src/modules/api/index.js.map +1 -1
  171. package/dist/src/modules/discord/discord-js-client.d.ts +43 -2
  172. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  173. package/dist/src/modules/discord/discord-js-client.js +286 -67
  174. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  175. package/dist/src/modules/discord/index.d.ts +53 -5
  176. package/dist/src/modules/discord/index.d.ts.map +1 -1
  177. package/dist/src/modules/discord/index.js +712 -186
  178. package/dist/src/modules/discord/index.js.map +1 -1
  179. package/dist/src/modules/discord/refusal.d.ts +14 -0
  180. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  181. package/dist/src/modules/discord/refusal.js +14 -0
  182. package/dist/src/modules/discord/refusal.js.map +1 -0
  183. package/dist/src/modules/discord/types.d.ts +27 -4
  184. package/dist/src/modules/discord/types.d.ts.map +1 -1
  185. package/dist/src/modules/health/index.d.ts.map +1 -1
  186. package/dist/src/modules/health/index.js +8 -3
  187. package/dist/src/modules/health/index.js.map +1 -1
  188. package/dist/src/modules/history/index.d.ts +3 -1
  189. package/dist/src/modules/history/index.d.ts.map +1 -1
  190. package/dist/src/modules/history/index.js +359 -127
  191. package/dist/src/modules/history/index.js.map +1 -1
  192. package/dist/src/modules/history/semantic.d.ts +28 -1
  193. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  194. package/dist/src/modules/history/semantic.js +177 -32
  195. package/dist/src/modules/history/semantic.js.map +1 -1
  196. package/dist/src/modules/index.d.ts +1 -1
  197. package/dist/src/modules/index.d.ts.map +1 -1
  198. package/dist/src/modules/index.js +1 -1
  199. package/dist/src/modules/index.js.map +1 -1
  200. package/dist/src/modules/unknown-tool.d.ts +10 -0
  201. package/dist/src/modules/unknown-tool.d.ts.map +1 -0
  202. package/dist/src/modules/unknown-tool.js +17 -0
  203. package/dist/src/modules/unknown-tool.js.map +1 -0
  204. package/dist/src/modules/workspace/disk-agreement.d.ts +57 -4
  205. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/disk-agreement.js +75 -7
  207. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  208. package/dist/src/modules/workspace/effects.d.ts +66 -7
  209. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/effects.js +303 -46
  211. package/dist/src/modules/workspace/effects.js.map +1 -1
  212. package/dist/src/modules/workspace/index.d.ts +313 -45
  213. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/index.js +1404 -304
  215. package/dist/src/modules/workspace/index.js.map +1 -1
  216. package/dist/src/modules/workspace/observe.d.ts +315 -16
  217. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  218. package/dist/src/modules/workspace/observe.js +727 -37
  219. package/dist/src/modules/workspace/observe.js.map +1 -1
  220. package/dist/src/modules/workspace/reconcile.d.ts +58 -7
  221. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  222. package/dist/src/modules/workspace/reconcile.js +521 -56
  223. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  224. package/dist/src/modules/workspace/types.d.ts +28 -5
  225. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  226. package/dist/src/modules/workspace/types.js.map +1 -1
  227. package/dist/src/modules/workspace/watcher.d.ts +49 -2
  228. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  229. package/dist/src/modules/workspace/watcher.js +158 -29
  230. package/dist/src/modules/workspace/watcher.js.map +1 -1
  231. package/dist/src/prose-drafts.d.ts +240 -0
  232. package/dist/src/prose-drafts.d.ts.map +1 -0
  233. package/dist/src/prose-drafts.js +362 -0
  234. package/dist/src/prose-drafts.js.map +1 -0
  235. package/dist/src/prose-segments.d.ts +21 -2
  236. package/dist/src/prose-segments.d.ts.map +1 -1
  237. package/dist/src/prose-segments.js +46 -9
  238. package/dist/src/prose-segments.js.map +1 -1
  239. package/dist/src/recovery/offline-branch.d.ts +1 -0
  240. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  241. package/dist/src/recovery/offline-branch.js +3 -1
  242. package/dist/src/recovery/offline-branch.js.map +1 -1
  243. package/dist/src/safe-slice.d.ts +5 -1
  244. package/dist/src/safe-slice.d.ts.map +1 -1
  245. package/dist/src/safe-slice.js +9 -1
  246. package/dist/src/safe-slice.js.map +1 -1
  247. package/dist/src/silent-heartbeat.d.ts +112 -39
  248. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  249. package/dist/src/silent-heartbeat.js +100 -29
  250. package/dist/src/silent-heartbeat.js.map +1 -1
  251. package/dist/src/tool-args.d.ts +40 -0
  252. package/dist/src/tool-args.d.ts.map +1 -0
  253. package/dist/src/tool-args.js +207 -0
  254. package/dist/src/tool-args.js.map +1 -0
  255. package/dist/src/tool-image-ledger.d.ts +87 -19
  256. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  257. package/dist/src/tool-image-ledger.js +135 -32
  258. package/dist/src/tool-image-ledger.js.map +1 -1
  259. package/dist/src/tool-presentation.d.ts +9 -0
  260. package/dist/src/tool-presentation.d.ts.map +1 -1
  261. package/dist/src/tool-presentation.js +266 -62
  262. package/dist/src/tool-presentation.js.map +1 -1
  263. package/dist/src/tool-result-guard.d.ts.map +1 -1
  264. package/dist/src/tool-result-guard.js +7 -10
  265. package/dist/src/tool-result-guard.js.map +1 -1
  266. package/dist/src/tool-result-history.d.ts +32 -3
  267. package/dist/src/tool-result-history.d.ts.map +1 -1
  268. package/dist/src/tool-result-history.js +106 -19
  269. package/dist/src/tool-result-history.js.map +1 -1
  270. package/dist/src/tune-out/coordinator.d.ts +25 -1
  271. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  272. package/dist/src/tune-out/coordinator.js +260 -65
  273. package/dist/src/tune-out/coordinator.js.map +1 -1
  274. package/dist/src/types/agent.d.ts +44 -22
  275. package/dist/src/types/agent.d.ts.map +1 -1
  276. package/dist/src/types/events.d.ts +25 -0
  277. package/dist/src/types/events.d.ts.map +1 -1
  278. package/dist/src/types/framework.d.ts +38 -7
  279. package/dist/src/types/framework.d.ts.map +1 -1
  280. package/dist/src/types/inference-log.d.ts +1 -1
  281. package/dist/src/types/inference-log.d.ts.map +1 -1
  282. package/dist/src/types/module.d.ts +77 -14
  283. package/dist/src/types/module.d.ts.map +1 -1
  284. package/dist/src/types/trace.d.ts +97 -8
  285. package/dist/src/types/trace.d.ts.map +1 -1
  286. package/dist/src/usage/usage-tracker.d.ts +5 -0
  287. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  288. package/dist/src/usage/usage-tracker.js +35 -8
  289. package/dist/src/usage/usage-tracker.js.map +1 -1
  290. package/package.json +5 -4
  291. package/src/agent.ts +257 -72
  292. package/src/api/mcp-server-core.ts +1 -1
  293. package/src/api/server.ts +4 -1
  294. package/src/api/types.ts +1 -1
  295. package/src/branch-names.ts +19 -0
  296. package/src/closest-names.ts +94 -0
  297. package/src/code-execution/index.ts +1 -0
  298. package/src/code-execution/py-runner.ts +304 -93
  299. package/src/code-execution/runtime-py.ts +94 -8
  300. package/src/code-execution/tool-definition.ts +85 -7
  301. package/src/config-checks.ts +50 -0
  302. package/src/deadline-timer.ts +44 -0
  303. package/src/error-message.ts +48 -0
  304. package/src/framework.ts +8625 -1984
  305. package/src/gate/event-gate.ts +38 -5
  306. package/src/held-writes.ts +59 -0
  307. package/src/index.ts +29 -5
  308. package/src/mcpl/capability-grant.ts +1 -1
  309. package/src/mcpl/channel-registry.ts +1095 -411
  310. package/src/mcpl/checkpoint-manager.ts +104 -29
  311. package/src/mcpl/conversation-router.ts +28 -8
  312. package/src/mcpl/convert-block.ts +105 -0
  313. package/src/mcpl/errors.ts +28 -0
  314. package/src/mcpl/hook-orchestrator.ts +84 -77
  315. package/src/mcpl/inbound-source.ts +156 -0
  316. package/src/mcpl/index.ts +23 -1
  317. package/src/mcpl/inference-router.ts +179 -103
  318. package/src/mcpl/log-value.ts +10 -0
  319. package/src/mcpl/modern-connection.ts +1182 -0
  320. package/src/mcpl/prefix-rule.ts +32 -0
  321. package/src/mcpl/protocol-family.ts +691 -0
  322. package/src/mcpl/push-coalescer.ts +110 -36
  323. package/src/mcpl/push-handler.ts +20 -90
  324. package/src/mcpl/reference-fetcher.ts +318 -67
  325. package/src/mcpl/references.ts +183 -34
  326. package/src/mcpl/server-connection.ts +1404 -280
  327. package/src/mcpl/server-registry.ts +63 -14
  328. package/src/mcpl/skip-reply-wake.ts +73 -0
  329. package/src/mcpl/tool-result-normalize.ts +294 -0
  330. package/src/mcpl/transport.ts +320 -76
  331. package/src/mcpl/types.ts +77 -19
  332. package/src/mcpl/visible-content.ts +28 -0
  333. package/src/module-registry.ts +126 -36
  334. package/src/modules/api/index.ts +3 -2
  335. package/src/modules/discord/discord-js-client.ts +306 -74
  336. package/src/modules/discord/index.ts +737 -201
  337. package/src/modules/discord/refusal.ts +13 -0
  338. package/src/modules/discord/types.ts +27 -6
  339. package/src/modules/health/index.ts +8 -3
  340. package/src/modules/history/index.ts +410 -120
  341. package/src/modules/history/semantic.ts +189 -32
  342. package/src/modules/index.ts +1 -1
  343. package/src/modules/unknown-tool.ts +16 -0
  344. package/src/modules/workspace/disk-agreement.ts +97 -8
  345. package/src/modules/workspace/effects.ts +291 -36
  346. package/src/modules/workspace/index.ts +1469 -321
  347. package/src/modules/workspace/observe.ts +784 -41
  348. package/src/modules/workspace/reconcile.ts +619 -64
  349. package/src/modules/workspace/types.ts +28 -5
  350. package/src/modules/workspace/watcher.ts +154 -30
  351. package/src/prose-drafts.ts +481 -0
  352. package/src/prose-segments.ts +51 -8
  353. package/src/recovery/offline-branch.ts +4 -1
  354. package/src/safe-slice.ts +9 -1
  355. package/src/silent-heartbeat.ts +184 -59
  356. package/src/tool-args.ts +213 -0
  357. package/src/tool-image-ledger.ts +153 -34
  358. package/src/tool-presentation.ts +233 -42
  359. package/src/tool-result-guard.ts +7 -8
  360. package/src/tool-result-history.ts +124 -20
  361. package/src/tune-out/coordinator.ts +259 -65
  362. package/src/types/agent.ts +44 -24
  363. package/src/types/events.ts +25 -0
  364. package/src/types/framework.ts +38 -7
  365. package/src/types/inference-log.ts +1 -1
  366. package/src/types/module.ts +79 -14
  367. package/src/types/trace.ts +103 -8
  368. package/src/usage/usage-tracker.ts +32 -8
  369. package/dist/test/activation-request-sanitize.test.d.ts +0 -2
  370. package/dist/test/activation-request-sanitize.test.d.ts.map +0 -1
  371. package/dist/test/activation-request-sanitize.test.js +0 -89
  372. package/dist/test/activation-request-sanitize.test.js.map +0 -1
  373. package/dist/test/agent-cache-ttl.test.d.ts +0 -2
  374. package/dist/test/agent-cache-ttl.test.d.ts.map +0 -1
  375. package/dist/test/agent-cache-ttl.test.js +0 -41
  376. package/dist/test/agent-cache-ttl.test.js.map +0 -1
  377. package/dist/test/agent-runtime-settings.test.d.ts +0 -2
  378. package/dist/test/agent-runtime-settings.test.d.ts.map +0 -1
  379. package/dist/test/agent-runtime-settings.test.js +0 -1598
  380. package/dist/test/agent-runtime-settings.test.js.map +0 -1
  381. package/dist/test/api-admin-gate.test.d.ts +0 -2
  382. package/dist/test/api-admin-gate.test.d.ts.map +0 -1
  383. package/dist/test/api-admin-gate.test.js +0 -116
  384. package/dist/test/api-admin-gate.test.js.map +0 -1
  385. package/dist/test/assistant-persist-refusal.test.d.ts +0 -2
  386. package/dist/test/assistant-persist-refusal.test.d.ts.map +0 -1
  387. package/dist/test/assistant-persist-refusal.test.js +0 -63
  388. package/dist/test/assistant-persist-refusal.test.js.map +0 -1
  389. package/dist/test/batched-wake-locus.test.d.ts +0 -2
  390. package/dist/test/batched-wake-locus.test.d.ts.map +0 -1
  391. package/dist/test/batched-wake-locus.test.js +0 -166
  392. package/dist/test/batched-wake-locus.test.js.map +0 -1
  393. package/dist/test/busy-agent-scheduler.test.d.ts +0 -2
  394. package/dist/test/busy-agent-scheduler.test.d.ts.map +0 -1
  395. package/dist/test/busy-agent-scheduler.test.js +0 -92
  396. package/dist/test/busy-agent-scheduler.test.js.map +0 -1
  397. package/dist/test/channel-incoming-targeting.test.d.ts +0 -2
  398. package/dist/test/channel-incoming-targeting.test.d.ts.map +0 -1
  399. package/dist/test/channel-incoming-targeting.test.js +0 -81
  400. package/dist/test/channel-incoming-targeting.test.js.map +0 -1
  401. package/dist/test/channel-label-history.test.d.ts +0 -2
  402. package/dist/test/channel-label-history.test.d.ts.map +0 -1
  403. package/dist/test/channel-label-history.test.js +0 -424
  404. package/dist/test/channel-label-history.test.js.map +0 -1
  405. package/dist/test/channel-lifecycle-provenance.test.d.ts +0 -2
  406. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +0 -1
  407. package/dist/test/channel-lifecycle-provenance.test.js +0 -47
  408. package/dist/test/channel-lifecycle-provenance.test.js.map +0 -1
  409. package/dist/test/channel-registry-routing.test.d.ts +0 -2
  410. package/dist/test/channel-registry-routing.test.d.ts.map +0 -1
  411. package/dist/test/channel-registry-routing.test.js +0 -446
  412. package/dist/test/channel-registry-routing.test.js.map +0 -1
  413. package/dist/test/channel-registry-typing.test.d.ts +0 -2
  414. package/dist/test/channel-registry-typing.test.d.ts.map +0 -1
  415. package/dist/test/channel-registry-typing.test.js +0 -91
  416. package/dist/test/channel-registry-typing.test.js.map +0 -1
  417. package/dist/test/channels-changed-respond-first.test.d.ts +0 -2
  418. package/dist/test/channels-changed-respond-first.test.d.ts.map +0 -1
  419. package/dist/test/channels-changed-respond-first.test.js +0 -74
  420. package/dist/test/channels-changed-respond-first.test.js.map +0 -1
  421. package/dist/test/checkpoint-attribution.test.d.ts +0 -2
  422. package/dist/test/checkpoint-attribution.test.d.ts.map +0 -1
  423. package/dist/test/checkpoint-attribution.test.js +0 -200
  424. package/dist/test/checkpoint-attribution.test.js.map +0 -1
  425. package/dist/test/code-execution.test.d.ts +0 -11
  426. package/dist/test/code-execution.test.d.ts.map +0 -1
  427. package/dist/test/code-execution.test.js +0 -1272
  428. package/dist/test/code-execution.test.js.map +0 -1
  429. package/dist/test/component-defaults.test.d.ts +0 -2
  430. package/dist/test/component-defaults.test.d.ts.map +0 -1
  431. package/dist/test/component-defaults.test.js +0 -54
  432. package/dist/test/component-defaults.test.js.map +0 -1
  433. package/dist/test/conversation-fork-injection-scoping.test.d.ts +0 -2
  434. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +0 -1
  435. package/dist/test/conversation-fork-injection-scoping.test.js +0 -200
  436. package/dist/test/conversation-fork-injection-scoping.test.js.map +0 -1
  437. package/dist/test/conversation-router.test.d.ts +0 -2
  438. package/dist/test/conversation-router.test.d.ts.map +0 -1
  439. package/dist/test/conversation-router.test.js +0 -174
  440. package/dist/test/conversation-router.test.js.map +0 -1
  441. package/dist/test/conversation-routing.test.d.ts +0 -7
  442. package/dist/test/conversation-routing.test.d.ts.map +0 -1
  443. package/dist/test/conversation-routing.test.js +0 -621
  444. package/dist/test/conversation-routing.test.js.map +0 -1
  445. package/dist/test/deferred-wake-integrity.test.d.ts +0 -29
  446. package/dist/test/deferred-wake-integrity.test.d.ts.map +0 -1
  447. package/dist/test/deferred-wake-integrity.test.js +0 -320
  448. package/dist/test/deferred-wake-integrity.test.js.map +0 -1
  449. package/dist/test/discord-awareness-outbox.test.d.ts +0 -2
  450. package/dist/test/discord-awareness-outbox.test.d.ts.map +0 -1
  451. package/dist/test/discord-awareness-outbox.test.js +0 -371
  452. package/dist/test/discord-awareness-outbox.test.js.map +0 -1
  453. package/dist/test/discord-history-delete-window.test.d.ts +0 -2
  454. package/dist/test/discord-history-delete-window.test.d.ts.map +0 -1
  455. package/dist/test/discord-history-delete-window.test.js +0 -81
  456. package/dist/test/discord-history-delete-window.test.js.map +0 -1
  457. package/dist/test/discord-history-sync.test.d.ts +0 -2
  458. package/dist/test/discord-history-sync.test.d.ts.map +0 -1
  459. package/dist/test/discord-history-sync.test.js +0 -56
  460. package/dist/test/discord-history-sync.test.js.map +0 -1
  461. package/dist/test/discord-typing-keepalive.test.d.ts +0 -2
  462. package/dist/test/discord-typing-keepalive.test.d.ts.map +0 -1
  463. package/dist/test/discord-typing-keepalive.test.js +0 -77
  464. package/dist/test/discord-typing-keepalive.test.js.map +0 -1
  465. package/dist/test/ephemeral-settle.test.d.ts +0 -2
  466. package/dist/test/ephemeral-settle.test.d.ts.map +0 -1
  467. package/dist/test/ephemeral-settle.test.js +0 -354
  468. package/dist/test/ephemeral-settle.test.js.map +0 -1
  469. package/dist/test/event-gate-passthrough.test.d.ts +0 -15
  470. package/dist/test/event-gate-passthrough.test.d.ts.map +0 -1
  471. package/dist/test/event-gate-passthrough.test.js +0 -289
  472. package/dist/test/event-gate-passthrough.test.js.map +0 -1
  473. package/dist/test/event-gate-policy-mutation.test.d.ts +0 -2
  474. package/dist/test/event-gate-policy-mutation.test.d.ts.map +0 -1
  475. package/dist/test/event-gate-policy-mutation.test.js +0 -137
  476. package/dist/test/event-gate-policy-mutation.test.js.map +0 -1
  477. package/dist/test/event-gate-rate-limit.test.d.ts +0 -2
  478. package/dist/test/event-gate-rate-limit.test.d.ts.map +0 -1
  479. package/dist/test/event-gate-rate-limit.test.js +0 -627
  480. package/dist/test/event-gate-rate-limit.test.js.map +0 -1
  481. package/dist/test/event-gate-script.test.d.ts +0 -2
  482. package/dist/test/event-gate-script.test.d.ts.map +0 -1
  483. package/dist/test/event-gate-script.test.js +0 -95
  484. package/dist/test/event-gate-script.test.js.map +0 -1
  485. package/dist/test/event-gate-selfwake.test.d.ts +0 -15
  486. package/dist/test/event-gate-selfwake.test.d.ts.map +0 -1
  487. package/dist/test/event-gate-selfwake.test.js +0 -125
  488. package/dist/test/event-gate-selfwake.test.js.map +0 -1
  489. package/dist/test/event-gate-tags.test.d.ts +0 -2
  490. package/dist/test/event-gate-tags.test.d.ts.map +0 -1
  491. package/dist/test/event-gate-tags.test.js +0 -76
  492. package/dist/test/event-gate-tags.test.js.map +0 -1
  493. package/dist/test/event-gate.test.d.ts +0 -2
  494. package/dist/test/event-gate.test.d.ts.map +0 -1
  495. package/dist/test/event-gate.test.js +0 -919
  496. package/dist/test/event-gate.test.js.map +0 -1
  497. package/dist/test/explicit-prose-routing.test.d.ts +0 -11
  498. package/dist/test/explicit-prose-routing.test.d.ts.map +0 -1
  499. package/dist/test/explicit-prose-routing.test.js +0 -1715
  500. package/dist/test/explicit-prose-routing.test.js.map +0 -1
  501. package/dist/test/framework.test.d.ts +0 -5
  502. package/dist/test/framework.test.d.ts.map +0 -1
  503. package/dist/test/framework.test.js +0 -1048
  504. package/dist/test/framework.test.js.map +0 -1
  505. package/dist/test/gather-context-timeout.test.d.ts +0 -2
  506. package/dist/test/gather-context-timeout.test.d.ts.map +0 -1
  507. package/dist/test/gather-context-timeout.test.js +0 -120
  508. package/dist/test/gather-context-timeout.test.js.map +0 -1
  509. package/dist/test/health-snapshot.test.d.ts +0 -2
  510. package/dist/test/health-snapshot.test.d.ts.map +0 -1
  511. package/dist/test/health-snapshot.test.js +0 -74
  512. package/dist/test/health-snapshot.test.js.map +0 -1
  513. package/dist/test/helpers/coalescing-fixture.d.ts +0 -74
  514. package/dist/test/helpers/coalescing-fixture.d.ts.map +0 -1
  515. package/dist/test/helpers/coalescing-fixture.js +0 -152
  516. package/dist/test/helpers/coalescing-fixture.js.map +0 -1
  517. package/dist/test/helpers/coalescing-kill-child.d.ts +0 -2
  518. package/dist/test/helpers/coalescing-kill-child.d.ts.map +0 -1
  519. package/dist/test/helpers/coalescing-kill-child.js +0 -24
  520. package/dist/test/helpers/coalescing-kill-child.js.map +0 -1
  521. package/dist/test/helpers/mock-membrane.d.ts +0 -53
  522. package/dist/test/helpers/mock-membrane.d.ts.map +0 -1
  523. package/dist/test/helpers/mock-membrane.js +0 -168
  524. package/dist/test/helpers/mock-membrane.js.map +0 -1
  525. package/dist/test/helpers/pytc-stop-rows.d.ts +0 -11
  526. package/dist/test/helpers/pytc-stop-rows.d.ts.map +0 -1
  527. package/dist/test/helpers/pytc-stop-rows.js +0 -143
  528. package/dist/test/helpers/pytc-stop-rows.js.map +0 -1
  529. package/dist/test/helpers/quiesce-crash-child.d.ts +0 -2
  530. package/dist/test/helpers/quiesce-crash-child.d.ts.map +0 -1
  531. package/dist/test/helpers/quiesce-crash-child.js +0 -156
  532. package/dist/test/helpers/quiesce-crash-child.js.map +0 -1
  533. package/dist/test/hide-then-turn.test.d.ts +0 -2
  534. package/dist/test/hide-then-turn.test.d.ts.map +0 -1
  535. package/dist/test/hide-then-turn.test.js +0 -101
  536. package/dist/test/hide-then-turn.test.js.map +0 -1
  537. package/dist/test/history-module-overview.test.d.ts +0 -2
  538. package/dist/test/history-module-overview.test.d.ts.map +0 -1
  539. package/dist/test/history-module-overview.test.js +0 -711
  540. package/dist/test/history-module-overview.test.js.map +0 -1
  541. package/dist/test/history-module-realstore.test.d.ts +0 -2
  542. package/dist/test/history-module-realstore.test.d.ts.map +0 -1
  543. package/dist/test/history-module-realstore.test.js +0 -363
  544. package/dist/test/history-module-realstore.test.js.map +0 -1
  545. package/dist/test/history-module-semantic.test.d.ts +0 -2
  546. package/dist/test/history-module-semantic.test.d.ts.map +0 -1
  547. package/dist/test/history-module-semantic.test.js +0 -213
  548. package/dist/test/history-module-semantic.test.js.map +0 -1
  549. package/dist/test/history-module-ux.test.d.ts +0 -2
  550. package/dist/test/history-module-ux.test.d.ts.map +0 -1
  551. package/dist/test/history-module-ux.test.js +0 -304
  552. package/dist/test/history-module-ux.test.js.map +0 -1
  553. package/dist/test/history-module.test.d.ts +0 -2
  554. package/dist/test/history-module.test.d.ts.map +0 -1
  555. package/dist/test/history-module.test.js +0 -619
  556. package/dist/test/history-module.test.js.map +0 -1
  557. package/dist/test/history-semantic-review-findings.test.d.ts +0 -2
  558. package/dist/test/history-semantic-review-findings.test.d.ts.map +0 -1
  559. package/dist/test/history-semantic-review-findings.test.js +0 -338
  560. package/dist/test/history-semantic-review-findings.test.js.map +0 -1
  561. package/dist/test/history-semantic-sync-converges.test.d.ts +0 -2
  562. package/dist/test/history-semantic-sync-converges.test.d.ts.map +0 -1
  563. package/dist/test/history-semantic-sync-converges.test.js +0 -109
  564. package/dist/test/history-semantic-sync-converges.test.js.map +0 -1
  565. package/dist/test/host-quiesce.test.d.ts +0 -2
  566. package/dist/test/host-quiesce.test.d.ts.map +0 -1
  567. package/dist/test/host-quiesce.test.js +0 -988
  568. package/dist/test/host-quiesce.test.js.map +0 -1
  569. package/dist/test/inference-exhaustion-rewind.test.d.ts +0 -2
  570. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +0 -1
  571. package/dist/test/inference-exhaustion-rewind.test.js +0 -387
  572. package/dist/test/inference-exhaustion-rewind.test.js.map +0 -1
  573. package/dist/test/inference-failure-bounds.test.d.ts +0 -2
  574. package/dist/test/inference-failure-bounds.test.d.ts.map +0 -1
  575. package/dist/test/inference-failure-bounds.test.js +0 -1202
  576. package/dist/test/inference-failure-bounds.test.js.map +0 -1
  577. package/dist/test/inference-failure-observability.test.d.ts +0 -2
  578. package/dist/test/inference-failure-observability.test.d.ts.map +0 -1
  579. package/dist/test/inference-failure-observability.test.js +0 -84
  580. package/dist/test/inference-failure-observability.test.js.map +0 -1
  581. package/dist/test/journal-tool.test.d.ts +0 -2
  582. package/dist/test/journal-tool.test.d.ts.map +0 -1
  583. package/dist/test/journal-tool.test.js +0 -33
  584. package/dist/test/journal-tool.test.js.map +0 -1
  585. package/dist/test/kv-unified-open-flight-retry.test.d.ts +0 -2
  586. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +0 -1
  587. package/dist/test/kv-unified-open-flight-retry.test.js +0 -189
  588. package/dist/test/kv-unified-open-flight-retry.test.js.map +0 -1
  589. package/dist/test/kv-unified-wiring.test.d.ts +0 -2
  590. package/dist/test/kv-unified-wiring.test.d.ts.map +0 -1
  591. package/dist/test/kv-unified-wiring.test.js +0 -95
  592. package/dist/test/kv-unified-wiring.test.js.map +0 -1
  593. package/dist/test/late-events-after-stop.test.d.ts +0 -2
  594. package/dist/test/late-events-after-stop.test.d.ts.map +0 -1
  595. package/dist/test/late-events-after-stop.test.js +0 -122
  596. package/dist/test/late-events-after-stop.test.js.map +0 -1
  597. package/dist/test/liveness-watchdog.test.d.ts +0 -2
  598. package/dist/test/liveness-watchdog.test.d.ts.map +0 -1
  599. package/dist/test/liveness-watchdog.test.js +0 -94
  600. package/dist/test/liveness-watchdog.test.js.map +0 -1
  601. package/dist/test/machine-close-provenance.test.d.ts +0 -2
  602. package/dist/test/machine-close-provenance.test.d.ts.map +0 -1
  603. package/dist/test/machine-close-provenance.test.js +0 -116
  604. package/dist/test/machine-close-provenance.test.js.map +0 -1
  605. package/dist/test/maintenance-timer.test.d.ts +0 -2
  606. package/dist/test/maintenance-timer.test.d.ts.map +0 -1
  607. package/dist/test/maintenance-timer.test.js +0 -1827
  608. package/dist/test/maintenance-timer.test.js.map +0 -1
  609. package/dist/test/mcp-server-bin.test.d.ts +0 -2
  610. package/dist/test/mcp-server-bin.test.d.ts.map +0 -1
  611. package/dist/test/mcp-server-bin.test.js +0 -142
  612. package/dist/test/mcp-server-bin.test.js.map +0 -1
  613. package/dist/test/mcpl-access-provider.test.d.ts +0 -2
  614. package/dist/test/mcpl-access-provider.test.d.ts.map +0 -1
  615. package/dist/test/mcpl-access-provider.test.js +0 -20
  616. package/dist/test/mcpl-access-provider.test.js.map +0 -1
  617. package/dist/test/mcpl-awareness-barrier.test.d.ts +0 -2
  618. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +0 -1
  619. package/dist/test/mcpl-awareness-barrier.test.js +0 -769
  620. package/dist/test/mcpl-awareness-barrier.test.js.map +0 -1
  621. package/dist/test/mcpl-capability-grant.test.d.ts +0 -10
  622. package/dist/test/mcpl-capability-grant.test.d.ts.map +0 -1
  623. package/dist/test/mcpl-capability-grant.test.js +0 -111
  624. package/dist/test/mcpl-capability-grant.test.js.map +0 -1
  625. package/dist/test/mcpl-capability-scoping.test.d.ts +0 -2
  626. package/dist/test/mcpl-capability-scoping.test.d.ts.map +0 -1
  627. package/dist/test/mcpl-capability-scoping.test.js +0 -266
  628. package/dist/test/mcpl-capability-scoping.test.js.map +0 -1
  629. package/dist/test/mcpl-child-env.test.d.ts +0 -2
  630. package/dist/test/mcpl-child-env.test.d.ts.map +0 -1
  631. package/dist/test/mcpl-child-env.test.js +0 -158
  632. package/dist/test/mcpl-child-env.test.js.map +0 -1
  633. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +0 -2
  634. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +0 -1
  635. package/dist/test/mcpl-coalesced-content-parity.test.js +0 -532
  636. package/dist/test/mcpl-coalesced-content-parity.test.js.map +0 -1
  637. package/dist/test/mcpl-coalescing.test.d.ts +0 -2
  638. package/dist/test/mcpl-coalescing.test.d.ts.map +0 -1
  639. package/dist/test/mcpl-coalescing.test.js +0 -1125
  640. package/dist/test/mcpl-coalescing.test.js.map +0 -1
  641. package/dist/test/mcpl-config-diagnostics.test.d.ts +0 -2
  642. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +0 -1
  643. package/dist/test/mcpl-config-diagnostics.test.js +0 -452
  644. package/dist/test/mcpl-config-diagnostics.test.js.map +0 -1
  645. package/dist/test/mcpl-empty-content.test.d.ts +0 -2
  646. package/dist/test/mcpl-empty-content.test.d.ts.map +0 -1
  647. package/dist/test/mcpl-empty-content.test.js +0 -368
  648. package/dist/test/mcpl-empty-content.test.js.map +0 -1
  649. package/dist/test/mcpl-gate-roundtrip.test.d.ts +0 -2
  650. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +0 -1
  651. package/dist/test/mcpl-gate-roundtrip.test.js +0 -285
  652. package/dist/test/mcpl-gate-roundtrip.test.js.map +0 -1
  653. package/dist/test/mcpl-list-status.test.d.ts +0 -2
  654. package/dist/test/mcpl-list-status.test.d.ts.map +0 -1
  655. package/dist/test/mcpl-list-status.test.js +0 -82
  656. package/dist/test/mcpl-list-status.test.js.map +0 -1
  657. package/dist/test/mcpl-manifest-changed.test.d.ts +0 -2
  658. package/dist/test/mcpl-manifest-changed.test.d.ts.map +0 -1
  659. package/dist/test/mcpl-manifest-changed.test.js +0 -178
  660. package/dist/test/mcpl-manifest-changed.test.js.map +0 -1
  661. package/dist/test/mcpl-push-convert.test.d.ts +0 -2
  662. package/dist/test/mcpl-push-convert.test.d.ts.map +0 -1
  663. package/dist/test/mcpl-push-convert.test.js +0 -42
  664. package/dist/test/mcpl-push-convert.test.js.map +0 -1
  665. package/dist/test/mcpl-reconnect-events.test.d.ts +0 -2
  666. package/dist/test/mcpl-reconnect-events.test.d.ts.map +0 -1
  667. package/dist/test/mcpl-reconnect-events.test.js +0 -144
  668. package/dist/test/mcpl-reconnect-events.test.js.map +0 -1
  669. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +0 -2
  670. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +0 -1
  671. package/dist/test/mcpl-reconnect-reregistration.test.js +0 -238
  672. package/dist/test/mcpl-reconnect-reregistration.test.js.map +0 -1
  673. package/dist/test/mcpl-reference-fetcher.test.d.ts +0 -2
  674. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +0 -1
  675. package/dist/test/mcpl-reference-fetcher.test.js +0 -427
  676. package/dist/test/mcpl-reference-fetcher.test.js.map +0 -1
  677. package/dist/test/mcpl-reference-fields.test.d.ts +0 -2
  678. package/dist/test/mcpl-reference-fields.test.d.ts.map +0 -1
  679. package/dist/test/mcpl-reference-fields.test.js +0 -149
  680. package/dist/test/mcpl-reference-fields.test.js.map +0 -1
  681. package/dist/test/mcpl-rejection-log-lines.test.d.ts +0 -2
  682. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +0 -1
  683. package/dist/test/mcpl-rejection-log-lines.test.js +0 -349
  684. package/dist/test/mcpl-rejection-log-lines.test.js.map +0 -1
  685. package/dist/test/mcpl-request-timeout.test.d.ts +0 -2
  686. package/dist/test/mcpl-request-timeout.test.d.ts.map +0 -1
  687. package/dist/test/mcpl-request-timeout.test.js +0 -138
  688. package/dist/test/mcpl-request-timeout.test.js.map +0 -1
  689. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +0 -7
  690. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +0 -1
  691. package/dist/test/mcpl-runtime-lifecycle.test.js +0 -142
  692. package/dist/test/mcpl-runtime-lifecycle.test.js.map +0 -1
  693. package/dist/test/mcpl-stderr-tail.test.d.ts +0 -2
  694. package/dist/test/mcpl-stderr-tail.test.d.ts.map +0 -1
  695. package/dist/test/mcpl-stderr-tail.test.js +0 -210
  696. package/dist/test/mcpl-stderr-tail.test.js.map +0 -1
  697. package/dist/test/mcpl-tool-class-listing.test.d.ts +0 -2
  698. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +0 -1
  699. package/dist/test/mcpl-tool-class-listing.test.js +0 -147
  700. package/dist/test/mcpl-tool-class-listing.test.js.map +0 -1
  701. package/dist/test/mcpl-tool-classes.test.d.ts +0 -7
  702. package/dist/test/mcpl-tool-classes.test.d.ts.map +0 -1
  703. package/dist/test/mcpl-tool-classes.test.js +0 -80
  704. package/dist/test/mcpl-tool-classes.test.js.map +0 -1
  705. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +0 -2
  706. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +0 -1
  707. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +0 -237
  708. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +0 -1
  709. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +0 -11
  710. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +0 -1
  711. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +0 -412
  712. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +0 -1
  713. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +0 -2
  714. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +0 -1
  715. package/dist/test/mcpl-tool-lifecycle-script.test.js +0 -288
  716. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +0 -1
  717. package/dist/test/mcpl-tool-lifecycle.test.d.ts +0 -11
  718. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +0 -1
  719. package/dist/test/mcpl-tool-lifecycle.test.js +0 -270
  720. package/dist/test/mcpl-tool-lifecycle.test.js.map +0 -1
  721. package/dist/test/mcpl-transport-close.test.d.ts +0 -2
  722. package/dist/test/mcpl-transport-close.test.d.ts.map +0 -1
  723. package/dist/test/mcpl-transport-close.test.js +0 -373
  724. package/dist/test/mcpl-transport-close.test.js.map +0 -1
  725. package/dist/test/mcpl-transport-spawn.test.d.ts +0 -2
  726. package/dist/test/mcpl-transport-spawn.test.d.ts.map +0 -1
  727. package/dist/test/mcpl-transport-spawn.test.js +0 -58
  728. package/dist/test/mcpl-transport-spawn.test.js.map +0 -1
  729. package/dist/test/mcpl-ws-transport.test.d.ts +0 -2
  730. package/dist/test/mcpl-ws-transport.test.d.ts.map +0 -1
  731. package/dist/test/mcpl-ws-transport.test.js +0 -259
  732. package/dist/test/mcpl-ws-transport.test.js.map +0 -1
  733. package/dist/test/module-context-integration.test.d.ts +0 -18
  734. package/dist/test/module-context-integration.test.d.ts.map +0 -1
  735. package/dist/test/module-context-integration.test.js +0 -181
  736. package/dist/test/module-context-integration.test.js.map +0 -1
  737. package/dist/test/offline-recovery-branch.test.d.ts +0 -2
  738. package/dist/test/offline-recovery-branch.test.d.ts.map +0 -1
  739. package/dist/test/offline-recovery-branch.test.js +0 -570
  740. package/dist/test/offline-recovery-branch.test.js.map +0 -1
  741. package/dist/test/operator-surgery.test.d.ts +0 -2
  742. package/dist/test/operator-surgery.test.d.ts.map +0 -1
  743. package/dist/test/operator-surgery.test.js +0 -595
  744. package/dist/test/operator-surgery.test.js.map +0 -1
  745. package/dist/test/ops-alert.test.d.ts +0 -2
  746. package/dist/test/ops-alert.test.d.ts.map +0 -1
  747. package/dist/test/ops-alert.test.js +0 -153
  748. package/dist/test/ops-alert.test.js.map +0 -1
  749. package/dist/test/overbudget-drain-breaker.test.d.ts +0 -2
  750. package/dist/test/overbudget-drain-breaker.test.d.ts.map +0 -1
  751. package/dist/test/overbudget-drain-breaker.test.js +0 -198
  752. package/dist/test/overbudget-drain-breaker.test.js.map +0 -1
  753. package/dist/test/pack-installs-alone.test.d.ts +0 -2
  754. package/dist/test/pack-installs-alone.test.d.ts.map +0 -1
  755. package/dist/test/pack-installs-alone.test.js +0 -284
  756. package/dist/test/pack-installs-alone.test.js.map +0 -1
  757. package/dist/test/per-agent-delivery.test.d.ts +0 -2
  758. package/dist/test/per-agent-delivery.test.d.ts.map +0 -1
  759. package/dist/test/per-agent-delivery.test.js +0 -108
  760. package/dist/test/per-agent-delivery.test.js.map +0 -1
  761. package/dist/test/physical-window-restart.test.d.ts +0 -16
  762. package/dist/test/physical-window-restart.test.d.ts.map +0 -1
  763. package/dist/test/physical-window-restart.test.js +0 -487
  764. package/dist/test/physical-window-restart.test.js.map +0 -1
  765. package/dist/test/placed-reaction-baseline.test.d.ts +0 -2
  766. package/dist/test/placed-reaction-baseline.test.d.ts.map +0 -1
  767. package/dist/test/placed-reaction-baseline.test.js +0 -312
  768. package/dist/test/placed-reaction-baseline.test.js.map +0 -1
  769. package/dist/test/present-while-acting.test.d.ts +0 -19
  770. package/dist/test/present-while-acting.test.d.ts.map +0 -1
  771. package/dist/test/present-while-acting.test.js +0 -1649
  772. package/dist/test/present-while-acting.test.js.map +0 -1
  773. package/dist/test/prose-segments.test.d.ts +0 -2
  774. package/dist/test/prose-segments.test.d.ts.map +0 -1
  775. package/dist/test/prose-segments.test.js +0 -52
  776. package/dist/test/prose-segments.test.js.map +0 -1
  777. package/dist/test/prose-stream-router.test.d.ts +0 -2
  778. package/dist/test/prose-stream-router.test.d.ts.map +0 -1
  779. package/dist/test/prose-stream-router.test.js +0 -171
  780. package/dist/test/prose-stream-router.test.js.map +0 -1
  781. package/dist/test/provider-acceleration-cooldown.test.d.ts +0 -2
  782. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +0 -1
  783. package/dist/test/provider-acceleration-cooldown.test.js +0 -435
  784. package/dist/test/provider-acceleration-cooldown.test.js.map +0 -1
  785. package/dist/test/provider-waits.test.d.ts +0 -2
  786. package/dist/test/provider-waits.test.d.ts.map +0 -1
  787. package/dist/test/provider-waits.test.js +0 -2259
  788. package/dist/test/provider-waits.test.js.map +0 -1
  789. package/dist/test/puppet-tool-call.test.d.ts +0 -2
  790. package/dist/test/puppet-tool-call.test.d.ts.map +0 -1
  791. package/dist/test/puppet-tool-call.test.js +0 -226
  792. package/dist/test/puppet-tool-call.test.js.map +0 -1
  793. package/dist/test/puppet-turn-reservation.test.d.ts +0 -2
  794. package/dist/test/puppet-turn-reservation.test.d.ts.map +0 -1
  795. package/dist/test/puppet-turn-reservation.test.js +0 -139
  796. package/dist/test/puppet-turn-reservation.test.js.map +0 -1
  797. package/dist/test/reaction-baseline-platform.test.d.ts +0 -2
  798. package/dist/test/reaction-baseline-platform.test.d.ts.map +0 -1
  799. package/dist/test/reaction-baseline-platform.test.js +0 -124
  800. package/dist/test/reaction-baseline-platform.test.js.map +0 -1
  801. package/dist/test/record-journal.test.d.ts +0 -6
  802. package/dist/test/record-journal.test.d.ts.map +0 -1
  803. package/dist/test/record-journal.test.js +0 -269
  804. package/dist/test/record-journal.test.js.map +0 -1
  805. package/dist/test/refusal-reaction-baseline.test.d.ts +0 -2
  806. package/dist/test/refusal-reaction-baseline.test.d.ts.map +0 -1
  807. package/dist/test/refusal-reaction-baseline.test.js +0 -81
  808. package/dist/test/refusal-reaction-baseline.test.js.map +0 -1
  809. package/dist/test/refusal-rewind.test.d.ts +0 -2
  810. package/dist/test/refusal-rewind.test.d.ts.map +0 -1
  811. package/dist/test/refusal-rewind.test.js +0 -117
  812. package/dist/test/refusal-rewind.test.js.map +0 -1
  813. package/dist/test/refusal-single-call.test.d.ts +0 -2
  814. package/dist/test/refusal-single-call.test.d.ts.map +0 -1
  815. package/dist/test/refusal-single-call.test.js +0 -177
  816. package/dist/test/refusal-single-call.test.js.map +0 -1
  817. package/dist/test/release-bump.test.d.ts +0 -2
  818. package/dist/test/release-bump.test.d.ts.map +0 -1
  819. package/dist/test/release-bump.test.js +0 -248
  820. package/dist/test/release-bump.test.js.map +0 -1
  821. package/dist/test/release-changelog.test.d.ts +0 -2
  822. package/dist/test/release-changelog.test.d.ts.map +0 -1
  823. package/dist/test/release-changelog.test.js +0 -159
  824. package/dist/test/release-changelog.test.js.map +0 -1
  825. package/dist/test/release-holds.test.d.ts +0 -2
  826. package/dist/test/release-holds.test.d.ts.map +0 -1
  827. package/dist/test/release-holds.test.js +0 -456
  828. package/dist/test/release-holds.test.js.map +0 -1
  829. package/dist/test/same-round-think-policy.test.d.ts +0 -2
  830. package/dist/test/same-round-think-policy.test.d.ts.map +0 -1
  831. package/dist/test/same-round-think-policy.test.js +0 -853
  832. package/dist/test/same-round-think-policy.test.js.map +0 -1
  833. package/dist/test/save-recent-image.test.d.ts +0 -26
  834. package/dist/test/save-recent-image.test.d.ts.map +0 -1
  835. package/dist/test/save-recent-image.test.js +0 -974
  836. package/dist/test/save-recent-image.test.js.map +0 -1
  837. package/dist/test/shutdown-errors.test.d.ts +0 -2
  838. package/dist/test/shutdown-errors.test.d.ts.map +0 -1
  839. package/dist/test/shutdown-errors.test.js +0 -662
  840. package/dist/test/shutdown-errors.test.js.map +0 -1
  841. package/dist/test/silent-heartbeat-rows.test.d.ts +0 -2
  842. package/dist/test/silent-heartbeat-rows.test.d.ts.map +0 -1
  843. package/dist/test/silent-heartbeat-rows.test.js +0 -451
  844. package/dist/test/silent-heartbeat-rows.test.js.map +0 -1
  845. package/dist/test/silent-heartbeat.test.d.ts +0 -2
  846. package/dist/test/silent-heartbeat.test.d.ts.map +0 -1
  847. package/dist/test/silent-heartbeat.test.js +0 -165
  848. package/dist/test/silent-heartbeat.test.js.map +0 -1
  849. package/dist/test/state-scaling-e2e.test.d.ts +0 -22
  850. package/dist/test/state-scaling-e2e.test.d.ts.map +0 -1
  851. package/dist/test/state-scaling-e2e.test.js +0 -206
  852. package/dist/test/state-scaling-e2e.test.js.map +0 -1
  853. package/dist/test/state-scaling.test.d.ts +0 -12
  854. package/dist/test/state-scaling.test.d.ts.map +0 -1
  855. package/dist/test/state-scaling.test.js +0 -251
  856. package/dist/test/state-scaling.test.js.map +0 -1
  857. package/dist/test/stop-with-host-operations.test.d.ts +0 -20
  858. package/dist/test/stop-with-host-operations.test.d.ts.map +0 -1
  859. package/dist/test/stop-with-host-operations.test.js +0 -1174
  860. package/dist/test/stop-with-host-operations.test.js.map +0 -1
  861. package/dist/test/stop-with-work-in-flight.test.d.ts +0 -23
  862. package/dist/test/stop-with-work-in-flight.test.d.ts.map +0 -1
  863. package/dist/test/stop-with-work-in-flight.test.js +0 -784
  864. package/dist/test/stop-with-work-in-flight.test.js.map +0 -1
  865. package/dist/test/surgery-marks-receipt.test.d.ts +0 -2
  866. package/dist/test/surgery-marks-receipt.test.d.ts.map +0 -1
  867. package/dist/test/surgery-marks-receipt.test.js +0 -540
  868. package/dist/test/surgery-marks-receipt.test.js.map +0 -1
  869. package/dist/test/thinking-token-stamp.test.d.ts +0 -12
  870. package/dist/test/thinking-token-stamp.test.d.ts.map +0 -1
  871. package/dist/test/thinking-token-stamp.test.js +0 -129
  872. package/dist/test/thinking-token-stamp.test.js.map +0 -1
  873. package/dist/test/timezone.test.d.ts +0 -2
  874. package/dist/test/timezone.test.d.ts.map +0 -1
  875. package/dist/test/timezone.test.js +0 -18
  876. package/dist/test/timezone.test.js.map +0 -1
  877. package/dist/test/tool-batch-hook.test.d.ts +0 -8
  878. package/dist/test/tool-batch-hook.test.d.ts.map +0 -1
  879. package/dist/test/tool-batch-hook.test.js +0 -275
  880. package/dist/test/tool-batch-hook.test.js.map +0 -1
  881. package/dist/test/tool-policy.test.d.ts +0 -2
  882. package/dist/test/tool-policy.test.d.ts.map +0 -1
  883. package/dist/test/tool-policy.test.js +0 -51
  884. package/dist/test/tool-policy.test.js.map +0 -1
  885. package/dist/test/tool-presentation.test.d.ts +0 -2
  886. package/dist/test/tool-presentation.test.d.ts.map +0 -1
  887. package/dist/test/tool-presentation.test.js +0 -268
  888. package/dist/test/tool-presentation.test.js.map +0 -1
  889. package/dist/test/tool-result-content.test.d.ts +0 -2
  890. package/dist/test/tool-result-content.test.d.ts.map +0 -1
  891. package/dist/test/tool-result-content.test.js +0 -37
  892. package/dist/test/tool-result-content.test.js.map +0 -1
  893. package/dist/test/tool-result-guard.test.d.ts +0 -2
  894. package/dist/test/tool-result-guard.test.d.ts.map +0 -1
  895. package/dist/test/tool-result-guard.test.js +0 -3729
  896. package/dist/test/tool-result-guard.test.js.map +0 -1
  897. package/dist/test/tool-result-history.test.d.ts +0 -7
  898. package/dist/test/tool-result-history.test.d.ts.map +0 -1
  899. package/dist/test/tool-result-history.test.js +0 -156
  900. package/dist/test/tool-result-history.test.js.map +0 -1
  901. package/dist/test/tool-result-spill.test.d.ts +0 -13
  902. package/dist/test/tool-result-spill.test.d.ts.map +0 -1
  903. package/dist/test/tool-result-spill.test.js +0 -586
  904. package/dist/test/tool-result-spill.test.js.map +0 -1
  905. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +0 -2
  906. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +0 -1
  907. package/dist/test/tool-wrapper-prose-guard-integration.test.js +0 -485
  908. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +0 -1
  909. package/dist/test/tool-wrapper-prose-guard.test.d.ts +0 -2
  910. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +0 -1
  911. package/dist/test/tool-wrapper-prose-guard.test.js +0 -34
  912. package/dist/test/tool-wrapper-prose-guard.test.js.map +0 -1
  913. package/dist/test/tools-observe-parser-conformance.test.d.ts +0 -2
  914. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +0 -1
  915. package/dist/test/tools-observe-parser-conformance.test.js +0 -129
  916. package/dist/test/tools-observe-parser-conformance.test.js.map +0 -1
  917. package/dist/test/trunk-channel-routing.test.d.ts +0 -19
  918. package/dist/test/trunk-channel-routing.test.d.ts.map +0 -1
  919. package/dist/test/trunk-channel-routing.test.js +0 -652
  920. package/dist/test/trunk-channel-routing.test.js.map +0 -1
  921. package/dist/test/tune-out-e2e.test.d.ts +0 -2
  922. package/dist/test/tune-out-e2e.test.d.ts.map +0 -1
  923. package/dist/test/tune-out-e2e.test.js +0 -142
  924. package/dist/test/tune-out-e2e.test.js.map +0 -1
  925. package/dist/test/tune-out-gate-composition.test.d.ts +0 -2
  926. package/dist/test/tune-out-gate-composition.test.d.ts.map +0 -1
  927. package/dist/test/tune-out-gate-composition.test.js +0 -221
  928. package/dist/test/tune-out-gate-composition.test.js.map +0 -1
  929. package/dist/test/tune-out-lifecycle.test.d.ts +0 -2
  930. package/dist/test/tune-out-lifecycle.test.d.ts.map +0 -1
  931. package/dist/test/tune-out-lifecycle.test.js +0 -121
  932. package/dist/test/tune-out-lifecycle.test.js.map +0 -1
  933. package/dist/test/turn-trigger-provenance.test.d.ts +0 -2
  934. package/dist/test/turn-trigger-provenance.test.d.ts.map +0 -1
  935. package/dist/test/turn-trigger-provenance.test.js +0 -137
  936. package/dist/test/turn-trigger-provenance.test.js.map +0 -1
  937. package/dist/test/unconvertible-tool-result.test.d.ts +0 -2
  938. package/dist/test/unconvertible-tool-result.test.d.ts.map +0 -1
  939. package/dist/test/unconvertible-tool-result.test.js +0 -358
  940. package/dist/test/unconvertible-tool-result.test.js.map +0 -1
  941. package/dist/test/unsent-compiles.test.d.ts +0 -2
  942. package/dist/test/unsent-compiles.test.d.ts.map +0 -1
  943. package/dist/test/unsent-compiles.test.js +0 -139
  944. package/dist/test/unsent-compiles.test.js.map +0 -1
  945. package/dist/test/usage-accounting.test.d.ts +0 -2
  946. package/dist/test/usage-accounting.test.d.ts.map +0 -1
  947. package/dist/test/usage-accounting.test.js +0 -13
  948. package/dist/test/usage-accounting.test.js.map +0 -1
  949. package/dist/test/usage-tracker.test.d.ts +0 -2
  950. package/dist/test/usage-tracker.test.d.ts.map +0 -1
  951. package/dist/test/usage-tracker.test.js +0 -129
  952. package/dist/test/usage-tracker.test.js.map +0 -1
  953. package/dist/test/utils-surface.test.d.ts +0 -2
  954. package/dist/test/utils-surface.test.d.ts.map +0 -1
  955. package/dist/test/utils-surface.test.js +0 -145
  956. package/dist/test/utils-surface.test.js.map +0 -1
  957. package/dist/test/workspace-branch-continuation.test.d.ts +0 -15
  958. package/dist/test/workspace-branch-continuation.test.d.ts.map +0 -1
  959. package/dist/test/workspace-branch-continuation.test.js +0 -183
  960. package/dist/test/workspace-branch-continuation.test.js.map +0 -1
  961. package/dist/test/workspace-materialize-freshness.test.d.ts +0 -16
  962. package/dist/test/workspace-materialize-freshness.test.d.ts.map +0 -1
  963. package/dist/test/workspace-materialize-freshness.test.js +0 -252
  964. package/dist/test/workspace-materialize-freshness.test.js.map +0 -1
  965. package/dist/test/workspace-path-containment.test.d.ts +0 -19
  966. package/dist/test/workspace-path-containment.test.d.ts.map +0 -1
  967. package/dist/test/workspace-path-containment.test.js +0 -91
  968. package/dist/test/workspace-path-containment.test.js.map +0 -1
  969. package/dist/test/workspace-read-file-from-disk.test.d.ts +0 -17
  970. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +0 -1
  971. package/dist/test/workspace-read-file-from-disk.test.js +0 -174
  972. package/dist/test/workspace-read-file-from-disk.test.js.map +0 -1
  973. package/dist/test/workspace-read-image.test.d.ts +0 -2
  974. package/dist/test/workspace-read-image.test.d.ts.map +0 -1
  975. package/dist/test/workspace-read-image.test.js +0 -597
  976. package/dist/test/workspace-read-image.test.js.map +0 -1
  977. package/dist/test/workspace-read-text.test.d.ts +0 -2
  978. package/dist/test/workspace-read-text.test.d.ts.map +0 -1
  979. package/dist/test/workspace-read-text.test.js +0 -101
  980. package/dist/test/workspace-read-text.test.js.map +0 -1
  981. package/dist/test/workspace-restore-order.test.d.ts +0 -16
  982. package/dist/test/workspace-restore-order.test.d.ts.map +0 -1
  983. package/dist/test/workspace-restore-order.test.js +0 -140
  984. package/dist/test/workspace-restore-order.test.js.map +0 -1
  985. package/dist/test/workspace-three-way.test.d.ts +0 -12
  986. package/dist/test/workspace-three-way.test.d.ts.map +0 -1
  987. package/dist/test/workspace-three-way.test.js +0 -4094
  988. package/dist/test/workspace-three-way.test.js.map +0 -1
  989. package/dist/test/workspace-watcher.test.d.ts +0 -2
  990. package/dist/test/workspace-watcher.test.d.ts.map +0 -1
  991. package/dist/test/workspace-watcher.test.js +0 -281
  992. package/dist/test/workspace-watcher.test.js.map +0 -1
  993. package/dist/test/xml-round-persist.test.d.ts +0 -19
  994. package/dist/test/xml-round-persist.test.d.ts.map +0 -1
  995. package/dist/test/xml-round-persist.test.js +0 -235
  996. package/dist/test/xml-round-persist.test.js.map +0 -1
  997. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  import type { ContentBlock } from '@animalabs/membrane';
17
- import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
17
+ import { convertBlock } from './convert-block.js';
18
18
  import type { JsStore } from '@animalabs/chronicle';
19
+ import { isDeepStrictEqual } from 'node:util';
20
+ import { ensureStateRegistered } from '../module-registry.js';
19
21
 
20
22
  import type {
21
23
  ChannelDescriptor,
@@ -30,7 +32,6 @@ import type {
30
32
  ChannelsPublishParams,
31
33
  ChannelsOpenResult,
32
34
  ChannelHistoryRequest,
33
- McplContentBlock,
34
35
  } from './types.js';
35
36
 
36
37
  import type { McplServerRegistry } from './server-registry.js';
@@ -38,18 +39,55 @@ import type { FeatureSetManager } from './feature-set-manager.js';
38
39
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
39
40
  import { expandCoreTags } from './tags.js';
40
41
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
41
- import { isVisiblyEmptyContent } from './visible-content.js';
42
+ import { isVisiblyEmptyConversion } from './visible-content.js';
42
43
  import { logDetail, logValue } from './log-value.js';
43
44
  import { RefusalLines } from './refusal-lines.js';
44
45
  import { CapabilityGrant } from './capability-grant.js';
46
+ import type { InboundSource } from './inbound-source.js';
47
+ import { McplRequestError } from './server-connection.js';
48
+ import { readSkipReplyWake } from './skip-reply-wake.js';
45
49
 
46
50
  // ============================================================================
47
51
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
48
52
  // ============================================================================
49
53
 
50
54
  const TYPING_INTERVAL_MS = 7_000;
55
+ const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
56
+
57
+ /** A successful RPC can still fail to establish the latest channel target. */
58
+ class ChannelLifecycleConvergenceError extends Error {}
59
+ /** A channels/open answered for a channel other than the one it named. */
60
+ class ChannelOpenSubstitutionError extends Error {}
61
+ /** An operation queued for a server's registrations that were removed. */
62
+ class ChannelLifecycleCancelledError extends Error {}
51
63
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
52
64
 
65
+ /**
66
+ * What a channels/open answer says about which channel the server opened:
67
+ * its `channel.id`, or else the `channelId` of a history item. Each is
68
+ * evidence. A string other than the requested id names another channel (ids
69
+ * compare exactly, as the server's traffic is matched). Null or absence names
70
+ * no channel, and gives undefined, as does an answer that names none at all.
71
+ * Any other value is malformed: it names nothing the host could register.
72
+ */
73
+ function substitutedChannel(
74
+ result: ChannelsOpenResult | undefined,
75
+ requested: string,
76
+ ): { kind: 'other'; id: string } | { kind: 'malformed'; value: unknown } | undefined {
77
+ const named = (value: unknown) => {
78
+ if (value === undefined || value === null || value === requested) return undefined;
79
+ return typeof value === 'string' ? { kind: 'other' as const, id: value } : { kind: 'malformed' as const, value };
80
+ };
81
+ const answered = named(result?.channel?.id);
82
+ if (answered) return answered;
83
+ const history: unknown[] = Array.isArray(result?.history) ? result.history : [];
84
+ for (const item of history) {
85
+ const from = named((item as { channelId?: unknown } | null)?.channelId);
86
+ if (from) return from;
87
+ }
88
+ return undefined;
89
+ }
90
+
53
91
  /**
54
92
  * Durable "which label did this channelId have, each time we saw it" log.
55
93
  * `resolveProseTarget()` resolves a label to an id only for channels
@@ -210,9 +248,14 @@ function shallowEqualRecord(
210
248
  interface ChannelEntry {
211
249
  serverId: string;
212
250
  descriptor: ChannelDescriptor;
251
+ /** Whether the current target is confirmed open; false can be unconfirmed. */
213
252
  open: boolean;
214
253
  }
215
254
 
255
+ function sameChannelTarget(a: ChannelDescriptor, b: ChannelDescriptor): boolean {
256
+ return a.type === b.type && isDeepStrictEqual(a.address, b.address);
257
+ }
258
+
216
259
  /** Minimal responder interface for sending JSON-RPC results back. */
217
260
  interface Responder {
218
261
  respond(result: unknown): void;
@@ -239,94 +282,30 @@ interface McplChannelIncomingEvent {
239
282
  tags?: string[];
240
283
  triggerInference?: boolean;
241
284
  targetAgents?: string[];
285
+ /** Host acceptance time (epoch ms), stamped where the message is admitted. */
286
+ acceptedAt?: number;
287
+ /** Source envelope frozen at admission (mcpl/inbound-source.ts). */
288
+ inboundSource?: InboundSource;
242
289
  }
243
290
 
244
291
  // ============================================================================
245
- // Content Conversion: McplContentBlock → membrane ContentBlock
292
+ // Channel Tool Definitions
246
293
  // ============================================================================
247
294
 
248
295
  /**
249
- * Convert a single MCPL wire-format content block to a membrane ContentBlock.
296
+ * `journal`'s description. It tells the model to call `skip_reply` to end the
297
+ * turn only where the agent may call it: the framework builds each agent's
298
+ * surface with that agent's permission, and a refused `skip_reply` leaves the
299
+ * turn going on.
250
300
  */
251
- function convertBlock(block: McplContentBlock): ContentBlock {
252
- switch (block.type) {
253
- case 'text':
254
- // Text that isn't a string is no text, as the request sanitizer and
255
- // the visible-content check already read it.
256
- return { type: 'text', text: typeof block.text === 'string' ? block.text : '' };
257
-
258
- case 'image':
259
- if (isInlineContradiction(block)) {
260
- // RFC-005 vector 2: inline data claiming bulk disposition — fail
261
- // closed, withhold the data (checked BEFORE the data branch).
262
- return { type: 'text', text: INLINE_WITHHELD_TEXT };
263
- }
264
- if (isFilledString(block.uri) && block.disposition) {
265
- // RFC-005 uri-form media with a disposition claim: stub, do not
266
- // hand the URI to the provider or inline it.
267
- return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
268
- }
269
- if (isFilledString(block.data) && isFilledString(block.mimeType)) {
270
- return {
271
- type: 'image',
272
- source: { type: 'base64', data: block.data, mediaType: block.mimeType },
273
- } as ContentBlock;
274
- }
275
- if (isFilledString(block.uri)) {
276
- return {
277
- type: 'image',
278
- source: { type: 'url', url: block.uri },
279
- } as ContentBlock;
280
- }
281
- return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };
282
-
283
- case 'audio':
284
- if (isInlineContradiction(block)) {
285
- // RFC-005 vector 2: inline data claiming bulk disposition — fail
286
- // closed, withhold the data (checked BEFORE the data branch).
287
- return { type: 'text', text: INLINE_WITHHELD_TEXT };
288
- }
289
- if (isFilledString(block.uri) && block.disposition) {
290
- // RFC-005 uri-form media with a disposition claim: stub, do not
291
- // hand the URI to the provider or inline it.
292
- return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
293
- }
294
- if (isFilledString(block.data) && isFilledString(block.mimeType)) {
295
- return {
296
- type: 'audio',
297
- source: { type: 'base64', data: block.data, mediaType: block.mimeType },
298
- } as ContentBlock;
299
- }
300
- return { type: 'text', text: isFilledString(block.data) ? '[Audio: no media type]' : '[Audio: no data]' };
301
-
302
- case 'resource':
303
- // RFC-005: reference blocks become bounded stubs — never raw URIs
304
- // (a signed URL is a bearer credential that looks like a location).
305
- return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
306
-
307
- default: {
308
- // Unknown wire block types previously fell off the exhaustive switch
309
- // and propagated `undefined` into ContentBlock[]. Fail visibly. A type
310
- // that isn't a string is no type, and is never converted to one: an
311
- // object can throw when it is.
312
- const type: unknown = (block as { type?: unknown }).type;
313
- return { type: 'text', text: `[unrecognized content block: ${typeof type === 'string' ? type : 'untyped'}]` };
314
- }
315
- }
316
- }
317
-
318
- /** A string with something in it: the only data, mimeType or uri a block's
319
- * source is built from, and the only uri that makes it a reference. Anything
320
- * else a sender puts there leaves the block without it, so conversion never
321
- * throws and never copies a non-string. */
322
- function isFilledString(value: unknown): value is string {
323
- return typeof value === 'string' && value !== '';
301
+ export function journalToolDescription(canSkipReply: boolean): string {
302
+ return 'Write an entry in your private journal. The entry stays in your own context and ' +
303
+ 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
304
+ 'than a line that you want to keep for yourself — reflections, what you decided and ' +
305
+ 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
306
+ 'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
324
307
  }
325
308
 
326
- // ============================================================================
327
- // Channel Tool Definitions
328
- // ============================================================================
329
-
330
309
  const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
331
310
  {
332
311
  name: 'channel_list',
@@ -336,9 +315,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
336
315
  {
337
316
  name: 'channel_open',
338
317
  description:
339
- 'Open a channel to start receiving its ordinary ongoing traffic. The MCPL ' +
340
- 'integration performs its own subscribe/join/attach operation. Optionally request ' +
341
- 'history preceding the message that invited you into the channel.',
318
+ 'Open a channel. What an open channel delivers to you depends on its MCPL server. ' +
319
+ 'Optionally request history preceding the message that invited you into the channel.',
342
320
  inputSchema: {
343
321
  type: 'object' as const,
344
322
  properties: {
@@ -378,7 +356,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
378
356
  },
379
357
  {
380
358
  name: 'channel_close',
381
- description: 'Close a channel to stop receiving messages',
359
+ description:
360
+ 'Close a channel. Messages that address you still reach you; what else stops reaching you ' +
361
+ 'depends on its server.',
382
362
  inputSchema: {
383
363
  type: 'object' as const,
384
364
  properties: {
@@ -390,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
390
370
  },
391
371
  {
392
372
  name: 'channel_publish',
393
- description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
373
+ description: 'Publish a message to a channel. If channelId is omitted, publishes to the channel this conversation is bound to, if any, else to the most recent incoming channel. A subagent has neither, and must name one.',
394
374
  inputSchema: {
395
375
  type: 'object' as const,
396
376
  properties: {
397
- channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
377
+ channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the conversation\'s bound channel, else the most recent incoming channel; required of a subagent)' },
398
378
  content: { type: 'string', description: 'Text content to publish' },
399
379
  text: { type: 'string', description: 'Alias for content' },
400
380
  },
@@ -403,6 +383,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
403
383
  },
404
384
  {
405
385
  name: 'think',
386
+ // No agent's surface shows this description: getToolsForAgent gives every
387
+ // agent its own think (buildThinkTool), which names skip_reply only where
388
+ // that agent may call it.
406
389
  description:
407
390
  'Reason privately. The content stays in your own context and is NOT sent to any ' +
408
391
  'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
@@ -422,12 +405,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
422
405
  },
423
406
  {
424
407
  name: 'journal',
425
- description:
426
- 'Write an entry in your private journal. The entry stays in your own context and ' +
427
- 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
428
- 'than a line that you want to keep for yourself — reflections, what you decided and ' +
429
- 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
430
- 'text is routed; to end the turn without replying, call skip_reply afterwards.',
408
+ description: journalToolDescription(true),
431
409
  inputSchema: {
432
410
  type: 'object' as const,
433
411
  properties: {
@@ -457,8 +435,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
457
435
  type: 'number',
458
436
  description:
459
437
  'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
460
- 'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
461
- 'wake before then cancels it. Omit to stay idle until the next external wake.',
438
+ 'many seconds, from 1 to 3600; a larger number waits 3600 (1 = go again almost ' +
439
+ 'immediately). Any other wake before then cancels it. Omit to stay idle until the ' +
440
+ 'next external wake.',
462
441
  },
463
442
  },
464
443
  required: [],
@@ -470,7 +449,130 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
470
449
  // Constructor Options
471
450
  // ============================================================================
472
451
 
452
+ /** Bound connector values for notices, logs and an outcome's reason. A
453
+ * PublishOutcome keeps the connector's structured data (`detail`) whole. */
454
+ export function connectorText(value: unknown): string {
455
+ let text: string;
456
+ try {
457
+ text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
458
+ } catch {
459
+ return '[unserializable connector value]';
460
+ }
461
+ const limit = 2000;
462
+ const suffix = '… [truncated]';
463
+ return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
464
+ }
465
+
466
+ /** Where a publish was resolved to go — named by the registry, never by
467
+ * the caller's spelling. */
468
+ export interface PublishDestination {
469
+ serverId: string;
470
+ channelId: string;
471
+ /** The channel's registered label when the attempt was made. */
472
+ label?: string;
473
+ }
474
+
475
+ /**
476
+ * What one channels/publish attempt established (ChannelRegistry.publish).
477
+ * - `delivered`: the connector returned `delivered: true`. A historical
478
+ * receipt (confirmed at `at`, to `destination`, message `messageId` when
479
+ * the connector named one), not a claim the message still exists.
480
+ * - `failed`: nothing was posted: the request never left the host
481
+ * (unresolvable or shared channel id, missing grant, failed open, closed
482
+ * connection), or the connector answered `delivered: false` and named no
483
+ * message.
484
+ * - `unknown`: the request was dispatched and no valid receipt came back (an
485
+ * error response, a timeout, a lost connection, or a missing, malformed or
486
+ * contradictory receipt, such as `delivered: false` beside a message id).
487
+ * It may or may not have been posted.
488
+ */
489
+ export interface PublishOutcome {
490
+ status: 'delivered' | 'failed' | 'unknown';
491
+ /** Absent only when no destination could be resolved. */
492
+ destination?: PublishDestination;
493
+ messageId?: string;
494
+ /** Why it failed, or why its outcome is unknown — for a connector error,
495
+ * its message, bounded (which may say what was already posted). */
496
+ reason?: string;
497
+ /** The connector error's structured `data`, verbatim, when it sent one. */
498
+ detail?: unknown;
499
+ /** When the outcome was established (epoch ms). */
500
+ at: number;
501
+ }
502
+
503
+ /**
504
+ * What a channels/publish request that threw establishes. Only a request
505
+ * that never left the host proves nothing was posted. An error response may
506
+ * follow a partial post (a multi-part send whose later part failed), so it
507
+ * is unknown, with the connector's own words, bounded, and its data kept for
508
+ * the resident to judge. The publish executor and channel_publish both read
509
+ * a failed request this way.
510
+ */
511
+ function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome {
512
+ // This host's own words for a request that wasn't sent or wasn't answered
513
+ // are sentences, and every composition of the reason adds its own ending,
514
+ // so one final period comes off them. A server's error answer keeps its
515
+ // words as they came.
516
+ const text = connectorText(err instanceof Error ? err.message : String(err));
517
+ const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
518
+ const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
519
+ const detail = err instanceof McplRequestError ? err.data : undefined;
520
+ return {
521
+ status: notSent ? 'failed' : 'unknown',
522
+ destination,
523
+ reason: notSent ? message : `${message} (delivery uncertain)`,
524
+ ...(detail !== undefined ? { detail } : {}),
525
+ at,
526
+ };
527
+ }
528
+
529
+ /**
530
+ * What a channels/publish answer establishes. A final publish is a request,
531
+ * so success requires an explicit receipt; a missing or malformed one leaves
532
+ * delivery uncertain (anima-research/agent-framework#163's rule).
533
+ * `delivered: false` proves nothing went out only when the receipt names no
534
+ * message at all: a message id beside it, valid or malformed, contradicts it,
535
+ * since something may have been posted. The publish executor and
536
+ * channel_publish both read an answer this way.
537
+ */
538
+ function publishReceiptOutcome(result: unknown, destination: PublishDestination, at: number): PublishOutcome {
539
+ const receipt = (result ?? {}) as { delivered?: unknown; messageId?: unknown };
540
+ const messageId = typeof receipt.messageId === 'string' && receipt.messageId ? receipt.messageId : undefined;
541
+ if (receipt.delivered === true) {
542
+ return { status: 'delivered', destination, ...(messageId ? { messageId } : {}), at };
543
+ }
544
+ if (receipt.delivered === false) {
545
+ if (receipt.messageId === undefined || receipt.messageId === null) {
546
+ return { status: 'failed', destination, reason: `server "${destination.serverId}" reported delivered:false`, at };
547
+ }
548
+ return {
549
+ status: 'unknown',
550
+ destination,
551
+ ...(messageId ? { messageId } : {}),
552
+ reason: messageId
553
+ ? `server "${destination.serverId}" reported delivered:false but named posted message ${connectorText(messageId)} (delivery uncertain)`
554
+ : `server "${destination.serverId}" reported delivered:false beside a malformed message id ` +
555
+ `${connectorText(receipt.messageId)} (contradictory receipt; delivery uncertain)`,
556
+ at,
557
+ };
558
+ }
559
+ return {
560
+ status: 'unknown',
561
+ destination,
562
+ ...(messageId ? { messageId } : {}),
563
+ reason: `server "${destination.serverId}" returned no valid delivery receipt (delivery uncertain)`,
564
+ at,
565
+ };
566
+ }
567
+
473
568
  interface ChannelRegistryOptions {
569
+ /**
570
+ * An ordinary (uncoalesced) `channels/incoming` message has just been
571
+ * admitted, before it is queued or acknowledged: return its source
572
+ * envelope, frozen now (mcpl/inbound-source.ts). The framework observes the
573
+ * acceptance here; coalesced work is stamped and observed by its own path.
574
+ */
575
+ acceptInbound?: (event: McplChannelIncomingEvent) => InboundSource | undefined;
474
576
  /**
475
577
  * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with
476
578
  * the event the ordinary path would have queued. The handler decides
@@ -489,8 +591,10 @@ interface ChannelRegistryOptions {
489
591
  /**
490
592
  * Called when a text-only turn's speech could NOT be delivered to its
491
593
  * conversational locus — no locus, an unregistered channel, a missing
492
- * server, or the server reporting `delivered: false`. The host wires this
493
- * to drop a `[discord-send-failed]` marker into chronicle so the failure is
594
+ * server, or the server reporting `delivered: false`. Not called for an
595
+ * ephemeral run's speech with no locus, which is its caller's result
596
+ * (`takesGlobalFallback`). The host wires this to drop a
597
+ * `[discord-send-failed]` marker into chronicle so the failure is
494
598
  * visible to the agent (and operator) rather than silently lost. Must not
495
599
  * itself trigger inference (avoid wake loops).
496
600
  */
@@ -499,15 +603,22 @@ interface ChannelRegistryOptions {
499
603
  channelId: string | null;
500
604
  reason: string;
501
605
  textLen: number;
606
+ /** `failed`: nothing was posted; `unknown`: the request was dispatched
607
+ * and no valid receipt came back, so it may or may not have been. */
608
+ outcome?: 'failed' | 'unknown';
609
+ /** Bounded display of the connector's structured error data, when present. */
610
+ detail?: string;
502
611
  }) => void;
503
612
  /**
504
- * Called when channels were opened WITHOUT the agent asking (subscription
505
- * policy admitting a newly discovered channel, or delivery into a closed
506
- * locus). The host wires this to drop a durable notice into the agent's
507
- * window — the agent must always learn that new traffic will start
508
- * flowing, and that `channel_close` opts out (their decision outranks
509
- * policy). Fires ONCE per channel ever: the desired-state decision is
510
- * durable, so reboots do not re-announce. Must not trigger inference.
613
+ * Announces either a durable subscription-policy admission (before its
614
+ * transport RPC; not an open confirmation), or a delivery that turned a
615
+ * closed channel open, once its transport is confirmed. Reconfirming the
616
+ * transport of a channel already decided open or tuned out isn't one.
617
+ * The host records a durable, non-waking notice. What an open channel
618
+ * delivers is the server's to say (#406).
619
+ * Policy admissions fire once per channel: the persisted desired-state
620
+ * decision prevents re-announcement on retry or reboot. channel_close
621
+ * supersedes policy; a fresh delivery into a closed locus engages it again.
511
622
  */
512
623
  onChannelAutoOpened?: (info: {
513
624
  /** Agent whose action caused the open (delivery); absent for policy opens. */
@@ -543,6 +654,20 @@ interface ChannelRegistryOptions {
543
654
  * `defaultPublishChannel`.
544
655
  */
545
656
  activeChannelResolver?: (agentName: string) => string | undefined;
657
+ /**
658
+ * Whether a conversation takes the process-global fallback, the channel of
659
+ * the most recent delivered inbound message: as where its plain speech lands
660
+ * when it has no home and no channel of its own this turn, as its channel
661
+ * context's `incoming` and default outgoing channel, and as
662
+ * `channel_publish`'s target when it names none. False for an ephemeral run
663
+ * (a subagent): its prose is its caller's result, so a channel that only the
664
+ * latest inbound message picked is never its destination, and its plain
665
+ * speech with no channel of its own is undelivered rather than a failed
666
+ * delivery. Absent, every conversation takes it.
667
+ */
668
+ takesGlobalFallback?: (conversationId: string) => boolean;
669
+ /** Whether this framework has EventGate and can honor skip_reply self-wakes. */
670
+ eventGatePresent?: boolean;
546
671
  }
547
672
 
548
673
  // ============================================================================
@@ -576,12 +701,7 @@ export class ChannelRegistry {
576
701
  op?: 'start' | 'stop',
577
702
  ) => void;
578
703
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
579
- private onRouteFailure?: (info: {
580
- conversationId: string;
581
- channelId: string | null;
582
- reason: string;
583
- textLen: number;
584
- }) => void;
704
+ private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
585
705
  private onChannelAutoOpened?: (info: {
586
706
  conversationId?: string;
587
707
  serverId: string;
@@ -590,6 +710,7 @@ export class ChannelRegistry {
590
710
  }) => void;
591
711
  private homeChannelResolver?: (agentName: string) => string | undefined;
592
712
  private activeChannelResolver?: (agentName: string) => string | undefined;
713
+ private takesGlobalFallback?: (conversationId: string) => boolean;
593
714
  private store?: JsStore;
594
715
 
595
716
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
@@ -679,7 +800,49 @@ export class ChannelRegistry {
679
800
  /** One-time migration inputs from the retired recipe auto-open policy. */
680
801
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
681
802
  private migratedLegacyPolicies = new Set<string>();
803
+
804
+ // Keep one transport operation in flight per logical channel, including
805
+ // across descriptor replacement. Settled tails release themselves; a
806
+ // failed operation must not poison later decisions.
807
+ private channelLifecycleTails = new Map<string, Promise<void>>();
808
+ // Bumped when a server's registrations are removed: an operation queued
809
+ // before that is cancelled when it would run, rather than acting on a
810
+ // closing connection or on a later re-registration's entries.
811
+ private serverRemovals = new Map<string, number>();
812
+ // Operations queued or running per channel that will still read its
813
+ // target before confirming anything. Unlike the tail, which settles a few
814
+ // microtasks after its operation's last check, this drops at that check.
815
+ private lifecycleOutstanding = new Map<string, number>();
816
+ // Losing an open receipt does not confirm closure. Keep that distinction
817
+ // private while preserving the public open:boolean availability projection.
818
+ private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
819
+
820
+ private invalidateTransportConfirmation(entry: ChannelEntry): void {
821
+ entry.open = false;
822
+ this.unconfirmedChannelTargets.add(entry);
823
+ }
824
+
825
+ /** Whether the host directs this server's channel lifecycle (§14.1: its
826
+ * `channels.lifecycle` grant). Where it doesn't, the server opens and
827
+ * closes its own channels, and sends no receipt the host could wait for. */
828
+ private directsLifecycle(serverId: string): boolean {
829
+ return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
830
+ }
831
+
832
+ /** A registration's entry: nothing about its transport is known until a
833
+ * receipt for its target confirms it open or closed. */
834
+ private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): void {
835
+ const entry: ChannelEntry = { serverId, descriptor, open: false };
836
+ this.channels.set(key, entry);
837
+ this.unconfirmedChannelTargets.add(entry);
838
+ }
839
+
840
+ private hasPendingLifecycle(entry: ChannelEntry): boolean {
841
+ return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
842
+ }
682
843
  private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming'];
844
+ private acceptInbound?: ChannelRegistryOptions['acceptInbound'];
845
+ private channelTools: ToolDefinition[];
683
846
 
684
847
  constructor(
685
848
  serverRegistry: McplServerRegistry,
@@ -696,6 +859,24 @@ export class ChannelRegistry {
696
859
  },
697
860
  ) {
698
861
  this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
862
+ this.acceptInbound = options?.acceptInbound;
863
+ this.channelTools = CHANNEL_TOOL_DEFINITIONS.map((tool) => {
864
+ if (tool.name !== 'skip_reply' || options?.eventGatePresent === true) return tool;
865
+ const schema = tool.inputSchema as {
866
+ type: 'object';
867
+ properties: NonNullable<ToolDefinition['inputSchema']['properties']>;
868
+ required?: string[];
869
+ };
870
+ const { wake_in_seconds: _wake, ...properties } = schema.properties;
871
+ return {
872
+ ...tool,
873
+ description: tool.description.replace(
874
+ ' To end this turn but come back on your own shortly, set wake_in_seconds.',
875
+ '',
876
+ ),
877
+ inputSchema: { ...schema, properties },
878
+ };
879
+ });
699
880
  this.serverRegistry = serverRegistry;
700
881
  this.featureSetManager = featureSetManager;
701
882
  this.pushEventFn = pushEventFn;
@@ -706,6 +887,7 @@ export class ChannelRegistry {
706
887
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
707
888
  this.homeChannelResolver = options?.homeChannelResolver;
708
889
  this.activeChannelResolver = options?.activeChannelResolver;
890
+ this.takesGlobalFallback = options?.takesGlobalFallback;
709
891
  this.store = options?.store;
710
892
  this.initializeLifecycleStore();
711
893
  this.initializeLabelHistoryStore();
@@ -749,11 +931,7 @@ export class ChannelRegistry {
749
931
  continue;
750
932
  }
751
933
  const key = `${serverId}:${channel.id}`;
752
- this.channels.set(key, {
753
- serverId,
754
- descriptor: channel,
755
- open: false,
756
- });
934
+ this.registerEntry(key, serverId, channel);
757
935
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
758
936
  registeredIds.push(channel.id);
759
937
  results.push({ id: channel.id, accepted: true });
@@ -794,6 +972,7 @@ export class ChannelRegistry {
794
972
  // channels/register — a changed-set cannot smuggle in what register
795
973
  // would have rejected (PR #79 review blocker 5).
796
974
  const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
975
+ const retargeted: ChannelDescriptor[] = [];
797
976
  // Process removed channels — itemized truthfully: removal of a channel
798
977
  // we never had is reported, not silently accepted.
799
978
  if (params.removed) {
@@ -823,6 +1002,21 @@ export class ChannelRegistry {
823
1002
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
824
1003
  continue;
825
1004
  }
1005
+ // A live subscription to the old target does not confirm the new
1006
+ // type/address. Label-only updates preserve that confirmation. A
1007
+ // channel decided open (or tuned out) is opened at its new target.
1008
+ if (!sameChannelTarget(existing.descriptor, channel)) {
1009
+ this.invalidateTransportConfirmation(existing);
1010
+ // An operation queued or running for this channel reads its target
1011
+ // again before it confirms anything, and after a failed RPC, so it
1012
+ // opens the new one. With none, a channel decided open (or tuned
1013
+ // out) is reconciled here.
1014
+ const desired = this.getDesiredState(serverId, channel.id);
1015
+ if ((desired === 'open' || desired === 'tuned-out') &&
1016
+ !this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
1017
+ retargeted.push(channel);
1018
+ }
1019
+ }
826
1020
  existing.descriptor = channel;
827
1021
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
828
1022
  addedResults.push({ id: channel.id, accepted: true });
@@ -840,11 +1034,7 @@ export class ChannelRegistry {
840
1034
  continue;
841
1035
  }
842
1036
  const key = `${serverId}:${channel.id}`;
843
- this.channels.set(key, {
844
- serverId,
845
- descriptor: channel,
846
- open: false,
847
- });
1037
+ this.registerEntry(key, serverId, channel);
848
1038
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
849
1039
  addedResults.push({ id: channel.id, accepted: true });
850
1040
  accepted.push(channel);
@@ -859,7 +1049,8 @@ export class ChannelRegistry {
859
1049
  // settled above, so nothing in the response depends on reconciling.
860
1050
  responder?.respond({ results: addedResults });
861
1051
 
862
- if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
1052
+ const reconcile = [...accepted, ...retargeted];
1053
+ if (reconcile.length > 0) await this.reconcileChannels(serverId, reconcile);
863
1054
 
864
1055
  this.emitTraceFn({
865
1056
  type: 'mcpl:channels-changed',
@@ -1031,8 +1222,8 @@ export class ChannelRegistry {
1031
1222
  }
1032
1223
  }
1033
1224
  if (message.tags) message.tags = expandCoreTags(message.tags);
1034
- const convertedContent: ContentBlock[] = message.content.map(convertBlock);
1035
- if (!emptyAllowed && isVisiblyEmptyContent(convertedContent)) {
1225
+ const convertedContent: ContentBlock[] = message.content.map((block) => convertBlock(block, 'attachment on channel message', serverId));
1226
+ if (!emptyAllowed && isVisiblyEmptyConversion(message.content, convertedContent)) {
1036
1227
  rejectEmpty();
1037
1228
  continue;
1038
1229
  }
@@ -1043,15 +1234,31 @@ export class ChannelRegistry {
1043
1234
  // exactly the authority a self-attested channel would be stealing).
1044
1235
  // A coalesced item is "accepted" only once the coalescer admits it, so
1045
1236
  // for those this runs after the hook (below).
1237
+ const incomingEntry = this.channels.get(incomingKey)!;
1046
1238
  const markAccepted = () => {
1047
1239
  this.defaultPublishChannel = message.channelId;
1048
1240
  this.defaultPublishMessageId = message.messageId;
1049
1241
  this.defaultPublishThreadId = message.threadId;
1050
- // A server sending channels/incoming is authoritative evidence that
1051
- // the transport is actually open. This repairs transient status only;
1052
- // durable desired state still changes exclusively through lifecycle
1053
- // operations.
1054
- this.channels.get(incomingKey)!.open = true;
1242
+ // Where the host directs this server's lifecycle, only a receipt for
1243
+ // the current target confirms it: traffic shows the transport open
1244
+ // only for a confirmed target, and only for a channel still decided
1245
+ // open or tuned out. After a retarget, traffic for this id may still
1246
+ // come from the old subscription, and a close receipt that landed
1247
+ // first (or during the coalescer's await) stands. Where the host
1248
+ // doesn't direct it, the server opens its own channels and its
1249
+ // traffic is the only evidence the host gets, so it marks the
1250
+ // channel open, as it always has. The entry is the one the message
1251
+ // was accepted for: one a re-registration put in its place meanwhile
1252
+ // waits for its own evidence. Durable desired state still changes
1253
+ // exclusively through lifecycle operations.
1254
+ if (this.directsLifecycle(serverId)) {
1255
+ if (this.unconfirmedChannelTargets.has(incomingEntry)) return;
1256
+ const desired = this.getDesiredState(serverId, message.channelId);
1257
+ if (desired !== 'open' && desired !== 'tuned-out') return;
1258
+ } else {
1259
+ this.unconfirmedChannelTargets.delete(incomingEntry);
1260
+ }
1261
+ incomingEntry.open = true;
1055
1262
  };
1056
1263
  if (!coalesced) markAccepted();
1057
1264
 
@@ -1060,7 +1267,7 @@ export class ChannelRegistry {
1060
1267
  if (this.shouldTriggerInference) {
1061
1268
  // The text the agent's context gets, as the push lane's filter reads
1062
1269
  // it: the converted text blocks, a placeholder or reference stub
1063
- // among them, and a text block's non-string text as empty text.
1270
+ // among them, NON_STRING_TEXT_PLACEHOLDER included.
1064
1271
  const textContent = convertedContent
1065
1272
  .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
1066
1273
  .map((b) => b.text)
@@ -1093,6 +1300,7 @@ export class ChannelRegistry {
1093
1300
  metadata: message.metadata,
1094
1301
  ...(message.tags ? { tags: message.tags } : {}),
1095
1302
  triggerInference,
1303
+ acceptedAt: Date.now(),
1096
1304
  };
1097
1305
 
1098
1306
  if (coalesced) {
@@ -1118,6 +1326,12 @@ export class ChannelRegistry {
1118
1326
  continue;
1119
1327
  }
1120
1328
 
1329
+ // The source envelope is frozen here, at admission, before the message
1330
+ // is queued or acknowledged: a rename or rebind while it waits in the
1331
+ // queue cannot change where it says it came from.
1332
+ const inboundSource = this.acceptInbound?.(event);
1333
+ if (inboundSource) event.inboundSource = inboundSource;
1334
+
1121
1335
  // Push to the processing queue
1122
1336
  // Cast through unknown because McplChannelIncomingEvent matches the
1123
1337
  // CustomEvent `${string}:${string}` type pattern but lacks an index signature.
@@ -1171,20 +1385,34 @@ export class ChannelRegistry {
1171
1385
  (existing.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered &&
1172
1386
  label && existing.descriptor.label === channelId
1173
1387
  ) {
1174
- existing.descriptor.label = label;
1175
- if (extraMetadata) {
1176
- existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
1177
- }
1388
+ const metadata = extraMetadata
1389
+ ? { ...existing.descriptor.metadata, ...extraMetadata }
1390
+ : existing.descriptor.metadata;
1178
1391
  // The channel now has a real label, not just the bare id — durable.
1179
- this.appendLabelSighting(
1180
- existing.descriptor.id,
1181
- existing.descriptor.label,
1182
- extractDmMeta(existing.descriptor.metadata),
1183
- );
1392
+ // Recorded before the live descriptor takes it, since this branch's
1393
+ // guard reads the descriptor: an append that throws leaves the
1394
+ // placeholder, so the next push tries again.
1395
+ this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
1396
+ existing.descriptor.label = label;
1397
+ existing.descriptor.metadata = metadata;
1184
1398
  }
1185
1399
  return;
1186
1400
  }
1187
1401
 
1402
+ // Only persist a REAL label durably. Falling back to the bare
1403
+ // channelId as a placeholder (no server-supplied label at all — e.g. a
1404
+ // Discord DM push missing both channelName and an author name,
1405
+ // framework.ts's derivePushEventChannel) must never overwrite a real
1406
+ // label already on file from a previous boot: after a restart the live
1407
+ // `channels` map starts empty, so THIS is the very branch a
1408
+ // label-less post-restart event takes — recording the placeholder here
1409
+ // would clobber the good label initializeLabelHistoryStore() just
1410
+ // replayed, defeating the entire point of the durable log. The live
1411
+ // descriptor below still carries the placeholder for in-process
1412
+ // routing; only the durable write is skipped when there's no real
1413
+ // label to record. A real label is recorded before the entry exists:
1414
+ // an append that throws registers nothing, so the next push tries again.
1415
+ if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
1188
1416
  const key = `${serverId}:${channelId}`;
1189
1417
  this.channels.set(key, {
1190
1418
  serverId,
@@ -1197,19 +1425,6 @@ export class ChannelRegistry {
1197
1425
  },
1198
1426
  open: false,
1199
1427
  });
1200
- // Only persist a REAL label durably. Falling back to the bare
1201
- // channelId as a placeholder (no server-supplied label at all — e.g. a
1202
- // Discord DM push missing both channelName and an author name,
1203
- // framework.ts's derivePushEventChannel) must never overwrite a real
1204
- // label already on file from a previous boot: after a restart the live
1205
- // `channels` map starts empty, so THIS is the very branch a
1206
- // label-less post-restart event takes — recording the placeholder here
1207
- // would clobber the good label initializeLabelHistoryStore() just
1208
- // replayed, defeating the entire point of the durable log. The live
1209
- // descriptor above still carries the placeholder for in-process
1210
- // routing; only the durable write is skipped when there's no real
1211
- // label to record.
1212
- if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
1213
1428
  this.emitTraceFn({
1214
1429
  type: 'mcpl:channel-lazy-registered',
1215
1430
  serverId,
@@ -1344,6 +1559,13 @@ export class ChannelRegistry {
1344
1559
  return this.findChannelEntry(channelId)?.descriptor;
1345
1560
  }
1346
1561
 
1562
+ /** The registered label of `channelId` on exactly `serverId` (no
1563
+ * cross-server first match), or undefined when it has none. */
1564
+ getChannelLabel(serverId: string, channelId: string): string | undefined {
1565
+ const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label;
1566
+ return typeof label === 'string' && label.length > 0 ? label : undefined;
1567
+ }
1568
+
1347
1569
  isChannelOpen(channelId: string): boolean {
1348
1570
  return this.findChannelEntry(channelId)?.open === true;
1349
1571
  }
@@ -1373,7 +1595,7 @@ export class ChannelRegistry {
1373
1595
  * Get synthesized tool definitions for channel operations.
1374
1596
  */
1375
1597
  getChannelTools(): ToolDefinition[] {
1376
- return CHANNEL_TOOL_DEFINITIONS;
1598
+ return this.channelTools;
1377
1599
  }
1378
1600
 
1379
1601
  /**
@@ -1425,7 +1647,7 @@ export class ChannelRegistry {
1425
1647
  });
1426
1648
 
1427
1649
  case 'channel_publish':
1428
- return this.handleToolPublish(input as { channelId: string; content: string });
1650
+ return this.handleToolPublish(input as { channelId: string; content: string }, origin);
1429
1651
 
1430
1652
  case 'think':
1431
1653
  return this.handleToolThink(input as { content?: string });
@@ -1434,7 +1656,7 @@ export class ChannelRegistry {
1434
1656
  return this.handleToolJournal(input as { content?: string });
1435
1657
 
1436
1658
  case 'skip_reply':
1437
- return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
1659
+ return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });
1438
1660
 
1439
1661
  default:
1440
1662
  return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
@@ -1460,9 +1682,15 @@ export class ChannelRegistry {
1460
1682
  // (heartbeats). Without this, a fork
1461
1683
  // was advertised the global locus but published somewhere else; and a trunk
1462
1684
  // agent was told the wrong channel under concurrency.
1463
- const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
1464
- const active = agentName ? this.activeChannelResolver?.(agentName) : undefined;
1465
- const outgoing = home ?? active ?? this.defaultPublishChannel;
1685
+ // Each lookup is keyed on whether a name was given, not its truthiness:
1686
+ // '' is a name an ephemeral run can have, and resolveLocus reads it too.
1687
+ const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
1688
+ const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
1689
+ // An ephemeral run takes no global fallback (takesGlobalFallback): neither
1690
+ // the latest inbound message's channel as its outgoing one, nor that
1691
+ // message as the one it is replying to.
1692
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1693
+ const outgoing = home ?? active ?? fallback;
1466
1694
 
1467
1695
  if (openChannels.length === 0 && !outgoing) {
1468
1696
  return undefined;
@@ -1473,9 +1701,9 @@ export class ChannelRegistry {
1473
1701
  // Incoming: the most-recent inbound message (what the agent is replying to).
1474
1702
  // Left process-global — per-channel inbound tracking (the right messageId for
1475
1703
  // a fork's own channel) is a separate concern from the outbound routing fix.
1476
- if (this.defaultPublishChannel && this.defaultPublishMessageId) {
1704
+ if (fallback && this.defaultPublishMessageId) {
1477
1705
  context.incoming = {
1478
- channelId: this.defaultPublishChannel,
1706
+ channelId: fallback,
1479
1707
  messageId: this.defaultPublishMessageId,
1480
1708
  threadId: this.defaultPublishThreadId,
1481
1709
  };
@@ -1506,6 +1734,10 @@ export class ChannelRegistry {
1506
1734
  * server's channels don't linger and route speech into the void.
1507
1735
  */
1508
1736
  removeServer(serverId: string): void {
1737
+ // Every lifecycle operation queued for this server is cancelled when it
1738
+ // would run (ChannelLifecycleCancelledError), so none acts on a closing
1739
+ // connection or on a later re-registration's entries.
1740
+ this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
1509
1741
  for (const [key, entry] of this.channels) {
1510
1742
  if (entry.serverId !== serverId) continue;
1511
1743
  this.channels.delete(key);
@@ -1549,13 +1781,7 @@ export class ChannelRegistry {
1549
1781
  private initializeLifecycleStore(): void {
1550
1782
  if (!this.store) return;
1551
1783
 
1552
- try {
1553
- this.store.registerState({ id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
1554
- } catch (error) {
1555
- if (!(error instanceof Error) || !error.message.includes('State already exists')) {
1556
- throw error;
1557
- }
1558
- }
1784
+ ensureStateRegistered(this.store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
1559
1785
 
1560
1786
  const raw = this.store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
1561
1787
  if (!Array.isArray(raw)) return;
@@ -1598,6 +1824,15 @@ export class ChannelRegistry {
1598
1824
  }
1599
1825
  }
1600
1826
 
1827
+ /**
1828
+ * Each writer appends its record here before it changes memory for it, so
1829
+ * an append that throws, as on a full disk, fails the operation with that
1830
+ * change unmade, and memory and the log still agree. A retry then records
1831
+ * the change rather than finding it in memory already, where
1832
+ * setDesiredState's same-state return would record nothing, and a restart
1833
+ * replays what memory held. appendLabelSighting keeps the same order for
1834
+ * the label history.
1835
+ */
1601
1836
  private appendLifecycleEvent(event: ChannelLifecycleEvent): void {
1602
1837
  this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
1603
1838
  }
@@ -1616,13 +1851,7 @@ export class ChannelRegistry {
1616
1851
  private initializeLabelHistoryStore(): void {
1617
1852
  if (!this.store) return;
1618
1853
 
1619
- try {
1620
- this.store.registerState({ id: CHANNEL_LABEL_HISTORY_LOG_ID, strategy: 'append_log' });
1621
- } catch (error) {
1622
- if (!(error instanceof Error) || !error.message.includes('State already exists')) {
1623
- throw error;
1624
- }
1625
- }
1854
+ ensureStateRegistered(this.store, { id: CHANNEL_LABEL_HISTORY_LOG_ID, strategy: 'append_log' });
1626
1855
 
1627
1856
  const raw = this.store.getStateJson(CHANNEL_LABEL_HISTORY_LOG_ID);
1628
1857
  if (!Array.isArray(raw)) return;
@@ -1703,6 +1932,16 @@ export class ChannelRegistry {
1703
1932
  const recipientNameUnchanged = recipientName === undefined || recipientName === this.dmRecipientNames.get(channelId);
1704
1933
  const isDmUnchanged = isDm === undefined || this.dmClassified.get(channelId) === true;
1705
1934
  if (labelUnchanged && recipientIdUnchanged && recipientNameUnchanged && isDmUnchanged) return;
1935
+ // The log first, as for lifecycle events: an append that throws leaves
1936
+ // the history in memory as it was, so the next sighting records it.
1937
+ this.store?.appendToStateJson(CHANNEL_LABEL_HISTORY_LOG_ID, {
1938
+ channelId,
1939
+ label,
1940
+ ts: Date.now(),
1941
+ ...(recipientId ? { recipientId } : {}),
1942
+ ...(recipientName ? { recipientName } : {}),
1943
+ ...(isDm ? { isDm } : {}),
1944
+ } satisfies ChannelLabelSightingEvent);
1706
1945
  this.labelHistory.set(channelId, label);
1707
1946
  let seen = this.allLabelsSeen.get(channelId);
1708
1947
  if (!seen) {
@@ -1713,14 +1952,6 @@ export class ChannelRegistry {
1713
1952
  if (recipientId) this.dmRecipientIds.set(channelId, recipientId);
1714
1953
  if (recipientName) this.dmRecipientNames.set(channelId, recipientName);
1715
1954
  if (isDm) this.dmClassified.set(channelId, true);
1716
- this.store?.appendToStateJson(CHANNEL_LABEL_HISTORY_LOG_ID, {
1717
- channelId,
1718
- label,
1719
- ts: Date.now(),
1720
- ...(recipientId ? { recipientId } : {}),
1721
- ...(recipientName ? { recipientName } : {}),
1722
- ...(isDm ? { isDm } : {}),
1723
- } satisfies ChannelLabelSightingEvent);
1724
1955
  }
1725
1956
 
1726
1957
  /**
@@ -1945,7 +2176,6 @@ export class ChannelRegistry {
1945
2176
  ): void {
1946
2177
  const key = this.lifecycleKey(serverId, channelId);
1947
2178
  if (this.desiredStates.get(key)?.state === desired) return;
1948
- this.desiredStates.set(key, { state: desired, source });
1949
2179
  this.appendLifecycleEvent({
1950
2180
  kind: 'desired-state',
1951
2181
  serverId,
@@ -1954,6 +2184,7 @@ export class ChannelRegistry {
1954
2184
  source,
1955
2185
  timestamp: new Date().toISOString(),
1956
2186
  });
2187
+ this.desiredStates.set(key, { state: desired, source });
1957
2188
  }
1958
2189
 
1959
2190
  // ==========================================================================
@@ -1973,12 +2204,6 @@ export class ChannelRegistry {
1973
2204
  source: string,
1974
2205
  ): void {
1975
2206
  const key = this.lifecycleKey(serverId, channelId);
1976
- this.desiredStates.set(key, {
1977
- state: 'tuned-out',
1978
- source,
1979
- tuneOut: params,
1980
- wakeCount: 0,
1981
- });
1982
2207
  this.appendLifecycleEvent({
1983
2208
  kind: 'desired-state',
1984
2209
  serverId,
@@ -1988,6 +2213,12 @@ export class ChannelRegistry {
1988
2213
  source,
1989
2214
  timestamp: new Date().toISOString(),
1990
2215
  });
2216
+ this.desiredStates.set(key, {
2217
+ state: 'tuned-out',
2218
+ source,
2219
+ tuneOut: params,
2220
+ wakeCount: 0,
2221
+ });
1991
2222
  }
1992
2223
 
1993
2224
  /**
@@ -2006,7 +2237,6 @@ export class ChannelRegistry {
2006
2237
  const current = this.desiredStates.get(key);
2007
2238
  if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
2008
2239
  const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
2009
- this.desiredStates.set(key, { state: nextState, source });
2010
2240
  this.appendLifecycleEvent({
2011
2241
  kind: 'desired-state',
2012
2242
  serverId,
@@ -2015,6 +2245,7 @@ export class ChannelRegistry {
2015
2245
  source,
2016
2246
  timestamp: new Date().toISOString(),
2017
2247
  });
2248
+ this.desiredStates.set(key, { state: nextState, source });
2018
2249
  return ended;
2019
2250
  }
2020
2251
 
@@ -2032,16 +2263,17 @@ export class ChannelRegistry {
2032
2263
  const key = this.lifecycleKey(serverId, channelId);
2033
2264
  const current = this.desiredStates.get(key);
2034
2265
  if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
2035
- current.wakeCount = (current.wakeCount ?? 0) + 1;
2266
+ const wakeCount = (current.wakeCount ?? 0) + 1;
2036
2267
  this.appendLifecycleEvent({
2037
2268
  kind: 'tune-out-wake',
2038
2269
  serverId,
2039
2270
  channelId,
2040
2271
  epochId: current.tuneOut.epochId,
2041
- wakeCount: current.wakeCount,
2272
+ wakeCount,
2042
2273
  timestamp: new Date().toISOString(),
2043
2274
  });
2044
- return { params: current.tuneOut, wakeCount: current.wakeCount };
2275
+ current.wakeCount = wakeCount;
2276
+ return { params: current.tuneOut, wakeCount };
2045
2277
  }
2046
2278
 
2047
2279
  /** Active tune-out params + wake count for a channel, or null. */
@@ -2069,17 +2301,13 @@ export class ChannelRegistry {
2069
2301
  */
2070
2302
  readCoordinatorState(stateId: string): unknown {
2071
2303
  if (!this.store) return null;
2072
- try {
2073
- this.store.registerState({ id: stateId, strategy: 'snapshot' });
2074
- } catch { /* already registered */ }
2304
+ ensureStateRegistered(this.store, { id: stateId, strategy: 'snapshot' });
2075
2305
  return this.store.getStateJson(stateId);
2076
2306
  }
2077
2307
 
2078
2308
  writeCoordinatorState(stateId: string, value: unknown): void {
2079
2309
  if (!this.store) return;
2080
- try {
2081
- this.store.registerState({ id: stateId, strategy: 'snapshot' });
2082
- } catch { /* already registered */ }
2310
+ ensureStateRegistered(this.store, { id: stateId, strategy: 'snapshot' });
2083
2311
  this.store.setStateJson(stateId, value);
2084
2312
  }
2085
2313
 
@@ -2116,13 +2344,13 @@ export class ChannelRegistry {
2116
2344
  this.setDesiredState(serverId, channel.id, desired, 'legacy-recipe-migration');
2117
2345
  }
2118
2346
 
2119
- this.migratedLegacyPolicies.add(serverId);
2120
2347
  this.appendLifecycleEvent({
2121
2348
  kind: 'legacy-policy-migrated',
2122
2349
  serverId,
2123
2350
  source: Array.isArray(policy) ? 'allow-list' : policy,
2124
2351
  timestamp: new Date().toISOString(),
2125
2352
  });
2353
+ this.migratedLegacyPolicies.add(serverId);
2126
2354
  }
2127
2355
 
2128
2356
  /**
@@ -2181,6 +2409,225 @@ export class ChannelRegistry {
2181
2409
  return channel.initiallyOpen !== true && byPolicy;
2182
2410
  }
2183
2411
 
2412
+ /**
2413
+ * Apply current durable intent, serializing the transport rather than the
2414
+ * server's registration ACK. Re-read after every await: registration,
2415
+ * descriptor updates, or a newer decision may have replaced our snapshot.
2416
+ */
2417
+ private applyDesiredChannelState(
2418
+ serverId: string,
2419
+ channelId: string,
2420
+ options?: { history?: ChannelHistoryRequest },
2421
+ ): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
2422
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2423
+ const removals = this.serverRemovals.get(serverId) ?? 0;
2424
+ const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
2425
+ this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
2426
+ const operation = previous.catch(() => {}).then(
2427
+ () => this.runLifecycleOperation(serverId, channelId, removals, options),
2428
+ );
2429
+ // Keep the rejection visible to existing joiners, while observing it
2430
+ // immediately and letting each queued successor start after either result.
2431
+ const tail = operation.then(() => {});
2432
+ this.channelLifecycleTails.set(lifecycleKey, tail);
2433
+ const cleanup = () => {
2434
+ if (this.channelLifecycleTails.get(lifecycleKey) === tail) {
2435
+ this.channelLifecycleTails.delete(lifecycleKey);
2436
+ }
2437
+ };
2438
+ void tail.then(cleanup, cleanup);
2439
+ return operation;
2440
+ }
2441
+
2442
+ /** One queued lifecycle operation, run when its turn in the queue comes. */
2443
+ private async runLifecycleOperation(
2444
+ serverId: string,
2445
+ channelId: string,
2446
+ removals: number,
2447
+ options?: { history?: ChannelHistoryRequest },
2448
+ ): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
2449
+ const channelKey = `${serverId}:${channelId}`;
2450
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2451
+ try {
2452
+ let attempts = 0;
2453
+ for (;;) {
2454
+ if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
2455
+ throw new ChannelLifecycleCancelledError(
2456
+ `Channel ${channelId}: its server's registrations were removed while this operation was queued`,
2457
+ );
2458
+ }
2459
+ const entry = this.channels.get(channelKey);
2460
+ if (!entry) throw new Error(`Channel is no longer registered: ${channelId}`);
2461
+ const server = this.serverRegistry.getServer(serverId);
2462
+ if (!server) throw new Error(`Server not found: ${serverId}`);
2463
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2464
+ throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
2465
+ }
2466
+ const descriptor = entry.descriptor;
2467
+ const desired = this.getDesiredState(serverId, channelId);
2468
+ const open = desired === 'open' || desired === 'tuned-out';
2469
+ // Per-RPC timeouts do not bound a stream of promptly successful but
2470
+ // continually superseded receipts. Bound this whole operation, then
2471
+ // release its queue slot and surface the failure to delivery joiners.
2472
+ // Its open flag needs no change here: every retry follows a replaced
2473
+ // registration, a retarget (already invalidated, whether the old
2474
+ // target's RPC was answered or failed), or a receipt for this very
2475
+ // target.
2476
+ if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
2477
+ const error = new ChannelLifecycleConvergenceError(
2478
+ `Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`,
2479
+ );
2480
+ this.emitTraceFn({
2481
+ type: 'mcpl:channel-reconcile-failed',
2482
+ serverId, channelId, desired, attempts, error: error.message,
2483
+ });
2484
+ throw error;
2485
+ }
2486
+ attempts++;
2487
+ const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
2488
+ sameChannelTarget(entry.descriptor, descriptor);
2489
+ let result: ChannelsOpenResult | undefined;
2490
+ try {
2491
+ if (open) {
2492
+ result = await server.sendChannelsOpen({
2493
+ channelId, type: descriptor.type, address: descriptor.address,
2494
+ ...(options?.history ? { history: options.history } : {}),
2495
+ });
2496
+ } else {
2497
+ await server.sendChannelsClose({ channelId });
2498
+ }
2499
+ } catch (error) {
2500
+ // An RPC can fail after the server acted, so a failure for the
2501
+ // current target leaves its outcome unknown: it is marked
2502
+ // unconfirmed with open unchanged, which keeps a confirmed-open
2503
+ // channel usable after a failed backscroll (the mark is read only
2504
+ // while not open). A failure from an older connection or target
2505
+ // also withdraws the open flag.
2506
+ if (this.channels.get(channelKey) === entry) {
2507
+ if (sameTarget()) this.unconfirmedChannelTargets.add(entry);
2508
+ else this.invalidateTransportConfirmation(entry);
2509
+ }
2510
+ // A retarget while this RPC was out queued nothing for the new
2511
+ // target, since handleChanged leaves that to this operation. So a
2512
+ // failure for the old target says nothing about the new one, and a
2513
+ // channel still decided open or tuned out tries its new target,
2514
+ // within the attempt limit.
2515
+ if (this.channels.get(channelKey) === entry && !sameChannelTarget(entry.descriptor, descriptor)) {
2516
+ const now = this.getDesiredState(serverId, channelId);
2517
+ if (now === 'open' || now === 'tuned-out') continue;
2518
+ }
2519
+ throw error;
2520
+ }
2521
+ // A server can answer for a channel other than the one named: one
2522
+ // that falls back to its first channel of the type for an id it no
2523
+ // longer knows opens that channel and returns its history. Whatever
2524
+ // becomes of this receipt, the server did that: say so, and undo the
2525
+ // open it made if the host wants that channel closed.
2526
+ const substitute = open ? substitutedChannel(result, channelId) : undefined;
2527
+ if (substitute !== undefined) {
2528
+ // Each id prints through logValue, as the rejection lines' ids do,
2529
+ // so an odd one can't add a line or a field.
2530
+ const answered = logValue(substitute.kind === 'other' ? substitute.id : substitute.value);
2531
+ console.error(
2532
+ `[channel-open-substituted] server=${serverId} requested=${logValue(channelId)} answered=${answered}`,
2533
+ );
2534
+ this.emitTraceFn({ type: 'mcpl:channel-open-substituted', serverId, channelId, answeredFor: answered });
2535
+ // A malformed id names nothing registered here, so there is
2536
+ // nothing to close.
2537
+ if (substitute.kind === 'other') this.closeSubstitutedChannel(serverId, substitute.id);
2538
+ }
2539
+ // Never apply an old receipt to the current target, even transiently:
2540
+ // a corrective open might fail before that stale bit is replaced.
2541
+ if (this.channels.get(channelKey) !== entry) continue;
2542
+ if (!sameTarget()) {
2543
+ this.invalidateTransportConfirmation(entry);
2544
+ continue;
2545
+ }
2546
+ // A current receipt for another channel confirms nothing about this
2547
+ // one, and its history is another channel's. So the open fails,
2548
+ // nothing in the answer is used, and this channel's open state is
2549
+ // unconfirmed: a server that doesn't resolve this id may not resolve
2550
+ // its sends either.
2551
+ if (substitute !== undefined) {
2552
+ this.invalidateTransportConfirmation(entry);
2553
+ throw new ChannelOpenSubstitutionError(
2554
+ `Server "${serverId}" answered channels/open for ${channelId} with ` +
2555
+ (substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
2556
+ `; ${channelId} is not marked open, and nothing in the answer is used`,
2557
+ );
2558
+ }
2559
+ entry.open = open;
2560
+ this.unconfirmedChannelTargets.delete(entry);
2561
+ const now = this.getDesiredState(serverId, channelId);
2562
+ if (open !== (now === 'open' || now === 'tuned-out')) continue;
2563
+ return { open, result };
2564
+ }
2565
+ } finally {
2566
+ // Synchronous with the operation's last look at its target: from here
2567
+ // on, nothing it does will open a new target.
2568
+ const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
2569
+ if (left > 0) this.lifecycleOutstanding.set(lifecycleKey, left);
2570
+ else this.lifecycleOutstanding.delete(lifecycleKey);
2571
+ }
2572
+ }
2573
+
2574
+ /**
2575
+ * A server that answered an open with another channel really opened that
2576
+ * one. When it's registered here and the host wants it closed, close it
2577
+ * through its own lifecycle queue: otherwise its traffic would reach the
2578
+ * resident, marked open by its first incoming message. A channel the host
2579
+ * wants open is left alone, and a close can't be answered for another
2580
+ * channel, so this never starts another substitution. An id the host
2581
+ * doesn't know needs nothing: §14.5 refuses its traffic.
2582
+ */
2583
+ private closeSubstitutedChannel(serverId: string, channelId: string): void {
2584
+ if (!this.channels.has(`${serverId}:${channelId}`)) return;
2585
+ if (this.getDesiredState(serverId, channelId) !== 'closed') return;
2586
+ this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2587
+ // Removal while queued is cancellation, as in reconcileChannels, and
2588
+ // exhausted convergence emitted its own diagnostic.
2589
+ if (!this.channels.has(`${serverId}:${channelId}`) ||
2590
+ err instanceof ChannelLifecycleCancelledError ||
2591
+ err instanceof ChannelLifecycleConvergenceError) return;
2592
+ this.emitTraceFn({
2593
+ type: 'mcpl:channel-reconcile-failed',
2594
+ serverId, channelId, desired: 'closed', error: (err as Error).message,
2595
+ });
2596
+ });
2597
+ }
2598
+
2599
+ /**
2600
+ * Join outstanding lifecycle work without recording new intent. Retargeting
2601
+ * may have invalidated open confirmation while the decision remains pending.
2602
+ * A close that settles during the wait remains authoritative; waiting for
2603
+ * it is not an instruction to open the channel again.
2604
+ */
2605
+ private async waitForOpenChannel(entry: ChannelEntry): Promise<ChannelEntry> {
2606
+ const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
2607
+ for (;;) {
2608
+ const pending = this.channelLifecycleTails.get(lifecycleKey);
2609
+ if (!pending) break;
2610
+ try {
2611
+ await pending;
2612
+ } catch (error) {
2613
+ // A redundant backscroll failure can leave a known-open channel
2614
+ // usable. Exhausted convergence is different: it never established
2615
+ // the current target, so joined delivery must fail visibly.
2616
+ if (error instanceof ChannelLifecycleConvergenceError) throw error;
2617
+ }
2618
+ // A settled tail releases its own key. One still current after it
2619
+ // settled would be awaited again without ever yielding to the event
2620
+ // loop, so stop waiting rather than spin.
2621
+ if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
2622
+ }
2623
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2624
+ if (!current) throw new Error('Channel registration was removed while delivery waited');
2625
+ if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
2626
+ throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
2627
+ }
2628
+ return current;
2629
+ }
2630
+
2184
2631
  private async reconcileChannels(
2185
2632
  serverId: string,
2186
2633
  channels: ChannelDescriptor[],
@@ -2206,54 +2653,18 @@ export class ChannelRegistry {
2206
2653
  // first boot on a new policy doesn't produce one notice per channel.
2207
2654
  const policyOpened: Array<{ channelId: string; label?: string }> = [];
2208
2655
 
2656
+ // Record all admissions before waiting on any channel. A slow first RPC
2657
+ // must not delay intent for the rest of this registration batch.
2209
2658
  for (const channel of channels) {
2210
- if (this.ensureInitialDesiredState(serverId, channel)) {
2211
- policyOpened.push({ channelId: channel.id, label: channel.label });
2212
- }
2213
- const key = `${serverId}:${channel.id}`;
2214
- const desired = this.getDesiredState(serverId, channel.id);
2215
- const entry = this.channels.get(key);
2216
- // Tuned-out sits on the OPEN side of reconcile: traffic must keep
2217
- // arriving (the subconscious reads it); only main's wake/visibility
2218
- // is diverted, downstream at ingestion.
2219
- if (desired !== 'open' && desired !== 'tuned-out') {
2220
- try {
2221
- await server.sendChannelsClose({ channelId: channel.id });
2222
- if (entry) entry.open = false;
2223
- } catch (err) {
2224
- this.emitTraceFn({
2225
- type: 'mcpl:channel-reconcile-failed',
2226
- serverId,
2227
- channelId: channel.id,
2228
- desired,
2229
- error: (err as Error).message,
2230
- });
2231
- }
2232
- continue;
2233
- }
2234
-
2235
- try {
2236
- await server.sendChannelsOpen({
2237
- channelId: channel.id,
2238
- type: channel.type,
2239
- address: channel.address,
2240
- });
2241
- if (entry) entry.open = true;
2242
- } catch (err) {
2243
- if (entry) entry.open = false;
2244
- this.emitTraceFn({
2245
- type: 'mcpl:channel-reconcile-failed',
2246
- serverId,
2247
- channelId: channel.id,
2248
- desired,
2249
- error: (err as Error).message,
2250
- });
2659
+ const entry = this.channels.get(`${serverId}:${channel.id}`);
2660
+ if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
2661
+ policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
2251
2662
  }
2252
2663
  }
2253
2664
 
2254
- // Announce policy admissions to the agent — nothing may start flowing
2255
- // traffic into their window without them being told, and told how to
2256
- // opt out (channel_close; their decision outranks policy).
2665
+ // Announce the durable admission decision before awaiting transport.
2666
+ // This is intent, not confirmation that an open RPC succeeded. It stays
2667
+ // truthful if the first attempt fails, and a retry needs no new admission.
2257
2668
  if (policyOpened.length > 0) {
2258
2669
  try {
2259
2670
  this.onChannelAutoOpened?.({
@@ -2265,6 +2676,30 @@ export class ChannelRegistry {
2265
2676
  console.error('onChannelAutoOpened (policy) failed:', err);
2266
2677
  }
2267
2678
  }
2679
+
2680
+ // Each channel has its own lifecycle queue, so a held RPC for one (up to
2681
+ // its timeout, times its attempts) doesn't delay its siblings.
2682
+ await Promise.allSettled(channels.map(async (channel) => {
2683
+ if (!this.channels.has(`${serverId}:${channel.id}`)) return;
2684
+ try {
2685
+ await this.applyDesiredChannelState(serverId, channel.id);
2686
+ } catch (err) {
2687
+ // Removal while queued is cancellation, not a failed subscription.
2688
+ // Exhausted convergence and a substituted answer already emitted
2689
+ // their own diagnostics.
2690
+ if (!this.channels.has(`${serverId}:${channel.id}`) ||
2691
+ err instanceof ChannelLifecycleCancelledError ||
2692
+ err instanceof ChannelLifecycleConvergenceError ||
2693
+ err instanceof ChannelOpenSubstitutionError) return;
2694
+ this.emitTraceFn({
2695
+ type: 'mcpl:channel-reconcile-failed',
2696
+ serverId,
2697
+ channelId: channel.id,
2698
+ desired: this.getDesiredState(serverId, channel.id),
2699
+ error: (err as Error).message,
2700
+ });
2701
+ }
2702
+ }));
2268
2703
  }
2269
2704
 
2270
2705
  // ==========================================================================
@@ -2386,37 +2821,39 @@ export class ChannelRegistry {
2386
2821
  }
2387
2822
 
2388
2823
  /**
2389
- * The single open executor: record durable desired-open intent, tell the
2390
- * server to subscribe, mark the live entry open. Every path that opens a
2391
- * channel — the agent's channel_open tool, delivery into a closed locus,
2392
- * an explicit send into a closed channel — funnels here, so lifecycle
2393
- * events, desired state, and live state can never diverge by path.
2824
+ * Record durable desired-open intent, then queue the channel's lifecycle
2825
+ * work (applyDesiredChannelState), which tells the server to subscribe and
2826
+ * marks the live entry open on a receipt for its current target. Every
2827
+ * open the agent or its speech asks for — the channel_open tool, delivery
2828
+ * into a closed locus, an explicit send into a closed channel — comes
2829
+ * here. Reconciliation queues that work directly, as the closes do
2830
+ * (channel_close, a decline, and the close of a substituted channel).
2394
2831
  * Desired state is recorded BEFORE the server round-trip: intent sticks
2395
2832
  * even if the subscribe fails, and reconciliation retries later.
2833
+ * Automatic delivery needs open transport, not a change of attention:
2834
+ * preserve tune-out epochs both before and during the round-trip.
2835
+ *
2836
+ * `opened` says whether this call turned a closed channel open, which is
2837
+ * what the delivery and reply notices tell the resident. Reconfirming the
2838
+ * transport of a channel already decided open or tuned out isn't one.
2396
2839
  */
2397
2840
  private async openChannelNow(
2398
2841
  entry: ChannelEntry,
2399
- source: string,
2842
+ source: 'agent-tool' | 'opened-by-reply' | 'opened-by-delivery',
2400
2843
  history?: ChannelHistoryRequest,
2401
- ): Promise<ChannelsOpenResult> {
2402
- this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
2403
- const server = this.serverRegistry.getServer(entry.serverId);
2404
- if (!server) {
2405
- throw new Error(`Server not found: ${entry.serverId}`);
2844
+ ): Promise<{ result: ChannelsOpenResult; opened: boolean }> {
2845
+ const transportOnly = source !== 'agent-tool';
2846
+ const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
2847
+ const opened = before !== 'open' && before !== 'tuned-out';
2848
+ if (!transportOnly || before !== 'tuned-out') {
2849
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
2406
2850
  }
2407
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2408
- // Desired state is already recorded — intent sticks; reconciliation
2409
- // will retry if the grant later widens (§6.7 expansion-on-receipt).
2410
- throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
2851
+ const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
2852
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2853
+ if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
2854
+ throw new Error('Channel open was superseded by a newer lifecycle decision');
2411
2855
  }
2412
- const result: ChannelsOpenResult = await server.sendChannelsOpen({
2413
- channelId: entry.descriptor.id,
2414
- type: entry.descriptor.type,
2415
- address: entry.descriptor.address,
2416
- ...(history ? { history } : {}),
2417
- });
2418
- entry.open = true;
2419
- return result;
2856
+ return { result: applied.result!, opened };
2420
2857
  }
2421
2858
 
2422
2859
  /**
@@ -2627,19 +3064,25 @@ export class ChannelRegistry {
2627
3064
  }
2628
3065
  if (matches.length === 0) return { status: 'unknown-channel' };
2629
3066
  if (matches.length > 1) return { status: 'ambiguous' };
2630
- const entry = matches[0]!;
2631
- const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
2632
- if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
2633
- return { status: 'already-open', ...resolved };
2634
- }
3067
+ let entry = matches[0]!;
2635
3068
  try {
2636
- await this.openChannelNow(entry, 'opened-by-reply');
2637
- this.emitTraceFn({
2638
- type: 'mcpl:channel-opened-by-send',
2639
- serverId: entry.serverId,
2640
- channelId: entry.descriptor.id,
2641
- });
2642
- return { status: 'opened', ...resolved };
3069
+ if (this.hasPendingLifecycle(entry)) {
3070
+ entry = await this.waitForOpenChannel(entry);
3071
+ }
3072
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
3073
+ if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
3074
+ return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
3075
+ }
3076
+ const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
3077
+ entry = await this.waitForOpenChannel(entry);
3078
+ if (opened) {
3079
+ this.emitTraceFn({
3080
+ type: 'mcpl:channel-opened-by-send',
3081
+ serverId: entry.serverId,
3082
+ channelId: entry.descriptor.id,
3083
+ });
3084
+ }
3085
+ return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2643
3086
  } catch (err) {
2644
3087
  this.emitTraceFn({
2645
3088
  type: 'mcpl:channel-open-failed',
@@ -2647,7 +3090,9 @@ export class ChannelRegistry {
2647
3090
  channelId: entry.descriptor.id,
2648
3091
  error: (err as Error).message,
2649
3092
  });
2650
- return { status: 'open-failed', ...resolved };
3093
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3094
+ return { status: 'open-failed', channelId: entry.descriptor.id,
3095
+ ...(current ? { label: current.descriptor.label } : {}) };
2651
3096
  }
2652
3097
  }
2653
3098
 
@@ -2670,11 +3115,13 @@ export class ChannelRegistry {
2670
3115
  // A tuned-out channel is transport-open but attention-diverted; a plain
2671
3116
  // open would silently discard the epoch (and its pending backlog dump).
2672
3117
  // Cancelling is the tune-out coordinator's flow, reached via its own
2673
- // tool — refuse with the pointer rather than eat the state.
3118
+ // tool — refuse with the pointer rather than eat the state. As an error
3119
+ // result, the agent reads the pointer itself; `data` names the refusal
3120
+ // for an in-process caller.
2674
3121
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
2675
3122
  return {
2676
3123
  success: false,
2677
- isError: false,
3124
+ isError: true,
2678
3125
  error:
2679
3126
  `Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
2680
3127
  `(tune_out with mode: 'cancel') to resume normal attention — ` +
@@ -2684,7 +3131,7 @@ export class ChannelRegistry {
2684
3131
  }
2685
3132
 
2686
3133
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
2687
- if (entry.open && alreadyDesiredOpen && !input.backscroll) {
3134
+ if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
2688
3135
  return {
2689
3136
  success: true,
2690
3137
  data: { channelId: input.channelId, status: 'already open' },
@@ -2698,7 +3145,7 @@ export class ChannelRegistry {
2698
3145
  ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
2699
3146
  }
2700
3147
  : undefined;
2701
- const result = await this.openChannelNow(entry, 'agent-tool', history);
3148
+ const { result } = await this.openChannelNow(entry, 'agent-tool', history);
2702
3149
  return {
2703
3150
  success: true,
2704
3151
  data: {
@@ -2709,9 +3156,18 @@ export class ChannelRegistry {
2709
3156
  },
2710
3157
  };
2711
3158
  } catch (err) {
3159
+ // The decision was recorded before the round-trip and outlives its
3160
+ // failure. Say so: otherwise a later delivery that opens the channel
3161
+ // would be the resident's first sign that it was still wanted open.
3162
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3163
+ const stillSelected = current !== undefined && !current.open &&
3164
+ this.getDesiredState(entry.serverId, input.channelId) === 'open';
2712
3165
  return {
2713
3166
  success: false,
2714
- error: `Failed to open channel: ${(err as Error).message}`,
3167
+ error: `Failed to open channel: ${(err as Error).message}` +
3168
+ (stillSelected
3169
+ ? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
3170
+ : ''),
2715
3171
  isError: true,
2716
3172
  };
2717
3173
  }
@@ -2747,7 +3203,8 @@ export class ChannelRegistry {
2747
3203
  }
2748
3204
 
2749
3205
  const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
2750
- if (!entry.open && alreadyDesiredClosed) {
3206
+ if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
3207
+ !this.unconfirmedChannelTargets.has(entry)) {
2751
3208
  return {
2752
3209
  success: true,
2753
3210
  data: { channelId: input.channelId, status: 'already closed' },
@@ -2816,8 +3273,10 @@ export class ChannelRegistry {
2816
3273
  isError: true,
2817
3274
  };
2818
3275
  }
2819
- await server.sendChannelsClose({ channelId: input.channelId });
2820
- entry.open = false;
3276
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3277
+ if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
3278
+ throw new Error('Channel close was superseded by a newer lifecycle decision');
3279
+ }
2821
3280
  return {
2822
3281
  success: true,
2823
3282
  data: { channelId: input.channelId, status: 'closed' },
@@ -2845,7 +3304,7 @@ export class ChannelRegistry {
2845
3304
  if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
2846
3305
  return {
2847
3306
  success: false,
2848
- error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`,
3307
+ error: `Channel is already open: ${input.channelId}. Use channel_close to close it.`,
2849
3308
  isError: true,
2850
3309
  };
2851
3310
  }
@@ -2883,6 +3342,23 @@ export class ChannelRegistry {
2883
3342
  }
2884
3343
  }
2885
3344
 
3345
+ // Where the host directs the lifecycle, a decline goes through the
3346
+ // lifecycle queue like any closed decision, unless a receipt confirmed
3347
+ // the transport closed with nothing pending: an unconfirmed transport may
3348
+ // be open, and pending work must settle on this decision. A failed close
3349
+ // is reported; the decision stands.
3350
+ let closeError: string | undefined;
3351
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3352
+ if (current && this.directsLifecycle(entry.serverId) &&
3353
+ (this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
3354
+ try {
3355
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3356
+ if (applied.open) closeError = 'the close was superseded by a newer lifecycle decision';
3357
+ } catch (error) {
3358
+ closeError = (error as Error).message;
3359
+ }
3360
+ }
3361
+
2886
3362
  this.appendLifecycleEvent({
2887
3363
  kind: 'invitation-declined',
2888
3364
  serverId: entry.serverId,
@@ -2899,6 +3375,7 @@ export class ChannelRegistry {
2899
3375
  acknowledged,
2900
3376
  ...(representation ? { representation } : {}),
2901
3377
  ...(acknowledgmentError ? { acknowledgmentError } : {}),
3378
+ ...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
2902
3379
  },
2903
3380
  };
2904
3381
  }
@@ -2907,7 +3384,8 @@ export class ChannelRegistry {
2907
3384
  * Handle the synthesized `think` tool — a private reasoning scratchpad. It
2908
3385
  * sends nothing, and (unlike before) does NOT silence the turn: trailing
2909
3386
  * plain text is still routed as the reply. The thought stays in the agent's
2910
- * own context/chronicle. To deliberately not reply, the agent uses skip_reply.
3387
+ * own context/chronicle. To deliberately not reply, an agent that may call
3388
+ * skip_reply uses it.
2911
3389
  */
2912
3390
  private handleToolThink(input: { content?: string }): ToolResult {
2913
3391
  return {
@@ -2916,10 +3394,12 @@ export class ChannelRegistry {
2916
3394
  // tool_use block; don't duplicate it in the result.
2917
3395
  data: {
2918
3396
  noted: true,
3397
+ // No tool to end the turn with: this handler can't see whether the
3398
+ // caller may call skip_reply, and think's own description says so
3399
+ // where it may.
2919
3400
  note:
2920
3401
  'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
2921
- 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
2922
- 'call skip_reply to end the turn without replying.',
3402
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it.',
2923
3403
  },
2924
3404
  };
2925
3405
  }
@@ -2953,14 +3433,15 @@ export class ChannelRegistry {
2953
3433
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
2954
3434
  * overloaded use of `think` for staying silent.
2955
3435
  */
2956
- private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: number }): ToolResult {
3436
+ private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }): ToolResult {
2957
3437
  // The self-wake itself is armed by the framework (which owns the
2958
- // EventGate) BEFORE this dispatch; when the gate is absent the framework
2959
- // strips the field so this confirmation stays truthful.
2960
- const wakeSecs = Number(input.wake_in_seconds);
2961
- const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
2962
- ? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
2963
- : undefined;
3438
+ // EventGate) BEFORE its dispatch reaches this handler, from the same
3439
+ // reading, and the framework refuses a wake the host can't set before
3440
+ // dispatching. A caller of this registry's handleChannelToolCall
3441
+ // (AgentFramework.channels) goes past both, so its note names a wake
3442
+ // nothing armed.
3443
+ const wake = readSkipReplyWake(input);
3444
+ const selfWake = wake.kind === 'wake' ? wake.seconds : undefined;
2964
3445
  return {
2965
3446
  success: true,
2966
3447
  // The note says "ended the turn" — make it TRUE. Without endTurn the
@@ -3053,10 +3534,220 @@ export class ChannelRegistry {
3053
3534
 
3054
3535
  resolveLocus(conversationId: string): string | null {
3055
3536
  const home = this.homeChannelResolver?.(conversationId);
3056
- return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
3537
+ return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
3057
3538
  }
3058
3539
 
3059
- async routeSpeech(
3540
+ /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
3541
+ private globalFallbackFor(conversationId: string): string | null {
3542
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
3543
+ }
3544
+
3545
+ /**
3546
+ * Resolve a publish target to exactly one registered channel: `serverId`
3547
+ * plus `channelId` when the caller has the server, otherwise a channel id
3548
+ * registered by exactly one server. A shared id is refused, never resolved
3549
+ * to whichever server registered it first.
3550
+ */
3551
+ resolveDestination(
3552
+ target: { serverId?: string; channelId: string },
3553
+ ): { destination: PublishDestination } | { error: string } {
3554
+ const entry = this.findExactEntry(target);
3555
+ if ('error' in entry) return entry;
3556
+ return {
3557
+ destination: {
3558
+ serverId: entry.entry.serverId,
3559
+ channelId: entry.entry.descriptor.id,
3560
+ ...(entry.entry.descriptor.label ? { label: entry.entry.descriptor.label } : {}),
3561
+ },
3562
+ };
3563
+ }
3564
+
3565
+ private findExactEntry(
3566
+ target: { serverId?: string; channelId: string },
3567
+ ): { entry: ChannelEntry } | { error: string } {
3568
+ // A supplied selector is checked, never read as omission: an empty or
3569
+ // non-string serverId would otherwise widen to "any server".
3570
+ if (typeof target.channelId !== 'string' || !target.channelId) {
3571
+ return { error: 'the destination names no channel id' };
3572
+ }
3573
+ if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) {
3574
+ return { error: 'serverId must name a server (omit it to resolve the channel id alone)' };
3575
+ }
3576
+ const matches = [...this.channels.values()].filter(
3577
+ (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId),
3578
+ );
3579
+ if (matches.length === 0) {
3580
+ return {
3581
+ error: target.serverId
3582
+ ? `no registered channel "${target.channelId}" on server "${target.serverId}"`
3583
+ : `no registered channel "${target.channelId}"`,
3584
+ };
3585
+ }
3586
+ if (matches.length > 1) {
3587
+ return {
3588
+ error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`,
3589
+ };
3590
+ }
3591
+ return { entry: matches[0]! };
3592
+ }
3593
+
3594
+ /**
3595
+ * Publish `text` to one registered channel and report what the attempt
3596
+ * established (PublishOutcome). The delivery executor behind plain speech
3597
+ * (deliverSpeech / routeSpeech) and resident resends of held drafts.
3598
+ * channel_publish (and publishForAgent, which shares its handler) resolves
3599
+ * and sends on its own path, and reads its answer through the same
3600
+ * publishReceiptOutcome and publishErrorOutcome.
3601
+ *
3602
+ * Resolution is exact (resolveDestination): `serverId` plus `channelId`
3603
+ * when the caller has the server, otherwise the channel id must be
3604
+ * registered by exactly one server (a shared id is refused, never routed
3605
+ * through whichever server came first). An omitted `serverId` means "not
3606
+ * in use"; a supplied one must be a non-empty server id, and an empty or
3607
+ * non-string selector is refused rather than read as omission. It is not
3608
+ * possible to send into a closed channel: a closed but registered
3609
+ * destination is opened first (`openSource` names why), once its server
3610
+ * and that server's channels.publish grant are known to take the send, so
3611
+ * a refused send opens nothing; and a failed open sends nothing.
3612
+ *
3613
+ * Only the connector's `delivered: true` confirms a post. `failed` means
3614
+ * nothing was posted: the attempt stopped before the request was written,
3615
+ * or the connector answered `delivered: false` and named no message (no
3616
+ * `messageId`, or null). Everything else is `unknown`: once the request
3617
+ * has been handed to the transport, an error response, a timeout, a lost
3618
+ * connection, or a missing, malformed or contradictory receipt (such as
3619
+ * `delivered: false` beside a message id, valid or not) means the post may
3620
+ * or may not exist, and the caller must not treat it as safe to retry
3621
+ * blindly.
3622
+ */
3623
+ async publish(
3624
+ conversationId: string,
3625
+ text: string,
3626
+ target: {
3627
+ serverId?: string;
3628
+ channelId: string;
3629
+ /** Set by speech routing (deliverSpeech): opening a closed channel
3630
+ * for this publish is logged under `[routeSpeech]`, with speech
3631
+ * routing's other lines. */
3632
+ routed?: boolean;
3633
+ },
3634
+ openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery',
3635
+ ): Promise<PublishOutcome> {
3636
+ const at = (): number => Date.now();
3637
+ const found = this.findExactEntry(target);
3638
+ if ('error' in found) return { status: 'failed', reason: found.error, at: at() };
3639
+ let entry = found.entry;
3640
+ const destination: PublishDestination = {
3641
+ serverId: entry.serverId,
3642
+ channelId: entry.descriptor.id,
3643
+ ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}),
3644
+ };
3645
+
3646
+ // A send that would be refused is refused before anything opens: a
3647
+ // closed channel opens only for words that can go out.
3648
+ const server = this.serverRegistry.getServer(entry.serverId);
3649
+ if (!server) {
3650
+ return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() };
3651
+ }
3652
+ // §14.1: channels/publish requires channels.publish in the grant. The
3653
+ // host not sending is the enforcement.
3654
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
3655
+ return {
3656
+ status: 'failed',
3657
+ destination,
3658
+ reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`,
3659
+ at: at(),
3660
+ };
3661
+ }
3662
+
3663
+ // Lifecycle work already queued for the channel settles first: words go
3664
+ // out only into the channel it leaves, and fail visibly if it leaves
3665
+ // none open.
3666
+ if (this.hasPendingLifecycle(entry)) {
3667
+ try {
3668
+ entry = await this.waitForOpenChannel(entry);
3669
+ } catch (err) {
3670
+ return {
3671
+ status: 'failed',
3672
+ destination,
3673
+ reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3674
+ at: at(),
3675
+ };
3676
+ }
3677
+ }
3678
+
3679
+ // INVARIANT: it is not possible to send into a closed channel. A send
3680
+ // into a closed-but-registered channel (the canonical case: a DM that
3681
+ // woke the agent, since DMs register closed) OPENS it first, so typing
3682
+ // indicators, reaction machinery and inbound forwarding come alive with
3683
+ // the words. If the open fails, nothing goes out: a half-alive delivery
3684
+ // is worse than a loud marker. A channel decided closed is engaged even
3685
+ // while its transport is still open (a close that failed), as a reply
3686
+ // would engage it, wherever an open can succeed. Without the lifecycle
3687
+ // grant it can't, and a send opens before it publishes, so there a
3688
+ // channel whose transport is open keeps publishing.
3689
+ const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
3690
+ CapabilityGrant.of(server).has('channels.lifecycle');
3691
+ if (!entry.open || engagesDecidedClosed) {
3692
+ try {
3693
+ const { opened } = await this.openChannelNow(entry, openSource);
3694
+ entry = await this.waitForOpenChannel(entry);
3695
+ if (opened) {
3696
+ if (target.routed) {
3697
+ console.error(
3698
+ `[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`,
3699
+ );
3700
+ }
3701
+ this.emitTraceFn({
3702
+ type: 'mcpl:channel-opened-by-send',
3703
+ serverId: entry.serverId,
3704
+ channelId: entry.descriptor.id,
3705
+ });
3706
+ if (openSource === 'opened-by-delivery') {
3707
+ try {
3708
+ this.onChannelAutoOpened?.({
3709
+ conversationId,
3710
+ serverId: entry.serverId,
3711
+ source: 'opened-by-delivery',
3712
+ channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
3713
+ });
3714
+ } catch (err) {
3715
+ console.error('onChannelAutoOpened (delivery) failed:', err);
3716
+ }
3717
+ }
3718
+ }
3719
+ } catch (err) {
3720
+ return {
3721
+ status: 'failed',
3722
+ destination,
3723
+ reason: `channel is closed and open failed: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3724
+ at: at(),
3725
+ };
3726
+ }
3727
+ }
3728
+
3729
+ let result: unknown;
3730
+ try {
3731
+ result = await server.sendChannelsPublish({
3732
+ conversationId,
3733
+ channelId: entry.descriptor.id,
3734
+ content: [{ type: 'text', text }],
3735
+ });
3736
+ } catch (err) {
3737
+ return publishErrorOutcome(err, destination, at());
3738
+ }
3739
+ return publishReceiptOutcome(result, destination, at());
3740
+ }
3741
+
3742
+ /**
3743
+ * Plain speech for one turn segment: publish `text` to the turn's frozen
3744
+ * locus and report the PublishOutcome. A failure (or an uncertain outcome)
3745
+ * is surfaced as a trace and through onRouteFailure, so the agent learns
3746
+ * its words did not (or may not have) reached anyone. An ephemeral run's
3747
+ * speech with no locus is the exception: it is its caller's result, so it
3748
+ * returns as not delivered and surfaces nothing.
3749
+ */
3750
+ async deliverSpeech(
3060
3751
  conversationId: string,
3061
3752
  text: string,
3062
3753
  /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
@@ -3065,123 +3756,98 @@ export class ChannelRegistry {
3065
3756
  * dispatch resolving live can read the NEXT turn's trigger state or a
3066
3757
  * stale global default and land the reply in the wrong channel (item-3,
3067
3758
  * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
3068
- * "this turn is pinned to no locus": fail loudly rather than guess. */
3759
+ * "this turn is pinned to no locus": fail loudly rather than guess,
3760
+ * except for an ephemeral run, whose speech is then its caller's
3761
+ * result. */
3069
3762
  locusChannelId: string | null,
3070
- ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
3071
- // Surface a routing failure: emit a trace AND notify the host (which drops
3072
- // a `[discord-send-failed]` marker into chronicle) so the agent learns her
3073
- // reply never reached the human, instead of it vanishing silently.
3074
- const fail = (channelId: string | null, reason: string): null => {
3075
- console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
3763
+ /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3764
+ * draft with its own notice, so no failure marker is written for it.
3765
+ * An `unknown` outcome still gets its marker. */
3766
+ opts?: { heldOnFailure?: boolean },
3767
+ ): Promise<PublishOutcome> {
3768
+ const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
3769
+ const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
3770
+ const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
3771
+ const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
3772
+ console.error(`[routeSpeech] ${conversationId}: ${reason}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
3076
3773
  this.emitTraceFn({
3077
3774
  type: 'mcpl:speech-route-failed',
3078
3775
  conversationId,
3079
3776
  channelId: channelId ?? '',
3080
3777
  reason,
3081
3778
  textLen: text.length,
3779
+ outcome: status,
3780
+ ...(detail !== undefined ? { detail } : {}),
3082
3781
  });
3083
- this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
3084
- return null;
3782
+ if (status === 'unknown' || !opts?.heldOnFailure) {
3783
+ this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) });
3784
+ }
3785
+ return outcome;
3085
3786
  };
3086
3787
 
3087
3788
  // Runtime backstop for JS callers the compiler can't see: an omitted
3088
3789
  // locus is a routing bug at the call site, never something to paper over
3089
3790
  // with a live re-resolution.
3090
3791
  if (locusChannelId === undefined) {
3091
- return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
3092
- }
3093
- const channelId = locusChannelId;
3094
- if (!channelId) {
3792
+ const reason = 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)';
3793
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3794
+ }
3795
+ if (!locusChannelId) {
3796
+ // An ephemeral run with no channel of its own: its plain speech is its
3797
+ // caller's result (takesGlobalFallback). It returns as not delivered,
3798
+ // with its own reason, and writes no line, trace or notice.
3799
+ if (this.takesGlobalFallback?.(conversationId) === false) {
3800
+ return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
3801
+ }
3095
3802
  // The turn froze with no locus (no home, no triggering channel, no
3096
3803
  // global inbound ever seen) — the agent was told its prose stays in
3097
3804
  // the archive; honor that.
3098
- return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
3805
+ const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)';
3806
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3099
3807
  }
3100
3808
 
3101
- const entry = this.findChannelEntry(channelId);
3102
- if (!entry) {
3103
- return fail(channelId, `no registered channel for locus "${channelId}"`);
3809
+ const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3810
+ const channelId = outcome.destination?.channelId ?? locusChannelId;
3811
+ if (outcome.status !== 'delivered') {
3812
+ return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
3104
3813
  }
3105
3814
 
3106
- // INVARIANT: it is not possible to send into a closed channel. Speech
3107
- // routed into a closed-but-registered locus (the canonical case: a DM
3108
- // that woke the agent — DMs register closed) OPENS the channel first,
3109
- // so typing indicators, reaction machinery, and inbound forwarding come
3110
- // alive with the reply. If the open fails, the speech does NOT go out:
3111
- // a half-alive delivery is worse than a loud marker.
3112
- if (!entry.open) {
3113
- try {
3114
- await this.openChannelNow(entry, 'opened-by-delivery');
3115
- console.error(
3116
- `[routeSpeech] ${conversationId}: locus ${logValue(channelId)} was closed — opened by delivery (speech implies engagement)`,
3117
- );
3118
- this.emitTraceFn({
3119
- type: 'mcpl:channel-opened-by-send',
3120
- serverId: entry.serverId,
3121
- channelId,
3122
- });
3123
- try {
3124
- this.onChannelAutoOpened?.({
3125
- conversationId,
3126
- serverId: entry.serverId,
3127
- source: 'opened-by-delivery',
3128
- channels: [{ channelId, label: entry.descriptor.label }],
3129
- });
3130
- } catch (err) {
3131
- console.error('onChannelAutoOpened (delivery) failed:', err);
3132
- }
3133
- } catch (err) {
3134
- return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`);
3135
- }
3136
- }
3137
-
3138
- const server = this.serverRegistry.getServer(entry.serverId);
3139
- if (!server) {
3140
- return fail(channelId, `server "${entry.serverId}" not found`);
3141
- }
3142
- // §14.1: channels/publish requires channels.publish in the grant. The
3143
- // host not sending is the enforcement — a send to an ungranted server
3144
- // would have it act on authority it was never told it has.
3145
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3146
- return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`);
3147
- }
3148
-
3149
- const publishParams: ChannelsPublishParams = {
3150
- conversationId,
3151
- channelId,
3152
- content: [{ type: 'text', text }],
3153
- };
3154
- const result = await server.sendChannelsPublish(publishParams);
3155
- const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
3156
- // Surface the posted message's id (ChannelsPublishResult.messageId) so
3157
- // trace consumers can act on the just-posted message — e.g. a TTS-relay
3158
- // tap editing it down to the words actually voiced on interruption.
3159
- // Previously this was silently dropped here.
3160
- const messageId = (result as { messageId?: string } | undefined)?.messageId;
3161
-
3162
- // The server accepted the publish RPC but reported the message was not
3163
- // actually delivered (e.g. missing Send Messages permission). Previously
3164
- // this returned `{ delivered: true }`, masking the failure. Surface it.
3165
- if (delivered === false) {
3166
- return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`);
3167
- }
3168
-
3169
- console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`);
3815
+ console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination!.serverId}, delivered=true)`);
3170
3816
  this.emitTraceFn({
3171
3817
  type: 'mcpl:speech-routed',
3172
3818
  conversationId,
3173
- serverId: entry.serverId,
3819
+ serverId: outcome.destination!.serverId,
3174
3820
  channelId,
3175
- delivered,
3821
+ delivered: true,
3176
3822
  textLen: text.length,
3177
3823
  text,
3178
- ...(messageId !== undefined ? { messageId } : {}),
3824
+ // Surface the posted message's id (ChannelsPublishResult.messageId) so
3825
+ // trace consumers can act on the just-posted message — e.g. a TTS-relay
3826
+ // tap editing it down to the words actually voiced on interruption.
3827
+ ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
3179
3828
  });
3829
+ return outcome;
3830
+ }
3180
3831
 
3181
- return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
3832
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
3833
+ async routeSpeech(
3834
+ conversationId: string,
3835
+ text: string,
3836
+ locusChannelId: string | null,
3837
+ ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
3838
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3839
+ if (outcome.status !== 'delivered') return null;
3840
+ return {
3841
+ delivered: true,
3842
+ channelId: outcome.destination!.channelId,
3843
+ ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
3844
+ };
3182
3845
  }
3183
3846
 
3184
- private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
3847
+ private async handleToolPublish(
3848
+ input: { channelId?: string; content?: string; text?: string },
3849
+ origin?: ChannelToolOrigin,
3850
+ ): Promise<ToolResult> {
3185
3851
  // Resolve content: accept both `content` and `text` (backward compat)
3186
3852
  const messageText = input.content ?? input.text;
3187
3853
  if (!messageText) {
@@ -3192,12 +3858,17 @@ export class ChannelRegistry {
3192
3858
  };
3193
3859
  }
3194
3860
 
3195
- // Resolve channelId: default to most recent incoming channel
3196
- const channelId = input.channelId ?? this.defaultPublishChannel;
3861
+ // Resolve channelId: default to the most recent incoming channel, which an
3862
+ // ephemeral run never takes (takesGlobalFallback), so it names one.
3863
+ const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
3864
+ const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
3197
3865
  if (!channelId) {
3198
3866
  return {
3199
3867
  success: false,
3200
- error: 'No channelId specified and no default channel available',
3868
+ error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
3869
+ ? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
3870
+ + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
3871
+ : 'No channelId specified and no default channel available',
3201
3872
  isError: true,
3202
3873
  };
3203
3874
  }
@@ -3220,31 +3891,44 @@ export class ChannelRegistry {
3220
3891
  };
3221
3892
  }
3222
3893
 
3223
- try {
3224
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3225
- return {
3226
- success: false,
3227
- error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
3228
- isError: true,
3229
- };
3230
- }
3231
- const publishParams: ChannelsPublishParams = {
3232
- conversationId: '', // Framework will fill this when wired
3233
- channelId,
3234
- content: [{ type: 'text', text: messageText }],
3235
- };
3236
-
3237
- const result = await server.sendChannelsPublish(publishParams);
3238
- return {
3239
- success: true,
3240
- data: result ?? { delivered: true },
3241
- };
3242
- } catch (err) {
3894
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
3243
3895
  return {
3244
3896
  success: false,
3245
- error: `Failed to publish to channel: ${(err as Error).message}`,
3897
+ error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
3246
3898
  isError: true,
3247
3899
  };
3248
3900
  }
3901
+ const publishParams: ChannelsPublishParams = {
3902
+ conversationId: '', // Framework will fill this when wired
3903
+ channelId,
3904
+ content: [{ type: 'text', text: messageText }],
3905
+ };
3906
+ const destination: PublishDestination = {
3907
+ serverId: entry.serverId,
3908
+ channelId: entry.descriptor.id,
3909
+ ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}),
3910
+ };
3911
+
3912
+ // The answer counts by the same rule as plain speech's: only the
3913
+ // connector's delivered:true confirms a post.
3914
+ let result: unknown;
3915
+ let outcome: PublishOutcome;
3916
+ try {
3917
+ result = await server.sendChannelsPublish(publishParams);
3918
+ outcome = publishReceiptOutcome(result, destination, Date.now());
3919
+ } catch (err) {
3920
+ outcome = publishErrorOutcome(err, destination, Date.now());
3921
+ }
3922
+ if (outcome.status === 'delivered') return { success: true, data: result };
3923
+ // The connector's data may say what a multi-part send already posted.
3924
+ const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
3925
+ return {
3926
+ success: false,
3927
+ error: (outcome.status === 'failed'
3928
+ ? `Publish to ${channelId} failed: ${outcome.reason}. Nothing was posted.`
3929
+ : `Publish to ${channelId} wasn't confirmed: ${outcome.reason}. ` +
3930
+ 'It may or may not have been posted, so sending it again could post it twice.') + detail,
3931
+ isError: true,
3932
+ };
3249
3933
  }
3250
3934
  }