@animalabs/agent-framework 0.23.3 → 0.24.1

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 (355) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +65 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +98 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/clock-rows.d.ts +115 -0
  10. package/dist/src/clock-rows.d.ts.map +1 -0
  11. package/dist/src/clock-rows.js +222 -0
  12. package/dist/src/clock-rows.js.map +1 -0
  13. package/dist/src/code-execution/engine.d.ts +177 -0
  14. package/dist/src/code-execution/engine.d.ts.map +1 -0
  15. package/dist/src/code-execution/engine.js +494 -0
  16. package/dist/src/code-execution/engine.js.map +1 -0
  17. package/dist/src/code-execution/index.d.ts +5 -10
  18. package/dist/src/code-execution/index.d.ts.map +1 -1
  19. package/dist/src/code-execution/index.js +5 -9
  20. package/dist/src/code-execution/index.js.map +1 -1
  21. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  22. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/calls.js +145 -0
  24. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  26. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  28. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  30. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child-main.js +132 -0
  32. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  33. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  34. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/child.js +315 -0
  36. package/dist/src/code-execution/isolate/child.js.map +1 -0
  37. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  38. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/frames.js +210 -0
  40. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  41. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  42. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/output.js +143 -0
  44. package/dist/src/code-execution/isolate/output.js.map +1 -0
  45. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  46. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/prelude.js +401 -0
  48. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  49. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  50. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/protocol.js +150 -0
  52. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  53. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  54. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/render.js +214 -0
  56. package/dist/src/code-execution/isolate/render.js.map +1 -0
  57. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  58. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/runner.js +854 -0
  60. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  61. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  62. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  63. package/dist/src/code-execution/isolate/scripts.js +357 -0
  64. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  65. package/dist/src/code-execution/quickjs.wasm +0 -0
  66. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  67. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  68. package/dist/src/code-execution/tool-definition.js +176 -142
  69. package/dist/src/code-execution/tool-definition.js.map +1 -1
  70. package/dist/src/deadline-timer.d.ts +3 -0
  71. package/dist/src/deadline-timer.d.ts.map +1 -1
  72. package/dist/src/deadline-timer.js +1 -1
  73. package/dist/src/deadline-timer.js.map +1 -1
  74. package/dist/src/framework.d.ts +1120 -457
  75. package/dist/src/framework.d.ts.map +1 -1
  76. package/dist/src/framework.js +4635 -2505
  77. package/dist/src/framework.js.map +1 -1
  78. package/dist/src/gate/event-gate.d.ts +28 -4
  79. package/dist/src/gate/event-gate.d.ts.map +1 -1
  80. package/dist/src/gate/event-gate.js +89 -13
  81. package/dist/src/gate/event-gate.js.map +1 -1
  82. package/dist/src/held-writes.d.ts +16 -3
  83. package/dist/src/held-writes.d.ts.map +1 -1
  84. package/dist/src/held-writes.js +16 -3
  85. package/dist/src/held-writes.js.map +1 -1
  86. package/dist/src/index.d.ts +8 -6
  87. package/dist/src/index.d.ts.map +1 -1
  88. package/dist/src/index.js +10 -8
  89. package/dist/src/index.js.map +1 -1
  90. package/dist/src/mcpl/channel-registry.d.ts +483 -115
  91. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  92. package/dist/src/mcpl/channel-registry.js +1735 -425
  93. package/dist/src/mcpl/channel-registry.js.map +1 -1
  94. package/dist/src/mcpl/conversation-router.d.ts +19 -39
  95. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  96. package/dist/src/mcpl/conversation-router.js +27 -59
  97. package/dist/src/mcpl/conversation-router.js.map +1 -1
  98. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  99. package/dist/src/mcpl/convert-block.js +9 -5
  100. package/dist/src/mcpl/convert-block.js.map +1 -1
  101. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  102. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  103. package/dist/src/mcpl/inbound-source.js +130 -0
  104. package/dist/src/mcpl/inbound-source.js.map +1 -1
  105. package/dist/src/mcpl/index.d.ts +2 -1
  106. package/dist/src/mcpl/index.d.ts.map +1 -1
  107. package/dist/src/mcpl/index.js +3 -2
  108. package/dist/src/mcpl/index.js.map +1 -1
  109. package/dist/src/mcpl/log-value.d.ts +6 -0
  110. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  111. package/dist/src/mcpl/log-value.js +8 -0
  112. package/dist/src/mcpl/log-value.js.map +1 -1
  113. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  114. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  115. package/dist/src/mcpl/modern-connection.js +168 -53
  116. package/dist/src/mcpl/modern-connection.js.map +1 -1
  117. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  118. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  119. package/dist/src/mcpl/prose-grammar.js +4 -5
  120. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  121. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  122. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  123. package/dist/src/mcpl/protocol-family.js +174 -17
  124. package/dist/src/mcpl/protocol-family.js.map +1 -1
  125. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  126. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-coalescer.js +227 -50
  128. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  129. package/dist/src/mcpl/push-handler.d.ts +9 -20
  130. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  131. package/dist/src/mcpl/push-handler.js +7 -49
  132. package/dist/src/mcpl/push-handler.js.map +1 -1
  133. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  134. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  135. package/dist/src/mcpl/reference-fetcher.js +4 -4
  136. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  137. package/dist/src/mcpl/server-connection.d.ts +50 -21
  138. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  139. package/dist/src/mcpl/server-connection.js +158 -74
  140. package/dist/src/mcpl/server-connection.js.map +1 -1
  141. package/dist/src/mcpl/tags.d.ts +24 -0
  142. package/dist/src/mcpl/tags.d.ts.map +1 -1
  143. package/dist/src/mcpl/tags.js +33 -0
  144. package/dist/src/mcpl/tags.js.map +1 -1
  145. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  146. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  147. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  148. package/dist/src/mcpl/transport.d.ts +55 -7
  149. package/dist/src/mcpl/transport.d.ts.map +1 -1
  150. package/dist/src/mcpl/transport.js +163 -34
  151. package/dist/src/mcpl/transport.js.map +1 -1
  152. package/dist/src/mcpl/types.d.ts +22 -6
  153. package/dist/src/mcpl/types.d.ts.map +1 -1
  154. package/dist/src/mcpl/types.js.map +1 -1
  155. package/dist/src/module-registry.d.ts +4 -2
  156. package/dist/src/module-registry.d.ts.map +1 -1
  157. package/dist/src/module-registry.js +100 -13
  158. package/dist/src/module-registry.js.map +1 -1
  159. package/dist/src/modules/discord/index.d.ts.map +1 -1
  160. package/dist/src/modules/discord/index.js +8 -13
  161. package/dist/src/modules/discord/index.js.map +1 -1
  162. package/dist/src/modules/history/index.d.ts.map +1 -1
  163. package/dist/src/modules/history/index.js +27 -4
  164. package/dist/src/modules/history/index.js.map +1 -1
  165. package/dist/src/modules/history/semantic.d.ts +15 -3
  166. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  167. package/dist/src/modules/history/semantic.js +27 -5
  168. package/dist/src/modules/history/semantic.js.map +1 -1
  169. package/dist/src/modules/index.d.ts +1 -1
  170. package/dist/src/modules/index.d.ts.map +1 -1
  171. package/dist/src/modules/index.js.map +1 -1
  172. package/dist/src/modules/unknown-tool.d.ts +4 -1
  173. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  174. package/dist/src/modules/unknown-tool.js +4 -1
  175. package/dist/src/modules/unknown-tool.js.map +1 -1
  176. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  177. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  178. package/dist/src/modules/workspace/branch-intent.js +3 -1
  179. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  180. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  181. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-plan.js +794 -0
  183. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  184. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  185. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  186. package/dist/src/modules/workspace/edit-text.js +584 -0
  187. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  188. package/dist/src/modules/workspace/effects.d.ts +5 -4
  189. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/effects.js +78 -25
  191. package/dist/src/modules/workspace/effects.js.map +1 -1
  192. package/dist/src/modules/workspace/index.d.ts +493 -62
  193. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/index.js +2216 -652
  195. package/dist/src/modules/workspace/index.js.map +1 -1
  196. package/dist/src/modules/workspace/observe.d.ts +213 -38
  197. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  198. package/dist/src/modules/workspace/observe.js +416 -36
  199. package/dist/src/modules/workspace/observe.js.map +1 -1
  200. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/reconcile.js +15 -8
  202. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  203. package/dist/src/modules/workspace/text.d.ts +32 -0
  204. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  205. package/dist/src/modules/workspace/text.js +67 -0
  206. package/dist/src/modules/workspace/text.js.map +1 -0
  207. package/dist/src/modules/workspace/types.d.ts +210 -6
  208. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  209. package/dist/src/modules/workspace/types.js.map +1 -1
  210. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  211. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  212. package/dist/src/modules/workspace/watcher.js.map +1 -1
  213. package/dist/src/operator-log.d.ts +52 -24
  214. package/dist/src/operator-log.d.ts.map +1 -1
  215. package/dist/src/operator-log.js.map +1 -1
  216. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  217. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  218. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  219. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  220. package/dist/src/recovery/offline-branch.d.ts +26 -1
  221. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  222. package/dist/src/recovery/offline-branch.js +164 -39
  223. package/dist/src/recovery/offline-branch.js.map +1 -1
  224. package/dist/src/recovery/recover-cli.js +23 -4
  225. package/dist/src/recovery/recover-cli.js.map +1 -1
  226. package/dist/src/send-tools.d.ts +11 -0
  227. package/dist/src/send-tools.d.ts.map +1 -0
  228. package/dist/src/send-tools.js +11 -0
  229. package/dist/src/send-tools.js.map +1 -0
  230. package/dist/src/timezone.d.ts +4 -2
  231. package/dist/src/timezone.d.ts.map +1 -1
  232. package/dist/src/timezone.js +4 -2
  233. package/dist/src/timezone.js.map +1 -1
  234. package/dist/src/tool-image-ledger.d.ts +46 -7
  235. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  236. package/dist/src/tool-image-ledger.js +60 -7
  237. package/dist/src/tool-image-ledger.js.map +1 -1
  238. package/dist/src/tool-result-guard.d.ts +14 -11
  239. package/dist/src/tool-result-guard.d.ts.map +1 -1
  240. package/dist/src/tool-result-guard.js +17 -14
  241. package/dist/src/tool-result-guard.js.map +1 -1
  242. package/dist/src/tool-result-history.d.ts +3 -2
  243. package/dist/src/tool-result-history.d.ts.map +1 -1
  244. package/dist/src/tool-result-history.js +3 -2
  245. package/dist/src/tool-result-history.js.map +1 -1
  246. package/dist/src/tune-out/coordinator.d.ts +21 -0
  247. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  248. package/dist/src/tune-out/coordinator.js +142 -40
  249. package/dist/src/tune-out/coordinator.js.map +1 -1
  250. package/dist/src/types/agent.d.ts +31 -6
  251. package/dist/src/types/agent.d.ts.map +1 -1
  252. package/dist/src/types/events.d.ts +66 -9
  253. package/dist/src/types/events.d.ts.map +1 -1
  254. package/dist/src/types/framework.d.ts +69 -62
  255. package/dist/src/types/framework.d.ts.map +1 -1
  256. package/dist/src/types/module.d.ts +40 -8
  257. package/dist/src/types/module.d.ts.map +1 -1
  258. package/dist/src/types/trace.d.ts +111 -10
  259. package/dist/src/types/trace.d.ts.map +1 -1
  260. package/dist/src/usage/types.d.ts +16 -0
  261. package/dist/src/usage/types.d.ts.map +1 -1
  262. package/dist/src/usage/usage-tracker.d.ts +14 -1
  263. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  264. package/dist/src/usage/usage-tracker.js +81 -41
  265. package/dist/src/usage/usage-tracker.js.map +1 -1
  266. package/package.json +6 -8
  267. package/src/agent.ts +99 -12
  268. package/src/api/server.ts +14 -12
  269. package/src/clock-rows.ts +302 -0
  270. package/src/code-execution/engine.ts +595 -0
  271. package/src/code-execution/index.ts +5 -17
  272. package/src/code-execution/isolate/calls.ts +199 -0
  273. package/src/code-execution/isolate/child-entry.ts +5 -0
  274. package/src/code-execution/isolate/child-main.ts +132 -0
  275. package/src/code-execution/isolate/child.ts +327 -0
  276. package/src/code-execution/isolate/frames.ts +209 -0
  277. package/src/code-execution/isolate/output.ts +161 -0
  278. package/src/code-execution/isolate/prelude.ts +409 -0
  279. package/src/code-execution/isolate/protocol.ts +240 -0
  280. package/src/code-execution/isolate/render.ts +230 -0
  281. package/src/code-execution/isolate/runner.ts +911 -0
  282. package/src/code-execution/isolate/scripts.ts +479 -0
  283. package/src/code-execution/quickjs.wasm +0 -0
  284. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  285. package/src/code-execution/tool-definition.ts +236 -167
  286. package/src/deadline-timer.ts +1 -1
  287. package/src/framework.ts +4978 -2671
  288. package/src/gate/event-gate.ts +79 -17
  289. package/src/held-writes.ts +17 -3
  290. package/src/index.ts +32 -22
  291. package/src/mcpl/channel-registry.ts +1892 -471
  292. package/src/mcpl/conversation-router.ts +26 -72
  293. package/src/mcpl/convert-block.ts +9 -5
  294. package/src/mcpl/inbound-source.ts +131 -0
  295. package/src/mcpl/index.ts +4 -2
  296. package/src/mcpl/log-value.ts +9 -0
  297. package/src/mcpl/modern-connection.ts +162 -53
  298. package/src/mcpl/prose-grammar.ts +4 -5
  299. package/src/mcpl/protocol-family.ts +200 -22
  300. package/src/mcpl/push-coalescer.ts +306 -62
  301. package/src/mcpl/push-handler.ts +12 -54
  302. package/src/mcpl/reference-fetcher.ts +4 -4
  303. package/src/mcpl/server-connection.ts +180 -79
  304. package/src/mcpl/tags.ts +36 -0
  305. package/src/mcpl/tool-result-normalize.ts +2 -1
  306. package/src/mcpl/transport.ts +162 -33
  307. package/src/mcpl/types.ts +23 -6
  308. package/src/module-registry.ts +92 -13
  309. package/src/modules/discord/index.ts +8 -13
  310. package/src/modules/history/index.ts +25 -3
  311. package/src/modules/history/semantic.ts +29 -6
  312. package/src/modules/index.ts +1 -0
  313. package/src/modules/unknown-tool.ts +4 -1
  314. package/src/modules/workspace/branch-intent.ts +3 -1
  315. package/src/modules/workspace/edit-plan.ts +954 -0
  316. package/src/modules/workspace/edit-text.ts +618 -0
  317. package/src/modules/workspace/effects.ts +70 -19
  318. package/src/modules/workspace/index.ts +2293 -650
  319. package/src/modules/workspace/observe.ts +465 -56
  320. package/src/modules/workspace/reconcile.ts +14 -7
  321. package/src/modules/workspace/text.ts +68 -0
  322. package/src/modules/workspace/types.ts +211 -6
  323. package/src/modules/workspace/watcher.ts +3 -2
  324. package/src/operator-log.ts +47 -19
  325. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  326. package/src/recovery/offline-branch.ts +189 -55
  327. package/src/recovery/recover-cli.ts +25 -4
  328. package/src/send-tools.ts +11 -0
  329. package/src/timezone.ts +4 -2
  330. package/src/tool-image-ledger.ts +62 -8
  331. package/src/tool-result-guard.ts +18 -15
  332. package/src/tool-result-history.ts +3 -2
  333. package/src/tune-out/coordinator.ts +166 -45
  334. package/src/types/agent.ts +32 -6
  335. package/src/types/events.ts +66 -9
  336. package/src/types/framework.ts +69 -62
  337. package/src/types/module.ts +40 -8
  338. package/src/types/trace.ts +121 -15
  339. package/src/usage/types.ts +16 -0
  340. package/src/usage/usage-tracker.ts +86 -43
  341. package/dist/src/code-execution/py-runner.d.ts +0 -191
  342. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  343. package/dist/src/code-execution/py-runner.js +0 -655
  344. package/dist/src/code-execution/py-runner.js.map +0 -1
  345. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  346. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  347. package/dist/src/code-execution/runtime-py.js +0 -677
  348. package/dist/src/code-execution/runtime-py.js.map +0 -1
  349. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  350. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  351. package/dist/src/mcpl/prose-stream-router.js +0 -291
  352. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  353. package/src/code-execution/py-runner.ts +0 -791
  354. package/src/code-execution/runtime-py.ts +0 -677
  355. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -37,14 +37,14 @@ import type {
37
37
  import type { McplServerRegistry } from './server-registry.js';
38
38
  import type { FeatureSetManager } from './feature-set-manager.js';
39
39
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
40
- import { expandCoreTags } from './tags.js';
40
+ import { expandCoreTags, isMarkerTagged } from './tags.js';
41
41
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
42
42
  import { isVisiblyEmptyConversion } from './visible-content.js';
43
43
  import { logDetail, logValue } from './log-value.js';
44
44
  import { RefusalLines } from './refusal-lines.js';
45
45
  import { CapabilityGrant } from './capability-grant.js';
46
46
  import type { InboundSource } from './inbound-source.js';
47
- import { McplRequestError } from './server-connection.js';
47
+ import { McplRequestError, type McplServerConnection } from './server-connection.js';
48
48
  import { readSkipReplyWake } from './skip-reply-wake.js';
49
49
 
50
50
  // ============================================================================
@@ -54,14 +54,97 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
54
54
  const TYPING_INTERVAL_MS = 7_000;
55
55
  const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
56
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 {}
57
+ /**
58
+ * Where a channel's server stands for a request this host would make of it
59
+ * (ChannelRegistry.serverReach). Only an `up` server's grant says what it
60
+ * allows: a connection's grant is empty from a transport drop until its next
61
+ * policy exchange settles, whatever the server will allow then.
62
+ * - `gone`: not registered, with no connect of its id in flight (closed for
63
+ * good without reconnect, whose channels stay registered here, or removed),
64
+ * or registered, closed and not reconnecting (one kept for a child it
65
+ * couldn't reap), with no restart of it in flight. This host doesn't bring
66
+ * it back on its own.
67
+ * - `connecting`: a connect or restart of its id in flight, or registered
68
+ * and connected, with the policy exchange of the transport it's on still
69
+ * in flight (the host's serverConnecting).
70
+ * - `down`: registered, between transports, and reconnecting.
71
+ * - `up`: connected, its exchange settled, granted or not, refused or
72
+ * unanswered: the grant it holds is that transport's answer.
73
+ */
74
+ type ServerReach = 'gone' | 'connecting' | 'down' | 'up';
75
+
76
+ /** What a lifecycle failure tells the agent it can do next
77
+ * (ChannelRegistry.lifecycleFailureText, openFailedReason). */
78
+ type ChannelLifecycleFailure =
79
+ | 'unreachable'
80
+ | 'not-permitted'
81
+ | 'cancelled'
82
+ | 'unlisted'
83
+ | 'superseded'
84
+ | 'converge'
85
+ | 'substituted'
86
+ | 'removed'
87
+ | 'not-open'
88
+ | 'unrecorded'
89
+ | 'overtaken'
90
+ | 'put-back-unrecorded'
91
+ | 'selected-again';
92
+
93
+ /**
94
+ * A channel's lifecycle operation, or a delivery waiting on one, that didn't
95
+ * complete. Its message is a clause in the agent's words, which its traces
96
+ * carry too, so the two never drift apart; `kind` says what the agent can do
97
+ * next, and `reach` where the server stood, for `unreachable`.
98
+ * - `unreachable`: its server isn't up (ServerReach).
99
+ * - `not-permitted`: its server is up, and its grant lacks channels.lifecycle.
100
+ * - `cancelled`: queued for a server whose registrations were removed.
101
+ * - `unlisted`: the server no longer lists the channel.
102
+ * - `superseded`: a later decision about the channel came first.
103
+ * - `converge`: a successful RPC kept failing to establish the latest target.
104
+ * - `substituted`: a channels/open answered for a channel other than the one
105
+ * it named.
106
+ * - `removed`, `not-open`: a delivery's wait (waitForOpenChannel) ended with
107
+ * the channel's registration gone, or with its close standing
108
+ * (closeStands) or its open not confirmed. These are whole reasons for
109
+ * words that didn't go out, said as they are.
110
+ * - `unrecorded`: the decision couldn't be written to the lifecycle log
111
+ * (appendLifecycleEvent), so nothing changed and nothing was sent.
112
+ * - `overtaken`, `put-back-unrecorded`, `selected-again`: an open by speech
113
+ * or a reply that failed (openChannelNow), carrying its own failure's
114
+ * clause. `overtaken`: a decision made during its round-trip, such as the
115
+ * agent's own close or channel_open, replaced the one the open wrote; that
116
+ * decision stands, and nothing is put back. `put-back-unrecorded`: the
117
+ * decision it replaced couldn't be put back on record, so the decision to
118
+ * open stays, the channel stays selected for opening, and reconciliation
119
+ * may open it later. `selected-again`: the pure default it put back is
120
+ * lifted by the channel's current descriptor or the subscription policy,
121
+ * so the channel is selected for opening again; `by` says which.
122
+ */
123
+ class ChannelLifecycleError extends Error {
124
+ readonly by?: Exclude<SelectedForOpening, 'unrecorded'>;
125
+ constructor(
126
+ message: string,
127
+ readonly kind: ChannelLifecycleFailure,
128
+ readonly reach?: Exclude<ServerReach, 'up'>,
129
+ options?: { by?: Exclude<SelectedForOpening, 'unrecorded'>; cause?: unknown },
130
+ ) {
131
+ super(message, options && 'cause' in options ? { cause: options.cause } : undefined);
132
+ this.name = 'ChannelLifecycleError';
133
+ this.by = options?.by;
134
+ }
135
+ }
136
+
137
+ /** Why a channel is selected for opening after a failed open by speech or a
138
+ * reply: its put-back couldn't be recorded, or the server's latest
139
+ * announcement or the subscription policy lifted the default it restored. */
140
+ export type SelectedForOpening = 'unrecorded' | 'server-bootstrap' | 'subscription-policy';
141
+
63
142
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
64
143
 
144
+ /** The sources of a decision the agent itself made: channel_open and
145
+ * channel_close, and channel_decline. */
146
+ const AGENT_DECISION_SOURCES: ReadonlySet<string> = new Set(['agent-tool', 'invitation-declined']);
147
+
65
148
  /**
66
149
  * What a channels/open answer says about which channel the server opened:
67
150
  * its `channel.id`, or else the `channelId` of a history item. Each is
@@ -116,8 +199,11 @@ export interface TuneOutParams {
116
199
  epochId: string;
117
200
  /** Subconscious summary cadence, in seconds. */
118
201
  cadenceSeconds: number;
119
- /** Maximum messages dumped raw at cancel; above the cap the subconscious
120
- * curates a digest and `fetch_history` covers the rest. */
202
+ /** Maximum messages dumped raw at cancel, the newest. Where the residents'
203
+ * strategies cap a message's size, the dump holds as many as fit whole in
204
+ * one message, or, when even the newest doesn't, as much of the newest as
205
+ * fits beside the cut's mark, if any does. For those left out the
206
+ * subconscious curates a digest, and `fetch_history` covers them. */
121
207
  backlogCap: number;
122
208
  /** Wake invocations before the tune-out auto-cancels. */
123
209
  maxWakes: number;
@@ -152,6 +238,43 @@ interface ChannelLifecycleEvent {
152
238
  acknowledgment?: string;
153
239
  }
154
240
 
241
+ /** A lifecycle-log item as a desired-state decision, or null when it isn't
242
+ * one this build reads: the one rule the registry's replay
243
+ * (initializeLifecycleStore) and desiredStateInLog read the log by. */
244
+ function desiredStateRecord(item: unknown): {
245
+ serverId: string; channelId: string; state: DesiredChannelState; source: string; tuneOut?: TuneOutParams;
246
+ } | null {
247
+ const event = item as Partial<ChannelLifecycleEvent> | null | undefined;
248
+ if (event?.kind !== 'desired-state' || typeof event.serverId !== 'string' || typeof event.channelId !== 'string') return null;
249
+ if (!(event.desired === 'open' || event.desired === 'closed' || (event.desired === 'tuned-out' && event.tuneOut))) return null;
250
+ return {
251
+ serverId: event.serverId,
252
+ channelId: event.channelId,
253
+ state: event.desired,
254
+ source: typeof event.source === 'string' ? event.source : 'unknown',
255
+ ...(event.desired === 'tuned-out' ? { tuneOut: event.tuneOut } : {}),
256
+ };
257
+ }
258
+
259
+ /**
260
+ * The desired state `store`'s lifecycle log records for `channelId` on
261
+ * `serverId`, for a reader with no registry yet, as a serving boot's
262
+ * deferred-write flush is, before initializeMcpl: the last decision wins, as
263
+ * the registry's replay reads it (getDesiredState answers once there is a
264
+ * registry). undefined when no decision names it.
265
+ */
266
+ export function desiredStateInLog(store: JsStore, serverId: string, channelId: string): DesiredChannelState | undefined {
267
+ ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
268
+ const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
269
+ if (!Array.isArray(raw)) return undefined;
270
+ let state: DesiredChannelState | undefined;
271
+ for (const item of raw) {
272
+ const decision = desiredStateRecord(item);
273
+ if (decision?.serverId === serverId && decision.channelId === channelId) state = decision.state;
274
+ }
275
+ return state;
276
+ }
277
+
155
278
  /** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this
156
279
  * channelId's label was observed to be `label`". See `labelHistory`. */
157
280
  interface ChannelLabelSightingEvent {
@@ -252,6 +375,29 @@ interface ChannelEntry {
252
375
  open: boolean;
253
376
  }
254
377
 
378
+ /**
379
+ * A channel its server declared inbound-only (SPEC §14.2 `direction`): its
380
+ * messages come in, and the host publishes nothing into it. No publish, from
381
+ * plain speech, `channel_publish` or `publishForAgent`, and no outgoing
382
+ * stream starts there, though one the channel took before the declaration
383
+ * ends there (sendOutgoingChunk). Typing and acknowledgements, which carry
384
+ * no content, still go (SPEC §14.5: "Delivery occurs only via
385
+ * `channels/publish`"). A channel declared `outbound` or `bidirectional`, or
386
+ * with no direction, isn't one.
387
+ */
388
+ function isInboundOnly(entry: ChannelEntry): boolean {
389
+ return entry.descriptor.direction === 'inbound';
390
+ }
391
+
392
+ /** Why the host sends nothing into an inbound-only channel, as the resident
393
+ * reads it in a send-failure marker or a tool error. */
394
+ function inboundOnlyReason(entry: ChannelEntry): string {
395
+ return (
396
+ `inbound-only: server "${entry.serverId}" declared this channel's direction "inbound", ` +
397
+ 'so the host publishes nothing into it; to speak there, use that server\'s own tools'
398
+ );
399
+ }
400
+
255
401
  function sameChannelTarget(a: ChannelDescriptor, b: ChannelDescriptor): boolean {
256
402
  return a.type === b.type && isDeepStrictEqual(a.address, b.address);
257
403
  }
@@ -306,6 +452,33 @@ export function journalToolDescription(canSkipReply: boolean): string {
306
452
  'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
307
453
  }
308
454
 
455
+ /**
456
+ * channel_open's and channel_close's descriptions for an agent whose window doesn't hold a channel's traffic: an
457
+ * ephemeral run, whose store is its own unless it's where channel messages are stored (on a host with no residents,
458
+ * the first registered agent), and on a host that routes conversations every agent but a fork, since a channel's
459
+ * messages go to its fork or, unrouted, nowhere. The descriptions in CHANNEL_TOOL_DEFINITIONS, written for the agents
460
+ * that read a channel, promised such an agent what it never gets: "Messages that address you still reach you", and
461
+ * history "preceding the message that invited you" when nothing invites it. Given per agent (AgentFramework's
462
+ * getToolsForAgent), so a holder's cached tool list doesn't change.
463
+ *
464
+ * Both say the call is for every agent: the decision is recorded per server and channel (setDesiredState), so a run's
465
+ * close closes the channel for the agents whose windows hold it, which "its messages don't come to you" alone could
466
+ * read as costing nothing. They claim only what holds on both lanes: a push that names a channel is stored in the
467
+ * residents' shared slot whether or not the channel is open (handleMcplPushEvent), so on a routed host the residents
468
+ * get the channel's pushes, and a server may push while a channel is closed and send channels/incoming once it's
469
+ * open. So opening brings the caller none of the channel's messages, and closing stops none reaching it. The history
470
+ * channel_open asks for comes back in its result (handleToolOpen), so that clause is true for anyone.
471
+ */
472
+ export const NON_HOLDER_CHANNEL_DESCRIPTIONS: Readonly<Record<'channel_open' | 'channel_close', string>> = {
473
+ channel_open:
474
+ 'Open a channel for every agent on this host. Opening it brings you none of its messages; for the agents they do ' +
475
+ 'come to, its server decides what it delivers. Optionally request some of its history, which comes back to you in ' +
476
+ 'this call.',
477
+ channel_close:
478
+ 'Close a channel for every agent on this host. Closing it stops none of its messages reaching you; for the agents ' +
479
+ 'they do come to, its server decides what closing it stops.',
480
+ };
481
+
309
482
  const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
310
483
  {
311
484
  name: 'channel_list',
@@ -370,11 +543,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
370
543
  },
371
544
  {
372
545
  name: 'channel_publish',
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.',
546
+ description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.",
374
547
  inputSchema: {
375
548
  type: 'object' as const,
376
549
  properties: {
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)' },
550
+ channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" },
378
551
  content: { type: 'string', description: 'Text content to publish' },
379
552
  text: { type: 'string', description: 'Alias for content' },
380
553
  },
@@ -450,7 +623,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
450
623
  // ============================================================================
451
624
 
452
625
  /** Bound connector values for notices, logs and an outcome's reason. A
453
- * PublishOutcome keeps the connector's structured data (`detail`) whole. */
626
+ * PublishOutcome keeps the connector's structured data (`detail`) as the
627
+ * connection gives it, its strings redacted (redactedData). */
454
628
  export function connectorText(value: unknown): string {
455
629
  let text: string;
456
630
  try {
@@ -472,6 +646,12 @@ export interface PublishDestination {
472
646
  label?: string;
473
647
  }
474
648
 
649
+ /** One key per destination, its server and channel: unlike joining the two
650
+ * with a separator, no two destinations share one, whatever their ids hold. */
651
+ export function destinationKey(to: PublishDestination): string {
652
+ return JSON.stringify([to.serverId, to.channelId]);
653
+ }
654
+
475
655
  /**
476
656
  * What one channels/publish attempt established (ChannelRegistry.publish).
477
657
  * - `delivered`: the connector returned `delivered: true`. A historical
@@ -494,12 +674,180 @@ export interface PublishOutcome {
494
674
  /** Why it failed, or why its outcome is unknown — for a connector error,
495
675
  * its message, bounded (which may say what was already posted). */
496
676
  reason?: string;
497
- /** The connector error's structured `data`, verbatim, when it sent one. */
677
+ /** The connector error's structured `data`, with the strings in it
678
+ * redacted (redactedData), when it sent one. */
498
679
  detail?: unknown;
499
680
  /** When the outcome was established (epoch ms). */
500
681
  at: number;
501
682
  }
502
683
 
684
+ /**
685
+ * Why the destination refused a publishForAgent before anything went out.
686
+ * Its words say the same, since both come from one check: the channel isn't
687
+ * registered here, it takes no posts, or its server is gone, this host is
688
+ * connecting to it, it's reconnecting, or it's up without channels.publish
689
+ * (serverReach); or the request wasn't sent, as its connection refused it
690
+ * before anything was written (McplRequestError's `not-sent`). A refusal of
691
+ * the caller's own input, such as no text or no channel id, carries none,
692
+ * and so does a publish that went out, whatever became of it: its words say
693
+ * whether it may have been posted.
694
+ */
695
+ export type PublishRefusalReason =
696
+ | 'channel-not-registered'
697
+ | 'inbound-only'
698
+ | 'server-gone'
699
+ | 'server-connecting'
700
+ | 'server-down'
701
+ | 'not-permitted'
702
+ | 'not-sent';
703
+
704
+ /**
705
+ * A failed request's words, bounded, for a reason that a caller composes.
706
+ * This host's own words, for a request that wasn't sent or wasn't answered
707
+ * or for an error of its own, such as its store's, are sentences, and every
708
+ * composition of a reason adds its own ending, so one final period comes
709
+ * off them. A server's error answer keeps its words as they came.
710
+ */
711
+ function requestErrorText(err: unknown): string {
712
+ const text = connectorText(err instanceof Error ? err.message : String(err));
713
+ return err instanceof McplRequestError && err.outcome === 'error-response' ? text : text.replace(/(?<!\.)\.$/, '');
714
+ }
715
+
716
+ /** A server that isn't up (ServerReach), as a clause the agent reads. */
717
+ function unreachableClause(serverId: string, reach: Exclude<ServerReach, 'up'>): string {
718
+ switch (reach) {
719
+ case 'gone': return `the "${serverId}" server isn't connected, and this host won't reconnect to it on its own`;
720
+ case 'connecting': return `this host is connecting to the "${serverId}" server`;
721
+ case 'down': return `the "${serverId}" server isn't connected, and this host is reconnecting to it`;
722
+ }
723
+ }
724
+
725
+ /** What helps when a server isn't up (ServerReach), as the end of the
726
+ * clause that says so. */
727
+ function unreachableRemedy(reach: Exclude<ServerReach, 'up'>): string {
728
+ switch (reach) {
729
+ case 'gone': return '; if you need it, ask whoever runs this host';
730
+ case 'connecting': return ', so try again in a moment';
731
+ case 'down': return ', so try again later';
732
+ }
733
+ }
734
+
735
+ /** Whether an entry's descriptor carries the key a push's placeholder has
736
+ * (ensureChannelRegistered's lazyRegistered metadata), as main reads it for
737
+ * whether the server has declared the channel: a registration or a
738
+ * channels/changed update brings the server's own descriptor, which
739
+ * doesn't normally carry it. The traffic rule doesn't read it, since a
740
+ * server's descriptor can (untargetedPushEntries). */
741
+ function pushPlaceholder(entry: ChannelEntry): boolean {
742
+ return (entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered === true;
743
+ }
744
+
745
+ /** When reconciling applies a recorded decision: at the server's next
746
+ * registration of the channel, where this host directs its channels'
747
+ * lifecycle then (reconcileChannels, §14.1). A channel made from a push
748
+ * may never be registered at all, so for one the text says "if". */
749
+ function laterClause(entry: ChannelEntry): string {
750
+ return pushPlaceholder(entry)
751
+ ? `if the "${entry.serverId}" server registers this channel at a time this host may open and close its channels`
752
+ : `when the "${entry.serverId}" server next registers this channel, if this host may open and close its channels then`;
753
+ }
754
+
755
+ /** A channel decision the lifecycle log refused to append, as on a full
756
+ * disk: nothing changed (appendLifecycleEvent). */
757
+ function unrecordedDecision(err: unknown): ChannelLifecycleError {
758
+ return new ChannelLifecycleError(`this host couldn't record the decision: ${requestErrorText(err)}`, 'unrecorded');
759
+ }
760
+
761
+ /** A failure's clause, then more said of the same failure. A server's own
762
+ * words keep their final stop (requestErrorText), so after them the rest
763
+ * is a sentence of its own. */
764
+ function afterClause(clause: string, more: string): string {
765
+ return /[.!?]$/.test(clause) ? `${clause} ${more.charAt(0).toUpperCase()}${more.slice(1)}` : `${clause}; ${more}`;
766
+ }
767
+
768
+ /** A lifecycle failure as a clause: its own words, or its request's. */
769
+ function lifecycleClause(err: unknown): string {
770
+ return err instanceof ChannelLifecycleError ? err.message : requestErrorText(err);
771
+ }
772
+
773
+ /** Why words didn't go out when their channel's registration went away
774
+ * first, at a delivery's wait or at its last check. */
775
+ const CHANNEL_REMOVED_CLAUSE = 'the channel was removed before these words went out';
776
+
777
+ /** A tune-out's open answered for a tune-out that ended, or that a later
778
+ * decision or a later tune-out replaced, while the open was out: by
779
+ * openForTuneOut, and by a repeated tune_out whose tune-out isn't the one it
780
+ * read before it waited. */
781
+ export const TUNE_OUT_REPLACED = "The tune-out was recorded, but it isn't in effect any more: it ended, or a later " +
782
+ 'decision about the channel or a later tune-out replaced it, while its transport was being opened.';
783
+
784
+ /**
785
+ * Why words a send opened their channel for didn't go out. The delivery's
786
+ * wait after the open says it whole (`removed`, `not-open`). The open's own
787
+ * failure is said as one, with what helps where there's a step to take. It
788
+ * names no state of the channel's transport: one decided closed can still
789
+ * be open, after a close that failed.
790
+ */
791
+ function openFailedReason(err: unknown): string {
792
+ if (err instanceof ChannelLifecycleError && (err.kind === 'removed' || err.kind === 'not-open')) return err.message;
793
+ return `opening the channel for these words didn't complete: ${lifecycleClause(err)}${refusalRemedy(err)}`;
794
+ }
795
+
796
+ /** What helps after a lifecycle failure, as the end of the clause that says
797
+ * it: the server state's remedy, whom to ask for a grant, or another try
798
+ * where the channel's target kept changing or the decision couldn't be
799
+ * recorded. Nothing for any other failure. */
800
+ function refusalRemedy(err: unknown): string {
801
+ if (!(err instanceof ChannelLifecycleError)) return '';
802
+ if (err.kind === 'unreachable' && err.reach) return unreachableRemedy(err.reach);
803
+ if (err.kind === 'not-permitted') return '; if you need it, ask whoever runs this host';
804
+ if (err.kind === 'converge') return ', so try again';
805
+ if (err.kind === 'unrecorded') return ', so try again; if it fails the same way, ask whoever runs this host';
806
+ return '';
807
+ }
808
+
809
+ /** Why an open or a close doesn't stand when a later decision about the
810
+ * channel came first (`now`, its desired state since). */
811
+ function supersededClause(action: 'open' | 'close', now: DesiredChannelState | undefined): string {
812
+ if (now === 'tuned-out') return 'it was tuned out meanwhile';
813
+ return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
814
+ }
815
+
816
+ /** What ends a tune-out, for a channel tool's answer about a tuned-out
817
+ * channel (`channel` as the answer shows it, quoted): its cancel, with the
818
+ * backlog it delivers, and, for a close, that channel_close closes the
819
+ * channel once the cancel has ended the tune-out. */
820
+ function tunedOutPointer(tool: string, channel: string): string {
821
+ return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
822
+ 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
823
+ }
824
+
825
+ /** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
826
+ * the tune-out, with the backlog it delivers. As an error result the agent
827
+ * reads the pointer itself; `data` names the refusal for an in-process
828
+ * caller. */
829
+ function tunedOutRefusal(tool: string, channelId: string): ToolResult {
830
+ return {
831
+ success: false,
832
+ isError: true,
833
+ error:
834
+ `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
835
+ tunedOutPointer(tool, `"${channelId}"`),
836
+ data: { refusal: 'tuned-out', channelId },
837
+ };
838
+ }
839
+
840
+ /** A clause as the start of a sentence. */
841
+ function sentenceStart(clause: string): string {
842
+ return clause.charAt(0).toUpperCase() + clause.slice(1);
843
+ }
844
+
845
+ /** Words as a sentence's end: a stop, unless they end in one already, as a
846
+ * server's own words may. */
847
+ function withStop(text: string): string {
848
+ return /[.!?]$/.test(text) ? text : `${text}.`;
849
+ }
850
+
503
851
  /**
504
852
  * What a channels/publish request that threw establishes. Only a request
505
853
  * that never left the host proves nothing was posted. An error response may
@@ -509,12 +857,7 @@ export interface PublishOutcome {
509
857
  * a failed request this way.
510
858
  */
511
859
  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;
860
+ const message = requestErrorText(err);
518
861
  const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
519
862
  const detail = err instanceof McplRequestError ? err.data : undefined;
520
863
  return {
@@ -543,16 +886,17 @@ function publishReceiptOutcome(result: unknown, destination: PublishDestination,
543
886
  }
544
887
  if (receipt.delivered === false) {
545
888
  if (receipt.messageId === undefined || receipt.messageId === null) {
546
- return { status: 'failed', destination, reason: `server "${destination.serverId}" reported delivered:false`, at };
889
+ return { status: 'failed', destination, reason: `the "${destination.serverId}" server said it didn't post the message`, at };
547
890
  }
548
891
  return {
549
892
  status: 'unknown',
550
893
  destination,
551
894
  ...(messageId ? { messageId } : {}),
552
895
  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)`,
896
+ ? `the "${destination.serverId}" server said it didn't post the message, yet named message ` +
897
+ `${connectorText(messageId)} as posted (delivery uncertain)`
898
+ : `the "${destination.serverId}" server said it didn't post the message, along with a message id that ` +
899
+ `isn't valid (${connectorText(receipt.messageId)}) (delivery uncertain)`,
556
900
  at,
557
901
  };
558
902
  }
@@ -560,7 +904,7 @@ function publishReceiptOutcome(result: unknown, destination: PublishDestination,
560
904
  status: 'unknown',
561
905
  destination,
562
906
  ...(messageId ? { messageId } : {}),
563
- reason: `server "${destination.serverId}" returned no valid delivery receipt (delivery uncertain)`,
907
+ reason: `the "${destination.serverId}" server's answer didn't say whether it posted the message (delivery uncertain)`,
564
908
  at,
565
909
  };
566
910
  }
@@ -577,26 +921,34 @@ interface ChannelRegistryOptions {
577
921
  * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with
578
922
  * the event the ordinary path would have queued. The handler decides
579
923
  * replace / append / withdraw and returns the per-message result. Throws a
580
- * `CoalesceError` for a malformed or unauthorized occurrence.
924
+ * `CoalesceError` for a malformed or unauthorized occurrence. `firstView`
925
+ * says the coalescer delivered the message as the agent's first view of it,
926
+ * with the delivery's own `firstView` mark; it is the host's own, and isn't
927
+ * in the answer to the server.
581
928
  */
582
929
  handleCoalescedIncoming?: (
583
930
  serverId: string,
584
931
  message: ChannelIncomingMessage,
585
932
  event: McplChannelIncomingEvent,
586
- ) => Promise<ChannelIncomingMessageResult>;
933
+ ) => Promise<ChannelIncomingMessageResult & { firstView?: true }>;
587
934
  /** Chronicle store used for durable desired channel lifecycle state. */
588
935
  store?: JsStore;
589
936
  /** Callback to determine whether an incoming message should trigger inference. */
590
937
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
591
938
  /**
592
- * Called when a text-only turn's speech could NOT be delivered to its
593
- * conversational locus — no locus, an unregistered channel, a missing
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
598
- * visible to the agent (and operator) rather than silently lost. Must not
599
- * itself trigger inference (avoid wake loops).
939
+ * Called when a text-only turn's speech was not delivered to its
940
+ * conversational locus (`failed`), or may not have been (`unknown`), for
941
+ * whatever reason deliverSpeech gives, such as no locus, a channel that
942
+ * isn't registered or refuses speech, a missing server or grant, a failed
943
+ * open, the server reporting `delivered: false`, or a publish answered
944
+ * with an error, a timeout or a lost connection, after which whether it
945
+ * was posted is unknown. It isn't called for an ephemeral run's speech
946
+ * whose turn has no locus (a null locus), which is its caller's result
947
+ * (`takesGlobalFallback`), nor for a `failed` delivery whose caller holds
948
+ * its words with a notice of its own (deliverSpeech's `heldOnFailure`).
949
+ * The host wires this to drop a `[discord-send-failed]` marker into
950
+ * chronicle so the failure is visible to the agent (and operator) rather
951
+ * than silently lost. Must not itself trigger inference (avoid wake loops).
600
952
  */
601
953
  onRouteFailure?: (info: {
602
954
  conversationId: string;
@@ -632,9 +984,11 @@ interface ChannelRegistryOptions {
632
984
  * forks are spawned bound to a single channel (the framework tracks this in
633
985
  * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
634
986
  * plain-text speech must route THERE. The process-global `defaultPublishChannel`
635
- * tracks only the most-recent inbound across ALL channels, so with one fork per
636
- * channel running concurrently it misroutes a fork's reply to whichever channel
637
- * last spoke (item 3). Returns undefined for the trunk/primary agent, which has
987
+ * tracks only the most recent inbound message across ALL channels (reaction,
988
+ * reaction-removal, edit and deletion markers aside, apart from an edit the
989
+ * coalescer delivers on acceptance as the agent's first view), so with one
990
+ * fork per channel running concurrently it misroutes a fork's reply to
991
+ * whichever channel last spoke (item 3). Returns undefined for the trunk/primary agent, which has
638
992
  * no home and correctly falls back to the global locus (heartbeats, etc.).
639
993
  */
640
994
  homeChannelResolver?: (agentName: string) => string | undefined;
@@ -644,11 +998,12 @@ interface ChannelRegistryOptions {
644
998
  * turn. This is the fix for single-TRUNK agents (the only mode
645
999
  * connectome-host runs — it never exposes conversation forks). A trunk agent
646
1000
  * has no fork home, so without this its plain-text speech falls back to the
647
- * process-global `defaultPublishChannel`, which tracks the most-recent inbound
648
- * across ALL channels and misroutes a reply to whichever channel last spoke
649
- * under concurrency (item-3 redux). The framework keeps both on the live
650
- * turn's routing state and exposes them here. For a heartbeat / no-trigger
651
- * turn it resolves to undefined until the turn's locus is resolved, so that
1001
+ * process-global `defaultPublishChannel`, which tracks the most recent inbound
1002
+ * message across ALL channels (markers aside) and misroutes a reply to
1003
+ * whichever channel last spoke under concurrency (item-3 redux). The
1004
+ * framework keeps both on the live turn's routing state and exposes them
1005
+ * here. For a heartbeat / no-trigger turn it resolves to undefined until the
1006
+ * turn's locus is resolved, so that
652
1007
  * resolution takes the global fallback, which the turn then keeps.
653
1008
  * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
654
1009
  * `defaultPublishChannel`.
@@ -668,6 +1023,14 @@ interface ChannelRegistryOptions {
668
1023
  takesGlobalFallback?: (conversationId: string) => boolean;
669
1024
  /** Whether this framework has EventGate and can honor skip_reply self-wakes. */
670
1025
  eventGatePresent?: boolean;
1026
+ /**
1027
+ * Whether this host is connecting to the server of this id: a connect or a
1028
+ * restart of the id is in flight, or the policy exchange of the transport
1029
+ * its registered connection is on is. Its channel tools and speech then say
1030
+ * so, as its tool calls do, rather than read the empty grant meanwhile as a
1031
+ * refusal (serverReach). It words a refusal only: the grant decides.
1032
+ */
1033
+ serverConnecting?: (serverId: string) => boolean;
671
1034
  }
672
1035
 
673
1036
  // ============================================================================
@@ -682,17 +1045,7 @@ interface ChannelRegistryOptions {
682
1045
  */
683
1046
  export type ChannelToolOrigin =
684
1047
  | { kind: 'module'; moduleName?: string }
685
- | {
686
- kind: 'agent';
687
- agentName: string;
688
- /** The calling turn's trigger channel with the server whose message it
689
- * was: a channel_publish into that channel goes out through that server
690
- * only. */
691
- trigger?: { channelId: string; serverId: string };
692
- /** The calling turn's locus with the server of the message that put it
693
- * there, where that isn't the trigger: the same holds for it. */
694
- locus?: { channelId: string; serverId: string };
695
- };
1048
+ | { kind: 'agent'; agentName: string };
696
1049
 
697
1050
  /** The machine decision sources a module-origin channel_close may record.
698
1051
  * Closed on purpose: honest provenance means a small, auditable vocabulary,
@@ -721,11 +1074,20 @@ export class ChannelRegistry {
721
1074
  private homeChannelResolver?: (agentName: string) => string | undefined;
722
1075
  private activeChannelResolver?: (agentName: string) => string | undefined;
723
1076
  private takesGlobalFallback?: (conversationId: string) => boolean;
1077
+ private serverConnecting?: (serverId: string) => boolean;
724
1078
  private store?: JsStore;
725
1079
 
726
1080
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
727
1081
  private channels = new Map<string, ChannelEntry>();
728
1082
 
1083
+ /**
1084
+ * Each outgoing stream, by `inferenceId`, with the destinations it has
1085
+ * started at (destinationKey): the rest of the stream there goes on there,
1086
+ * and its `complete` goes only there (sendOutgoingChunk). An entry goes
1087
+ * with the stream's `complete` for its destination.
1088
+ */
1089
+ private takenStreams = new Map<string, Set<string>>();
1090
+
729
1091
  /** Holds back repeats of the line for a message refused because its
730
1092
  * channel isn't registered (RefusalLines). */
731
1093
  private unknownChannelLines = new RefusalLines();
@@ -780,12 +1142,13 @@ export class ChannelRegistry {
780
1142
  */
781
1143
  private dmClassified = new Map<string, true>();
782
1144
 
783
- /** Most recent incoming channel ID — used for speech routing / default publish. */
1145
+ /** The channel of the most recent incoming message that isn't a reaction,
1146
+ * reaction removal, edit or deletion marker, apart from an edit the
1147
+ * coalescer delivers on acceptance as the agent's first view: used for
1148
+ * speech routing and the default publish. */
784
1149
  private defaultPublishChannel: string | null = null;
785
- /** The server whose accepted message set defaultPublishChannel. */
786
- private defaultPublishServer: string | null = null;
787
1150
 
788
- /** Most recent incoming message metadata, used for buildChannelContext. */
1151
+ /** The same message's id and thread, used for buildChannelContext. */
789
1152
  private defaultPublishMessageId: string | null = null;
790
1153
  private defaultPublishThreadId: string | undefined = undefined;
791
1154
 
@@ -825,9 +1188,31 @@ export class ChannelRegistry {
825
1188
  // target before confirming anything. Unlike the tail, which settles a few
826
1189
  // microtasks after its operation's last check, this drops at that check.
827
1190
  private lifecycleOutstanding = new Map<string, number>();
1191
+ // The removal count each channel's newest outstanding operation was queued
1192
+ // under. Where it's older than its server's, every operation still
1193
+ // outstanding for the channel cancels when it would run.
1194
+ private lifecycleQueuedRemovals = new Map<string, number>();
828
1195
  // Losing an open receipt does not confirm closure. Keep that distinction
829
1196
  // private while preserving the public open:boolean availability projection.
830
1197
  private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
1198
+ // Marked entries a close failed for since a receipt or traffic last
1199
+ // confirmed them, cleared where the mark is: a failed request's outcome is
1200
+ // unknown, so such a close may have closed the channel, where an open,
1201
+ // failed or not, can't have. Only a tune-out reads it (openForTuneOut),
1202
+ // since its success says the channel's traffic will arrive.
1203
+ private failedCloseTargets = new WeakSet<ChannelEntry>();
1204
+ // Each tune-out's open in flight, by channel and epoch, so tune_out
1205
+ // repeated meanwhile waits for that open's answer rather than send another.
1206
+ private tuneOutOpens = new Map<string, Promise<string | undefined>>();
1207
+ // Entries ensureChannelRegistered made from a push, until a channels/changed
1208
+ // update gives one a target (handleChanged's retarget). A registration makes
1209
+ // a new entry, which isn't here. Kept by the host, not read from the
1210
+ // descriptor, whose metadata a server's own registration supplies.
1211
+ private untargetedPushEntries = new WeakSet<ChannelEntry>();
1212
+ // How many times each server's transport has closed (transportClosed).
1213
+ // handleIncoming reads it as a batch arrives, so traffic admitted after a
1214
+ // close it arrived before confirms nothing.
1215
+ private transportCloses = new Map<string, number>();
831
1216
 
832
1217
  private invalidateTransportConfirmation(entry: ChannelEntry): void {
833
1218
  entry.open = false;
@@ -837,16 +1222,211 @@ export class ChannelRegistry {
837
1222
  /** Whether the host directs this server's channel lifecycle (§14.1: its
838
1223
  * `channels.lifecycle` grant). Where it doesn't, the server opens and
839
1224
  * closes its own channels, and sends no receipt the host could wait for. */
840
- private directsLifecycle(serverId: string): boolean {
1225
+ directsLifecycle(serverId: string): boolean {
841
1226
  return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
842
1227
  }
843
1228
 
844
- /** A registration's entry: nothing about its transport is known until a
845
- * receipt for its target confirms it open or closed. */
846
- private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): void {
1229
+ /** Whether a decided close holds words back from a channel: only where
1230
+ * the host directs the server's lifecycle. Without that grant the host
1231
+ * can't close the channel, and words go into a transport that is open,
1232
+ * as the open step's engagesDecidedClosed reads it too. */
1233
+ private closeStands(entry: ChannelEntry): boolean {
1234
+ return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
1235
+ this.directsLifecycle(entry.serverId);
1236
+ }
1237
+
1238
+ /** Where this server stands for a request this host would make of it
1239
+ * (ServerReach). One without a connection's state reads as connected.
1240
+ * A closed connection the host is restarting reads as connecting, as
1241
+ * one being connected does. */
1242
+ private serverReach(serverId: string): ServerReach {
1243
+ const server = this.serverRegistry.getServer(serverId);
1244
+ const connecting = this.serverConnecting?.(serverId) ?? false;
1245
+ if (!server) return connecting ? 'connecting' : 'gone';
1246
+ if (server.isConnected === false) return connecting ? 'connecting' : server.willReconnect ? 'down' : 'gone';
1247
+ return connecting ? 'connecting' : 'up';
1248
+ }
1249
+
1250
+ /**
1251
+ * Why this host won't open or close this server's channels, for a caller
1252
+ * that found its grant without channels.lifecycle (§14.1), which decides:
1253
+ * the server's state says why. It isn't up, or it's up without that grant.
1254
+ * Neither the grant's name, which is the operator's, nor an empty grant's
1255
+ * refusal, which needn't be the server's, reaches the agent.
1256
+ */
1257
+ private lifecycleRefusal(serverId: string): ChannelLifecycleError {
1258
+ const reach = this.serverReach(serverId);
1259
+ if (reach !== 'up') return new ChannelLifecycleError(unreachableClause(serverId, reach), 'unreachable', reach);
1260
+ return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
1261
+ }
1262
+
1263
+ /**
1264
+ * Why a send to this server's channels is refused before anything goes
1265
+ * out, as one clause with what works from there, for a caller that found
1266
+ * no server or a grant without channels.publish (§14.1), which decides:
1267
+ * the server's state says why. Plain speech gives it as its reason, and
1268
+ * channel_publish and speak_in_channel as theirs, so one refusal has one
1269
+ * wording. `checked` is the connection whose grant refused, where that
1270
+ * was read before the words waited: one the registry has replaced since
1271
+ * says nothing about the server that stands now.
1272
+ */
1273
+ private sendRefusal(serverId: string, checked?: McplServerConnection): string {
1274
+ const now = this.serverRegistry.getServer(serverId);
1275
+ const reach = this.serverReach(serverId);
1276
+ // A replacement that would take the words is worth another try; one
1277
+ // that wouldn't is worded as it stands.
1278
+ if (checked && now && now !== checked && reach === 'up' && CapabilityGrant.of(now).has('channels.publish')) {
1279
+ return `the "${serverId}" server's connection was replaced while these words waited, so try again`;
1280
+ }
1281
+ if (reach !== 'up') return unreachableClause(serverId, reach) + unreachableRemedy(reach);
1282
+ return `this host isn't permitted to post to the "${serverId}" server's channels, and trying again won't change that; ` +
1283
+ 'if you need it, ask whoever runs this host';
1284
+ }
1285
+
1286
+ /**
1287
+ * What a failed channel_open or channel_close tells the agent. Its
1288
+ * decision was recorded before the server was asked, and reconciling
1289
+ * applies it when the server next registers the channel (for a push's
1290
+ * placeholder, if it ever does), if this host directs their lifecycle then
1291
+ * (laterClause). A decision the log refused changed nothing, so that comes
1292
+ * first. A channel no longer registered
1293
+ * can't be reached by any channel tool, so its text names none. A later
1294
+ * decision that came first is what stands, so that comes next. Then the
1295
+ * server's state: one that isn't up gets the change when it's back, though
1296
+ * not a backscroll's history. For the request's own failure, its state is
1297
+ * read now, since a request out when the server dropped may have taken
1298
+ * effect, and reconciling settles it either way. A decision already in
1299
+ * effect, with no other work for the channel under way, says so, as one
1300
+ * whose backscroll alone failed is. Then the failure's kind, since each
1301
+ * has its own next step (ChannelLifecycleError). A request's own failure
1302
+ * is in the connection's words.
1303
+ */
1304
+ private lifecycleFailureText(
1305
+ action: 'open' | 'close',
1306
+ entry: ChannelEntry,
1307
+ err: unknown,
1308
+ opts?: { history?: boolean },
1309
+ ): string {
1310
+ const channel = `"${entry.descriptor.id}"`;
1311
+ const tool = action === 'open' ? 'channel_open' : 'channel_close';
1312
+ const done = action === 'open' ? 'opened' : 'closed';
1313
+ const later = laterClause(entry);
1314
+ // A decision never recorded is no decision a later one overtook, and
1315
+ // nothing about the channel changed, whatever its transport's state.
1316
+ if (err instanceof ChannelLifecycleError && err.kind === 'unrecorded') {
1317
+ return `Nothing changed for channel ${channel}: ${err.message}, so nothing was sent to the server. ` +
1318
+ `Calling ${tool} again tries again; if it fails the same way, ask whoever runs this host.`;
1319
+ }
1320
+ const failure = err instanceof ChannelLifecycleError ? err : undefined;
1321
+ const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
1322
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
1323
+ if (!current) {
1324
+ // Its registration may have gone at the server's word or at this
1325
+ // host's (removeServer). Only a failure that says which (cancelled,
1326
+ // unlisted) names it; otherwise the server's state where it isn't up,
1327
+ // and a request's own words where it failed with them. Another
1328
+ // failure's words are about a request whose channel is gone.
1329
+ const reach = this.serverReach(entry.serverId);
1330
+ const why = failure?.kind === 'cancelled' || failure?.kind === 'unlisted' ? `: ${failure.message}`
1331
+ : reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : '';
1332
+ // A tune-out is in effect already: only its transport waits for the
1333
+ // server's next registration.
1334
+ const stays = now === 'tuned-out' ? 'It was tuned out meanwhile, and that stays recorded.'
1335
+ : `Its ${now === 'closed' ? 'close' : 'open'} stays recorded, and happens ${later}.`;
1336
+ return `Channel ${channel} isn't registered here any more${why}. ${stays}` +
1337
+ (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
1338
+ (failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
1339
+ }
1340
+ // A tune-out sends the channel's messages to the subconscious, and
1341
+ // channel_open refuses a tuned-out channel.
1342
+ if (action === 'open' ? now !== 'open' : now !== 'closed') {
1343
+ if (action === 'open' && now === 'tuned-out') {
1344
+ return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
1345
+ `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
1346
+ }
1347
+ // channel_close refuses a tuned-out channel too, so calling it again
1348
+ // isn't the way: the tune-out's cancel is, and it delivers the backlog.
1349
+ if (action === 'close' && now === 'tuned-out') {
1350
+ return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
1351
+ `change that. ${tunedOutPointer('channel_close', channel)}`;
1352
+ }
1353
+ // The later decision's own work settles the transport, and its call
1354
+ // says what became of it, so this says only that this call's change
1355
+ // doesn't stand.
1356
+ return `Channel ${channel}: ${supersededClause(action, now)}, so this ${action} doesn't stand. Call ${tool} again to ` +
1357
+ `${action} it.`;
1358
+ }
1359
+ const reach = failure ? (failure.kind === 'unreachable' ? failure.reach : undefined) : this.serverReach(entry.serverId);
1360
+ if (reach !== undefined && reach !== 'up') {
1361
+ // Reconciling opens a channel, but asks for no history.
1362
+ const history = action === 'open' && opts?.history;
1363
+ return `${sentenceStart(unreachableClause(entry.serverId, reach))}, so channel ${channel} may not be ` +
1364
+ `${action === 'open' ? 'open' : 'closed'} there yet. The ${action} is recorded and happens ${later}` +
1365
+ (history
1366
+ ? ', but the history you asked for doesn\'t come with it: calling channel_open with backscroll once the server is ' +
1367
+ 'connected asks for it.'
1368
+ : `, so there's no need to call ${tool} again.`) +
1369
+ (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
1370
+ (action === 'open' ? ' channel_close withdraws it.' : '');
1371
+ }
1372
+ const said = lifecycleClause(err);
1373
+ // Other work for the channel still under way (lifecycleOutstanding,
1374
+ // released as each operation's last look ends) may change its state.
1375
+ const settled = !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
1376
+ // The registry's own reading, as its checks make it (channel_open's
1377
+ // already-open return, the decline's refusal, delivery): a request that
1378
+ // failed leaves the open flag as it was, and the unconfirmed mark counts
1379
+ // only while the channel isn't open (runLifecycleOperation).
1380
+ const inEffect = current !== undefined && settled && (action === 'open'
1381
+ ? current.open
1382
+ : !current.open && !this.unconfirmedChannelTargets.has(current));
1383
+ if (inEffect) {
1384
+ // What was asked already holds. Without the grant, nothing more can be
1385
+ // asked of the server, so calling again won't change anything.
1386
+ const state = `Channel ${channel} is ${action === 'open' ? 'open' : 'closed'}`;
1387
+ if (action === 'open' && opts?.history) {
1388
+ return failure?.kind === 'not-permitted'
1389
+ ? `${state}, but the history you asked for didn't come: ${failure.message}, so calling channel_open again ` +
1390
+ "won't change that. If you need it, ask whoever runs this host."
1391
+ : withStop(`${state}, but the history you asked for didn't come: ${said}`) +
1392
+ ' Calling channel_open again with backscroll tries once more.';
1393
+ }
1394
+ return failure?.kind === 'not-permitted'
1395
+ ? `${state}. ${sentenceStart(failure.message)}, so it sent the server nothing.`
1396
+ : withStop(`${state}, though this call didn't complete: ${said}`);
1397
+ }
1398
+ switch (failure?.kind) {
1399
+ case 'not-permitted':
1400
+ // The decision was recorded before the refusal, and reconciling
1401
+ // applies it at a registration whose grant allows it.
1402
+ return `${sentenceStart(failure.message)}, so this ${action} of channel ${channel} didn't go through, and calling ${tool} ` +
1403
+ `again won't change that. The ${action} stays recorded, and happens ${later}. If you need it, ask whoever runs this host.`;
1404
+ case 'cancelled':
1405
+ case 'unlisted':
1406
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and happens ${later}.`;
1407
+ case 'converge':
1408
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and calling ${tool} again ` +
1409
+ 'tries once more.';
1410
+ case 'substituted':
1411
+ // Only an open is answered for another channel.
1412
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and this host tries it ` +
1413
+ `again ${later}; calling ${tool} again is likely to get the same answer. channel_close withdraws it.`;
1414
+ }
1415
+ return `${action === 'open' ? 'Opening' : 'Closing'} channel ${channel} didn't complete; the ${action} stays recorded, and this ` +
1416
+ `host tries it again ${later}, or now if you call ${tool} again. ${withStop(said)}`;
1417
+ }
1418
+
1419
+ /** A registration's entry, or a push's (ensureChannelRegistered): nothing
1420
+ * about its transport is known until a receipt for its target confirms it
1421
+ * open or closed, or the channel's traffic shows it open, which it does
1422
+ * where the host doesn't direct the lifecycle, and for a push's entry
1423
+ * decided open or tuned out that no update has given a target
1424
+ * (handleIncoming). */
1425
+ private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): ChannelEntry {
847
1426
  const entry: ChannelEntry = { serverId, descriptor, open: false };
848
1427
  this.channels.set(key, entry);
849
1428
  this.unconfirmedChannelTargets.add(entry);
1429
+ return entry;
850
1430
  }
851
1431
 
852
1432
  private hasPendingLifecycle(entry: ChannelEntry): boolean {
@@ -900,6 +1480,7 @@ export class ChannelRegistry {
900
1480
  this.homeChannelResolver = options?.homeChannelResolver;
901
1481
  this.activeChannelResolver = options?.activeChannelResolver;
902
1482
  this.takesGlobalFallback = options?.takesGlobalFallback;
1483
+ this.serverConnecting = options?.serverConnecting;
903
1484
  this.store = options?.store;
904
1485
  this.initializeLifecycleStore();
905
1486
  this.initializeLabelHistoryStore();
@@ -944,7 +1525,7 @@ export class ChannelRegistry {
944
1525
  }
945
1526
  const key = `${serverId}:${channel.id}`;
946
1527
  this.registerEntry(key, serverId, channel);
947
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1528
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
948
1529
  registeredIds.push(channel.id);
949
1530
  results.push({ id: channel.id, accepted: true });
950
1531
  }
@@ -1019,18 +1600,21 @@ export class ChannelRegistry {
1019
1600
  // channel decided open (or tuned out) is opened at its new target.
1020
1601
  if (!sameChannelTarget(existing.descriptor, channel)) {
1021
1602
  this.invalidateTransportConfirmation(existing);
1603
+ this.untargetedPushEntries.delete(existing);
1022
1604
  // An operation queued or running for this channel reads its target
1023
1605
  // again before it confirms anything, and after a failed RPC, so it
1024
- // opens the new one. With none, a channel decided open (or tuned
1025
- // out) is reconciled here.
1606
+ // opens the new one, unless it was queued before its server's
1607
+ // registrations were last removed: that one cancels when it would
1608
+ // run. So a channel decided open (or tuned out) is reconciled here
1609
+ // unless work queued since then is outstanding.
1026
1610
  const desired = this.getDesiredState(serverId, channel.id);
1027
1611
  if ((desired === 'open' || desired === 'tuned-out') &&
1028
- !this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
1612
+ !this.outstandingWorkCarriesOut(serverId, this.lifecycleKey(serverId, channel.id))) {
1029
1613
  retargeted.push(channel);
1030
1614
  }
1031
1615
  }
1032
1616
  existing.descriptor = channel;
1033
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1617
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1034
1618
  addedResults.push({ id: channel.id, accepted: true });
1035
1619
  }
1036
1620
  }
@@ -1047,7 +1631,7 @@ export class ChannelRegistry {
1047
1631
  }
1048
1632
  const key = `${serverId}:${channel.id}`;
1049
1633
  this.registerEntry(key, serverId, channel);
1050
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1634
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1051
1635
  addedResults.push({ id: channel.id, accepted: true });
1052
1636
  accepted.push(channel);
1053
1637
  }
@@ -1124,6 +1708,9 @@ export class ChannelRegistry {
1124
1708
  responder?: Responder,
1125
1709
  ): Promise<void> {
1126
1710
  const results: ChannelIncomingMessageResult[] = [];
1711
+ // The batch came on the transport open now: a close after this point
1712
+ // (transportClosed) leaves its traffic nothing to confirm.
1713
+ const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1127
1714
 
1128
1715
  for (const message of params.messages) {
1129
1716
  if (!message || typeof message.channelId !== 'string' || !message.channelId
@@ -1151,17 +1738,6 @@ export class ChannelRegistry {
1151
1738
  // inline base64) is allocation and parsing work done for a message
1152
1739
  // that must be rejected (Sol, PR #79 re-review blocker 2).
1153
1740
 
1154
- // Lazy-register the channel if we've never seen it. A channel can deliver
1155
- // an incoming message before its channels/register (boot enumeration) or
1156
- // channels/changed (post-boot create / View-permission grant) round-trip
1157
- // lands — or the registration event can be missed entirely (e.g. the bot
1158
- // gains visibility in a way that fires neither `channelCreate` nor a
1159
- // View-permission transition). Without a registry entry, routeSpeech()
1160
- // can't resolve this channel as an outbound locus and the agent's reply
1161
- // is silently dropped, even though this very message proves the channel
1162
- // is reachable. The inbound message carries enough to make it publishable,
1163
- // so register it here; a later authoritative channels/register or
1164
- // channels/changed will overwrite this descriptor with the richer one.
1165
1741
  // §14.5: channels/incoming is validated against the ACTUALLY
1166
1742
  // REGISTERED channel. An unknown claimed channelId is rejected
1167
1743
  // per-message with an itemized result — never minted by its first
@@ -1169,8 +1745,8 @@ export class ChannelRegistry {
1169
1745
  // self-attest a channel identity without ever passing
1170
1746
  // channels/register authorization). A server whose channel genuinely
1171
1747
  // exists registers it first; discord's DM case goes through
1172
- // ensureChannelRegistered on push/event, which creates a CLOSED
1173
- // routable entry rather than an open self-attested one.
1748
+ // ensureChannelRegistered on push/event, which creates a routable
1749
+ // entry that isn't open, rather than an open self-attested one.
1174
1750
  const incomingKey = `${serverId}:${message.channelId}`;
1175
1751
  if (!this.channels.has(incomingKey)) {
1176
1752
  // Loud, coalesced or not, like every other refusal of a message, but
@@ -1247,30 +1823,47 @@ export class ChannelRegistry {
1247
1823
  // A coalesced item is "accepted" only once the coalescer admits it, so
1248
1824
  // for those this runs after the hook (below).
1249
1825
  const incomingEntry = this.channels.get(incomingKey)!;
1250
- const markAccepted = () => {
1251
- this.defaultPublishChannel = message.channelId;
1252
- this.defaultPublishServer = serverId;
1253
- this.defaultPublishMessageId = message.messageId;
1254
- this.defaultPublishThreadId = message.threadId;
1255
- // Where the host directs this server's lifecycle, only a receipt for
1256
- // the current target confirms it: traffic shows the transport open
1257
- // only for a confirmed target, and only for a channel still decided
1258
- // open or tuned out. After a retarget, traffic for this id may still
1259
- // come from the old subscription, and a close receipt that landed
1260
- // first (or during the coalescer's await) stands. Where the host
1261
- // doesn't direct it, the server opens its own channels and its
1262
- // traffic is the only evidence the host gets, so it marks the
1826
+ const markAccepted = (firstView = false) => {
1827
+ // Non-conversational markers (a reaction added or removed, an edit, a
1828
+ // deletion) are ABOUT a message, not one the agent replies to: they
1829
+ // must not retarget the default publish locus or become
1830
+ // `context.incoming`'s reply anchor. An edit the coalescer delivered
1831
+ // as the agent's first view counts as the message it replaced.
1832
+ if (!isMarkerTagged(message.tags, firstView)) {
1833
+ this.defaultPublishChannel = message.channelId;
1834
+ this.defaultPublishMessageId = message.messageId;
1835
+ this.defaultPublishThreadId = message.threadId;
1836
+ }
1837
+ // Where the host directs this server's lifecycle, traffic shows the
1838
+ // transport open only for a channel still decided open or tuned out,
1839
+ // and, but for one exception, only for a confirmed target: after a
1840
+ // retarget, traffic for this id may still come from the old
1841
+ // subscription, and a close receipt that landed first (or during the
1842
+ // coalescer's await) stands. The exception is an entry made from a
1843
+ // push that no update has given a target (untargetedPushEntries):
1844
+ // there's no earlier target its traffic could come from, so for the
1845
+ // receipt rule's own reason its traffic shows the transport open. It
1846
+ // stops being one when a registration replaces it (registerEntry) or
1847
+ // a channels/changed update gives it a target (handleChanged's
1848
+ // retarget), and the receipt rule holds from then. Where the host
1849
+ // doesn't direct the lifecycle, the server opens its own channels and
1850
+ // its traffic is the only evidence the host gets, so it marks the
1263
1851
  // channel open, as it always has. The entry is the one the message
1264
1852
  // was accepted for: one a re-registration put in its place meanwhile
1265
- // waits for its own evidence. Durable desired state still changes
1266
- // exclusively through lifecycle operations.
1853
+ // waits for its own evidence. Traffic from a transport that has closed
1854
+ // since its batch arrived (here after the coalescer's await, or behind
1855
+ // a sibling's) is no evidence either: the close withdrew what that
1856
+ // transport confirmed, and its connection's grant is empty until the
1857
+ // next one's. Durable desired state still changes exclusively through
1858
+ // lifecycle operations. A marker is evidence like any other message.
1859
+ if ((this.transportCloses.get(serverId) ?? 0) !== closesAtArrival) return;
1267
1860
  if (this.directsLifecycle(serverId)) {
1268
- if (this.unconfirmedChannelTargets.has(incomingEntry)) return;
1861
+ if (this.unconfirmedChannelTargets.has(incomingEntry) && !this.untargetedPushEntries.has(incomingEntry)) return;
1269
1862
  const desired = this.getDesiredState(serverId, message.channelId);
1270
1863
  if (desired !== 'open' && desired !== 'tuned-out') return;
1271
- } else {
1272
- this.unconfirmedChannelTargets.delete(incomingEntry);
1273
1864
  }
1865
+ this.unconfirmedChannelTargets.delete(incomingEntry);
1866
+ this.failedCloseTargets.delete(incomingEntry);
1274
1867
  incomingEntry.open = true;
1275
1868
  };
1276
1869
  if (!coalesced) markAccepted();
@@ -1322,8 +1915,8 @@ export class ChannelRegistry {
1322
1915
  // the coalescer, which replaces, appends or withdraws. Malformed
1323
1916
  // `coalesce` is a per-message failure; siblings are unaffected.
1324
1917
  try {
1325
- const result = await this.handleCoalescedIncoming!(serverId, message, event);
1326
- if (result.accepted) markAccepted();
1918
+ const { firstView, ...result } = await this.handleCoalescedIncoming!(serverId, message, event);
1919
+ if (result.accepted) markAccepted(firstView === true);
1327
1920
  results.push(result);
1328
1921
  } catch (error) {
1329
1922
  const err = error as Error & { code?: number; field?: string };
@@ -1373,15 +1966,17 @@ export class ChannelRegistry {
1373
1966
  * locus. A push event from a closed channel must never mutate lifecycle
1374
1967
  * state: direct-address events are intentionally usable without subscribing.
1375
1968
  *
1376
- * Mirrors the lazy-registration inside handleIncoming(), but for channels
1377
- * that only ever arrive as push/events rather than channels/incoming — the
1378
- * motivating case is Discord DMs, which discord-mcpl forwards via push/event
1379
- * with the channel closed (`channelIsOpen:false`). Such a channel is never
1380
- * registered, so routeSpeech() can't resolve it and the agent's reply is
1381
- * silently dropped even though the inbound message proves the channel is
1382
- * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
1383
- * channels/register or channels/changed overwrites this descriptor with the
1384
- * richer one.
1969
+ * For channels that only ever arrive as push/events rather than
1970
+ * channels/incoming — the motivating case is Discord DMs, which
1971
+ * discord-mcpl forwards via push/event with the channel closed
1972
+ * (`channelIsOpen:false`). Such a channel is never registered, so
1973
+ * routeSpeech() can't resolve it and the agent's reply is silently dropped
1974
+ * even though the inbound message proves the channel is reachable (item-3
1975
+ * redux, DM sub-case). Its entry starts with its transport unknown, as a
1976
+ * registration's does. Its traffic shows it open, for a channel decided
1977
+ * open or tuned out, until an update gives it a target (handleIncoming).
1978
+ * Idempotent: a later authoritative channels/register or channels/changed
1979
+ * overwrites this descriptor with the richer one.
1385
1980
  */
1386
1981
  ensureChannelRegistered(
1387
1982
  serverId: string,
@@ -1389,15 +1984,13 @@ export class ChannelRegistry {
1389
1984
  label?: string,
1390
1985
  extraMetadata?: Record<string, unknown>,
1391
1986
  ): void {
1392
- // The pushing server's own entry: another server's entry for the same id
1393
- // is that server's, and a push leaves it as it was.
1394
- const existing = this.channels.get(`${serverId}:${channelId}`);
1987
+ const existing = this.findChannelEntry(channelId);
1395
1988
  if (existing) {
1396
1989
  // Backfill identity onto a bare lazy registration: a DM that first
1397
1990
  // arrived with no label/recipient info gets a usable name the next
1398
1991
  // time a message reveals it (labels must show people, not ids).
1399
1992
  if (
1400
- (existing.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered &&
1993
+ pushPlaceholder(existing) &&
1401
1994
  label && existing.descriptor.label === channelId
1402
1995
  ) {
1403
1996
  const metadata = extraMetadata
@@ -1407,7 +2000,7 @@ export class ChannelRegistry {
1407
2000
  // Recorded before the live descriptor takes it, since this branch's
1408
2001
  // guard reads the descriptor: an append that throws leaves the
1409
2002
  // placeholder, so the next push tries again.
1410
- this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
2003
+ this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
1411
2004
  existing.descriptor.label = label;
1412
2005
  existing.descriptor.metadata = metadata;
1413
2006
  }
@@ -1427,19 +2020,20 @@ export class ChannelRegistry {
1427
2020
  // routing; only the durable write is skipped when there's no real
1428
2021
  // label to record. A real label is recorded before the entry exists:
1429
2022
  // an append that throws registers nothing, so the next push tries again.
1430
- if (label) this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
1431
- const key = `${serverId}:${channelId}`;
1432
- this.channels.set(key, {
1433
- serverId,
1434
- descriptor: {
1435
- id: channelId,
1436
- type: serverId,
1437
- label: label ?? channelId,
1438
- direction: 'bidirectional',
1439
- metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
1440
- },
1441
- open: false,
2023
+ if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
2024
+ // Its transport is as unknown as a registration's (registerEntry). After
2025
+ // a restart, a push for a channel no registration has comes here, and
2026
+ // the server may still hold a channel an earlier process opened. Counting
2027
+ // it confirmed closed would answer a close or a decline as already done
2028
+ // and send nothing, even where the server still delivers.
2029
+ const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, {
2030
+ id: channelId,
2031
+ type: serverId,
2032
+ label: label ?? channelId,
2033
+ direction: 'bidirectional',
2034
+ metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
1442
2035
  });
2036
+ this.untargetedPushEntries.add(entry);
1443
2037
  this.emitTraceFn({
1444
2038
  type: 'mcpl:channel-lazy-registered',
1445
2039
  serverId,
@@ -1548,7 +2142,10 @@ export class ChannelRegistry {
1548
2142
  // ==========================================================================
1549
2143
 
1550
2144
  /**
1551
- * Get the default publish channel ID (most recent incoming channel).
2145
+ * Get the default publish channel ID: the channel of the most recent
2146
+ * incoming message that isn't a reaction, reaction removal, edit or
2147
+ * deletion marker, apart from an edit the coalescer delivers on acceptance
2148
+ * as the agent's first view.
1552
2149
  */
1553
2150
  getDefaultPublishChannel(): string | null {
1554
2151
  return this.defaultPublishChannel;
@@ -1567,7 +2164,7 @@ export class ChannelRegistry {
1567
2164
  */
1568
2165
  isDeclaredChannel(serverId: string, channelId: string): boolean {
1569
2166
  const entry = this.channels.get(`${serverId}:${channelId}`);
1570
- return !!entry && !(entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered;
2167
+ return !!entry && !pushPlaceholder(entry);
1571
2168
  }
1572
2169
 
1573
2170
  getDescriptor(channelId: string): ChannelDescriptor | undefined {
@@ -1581,8 +2178,15 @@ export class ChannelRegistry {
1581
2178
  return typeof label === 'string' && label.length > 0 ? label : undefined;
1582
2179
  }
1583
2180
 
1584
- isChannelOpen(channelId: string): boolean {
1585
- return this.findChannelEntry(channelId)?.open === true;
2181
+ /** Whether `channelId`'s transport is open: on `serverId` where the caller
2182
+ * knows the channel's server, and otherwise on the first server found to
2183
+ * register that id (findChannelEntry). The map's key joins the two ids
2184
+ * with a colon, so another server's entry can sit under a key: it has to
2185
+ * be this server's (as in streamingServerAt). */
2186
+ isChannelOpen(channelId: string, serverId?: string): boolean {
2187
+ if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true;
2188
+ const entry = this.channels.get(`${serverId}:${channelId}`);
2189
+ return entry?.serverId === serverId && entry.open === true;
1586
2190
  }
1587
2191
 
1588
2192
  getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
@@ -1674,7 +2278,13 @@ export class ChannelRegistry {
1674
2278
  return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });
1675
2279
 
1676
2280
  default:
1677
- return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
2281
+ // Not every agent is given every channel tool, so the refusal points
2282
+ // to the ones this agent has rather than listing them all.
2283
+ return {
2284
+ success: false,
2285
+ error: `There's no channel tool "${toolName}", so nothing ran. Use one of the channel tools you were given.`,
2286
+ isError: true,
2287
+ };
1678
2288
  }
1679
2289
  }
1680
2290
 
@@ -1691,8 +2301,9 @@ export class ChannelRegistry {
1691
2301
  const openChannels = this.getOpenChannels();
1692
2302
 
1693
2303
  // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
1694
- // told the channel its speech will actually land in: a conversation fork's
1695
- // home channel, else this turn's channel (single trunk agent — item-3
2304
+ // told the channel its speech will actually land in (none, when its server
2305
+ // declared it inbound-only, since speech isn't posted there): a conversation
2306
+ // fork's home channel, else this turn's channel (single trunk agent — item-3
1696
2307
  // redux: its locus, else its triggering channel), else the global default
1697
2308
  // (heartbeats). Without this, a fork
1698
2309
  // was advertised the global locus but published somewhere else; and a trunk
@@ -1713,7 +2324,10 @@ export class ChannelRegistry {
1713
2324
 
1714
2325
  const context: ChannelContext = {};
1715
2326
 
1716
- // Incoming: the most-recent inbound message (what the agent is replying to).
2327
+ // Incoming: the most recent inbound message that isn't a reaction,
2328
+ // reaction removal, edit or deletion marker, apart from an edit the
2329
+ // coalescer delivers on acceptance as the agent's first view (what the
2330
+ // agent is replying to).
1717
2331
  // Left process-global — per-channel inbound tracking (the right messageId for
1718
2332
  // a fork's own channel) is a separate concern from the outbound routing fix.
1719
2333
  if (fallback && this.defaultPublishMessageId) {
@@ -1724,8 +2338,10 @@ export class ChannelRegistry {
1724
2338
  };
1725
2339
  }
1726
2340
 
1727
- // Default outgoing: the resolved outbound locus (home channel for forks).
1728
- if (outgoing) {
2341
+ // Default outgoing: the resolved outbound locus (home channel for forks),
2342
+ // unless its server declared it inbound-only: routeSpeech publishes nothing
2343
+ // there, so it isn't where the agent's speech goes.
2344
+ if (outgoing && !this.inboundOnlyServer(outgoing)) {
1729
2345
  context.defaultOutgoing = {
1730
2346
  channelId: outgoing,
1731
2347
  };
@@ -1750,18 +2366,15 @@ export class ChannelRegistry {
1750
2366
  */
1751
2367
  removeServer(serverId: string): void {
1752
2368
  // Every lifecycle operation queued for this server is cancelled when it
1753
- // would run (ChannelLifecycleCancelledError), so none acts on a closing
2369
+ // would run (a ChannelLifecycleError of kind `cancelled`), so none acts on a closing
1754
2370
  // connection or on a later re-registration's entries.
1755
2371
  this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
1756
2372
  for (const [key, entry] of this.channels) {
1757
2373
  if (entry.serverId !== serverId) continue;
1758
2374
  this.channels.delete(key);
1759
2375
  this.stopTyping(entry.descriptor.id);
1760
- // The fallback goes with the server whose message set it, not with
1761
- // another server's entry for the same id.
1762
- if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
2376
+ if (this.defaultPublishChannel === entry.descriptor.id) {
1763
2377
  this.defaultPublishChannel = null;
1764
- this.defaultPublishServer = null;
1765
2378
  this.defaultPublishMessageId = null;
1766
2379
  this.defaultPublishThreadId = undefined;
1767
2380
  }
@@ -1769,6 +2382,42 @@ export class ChannelRegistry {
1769
2382
  // Desired state and migration markers deliberately survive disconnects.
1770
2383
  }
1771
2384
 
2385
+ /**
2386
+ * The server's transport closed. Whatever it confirmed about a channel,
2387
+ * open or closed, held for that transport only, so each of the server's
2388
+ * channels reads as a registration's does: not open, and not confirmed
2389
+ * closed. Where the host directs the server's lifecycle, a receipt
2390
+ * confirms it again: a `channel_open` asks the server, and delivery opens
2391
+ * before it publishes (an untargeted push entry's traffic confirms it, as
2392
+ * before). Where the host doesn't, its traffic does, as after a
2393
+ * registration. The entries, their desired state, labels and tune-out
2394
+ * state stay, since the server is expected back (`removeServer` is for one
2395
+ * that isn't).
2396
+ *
2397
+ * What was under way here at the close confirms nothing afterwards
2398
+ * (transportCloses). Traffic that arrived before the close and is admitted
2399
+ * after it, behind the coalescer's await, marks nothing open
2400
+ * (handleIncoming). A lifecycle request sent before the close is answered
2401
+ * for an older transport: its failure withdraws the open flag, and its
2402
+ * receipt confirms nothing, and the operation tries again, which on the
2403
+ * closed connection is refused as unreachable, so the decision waits for
2404
+ * the next registration (runLifecycleOperation's sameTarget). What the
2405
+ * connection holds across the boundary is another matter: it reaches
2406
+ * handleIncoming after the next transport's grant, as that transport's
2407
+ * traffic (resetPolicyForTransportBoundary), as it does after a
2408
+ * registration.
2409
+ *
2410
+ * The framework calls this first in its handler for the connection's
2411
+ * `close`, so a registration on the next transport, whose entries are new,
2412
+ * can't come first and lose its confirmations to it.
2413
+ */
2414
+ transportClosed(serverId: string): void {
2415
+ this.transportCloses.set(serverId, (this.transportCloses.get(serverId) ?? 0) + 1);
2416
+ for (const entry of this.channels.values()) {
2417
+ if (entry.serverId === serverId) this.invalidateTransportConfirmation(entry);
2418
+ }
2419
+ }
2420
+
1772
2421
  /**
1773
2422
  * Stop all typing intervals and clear all channel registrations.
1774
2423
  */
@@ -1784,7 +2433,6 @@ export class ChannelRegistry {
1784
2433
 
1785
2434
  // Reset default publish tracking
1786
2435
  this.defaultPublishChannel = null;
1787
- this.defaultPublishServer = null;
1788
2436
  this.defaultPublishMessageId = null;
1789
2437
  this.defaultPublishThreadId = undefined;
1790
2438
  }
@@ -1809,20 +2457,11 @@ export class ChannelRegistry {
1809
2457
  if (!item || typeof item !== 'object') continue;
1810
2458
  const event = item as Partial<ChannelLifecycleEvent>;
1811
2459
  if (typeof event.serverId !== 'string') continue;
1812
- if (
1813
- event.kind === 'desired-state' &&
1814
- typeof event.channelId === 'string' &&
1815
- (event.desired === 'open' || event.desired === 'closed' ||
1816
- (event.desired === 'tuned-out' && event.tuneOut))
1817
- ) {
2460
+ const decision = desiredStateRecord(item);
2461
+ if (decision) {
1818
2462
  this.desiredStates.set(
1819
- this.lifecycleKey(event.serverId, event.channelId),
1820
- {
1821
- state: event.desired,
1822
- source: typeof event.source === "string" ? event.source : "unknown",
1823
- tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
1824
- wakeCount: 0,
1825
- },
2463
+ this.lifecycleKey(decision.serverId, decision.channelId),
2464
+ { state: decision.state, source: decision.source, tuneOut: decision.tuneOut, wakeCount: 0 },
1826
2465
  );
1827
2466
  } else if (
1828
2467
  event.kind === 'tune-out-wake' &&
@@ -1846,7 +2485,9 @@ export class ChannelRegistry {
1846
2485
  /**
1847
2486
  * Each writer appends its record here before it changes memory for it, so
1848
2487
  * an append that throws, as on a full disk, fails the operation with that
1849
- * change unmade, and memory and the log still agree. A retry then records
2488
+ * change unmade, and memory and the log still agree. A record that changes
2489
+ * no memory, as a decline's of the invitation it declined, is its
2490
+ * caller's to handle (handleToolDecline). A retry then records
1850
2491
  * the change rather than finding it in memory already, where
1851
2492
  * setDesiredState's same-state return would record nothing, and a restart
1852
2493
  * replays what memory held. appendLabelSighting keeps the same order for
@@ -1929,17 +2570,11 @@ export class ChannelRegistry {
1929
2570
  * `resolveProseTargetDurable()`.
1930
2571
  */
1931
2572
  private appendLabelSighting(
1932
- serverId: string,
1933
2573
  channelId: string,
1934
2574
  label: string | undefined,
1935
2575
  dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
1936
2576
  ): void {
1937
2577
  if (typeof label !== 'string' || !label) return;
1938
- // The log is kept by the bare id, and a durable lookup by label resolves
1939
- // to that id with no server. So a server's label for an id another
1940
- // server registers isn't recorded: it would name the other server's
1941
- // channel, across restarts.
1942
- if (this.registeredByAnotherServer(serverId, channelId)) return;
1943
2578
  const { recipientId, recipientName, isDm } = dmMeta;
1944
2579
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1945
2580
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -2200,7 +2835,20 @@ export class ChannelRegistry {
2200
2835
  source: string,
2201
2836
  ): void {
2202
2837
  const key = this.lifecycleKey(serverId, channelId);
2203
- if (this.desiredStates.get(key)?.state === desired) return;
2838
+ const current = this.desiredStates.get(key);
2839
+ // A decision that records the state already on record changes nothing,
2840
+ // so it writes nothing, unless it's the agent's own (channel_open,
2841
+ // channel_close or channel_decline) and the record isn't. The record
2842
+ // then says the agent decided, which is all handleToolClose's
2843
+ // explicit-open guard and ensureInitialDesiredState's lift of a default
2844
+ // ask of it. openChannelNow depends on it too: a failed open by speech
2845
+ // or a reply tells a decision made during its round-trip by the record
2846
+ // no longer being the object it wrote, and the agent's channel_open,
2847
+ // the only decision of the same state that can be made then, gets a new
2848
+ // record here.
2849
+ if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) {
2850
+ return;
2851
+ }
2204
2852
  this.appendLifecycleEvent({
2205
2853
  kind: 'desired-state',
2206
2854
  serverId,
@@ -2220,7 +2868,9 @@ export class ChannelRegistry {
2220
2868
  * Enter (or re-enter with fresh params) the tuned-out state for a channel.
2221
2869
  * Re-entering under a new epochId replaces the active epoch; the previous
2222
2870
  * epoch's stamped messages stay excluded (stamps are permanent) and its
2223
- * wake count is superseded. Transport stays open (see reconcile).
2871
+ * wake count is superseded. Its transport is kept open: tune_out opens a
2872
+ * closed one (openForTuneOut), and reconcile opens it at the channel's
2873
+ * registration.
2224
2874
  */
2225
2875
  enterTuneOut(
2226
2876
  serverId: string,
@@ -2246,6 +2896,182 @@ export class ChannelRegistry {
2246
2896
  });
2247
2897
  }
2248
2898
 
2899
+ /**
2900
+ * Make a tuned-out channel's transport match its tune-out, which keeps it
2901
+ * open so its traffic reaches the subconscious (TuneOutParams). The
2902
+ * tune-out is recorded first (enterTuneOut), and this writes no decision,
2903
+ * so the tune-out stands whatever becomes of the open. Lifecycle work
2904
+ * queued for the channel goes first, until none is left, since each
2905
+ * operation reads the decision when it runs and so opens a channel tuned
2906
+ * out meanwhile; its own failure, such as a close the server refused,
2907
+ * isn't this open's. Then the state is read. Where this host directs the
2908
+ * lifecycle and the transport isn't confirmed open, the channel's
2909
+ * lifecycle queue opens it once (applyDesiredChannelState reads a
2910
+ * tune-out as open), and the answer waits for the work queued after it
2911
+ * too, such as a registration that came back, and comes from the state
2912
+ * that work leaves. Returns nothing where the transport is open, or, for
2913
+ * tune_out's answer, why the channel's messages don't or may not reach the
2914
+ * subconscious yet and when they will, or that the tune-out isn't in
2915
+ * effect any more. A call while this tune-out's open is in flight, as a
2916
+ * repeated tune_out makes, gets that open's answer rather than send
2917
+ * another.
2918
+ */
2919
+ async openForTuneOut(serverId: string, channelId: string): Promise<string | undefined> {
2920
+ // The tune-out this answer is for, by its epoch: one that ended, or that
2921
+ // a later decision or a later tune-out replaced, isn't this one.
2922
+ const epochId = this.getTuneOutState(serverId, channelId)?.params.epochId;
2923
+ const key = `${this.lifecycleKey(serverId, channelId)}\u0000${epochId ?? ''}`;
2924
+ let opening = this.tuneOutOpens.get(key);
2925
+ if (!opening) {
2926
+ const started = this.openTuneOutTransport(serverId, channelId, epochId);
2927
+ this.tuneOutOpens.set(key, started);
2928
+ const release = () => {
2929
+ if (this.tuneOutOpens.get(key) === started) this.tuneOutOpens.delete(key);
2930
+ };
2931
+ void started.then(release, release);
2932
+ opening = started;
2933
+ }
2934
+ return opening;
2935
+ }
2936
+
2937
+ /**
2938
+ * Whether a tune_out repeated now has nothing to wait for and finds its
2939
+ * channel's transport open, as the tune-out reads it: no lifecycle work
2940
+ * queued for the channel, since the tune-out's own call may still be
2941
+ * waiting for the work queued after its open; its server up; and an open
2942
+ * flag the tune-out takes as open (tuneOutReadsOpen). Where it doesn't,
2943
+ * the repeat goes through openForTuneOut, which joins this tune-out's open
2944
+ * in flight, or waits for the queued work and, where this host can open
2945
+ * the channel, tries the open again.
2946
+ */
2947
+ tuneOutTransportOpen(serverId: string, channelId: string): boolean {
2948
+ return this.getTuneOutState(serverId, channelId) !== null &&
2949
+ !this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
2950
+ this.serverReach(serverId) === 'up' &&
2951
+ this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`));
2952
+ }
2953
+
2954
+ /**
2955
+ * Whether a tune-out takes an entry's transport as open: its open flag,
2956
+ * unless a close for it failed since it was confirmed (failedCloseTargets),
2957
+ * since that close may have taken effect. The other readers take any flag
2958
+ * a failed request left as it was (runLifecycleOperation), since a send
2959
+ * through it fails where it can be seen, and channel_open sends an open
2960
+ * after a failed close anyway, its decision having been closed. A
2961
+ * tune-out's success says traffic will arrive, nothing would show it
2962
+ * doesn't, and the decision it replaced isn't known here. An open that
2963
+ * failed, as a redundant one queued behind a confirming one may, can't
2964
+ * have closed the channel: one for the current target leaves the flag as
2965
+ * it was, so an open flag stays open (one for a target no longer current
2966
+ * withdraws it).
2967
+ */
2968
+ private tuneOutReadsOpen(entry: ChannelEntry | undefined): boolean {
2969
+ // A flag is set open only where both marks are cleared, so a failed
2970
+ // close on an open flag is one since its confirmation.
2971
+ return entry !== undefined && entry.open && !this.failedCloseTargets.has(entry);
2972
+ }
2973
+
2974
+ private async openTuneOutTransport(
2975
+ serverId: string,
2976
+ channelId: string,
2977
+ epochId: string | undefined,
2978
+ ): Promise<string | undefined> {
2979
+ const channelKey = `${serverId}:${channelId}`;
2980
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2981
+ const inEffect = () =>
2982
+ epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
2983
+ // "May not": a failed request leaves its outcome unknown, and an open
2984
+ // that took effect would have its traffic diverted. `again` where calling
2985
+ // tune_out again tries the open now: the channel is registered here and
2986
+ // its server up.
2987
+ const notYet = (later: string, why: string, again = false): string => withStop(
2988
+ `The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, ` +
2989
+ `which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
2990
+ );
2991
+ const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
2992
+ 'close its channels then';
2993
+ // The work queued for the channel as it stands: its tail, waited for,
2994
+ // and its failure returned, if it failed. Each wait below takes it until
2995
+ // no work is left, as a delivery's wait does (waitForOpenChannel), so
2996
+ // work queued while earlier work ran, such as a registration that came
2997
+ // back, goes first too.
2998
+ let awaited: Promise<void> | undefined;
2999
+ const settle = async (): Promise<unknown> => {
3000
+ const pending = this.channelLifecycleTails.get(lifecycleKey);
3001
+ if (!pending) return undefined;
3002
+ awaited = pending;
3003
+ try {
3004
+ await pending;
3005
+ return undefined;
3006
+ } catch (error) {
3007
+ return error;
3008
+ }
3009
+ };
3010
+ // What the state answers now, or null where this host is to open the
3011
+ // transport itself. `missing` answers for a channel that isn't
3012
+ // registered here, where this host directs the lifecycle.
3013
+ const answered = (entry: ChannelEntry | undefined, missing: string): string | undefined | null => {
3014
+ if (!inEffect()) return TUNE_OUT_REPLACED;
3015
+ // A connection that dropped keeps its entries' open flags, and whether
3016
+ // this host or the server opens the channel isn't known until it's
3017
+ // back, since a reconnect empties the grant until the new one is
3018
+ // established.
3019
+ const reach = this.serverReach(serverId);
3020
+ if (reach !== 'up') {
3021
+ return withStop(
3022
+ `The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
3023
+ `connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
3024
+ (reach === 'gone' ? unreachableRemedy(reach) : ''),
3025
+ );
3026
+ }
3027
+ const transportOpen = this.tuneOutReadsOpen(entry);
3028
+ if (!this.directsLifecycle(serverId)) {
3029
+ return transportOpen ? undefined
3030
+ : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
3031
+ 'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
3032
+ 'this host to open it, ask whoever runs this host.';
3033
+ }
3034
+ if (!entry) return missing;
3035
+ // A flag a close that may have taken effect left gets an open.
3036
+ return transportOpen ? undefined : null;
3037
+ };
3038
+ // Whether work is queued that settle hasn't waited for. It's looked at
3039
+ // in the turn that reads the state and queues the open, or answers, so
3040
+ // work queued after settle's look is waited for too. A settled tail
3041
+ // releases its own key before this runs, since its cleanup was
3042
+ // registered first (applyDesiredChannelState); one still standing isn't
3043
+ // waited for again, so the wait can't spin on it should that order ever
3044
+ // change, as waitForOpenChannel's guard keeps its own from doing.
3045
+ const queuedSince = (): boolean => {
3046
+ const tail = this.channelLifecycleTails.get(lifecycleKey);
3047
+ return tail !== undefined && tail !== awaited;
3048
+ };
3049
+ do await settle(); while (queuedSince());
3050
+ const before = answered(this.channels.get(channelKey), notYet(nextRegistration, "it isn't registered here now"));
3051
+ if (before !== null) return before;
3052
+ let failure: unknown;
3053
+ try {
3054
+ await this.applyDesiredChannelState(serverId, channelId);
3055
+ } catch (error) {
3056
+ failure = error;
3057
+ }
3058
+ // A registration that came back while the open was out queued its own
3059
+ // reconcile, which the answer waits for too. The answer says why the
3060
+ // channel isn't open now, so where the last work failed, its failure,
3061
+ // the last attempt's, is the reason: this open's may concern a
3062
+ // registration that work replaced.
3063
+ let later: unknown;
3064
+ do later = await settle(); while (queuedSince());
3065
+ failure = later ?? failure;
3066
+ const clause = failure !== undefined ? `: ${lifecycleClause(failure)}` : '';
3067
+ const after = this.channels.get(channelKey);
3068
+ const settled = answered(after, notYet(nextRegistration, `it isn't registered here any more${clause}`));
3069
+ if (settled !== null) return settled;
3070
+ return notYet(
3071
+ laterClause(after!), failure !== undefined ? `opening it now didn't complete${clause}` : "it isn't open now", true,
3072
+ );
3073
+ }
3074
+
2249
3075
  /**
2250
3076
  * End the active tune-out, returning the channel to `nextState`.
2251
3077
  * The caller (tune-out coordinator) owns the dump/notice flow; this is
@@ -2311,6 +3137,19 @@ export class ChannelRegistry {
2311
3137
  return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
2312
3138
  }
2313
3139
 
3140
+ /** Every tune-out on record, whether or not its channel is registered
3141
+ * here yet: the decisions the lifecycle log's replay restored, and any
3142
+ * made since. */
3143
+ tuneOutsOnRecord(): Array<{ serverId: string; channelId: string; params: TuneOutParams; source: string }> {
3144
+ const found: Array<{ serverId: string; channelId: string; params: TuneOutParams; source: string }> = [];
3145
+ for (const [key, record] of this.desiredStates) {
3146
+ if (record.state !== 'tuned-out' || !record.tuneOut) continue;
3147
+ const at = key.indexOf('\u0000');
3148
+ found.push({ serverId: key.slice(0, at), channelId: key.slice(at + 1), params: record.tuneOut, source: record.source });
3149
+ }
3150
+ return found;
3151
+ }
3152
+
2314
3153
  /** Registered channel entries (read-only iteration for the coordinator). */
2315
3154
  listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> {
2316
3155
  return [...this.channels.values()].map((e) => ({
@@ -2338,16 +3177,52 @@ export class ChannelRegistry {
2338
3177
 
2339
3178
  /**
2340
3179
  * Publish into a channel on behalf of a named non-resident agent (the
2341
- * subconscious's speak_in_channel). Same delivery path as the
2342
- * channel_publish tool; the agent name rides the speech-routed trace.
3180
+ * subconscious's speak_in_channel). Same delivery path, and the same result
3181
+ * as the channel_publish tool apart from `reason` below. Its traces carry the agent's name and follow
3182
+ * the publish's outcome as deliverSpeech's do: speech-routed after a
3183
+ * confirmed delivery, and speech-route-failed otherwise, `unknown` when the
3184
+ * words may have been posted and `failed` when nothing was, a refusal before
3185
+ * the publish included. The routed trace carries no message id, as before:
3186
+ * nothing here reads one, and a consumer that edits the newest traced
3187
+ * message in a channel would have this post to edit when an interruption of
3188
+ * the resident matches none. A refusal of the destination before anything
3189
+ * went out carries its `reason` (PublishRefusalReason), so a caller can act
3190
+ * on why nothing was posted without reading the words.
2343
3191
  */
2344
3192
  async publishForAgent(
2345
3193
  channelId: string,
2346
3194
  text: string,
2347
3195
  agentName: string,
2348
- ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
2349
- this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text });
2350
- return this.handleToolPublish({ channelId, content: text });
3196
+ ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean; reason?: PublishRefusalReason }> {
3197
+ // The server the publish goes to, as handleToolPublish finds it before
3198
+ // it awaits: the channel may be removed while the reply is pending.
3199
+ const serverId = this.findChannelEntry(channelId)?.serverId ?? '';
3200
+ const seen: { outcome?: PublishOutcome; refusal?: PublishRefusalReason } = {};
3201
+ const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel');
3202
+ if (seen.outcome?.status === 'delivered') {
3203
+ this.emitTraceFn({
3204
+ type: 'mcpl:speech-routed',
3205
+ conversationId: agentName,
3206
+ serverId,
3207
+ channelId,
3208
+ delivered: true,
3209
+ textLen: text.length,
3210
+ text,
3211
+ });
3212
+ } else {
3213
+ this.emitTraceFn({
3214
+ type: 'mcpl:speech-route-failed',
3215
+ conversationId: agentName,
3216
+ channelId,
3217
+ // The publish's own reason and the connector's detail, as
3218
+ // deliverSpeech's trace gives them, or the refusal before any publish.
3219
+ reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
3220
+ textLen: text.length,
3221
+ outcome: seen.outcome?.status === 'unknown' ? 'unknown' : 'failed',
3222
+ ...(seen.outcome?.detail !== undefined ? { detail: connectorText(seen.outcome.detail) } : {}),
3223
+ });
3224
+ }
3225
+ return seen.refusal !== undefined ? { ...result, reason: seen.refusal } : result;
2351
3226
  }
2352
3227
 
2353
3228
  /**
@@ -2448,6 +3323,7 @@ export class ChannelRegistry {
2448
3323
  const removals = this.serverRemovals.get(serverId) ?? 0;
2449
3324
  const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
2450
3325
  this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
3326
+ this.lifecycleQueuedRemovals.set(lifecycleKey, removals);
2451
3327
  const operation = previous.catch(() => {}).then(
2452
3328
  () => this.runLifecycleOperation(serverId, channelId, removals, options),
2453
3329
  );
@@ -2477,17 +3353,14 @@ export class ChannelRegistry {
2477
3353
  let attempts = 0;
2478
3354
  for (;;) {
2479
3355
  if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
2480
- throw new ChannelLifecycleCancelledError(
2481
- `Channel ${channelId}: its server's registrations were removed while this operation was queued`,
3356
+ throw new ChannelLifecycleError(
3357
+ `this host disconnected the "${serverId}" server while the change was being made`, 'cancelled',
2482
3358
  );
2483
3359
  }
2484
3360
  const entry = this.channels.get(channelKey);
2485
- if (!entry) throw new Error(`Channel is no longer registered: ${channelId}`);
3361
+ if (!entry) throw new ChannelLifecycleError(`the "${serverId}" server no longer lists the channel`, 'unlisted');
2486
3362
  const server = this.serverRegistry.getServer(serverId);
2487
- if (!server) throw new Error(`Server not found: ${serverId}`);
2488
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2489
- throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
2490
- }
3363
+ if (!server || !CapabilityGrant.of(server).has('channels.lifecycle')) throw this.lifecycleRefusal(serverId);
2491
3364
  const descriptor = entry.descriptor;
2492
3365
  const desired = this.getDesiredState(serverId, channelId);
2493
3366
  const open = desired === 'open' || desired === 'tuned-out';
@@ -2496,11 +3369,14 @@ export class ChannelRegistry {
2496
3369
  // release its queue slot and surface the failure to delivery joiners.
2497
3370
  // Its open flag needs no change here: every retry follows a replaced
2498
3371
  // registration, a retarget (already invalidated, whether the old
2499
- // target's RPC was answered or failed), or a receipt for this very
2500
- // target.
3372
+ // target's RPC was answered or failed), a receipt from before a
3373
+ // transport's close (invalidated by the close), or a receipt for this
3374
+ // very target.
2501
3375
  if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
2502
- const error = new ChannelLifecycleConvergenceError(
2503
- `Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`,
3376
+ const error = new ChannelLifecycleError(
3377
+ `the channel's address at the "${serverId}" server, or the decision about it, kept changing, and ${attempts} tries ` +
3378
+ "didn't settle it",
3379
+ 'converge',
2504
3380
  );
2505
3381
  this.emitTraceFn({
2506
3382
  type: 'mcpl:channel-reconcile-failed',
@@ -2509,7 +3385,14 @@ export class ChannelRegistry {
2509
3385
  throw error;
2510
3386
  }
2511
3387
  attempts++;
3388
+ // A reconnect keeps the connection object, so the count of its
3389
+ // transport's closes (transportClosed) is what tells an answer from
3390
+ // the transport it was sent on apart from one on its successor: an
3391
+ // answer applied after a close it was sent before is from an older
3392
+ // transport, and confirms nothing.
3393
+ const closesAtSend = this.transportCloses.get(serverId) ?? 0;
2512
3394
  const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
3395
+ (this.transportCloses.get(serverId) ?? 0) === closesAtSend &&
2513
3396
  sameChannelTarget(entry.descriptor, descriptor);
2514
3397
  let result: ChannelsOpenResult | undefined;
2515
3398
  try {
@@ -2529,8 +3412,10 @@ export class ChannelRegistry {
2529
3412
  // while not open). A failure from an older connection or target
2530
3413
  // also withdraws the open flag.
2531
3414
  if (this.channels.get(channelKey) === entry) {
2532
- if (sameTarget()) this.unconfirmedChannelTargets.add(entry);
2533
- else this.invalidateTransportConfirmation(entry);
3415
+ if (sameTarget()) {
3416
+ this.unconfirmedChannelTargets.add(entry);
3417
+ if (!open) this.failedCloseTargets.add(entry);
3418
+ } else this.invalidateTransportConfirmation(entry);
2534
3419
  }
2535
3420
  // A retarget while this RPC was out queued nothing for the new
2536
3421
  // target, since handleChanged leaves that to this operation. So a
@@ -2575,14 +3460,17 @@ export class ChannelRegistry {
2575
3460
  // its sends either.
2576
3461
  if (substitute !== undefined) {
2577
3462
  this.invalidateTransportConfirmation(entry);
2578
- throw new ChannelOpenSubstitutionError(
2579
- `Server "${serverId}" answered channels/open for ${channelId} with ` +
2580
- (substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
2581
- `; ${channelId} is not marked open, and nothing in the answer is used`,
3463
+ throw new ChannelLifecycleError(
3464
+ `the "${serverId}" server answered for ` +
3465
+ (substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
3466
+ ', so nothing in its answer is used',
3467
+ 'substituted',
2582
3468
  );
2583
3469
  }
3470
+ if (open) this.applyOpenDeclaration(entry, result);
2584
3471
  entry.open = open;
2585
3472
  this.unconfirmedChannelTargets.delete(entry);
3473
+ this.failedCloseTargets.delete(entry);
2586
3474
  const now = this.getDesiredState(serverId, channelId);
2587
3475
  if (open !== (now === 'open' || now === 'tuned-out')) continue;
2588
3476
  return { open, result };
@@ -2592,10 +3480,36 @@ export class ChannelRegistry {
2592
3480
  // on, nothing it does will open a new target.
2593
3481
  const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
2594
3482
  if (left > 0) this.lifecycleOutstanding.set(lifecycleKey, left);
2595
- else this.lifecycleOutstanding.delete(lifecycleKey);
3483
+ else {
3484
+ this.lifecycleOutstanding.delete(lifecycleKey);
3485
+ this.lifecycleQueuedRemovals.delete(lifecycleKey);
3486
+ }
2596
3487
  }
2597
3488
  }
2598
3489
 
3490
+ /** Whether lifecycle work already outstanding for the channel will carry out
3491
+ * its current decision: some is, and the newest was queued since its
3492
+ * server's registrations were last removed, so it won't cancel when it
3493
+ * would run. Each operation reads the decision when it runs. It answers for
3494
+ * now: a removal after it cancels that work too, and takes the channel's
3495
+ * entry with it, so the channel's next registration by channels/register,
3496
+ * or as added by channels/changed, carries the decision out, where this
3497
+ * host directs the lifecycle then. A push's registration
3498
+ * (ensureChannelRegistered) queues no lifecycle work. So where a push
3499
+ * re-makes an entry that a channels/changed removal took while an RPC for
3500
+ * the old entry was out, the operation running that RPC carries the
3501
+ * decision out for the push's entry only if the RPC is answered: a failed
3502
+ * one rethrows, since its retry covers only a retarget of its own entry.
3503
+ * This answers true meanwhile, so a channels/changed update that retargets
3504
+ * the push's entry is left to that operation, and stays undone after a
3505
+ * failed RPC until lifecycle work is queued for the channel again
3506
+ * (applyDesiredChannelState). No server we know of sends `updated` for a
3507
+ * channel it has removed. */
3508
+ private outstandingWorkCarriesOut(serverId: string, key: string): boolean {
3509
+ return this.lifecycleOutstanding.has(key) &&
3510
+ this.lifecycleQueuedRemovals.get(key) === (this.serverRemovals.get(serverId) ?? 0);
3511
+ }
3512
+
2599
3513
  /**
2600
3514
  * A server that answered an open with another channel really opened that
2601
3515
  * one. When it's registered here and the host wants it closed, close it
@@ -2608,15 +3522,27 @@ export class ChannelRegistry {
2608
3522
  private closeSubstitutedChannel(serverId: string, channelId: string): void {
2609
3523
  if (!this.channels.has(`${serverId}:${channelId}`)) return;
2610
3524
  if (this.getDesiredState(serverId, channelId) !== 'closed') return;
3525
+ this.applyThroughQueue(serverId, channelId);
3526
+ }
3527
+
3528
+ /**
3529
+ * Carry out a channel's decision through its lifecycle queue, for a
3530
+ * transport the server may have opened without the host's knowing: one it
3531
+ * opened for another channel's request (closeSubstitutedChannel), or one a
3532
+ * failed open's request may have reached (openChannelNow). The decision is
3533
+ * a closed one, except after a failed open whose put-back the server's
3534
+ * latest announcement or the policy lifted. A failure is reported as a
3535
+ * trace; the decision stands.
3536
+ */
3537
+ private applyThroughQueue(serverId: string, channelId: string): void {
2611
3538
  this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2612
3539
  // Removal while queued is cancellation, as in reconcileChannels, and
2613
3540
  // exhausted convergence emitted its own diagnostic.
2614
3541
  if (!this.channels.has(`${serverId}:${channelId}`) ||
2615
- err instanceof ChannelLifecycleCancelledError ||
2616
- err instanceof ChannelLifecycleConvergenceError) return;
3542
+ (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge'))) return;
2617
3543
  this.emitTraceFn({
2618
3544
  type: 'mcpl:channel-reconcile-failed',
2619
- serverId, channelId, desired: 'closed', error: (err as Error).message,
3545
+ serverId, channelId, desired: this.getDesiredState(serverId, channelId), error: (err as Error).message,
2620
3546
  });
2621
3547
  });
2622
3548
  }
@@ -2624,21 +3550,27 @@ export class ChannelRegistry {
2624
3550
  /**
2625
3551
  * Join outstanding lifecycle work without recording new intent. Retargeting
2626
3552
  * may have invalidated open confirmation while the decision remains pending.
2627
- * A close that settles during the wait remains authoritative; waiting for
2628
- * it is not an instruction to open the channel again.
3553
+ * A close that settles during the wait remains authoritative where the host
3554
+ * directs the server's lifecycle (closeStands); waiting for it is not an
3555
+ * instruction to open the channel again.
2629
3556
  */
2630
3557
  private async waitForOpenChannel(entry: ChannelEntry): Promise<ChannelEntry> {
2631
3558
  const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
3559
+ // The failure of the last work waited on, if it failed, which says why a
3560
+ // channel it left unopened isn't open.
3561
+ let failed: unknown;
2632
3562
  for (;;) {
2633
3563
  const pending = this.channelLifecycleTails.get(lifecycleKey);
2634
3564
  if (!pending) break;
2635
3565
  try {
2636
3566
  await pending;
3567
+ failed = undefined;
2637
3568
  } catch (error) {
2638
3569
  // A redundant backscroll failure can leave a known-open channel
2639
3570
  // usable. Exhausted convergence is different: it never established
2640
3571
  // the current target, so joined delivery must fail visibly.
2641
- if (error instanceof ChannelLifecycleConvergenceError) throw error;
3572
+ if (error instanceof ChannelLifecycleError && error.kind === 'converge') throw error;
3573
+ failed = error;
2642
3574
  }
2643
3575
  // A settled tail releases its own key. One still current after it
2644
3576
  // settled would be awaited again without ever yielding to the event
@@ -2646,9 +3578,18 @@ export class ChannelRegistry {
2646
3578
  if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
2647
3579
  }
2648
3580
  const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2649
- if (!current) throw new Error('Channel registration was removed while delivery waited');
2650
- if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
2651
- throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
3581
+ if (!current) throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
3582
+ // A close that stands is what the words met, whatever became of an open,
3583
+ // and whether or not the close itself took: one that failed may have
3584
+ // left the transport open.
3585
+ if (this.closeStands(current)) {
3586
+ throw new ChannelLifecycleError('a decision to close the channel came before these words went out', 'not-open');
3587
+ }
3588
+ if (!current.open) {
3589
+ throw new ChannelLifecycleError(
3590
+ "the channel isn't confirmed open" + (failed !== undefined ? `: ${lifecycleClause(failed)}${refusalRemedy(failed)}` : ''),
3591
+ 'not-open',
3592
+ );
2652
3593
  }
2653
3594
  return current;
2654
3595
  }
@@ -2713,9 +3654,8 @@ export class ChannelRegistry {
2713
3654
  // Exhausted convergence and a substituted answer already emitted
2714
3655
  // their own diagnostics.
2715
3656
  if (!this.channels.has(`${serverId}:${channel.id}`) ||
2716
- err instanceof ChannelLifecycleCancelledError ||
2717
- err instanceof ChannelLifecycleConvergenceError ||
2718
- err instanceof ChannelOpenSubstitutionError) return;
3657
+ (err instanceof ChannelLifecycleError &&
3658
+ (err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted'))) return;
2719
3659
  this.emitTraceFn({
2720
3660
  type: 'mcpl:channel-reconcile-failed',
2721
3661
  serverId,
@@ -2760,26 +3700,20 @@ export class ChannelRegistry {
2760
3700
  .map((e) => e.descriptor);
2761
3701
  }
2762
3702
 
2763
- /**
2764
- * The server whose accepted message set the global fallback, where
2765
- * `channelId` is the fallback's channel: a turn whose locus is the
2766
- * fallback resolves it with this server, taken with the locus.
2767
- */
2768
- fallbackServerFor(channelId: string): string | undefined {
2769
- return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
2770
- }
2771
-
2772
3703
  /** Server id owning a registered channel (by descriptor id), or null. */
2773
3704
  getChannelServerId(channelId: string): string | null {
2774
3705
  return this.findChannelEntry(channelId)?.serverId ?? null;
2775
3706
  }
2776
3707
 
2777
- /** Whether a server other than `serverId` registers `channelId`. */
2778
- private registeredByAnotherServer(serverId: string, channelId: string): boolean {
2779
- for (const entry of this.channels.values()) {
2780
- if (entry.descriptor.id === channelId && entry.serverId !== serverId) return true;
2781
- }
2782
- return false;
3708
+ /**
3709
+ * The server that declared this channel inbound-only (isInboundOnly), or
3710
+ * undefined when it isn't one. The notices name that server. A caller that
3711
+ * knows the channel's server passes it; otherwise the channel is found by
3712
+ * its id alone (findChannelEntry).
3713
+ */
3714
+ inboundOnlyServer(channelId: string, serverId?: string): string | undefined {
3715
+ const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId);
3716
+ return entry && isInboundOnly(entry) ? entry.serverId : undefined;
2783
3717
  }
2784
3718
 
2785
3719
  /**
@@ -2799,19 +3733,28 @@ export class ChannelRegistry {
2799
3733
  channelId: string,
2800
3734
  serverId?: string,
2801
3735
  ): { entry?: ChannelEntry; error?: string } {
2802
- const matches = [...this.channels.values()].filter(
2803
- (entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId),
2804
- );
3736
+ const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId);
3737
+ const matches = withId.filter((entry) => !serverId || entry.serverId === serverId);
2805
3738
  if (matches.length === 0) {
3739
+ if (serverId && withId.length > 0) {
3740
+ // Leaving serverId out works only when one server has the id.
3741
+ const servers = withId.map((entry) => `"${entry.serverId}"`);
3742
+ return {
3743
+ error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1
3744
+ ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.`
3745
+ : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`),
3746
+ };
3747
+ }
2806
3748
  return {
2807
3749
  error: serverId
2808
- ? `Channel not found: ${channelId} on server ${serverId}`
2809
- : `Channel not found: ${channelId}`,
3750
+ ? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.`
3751
+ : `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`,
2810
3752
  };
2811
3753
  }
2812
3754
  if (matches.length > 1) {
2813
3755
  return {
2814
- error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
3756
+ error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` +
3757
+ 'so nothing ran. Call again with serverId set to one of them.',
2815
3758
  };
2816
3759
  }
2817
3760
  return { entry: matches[0] };
@@ -2865,15 +3808,31 @@ export class ChannelRegistry {
2865
3808
  /**
2866
3809
  * Record durable desired-open intent, then queue the channel's lifecycle
2867
3810
  * work (applyDesiredChannelState), which tells the server to subscribe and
2868
- * marks the live entry open on a receipt for its current target. Every
2869
- * open the agent or its speech asks for — the channel_open tool, delivery
3811
+ * marks the live entry open on a receipt for its current target, applying
3812
+ * an inbound-only declaration the answer carries (applyOpenDeclaration).
3813
+ * Every open the agent or its speech asks for — the channel_open tool, delivery
2870
3814
  * into a closed locus, an explicit send into a closed channel — comes
2871
3815
  * here. Reconciliation queues that work directly, as the closes do
2872
3816
  * (channel_close, a decline, and the close of a substituted channel).
2873
- * Desired state is recorded BEFORE the server round-trip: intent sticks
2874
- * even if the subscribe fails, and reconciliation retries later.
2875
- * Automatic delivery needs open transport, not a change of attention:
2876
- * preserve tune-out epochs both before and during the round-trip.
3817
+ * Desired state is recorded BEFORE the server round-trip. The agent's own
3818
+ * open is a decision about the channel: it sticks even if the subscribe
3819
+ * fails, and reconciliation retries later. An open by speech or a reply is
3820
+ * incidental to its words: when its round-trip fails, the decision it
3821
+ * wrote goes back to the one it replaced, so the agent, told the channel
3822
+ * is closed and the open failed, isn't later put into it by that decision.
3823
+ * That holds only while the record is still the one it wrote: a decision
3824
+ * made meanwhile stands, and the failure then comes as `overtaken`
3825
+ * (ChannelLifecycleError). Where the decision put back is a pure default,
3826
+ * nobody had decided, so the channel then follows its server's latest
3827
+ * announcement and the policy: a re-announcement during the round-trip
3828
+ * found the open's own record and lifted nothing. If they select it for
3829
+ * opening, the failure says so (`selected-again`). And since
3830
+ * a failed request may have reached the server, the decision that stands
3831
+ * is carried out on the wire too. An open by speech or a reply that can't
3832
+ * send its request, on a server without channels.lifecycle, writes
3833
+ * nothing. Automatic delivery needs open transport, not a change of
3834
+ * attention: preserve tune-out epochs both before and during the
3835
+ * round-trip.
2877
3836
  *
2878
3837
  * `opened` says whether this call turned a closed channel open, which is
2879
3838
  * what the delivery and reply notices tell the resident. Reconfirming the
@@ -2885,17 +3844,140 @@ export class ChannelRegistry {
2885
3844
  history?: ChannelHistoryRequest,
2886
3845
  ): Promise<{ result: ChannelsOpenResult; opened: boolean }> {
2887
3846
  const transportOnly = source !== 'agent-tool';
3847
+ const key = this.lifecycleKey(entry.serverId, entry.descriptor.id);
3848
+ if (transportOnly) {
3849
+ // Without channels.lifecycle the open fails before any request leaves
3850
+ // (§14.1), as it does without the server, so an incidental open would
3851
+ // only write a decision to put back. It writes none.
3852
+ const server = this.serverRegistry.getServer(entry.serverId);
3853
+ if (!server || !CapabilityGrant.of(server).has('channels.lifecycle')) throw this.lifecycleRefusal(entry.serverId);
3854
+ }
3855
+ // The decision this open replaces. A channel with none on record, as one
3856
+ // registered from a push, was closed by default: nobody had decided.
3857
+ const replaced = this.desiredStates.get(key) ?? { state: 'closed' as const, source: 'default-closed' };
2888
3858
  const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
2889
3859
  const opened = before !== 'open' && before !== 'tuned-out';
2890
3860
  if (!transportOnly || before !== 'tuned-out') {
2891
- this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
3861
+ // A decision the log refused changed nothing, and its words say so
3862
+ // rather than read as one a later decision overtook.
3863
+ try {
3864
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
3865
+ } catch (err) {
3866
+ throw unrecordedDecision(err);
3867
+ }
2892
3868
  }
2893
- const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
2894
- const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2895
- if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
2896
- throw new Error('Channel open was superseded by a newer lifecycle decision');
3869
+ // The record an incidental open wrote, to put back if its round-trip fails.
3870
+ const wrote = transportOnly && opened ? this.desiredStates.get(key) : undefined;
3871
+ try {
3872
+ const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
3873
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
3874
+ if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
3875
+ throw new ChannelLifecycleError(supersededClause('open', desired), 'superseded');
3876
+ }
3877
+ return { result: applied.result!, opened };
3878
+ } catch (err) {
3879
+ if (wrote && this.desiredStates.get(key) !== wrote) {
3880
+ // That decision stands, and the failure says so where its own clause
3881
+ // doesn't, as a superseded open's does. What helps turns on that
3882
+ // decision, so no step is offered.
3883
+ const own = lifecycleClause(err);
3884
+ throw new ChannelLifecycleError(
3885
+ err instanceof ChannelLifecycleError && err.kind === 'superseded'
3886
+ ? own : afterClause(own, 'a decision about the channel made while the open was out stands'),
3887
+ 'overtaken',
3888
+ undefined,
3889
+ { cause: err },
3890
+ );
3891
+ }
3892
+ if (wrote) {
3893
+ try {
3894
+ this.setDesiredState(entry.serverId, entry.descriptor.id, replaced.state, replaced.source);
3895
+ } catch (putBackErr) {
3896
+ // The caller reports both: the channel isn't open, but it stays
3897
+ // selected for opening, so reconciliation may open it later.
3898
+ throw new ChannelLifecycleError(
3899
+ afterClause(lifecycleClause(err), 'the channel stays selected for opening, because this host couldn\'t record ' +
3900
+ `undoing that: ${requestErrorText(putBackErr)}`),
3901
+ 'put-back-unrecorded',
3902
+ undefined,
3903
+ { cause: err },
3904
+ );
3905
+ }
3906
+ // The decision put back is a closed one: an open that turned the
3907
+ // channel open replaced no open decision. Where it's a pure default,
3908
+ // nobody had decided, so the channel follows its server's latest
3909
+ // announcement and the policy, not the ones from before the open: a
3910
+ // re-announcement during the round-trip found the open's own record
3911
+ // and lifted nothing. The lift is ensureInitialDesiredState's own,
3912
+ // which leaves every real decision alone. One that can't be recorded
3913
+ // leaves the default, closed.
3914
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3915
+ let selectedAgain: Exclude<SelectedForOpening, 'unrecorded'> | undefined;
3916
+ if (current) {
3917
+ try {
3918
+ this.ensureInitialDesiredState(entry.serverId, current.descriptor);
3919
+ } catch (liftErr) {
3920
+ console.error(
3921
+ `[channel] ${entry.serverId}:${logValue(entry.descriptor.id)}: the lift after a failed open couldn't be recorded:`,
3922
+ liftErr,
3923
+ );
3924
+ }
3925
+ const now = this.desiredStates.get(key);
3926
+ if (now?.state === 'open' && (now.source === 'server-bootstrap' || now.source === 'subscription-policy')) {
3927
+ selectedAgain = now.source;
3928
+ }
3929
+ }
3930
+ // The failed request may have reached the server, so the decision
3931
+ // that stands is carried out through the queue, as a decline's close
3932
+ // is, unless other lifecycle work for the channel is queued that will
3933
+ // carry it out anyway: work queued before its server's registrations
3934
+ // were removed cancels instead. (An incidental open gets here only
3935
+ // where the host directed the lifecycle when it opened: see the grant
3936
+ // check above. Where the grant has lost channels.lifecycle since, as a
3937
+ // transport drop clears it, the queued work fails for it, and the
3938
+ // channel's next registration by channels/register, or as added by
3939
+ // channels/changed, carries the decision out, where this host directs
3940
+ // the lifecycle then.)
3941
+ if (current && !this.outstandingWorkCarriesOut(entry.serverId, key)) {
3942
+ this.applyThroughQueue(entry.serverId, entry.descriptor.id);
3943
+ }
3944
+ if (selectedAgain) {
3945
+ throw new ChannelLifecycleError(
3946
+ afterClause(lifecycleClause(err), 'the channel is selected for opening again, because ' +
3947
+ (selectedAgain === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it')),
3948
+ 'selected-again',
3949
+ undefined,
3950
+ { by: selectedAgain, cause: err },
3951
+ );
3952
+ }
3953
+ }
3954
+ throw err;
3955
+ }
3956
+ }
3957
+
3958
+ /**
3959
+ * An open's answer carries the server's descriptor for the channel
3960
+ * (§14.3), the first place a lazily registered channel's own declaration
3961
+ * can arrive. runLifecycleOperation hands it here, wherever the host opens
3962
+ * a channel, once the answer is known to be for the channel's current
3963
+ * entry and target. An answer that names another channel never arrives
3964
+ * here: it's a substitution, which fails the open. One whose channel names
3965
+ * no id is this channel's, as runLifecycleOperation takes it, so its
3966
+ * declaration applies too.
3967
+ *
3968
+ * Only an inbound-only declaration is applied. The answer isn't always the
3969
+ * server's latest word: a `channels/changed` that arrives in the same read
3970
+ * as the answer is handled before the open's operation resumes, so the
3971
+ * answer is applied over it. Applying only a refusal keeps that race
3972
+ * fail-closed: a stale answer can keep a channel refused until the
3973
+ * server's next update, but never lift a newer refusal. A server lifts its
3974
+ * declaration by changing or registering the channel again.
3975
+ */
3976
+ private applyOpenDeclaration(entry: ChannelEntry, answer: unknown): void {
3977
+ const declared = (answer as { channel?: { direction?: unknown } } | undefined)?.channel;
3978
+ if (declared?.direction === 'inbound') {
3979
+ entry.descriptor = { ...entry.descriptor, direction: 'inbound' };
2897
3980
  }
2898
- return { result: applied.result!, opened };
2899
3981
  }
2900
3982
 
2901
3983
  /**
@@ -2911,9 +3993,28 @@ export class ChannelRegistry {
2911
3993
  *
2912
3994
  * Ambiguity is an error carrying candidates — never a guess: this is the
2913
3995
  * mechanism that makes prose misdelivery structurally impossible.
3996
+ *
3997
+ * `forSpeech` is for a target the agent's speech will be posted to: a
3998
+ * channel its server declared inbound-only (isInboundOnly) then resolves
3999
+ * to an error that says so, since nothing is published there, and that
4000
+ * names the channel as `refusedChannelId`, so a caller can tell a refused
4001
+ * channel from a name that resolved to nothing. Without it, as for reading
4002
+ * a channel's history, any registered channel resolves.
2914
4003
  */
2915
4004
  resolveProseTarget(
2916
4005
  spec: string,
4006
+ opts?: { forSpeech?: boolean },
4007
+ ): { channelId: string; label?: string } | { error: string; candidates?: string[]; refusedChannelId?: string } {
4008
+ const resolved = this.resolveTargetByName(spec);
4009
+ if (!opts?.forSpeech || 'error' in resolved) return resolved;
4010
+ const entry = this.findChannelEntry(resolved.channelId);
4011
+ return entry && isInboundOnly(entry)
4012
+ ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
4013
+ : resolved;
4014
+ }
4015
+
4016
+ private resolveTargetByName(
4017
+ spec: string,
2917
4018
  ): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
2918
4019
  const entries = [...this.channels.values()];
2919
4020
  const trimmed = spec.trim();
@@ -3072,7 +4173,7 @@ export class ChannelRegistry {
3072
4173
  ];
3073
4174
  for (const c of candidates) {
3074
4175
  if (/\s/.test(c) || c === '#') continue;
3075
- const r = this.resolveProseTarget(c);
4176
+ const r = this.resolveProseTarget(c, { forSpeech: true });
3076
4177
  if ('channelId' in r && r.channelId === d.id) return c;
3077
4178
  }
3078
4179
  return undefined;
@@ -3084,7 +4185,17 @@ export class ChannelRegistry {
3084
4185
  * engaging a channel opens it, so typing indicators, reaction machinery,
3085
4186
  * and inbound forwarding all come alive with the first outbound message.
3086
4187
  * Accepts composite MCPL ids or raw server-internal ids (explicit send
3087
- * tools receive whatever the agent typed).
4188
+ * tools receive whatever the agent typed). An `open-failed` answer says
4189
+ * whether the open that failed was one that would have opened a channel
4190
+ * not selected open (`opening`), as opposed to one reconfirming the
4191
+ * transport of a channel already selected open or tuned out. It isn't one
4192
+ * either where a decision made during its round-trip replaced it
4193
+ * (`overtaken`), or where queued lifecycle work left the
4194
+ * channel closed before any open was sent. `selectedForOpening` says the
4195
+ * channel is selected for opening after the failure, and why: the put-back
4196
+ * couldn't be recorded (`put-back-unrecorded`), or the server's latest
4197
+ * announcement or the policy lifted the default it restored
4198
+ * (`selected-again`).
3088
4199
  */
3089
4200
  async openIfClosedForSend(
3090
4201
  rawChannelId: string,
@@ -3093,6 +4204,8 @@ export class ChannelRegistry {
3093
4204
  status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
3094
4205
  channelId?: string;
3095
4206
  label?: string;
4207
+ opening?: boolean;
4208
+ selectedForOpening?: SelectedForOpening;
3096
4209
  }> {
3097
4210
  let matches = [...this.channels.values()].filter(
3098
4211
  (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
@@ -3107,6 +4220,7 @@ export class ChannelRegistry {
3107
4220
  if (matches.length === 0) return { status: 'unknown-channel' };
3108
4221
  if (matches.length > 1) return { status: 'ambiguous' };
3109
4222
  let entry = matches[0]!;
4223
+ let opening = false;
3110
4224
  try {
3111
4225
  if (this.hasPendingLifecycle(entry)) {
3112
4226
  entry = await this.waitForOpenChannel(entry);
@@ -3115,6 +4229,7 @@ export class ChannelRegistry {
3115
4229
  if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
3116
4230
  return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
3117
4231
  }
4232
+ opening = desired !== 'open' && desired !== 'tuned-out';
3118
4233
  const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
3119
4234
  entry = await this.waitForOpenChannel(entry);
3120
4235
  if (opened) {
@@ -3126,6 +4241,10 @@ export class ChannelRegistry {
3126
4241
  }
3127
4242
  return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
3128
4243
  } catch (err) {
4244
+ // Where a decision made during the open's round-trip replaced it, such
4245
+ // as the agent's own close or channel_open, the channel is as that
4246
+ // decision left it, and the agent isn't told the open failed.
4247
+ if (err instanceof ChannelLifecycleError && err.kind === 'overtaken') opening = false;
3129
4248
  this.emitTraceFn({
3130
4249
  type: 'mcpl:channel-open-failed',
3131
4250
  serverId: entry.serverId,
@@ -3133,7 +4252,11 @@ export class ChannelRegistry {
3133
4252
  error: (err as Error).message,
3134
4253
  });
3135
4254
  const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3136
- return { status: 'open-failed', channelId: entry.descriptor.id,
4255
+ return { status: 'open-failed', channelId: entry.descriptor.id, opening,
4256
+ // Where the channel is selected for opening after the failure, the
4257
+ // agent is told so and why, as speech's failure says.
4258
+ ...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' as const } : {}),
4259
+ ...(err instanceof ChannelLifecycleError && err.kind === 'selected-again' ? { selectedForOpening: err.by } : {}),
3137
4260
  ...(current ? { label: current.descriptor.label } : {}) };
3138
4261
  }
3139
4262
  }
@@ -3157,23 +4280,26 @@ export class ChannelRegistry {
3157
4280
  // A tuned-out channel is transport-open but attention-diverted; a plain
3158
4281
  // open would silently discard the epoch (and its pending backlog dump).
3159
4282
  // Cancelling is the tune-out coordinator's flow, reached via its own
3160
- // tool — refuse with the pointer rather than eat the state. As an error
3161
- // result, the agent reads the pointer itself; `data` names the refusal
3162
- // for an in-process caller.
4283
+ // tool — refuse with the pointer rather than eat the state.
3163
4284
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3164
- return {
3165
- success: false,
3166
- isError: true,
3167
- error:
3168
- `Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
3169
- `(tune_out with mode: 'cancel') to resume normal attention — ` +
3170
- `cancelling delivers the diverted backlog.`,
3171
- data: { refusal: 'tuned-out', channelId: input.channelId },
3172
- };
4285
+ return tunedOutRefusal('channel_open', input.channelId);
3173
4286
  }
3174
4287
 
3175
4288
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
3176
4289
  if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
4290
+ // Open already, perhaps because speech, a reply, the server or a
4291
+ // policy opened it: the agent's open is still its decision, and is
4292
+ // recorded as one (setDesiredState), so the explicit-open guard of
4293
+ // handleToolClose reads it.
4294
+ try {
4295
+ this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
4296
+ } catch (err) {
4297
+ return {
4298
+ success: false,
4299
+ error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${(err as Error).message}`,
4300
+ isError: true,
4301
+ };
4302
+ }
3177
4303
  return {
3178
4304
  success: true,
3179
4305
  data: { channelId: input.channelId, status: 'already open' },
@@ -3199,17 +4325,12 @@ export class ChannelRegistry {
3199
4325
  };
3200
4326
  } catch (err) {
3201
4327
  // The decision was recorded before the round-trip and outlives its
3202
- // failure. Say so: otherwise a later delivery that opens the channel
3203
- // would be the resident's first sign that it was still wanted open.
3204
- const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3205
- const stillSelected = current !== undefined && !current.open &&
3206
- this.getDesiredState(entry.serverId, input.channelId) === 'open';
4328
+ // failure. The text says so where it stands: otherwise a later
4329
+ // delivery that opens the channel would be the resident's first sign
4330
+ // that it was still wanted open.
3207
4331
  return {
3208
4332
  success: false,
3209
- error: `Failed to open channel: ${(err as Error).message}` +
3210
- (stillSelected
3211
- ? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
3212
- : ''),
4333
+ error: this.lifecycleFailureText('open', entry, err, { history: !!input.backscroll }),
3213
4334
  isError: true,
3214
4335
  };
3215
4336
  }
@@ -3228,8 +4349,9 @@ export class ChannelRegistry {
3228
4349
  source?: string;
3229
4350
  /** A machine close aimed at an explicitly-opened channel is refused
3230
4351
  * unless the caller certifies an explicit idle lease (a configured
3231
- * per-channel budget is consent to close at that budget). Module
3232
- * origin only, like `source`. */
4352
+ * per-channel budget is consent to close at that budget). A tuned-out
4353
+ * channel is refused even so, since its tune-out's cancel is what
4354
+ * ends it. Module origin only, like `source`. */
3233
4355
  overrideExplicitOpen?: boolean;
3234
4356
  },
3235
4357
  origin?: ChannelToolOrigin,
@@ -3244,15 +4366,6 @@ export class ChannelRegistry {
3244
4366
  };
3245
4367
  }
3246
4368
 
3247
- const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
3248
- if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
3249
- !this.unconfirmedChannelTargets.has(entry)) {
3250
- return {
3251
- success: true,
3252
- data: { channelId: input.channelId, status: 'already closed' },
3253
- };
3254
- }
3255
-
3256
4369
  // Provenance comes from trusted dispatch, not from self-description:
3257
4370
  // only module-origin calls may record a machine source, and only from
3258
4371
  // the closed vocabulary. Agent-origin (or origin-less — the safe
@@ -3273,6 +4386,27 @@ export class ChannelRegistry {
3273
4386
  closeSource = input.source;
3274
4387
  }
3275
4388
 
4389
+ const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
4390
+ if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
4391
+ !this.unconfirmedChannelTargets.has(entry)) {
4392
+ // Closed already, perhaps only by default: the agent's close is still
4393
+ // its decision, and is recorded as one (setDesiredState), so a later
4394
+ // registration's lift of a default leaves the channel closed.
4395
+ try {
4396
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4397
+ } catch (err) {
4398
+ return {
4399
+ success: false,
4400
+ error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
4401
+ isError: true,
4402
+ };
4403
+ }
4404
+ return {
4405
+ success: true,
4406
+ data: { channelId: input.channelId, status: 'already closed' },
4407
+ };
4408
+ }
4409
+
3276
4410
  // Housekeeping must not override stated intent (issue #5: GC closes wore
3277
4411
  // the agent's badge and reset explicitly-opened doors). A machine-sourced
3278
4412
  // close of a channel whose current desired state is an agent/operator
@@ -3295,40 +4429,42 @@ export class ChannelRegistry {
3295
4429
  }
3296
4430
  }
3297
4431
 
3298
- this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4432
+ // A tuned-out channel's decision is the tune-out, and a close recorded
4433
+ // over it would end it without its cancel, so the backlog it diverted
4434
+ // would never reach the agent. Its cancel is what ends it, as
4435
+ // channel_open's and channel_decline's refusals of a tuned-out channel
4436
+ // say, and the agent closes the channel after that. A module's close
4437
+ // that certified an idle lease, which the guard above lets through, is
4438
+ // refused too, as a refusal it stands down on rather than an error.
4439
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4440
+ const refusal = tunedOutRefusal('channel_close', input.channelId);
4441
+ return isModuleOrigin ? { ...refusal, isError: false } : refusal;
4442
+ }
4443
+
4444
+ // A decision the log refused changed nothing, and the answer says so.
4445
+ try {
4446
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4447
+ } catch (err) {
4448
+ return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
4449
+ }
3299
4450
  this.stopTyping(input.channelId);
3300
4451
 
3301
- const server = this.serverRegistry.getServer(entry.serverId);
3302
- if (!server) {
3303
- return {
3304
- success: false,
3305
- error: `Server not found: ${entry.serverId}`,
3306
- isError: true,
3307
- };
4452
+ // Refused now, the close stays recorded for the server's next
4453
+ // registration, and the text says so.
4454
+ if (!this.directsLifecycle(entry.serverId)) {
4455
+ return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
3308
4456
  }
3309
4457
 
3310
4458
  try {
3311
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
3312
- return {
3313
- success: false,
3314
- error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
3315
- isError: true,
3316
- };
3317
- }
3318
4459
  const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3319
- if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
3320
- throw new Error('Channel close was superseded by a newer lifecycle decision');
3321
- }
4460
+ const now = this.getDesiredState(entry.serverId, input.channelId);
4461
+ if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
3322
4462
  return {
3323
4463
  success: true,
3324
4464
  data: { channelId: input.channelId, status: 'closed' },
3325
4465
  };
3326
4466
  } catch (err) {
3327
- return {
3328
- success: false,
3329
- error: `Failed to close channel: ${(err as Error).message}`,
3330
- isError: true,
3331
- };
4467
+ return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
3332
4468
  }
3333
4469
  }
3334
4470
 
@@ -3343,81 +4479,258 @@ export class ChannelRegistry {
3343
4479
  if (!entry) {
3344
4480
  return { success: false, error: resolved.error, isError: true };
3345
4481
  }
4482
+ // A tuned-out channel's decision is the tune-out, whatever its transport,
4483
+ // and a decline recorded over it would end it without its cancel, so the
4484
+ // backlog it diverted would never reach the agent. Its cancel is what ends
4485
+ // it, as channel_open's refusal of a tuned-out channel says.
4486
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4487
+ return tunedOutRefusal('channel_decline', input.channelId);
4488
+ }
3346
4489
  if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
4490
+ // channel_close is the call that closes it: its own answer says when,
4491
+ // as for a server that isn't up. A server that's up without that grant
4492
+ // keeps its channels as it chooses, so the text says so, and whom to
4493
+ // ask.
4494
+ const refusal = this.serverReach(entry.serverId) === 'up' && !this.directsLifecycle(entry.serverId)
4495
+ ? this.lifecycleRefusal(entry.serverId) : undefined;
3347
4496
  return {
3348
4497
  success: false,
3349
- error: `Channel is already open: ${input.channelId}. Use channel_close to close it.`,
4498
+ error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` +
4499
+ (refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
4500
+ : '. If you need it closed, call channel_close.'),
3350
4501
  isError: true,
3351
4502
  };
3352
4503
  }
3353
4504
 
3354
4505
  // The lifecycle decision is authoritative even if the optional public
3355
- // acknowledgment cannot be rendered by the surface.
3356
- this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
4506
+ // acknowledgment cannot be rendered by the surface. One the log refused
4507
+ // changed nothing, and nothing after it runs.
4508
+ try {
4509
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
4510
+ } catch (err) {
4511
+ return {
4512
+ success: false,
4513
+ error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` +
4514
+ 'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
4515
+ 'way, ask whoever runs this host.',
4516
+ isError: true,
4517
+ };
4518
+ }
3357
4519
 
3358
4520
  let acknowledged = false;
3359
4521
  let representation: string | undefined;
3360
- let acknowledgmentError: string | undefined;
4522
+ // What became of an acknowledgment that wasn't posted, worded once the
4523
+ // decline's own outcome is known: each says the decline is recorded,
4524
+ // since it is whatever becomes of the acknowledgment, and a repeated
4525
+ // decline records it again and posts, but only where another decline
4526
+ // would be taken (`retry`).
4527
+ let ackFailure: ((retry: string | undefined) => string) | undefined;
3361
4528
  if (input.acknowledge) {
3362
4529
  const server = this.serverRegistry.getServer(entry.serverId);
3363
- if (!server) {
3364
- acknowledgmentError = `Server not found: ${entry.serverId}`;
3365
- } else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
3366
- acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
3367
- } else {
4530
+ if (server && CapabilityGrant.of(server).has('channels.acknowledge')) {
3368
4531
  try {
3369
- const result = await server.sendChannelsAcknowledge({
4532
+ const result: unknown = await server.sendChannelsAcknowledge({
3370
4533
  channelId: input.channelId,
3371
4534
  messageId: input.messageId,
3372
4535
  intent: 'seen-not-opening',
3373
4536
  value: input.acknowledge,
3374
4537
  });
3375
- acknowledged = result.acknowledged;
3376
- representation = result.representation;
3377
- if (!acknowledged) {
3378
- acknowledgmentError = result.reason ??
3379
- 'The channel integration could not post the acknowledgment.';
4538
+ // An answer counts by #163's rule, as a publish's does: only
4539
+ // acknowledged: true confirms a post, and only false denies one.
4540
+ const receipt = (result ?? {}) as { acknowledged?: unknown; representation?: unknown; reason?: unknown };
4541
+ if (receipt.acknowledged === true) {
4542
+ acknowledged = true;
4543
+ if (typeof receipt.representation === 'string') representation = receipt.representation;
4544
+ } else if (receipt.acknowledged === false) {
4545
+ // A reason that isn't a non-empty string is none.
4546
+ const reason = typeof receipt.reason === 'string' && receipt.reason !== '' ? connectorText(receipt.reason) : undefined;
4547
+ ackFailure = (retry) => reason !== undefined
4548
+ ? withStop(`The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment: ${reason}`)
4549
+ : `The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment, and gave no reason.` +
4550
+ (retry ? ` To try once more, ${retry}.` : '');
4551
+ } else {
4552
+ ackFailure = (retry) => `The decline is recorded, but the "${entry.serverId}" server's answer didn't say whether it ` +
4553
+ 'posted the acknowledgment.' + (retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
3380
4554
  }
3381
4555
  } catch (error) {
3382
- acknowledgmentError = (error as Error).message;
4556
+ // Only a request that never left the host proves nothing was
4557
+ // posted, as for a publish (publishErrorOutcome).
4558
+ const words = requestErrorText(error);
4559
+ ackFailure = error instanceof McplRequestError && error.outcome === 'not-sent'
4560
+ ? (retry) => withStop(`The decline is recorded, but the acknowledgment wasn't posted: ${words}`) +
4561
+ (retry ? ` To try again, ${retry}.` : '')
4562
+ : (retry) => withStop(`The decline is recorded, but whether the acknowledgment was posted isn't known: ${words}`) +
4563
+ (retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
3383
4564
  }
4565
+ } else {
4566
+ const reach = this.serverReach(entry.serverId);
4567
+ // Read when the acknowledgment was attempted. Where the server is up,
4568
+ // the decline's close can await past a change to its grant, so that
4569
+ // text says what kept the acknowledgment back then.
4570
+ ackFailure = (retry) => reach === 'up'
4571
+ ? `The decline is recorded, but this host wasn't permitted to post acknowledgments to the "${entry.serverId}" server, ` +
4572
+ 'so none was posted. If you need it, ask whoever runs this host.'
4573
+ : `The decline is recorded, but the acknowledgment wasn't posted: ${unreachableClause(entry.serverId, reach)}.` +
4574
+ (reach === 'gone' ? ' If you need it, ask whoever runs this host.'
4575
+ : retry ? ` To post it, ${retry} ${reach === 'connecting' ? 'in a moment' : "once it's back"}.` : '');
3384
4576
  }
3385
4577
  }
3386
4578
 
3387
4579
  // Where the host directs the lifecycle, a decline goes through the
3388
4580
  // lifecycle queue like any closed decision, unless a receipt confirmed
3389
- // the transport closed with nothing pending: an unconfirmed transport may
3390
- // be open, and pending work must settle on this decision. A failed close
3391
- // is reported; the decision stands.
3392
- let closeError: string | undefined;
4581
+ // the transport closed with nothing pending: an open or unconfirmed
4582
+ // transport may be open, and pending work must settle on this decision.
4583
+ // The decline refused an open channel before its acknowledgment, but
4584
+ // traffic can show one open meanwhile where this host didn't direct the
4585
+ // server's channels, before a grant that now lets it. A decline acts
4586
+ // only on its own decision: one made meanwhile, to open the channel or
4587
+ // tune it out, isn't the decline's to apply. A failed close is reported
4588
+ // below; the decision stands.
4589
+ let closeFailure: unknown;
4590
+ let closeFailed = false;
3393
4591
  const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3394
4592
  if (current && this.directsLifecycle(entry.serverId) &&
3395
- (this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
4593
+ this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
4594
+ (current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
3396
4595
  try {
3397
- const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3398
- if (applied.open) closeError = 'the close was superseded by a newer lifecycle decision';
4596
+ await this.applyDesiredChannelState(entry.serverId, input.channelId);
3399
4597
  } catch (error) {
3400
- closeError = (error as Error).message;
4598
+ closeFailure = error;
4599
+ closeFailed = true;
3401
4600
  }
3402
4601
  }
3403
-
3404
- this.appendLifecycleEvent({
3405
- kind: 'invitation-declined',
3406
- serverId: entry.serverId,
3407
- channelId: input.channelId,
3408
- messageId: input.messageId,
3409
- acknowledgment: representation ?? input.acknowledge,
3410
- timestamp: new Date().toISOString(),
3411
- });
4602
+ // A decision made while the decline ran, to open the channel or tune it
4603
+ // out, replaced it, and that is what stands.
4604
+ const decided = this.getDesiredState(entry.serverId, input.channelId);
4605
+ const stands = decided === 'closed';
4606
+ // The channel as the decline leaves it: closed for certain only where
4607
+ // it is registered, not open, its target confirmed, and no lifecycle
4608
+ // work for it outstanding, as the in-effect texts read it. A close that
4609
+ // didn't complete, a channel this host never confirmed closed, or a
4610
+ // registration that went leaves that unknown. A server without
4611
+ // channels.lifecycle keeps its channels as it chooses, and this host
4612
+ // asks it to open or close none of them, so a channel there is confirmed
4613
+ // closed only by the receipt of a request sent while the server had the
4614
+ // grant, whether the channel was registered or known only from a push.
4615
+ const after = this.channels.get(`${entry.serverId}:${input.channelId}`);
4616
+ const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
4617
+ !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
4618
+ // `closeError` is the result's sentence for the agent: what became of
4619
+ // the channel, and what follows. Every status but `remained closed`
4620
+ // carries one, and that one none, by this tree; `status` is the word a
4621
+ // caller's code reads. Reconciling closes a channel decided closed when
4622
+ // the server next registers it (for a push's placeholder, if it ever
4623
+ // does), if this host directs their lifecycle then (§14.1), which
4624
+ // laterClause says.
4625
+ let closeError: string | undefined;
4626
+ const reach = this.serverReach(entry.serverId);
4627
+ const serverBack = reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '';
4628
+ // What helps now with a registered channel the decline didn't leave
4629
+ // closed for certain, read from the state the reads above found, not
4630
+ // from how it came to be: whom to ask where this host doesn't direct the
4631
+ // server's channels, work still under way where some will carry the
4632
+ // decision out (outstandingWorkCarriesOut, not work a removal cancels),
4633
+ // and otherwise channel_close, which asks for the close now.
4634
+ const stepNow = (): string => !this.directsLifecycle(entry.serverId)
4635
+ ? (reach === 'up' ? ' If you need it closed sooner, ask whoever runs this host.' : serverBack)
4636
+ : this.outstandingWorkCarriesOut(entry.serverId, this.lifecycleKey(entry.serverId, input.channelId))
4637
+ ? ' Other work for it that\'s still under way applies the decline when its turn comes, so there\'s no need to ' +
4638
+ 'decline again to close it.'
4639
+ : ' If you need it closed now, call channel_close.';
4640
+ if (!stands) {
4641
+ // channel_close closes it after all while the channel is registered,
4642
+ // over an open, or once a tune-out's cancel has ended the tune-out,
4643
+ // since it refuses a tuned-out channel; a server that's up without the
4644
+ // grant keeps its channels as it chooses, as the decline's refusal
4645
+ // says.
4646
+ const step = after === undefined
4647
+ ? `. It isn't registered here any more, so no channel tool can reach it until it's registered here again.${serverBack}`
4648
+ : reach === 'up' && !this.directsLifecycle(entry.serverId)
4649
+ ? `, and ${this.lifecycleRefusal(entry.serverId).message}; if you need it closed, ask whoever runs this host.`
4650
+ // channel_close refuses a tuned-out channel, so the cancel comes first.
4651
+ : decided === 'tuned-out'
4652
+ ? `. If you still want it closed, cancel the tune-out with tune_out mode "cancel", which delivers what it ` +
4653
+ `diverted, and then call channel_close.${serverBack}`
4654
+ : `. If you still want it closed, call channel_close.${serverBack}`;
4655
+ closeError = decided === 'tuned-out'
4656
+ ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
4657
+ `the subconscious${step}`
4658
+ : `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
4659
+ } else if (closed) {
4660
+ // Closed for certain, whatever became of the decline's own close: one
4661
+ // refused for a grant that went after an earlier operation confirmed
4662
+ // the close changes nothing the agent needs.
4663
+ } else if (closeFailed) {
4664
+ // What happened comes from the failure: a registration that went
4665
+ // while the close was out says so, since the server may have closed it
4666
+ // first, and one that came back since says that; a request that failed
4667
+ // while the registration went says both. What helps comes from the
4668
+ // state now.
4669
+ const failure = closeFailure instanceof ChannelLifecycleError ? closeFailure : undefined;
4670
+ const went = failure?.kind === 'cancelled' || failure?.kind === 'unlisted';
4671
+ closeError = withStop(
4672
+ `The decline is recorded, and this host closes the channel ${laterClause(entry)}; ` +
4673
+ (!went ? `closing it now didn't complete: ${lifecycleClause(closeFailure)}`
4674
+ : after !== undefined
4675
+ ? 'its registration went and came back while the close was out'
4676
+ : `it isn't registered here any more: ${failure!.message}`),
4677
+ ) + (after === undefined ? (went ? '' : ' It isn\'t registered here any more.') + serverBack : stepNow());
4678
+ } else if (after === undefined) {
4679
+ // The registration went while the acknowledgment was out, so no close
4680
+ // was tried.
4681
+ closeError = withStop(
4682
+ `The decline is recorded, and this host closes the channel ${laterClause(entry)}; it isn't registered here any more` +
4683
+ (reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : ''),
4684
+ ) + serverBack;
4685
+ } else if (!this.directsLifecycle(entry.serverId)) {
4686
+ closeError = reach === 'up'
4687
+ ? `The decline is recorded, but ${this.lifecycleRefusal(entry.serverId).message}, so the server keeps this one open ` +
4688
+ `or closed as it chooses, and declining again won't change that. This host closes it ${laterClause(entry)}. If you ` +
4689
+ 'need it closed sooner, ask whoever runs this host.'
4690
+ : `The decline is recorded, but ${unreachableClause(entry.serverId, reach)}, so the channel may not be closed there ` +
4691
+ `yet. This host closes it ${laterClause(entry)}, so there's no need to decline again to close it.` + serverBack;
4692
+ } else {
4693
+ // This host directs the channel's lifecycle and its close didn't fail,
4694
+ // so what remains is other work for it, or a change since that close's
4695
+ // receipt that left it unconfirmed; the step says which.
4696
+ closeError = 'The decline is recorded, but the channel isn\'t closed for certain yet.' + stepNow();
4697
+ }
4698
+ // Another decline is taken only where this one was: the channel
4699
+ // registered, not open, and not decided open.
4700
+ const again = stands && after !== undefined && !after.open;
4701
+ const acknowledgmentError = ackFailure?.(again ? 'call channel_decline again with the same arguments' : undefined);
4702
+ // What the decline found, in a word: the channel remained closed, the
4703
+ // decline stands though the channel may not be closed, or a later
4704
+ // decision replaced it.
4705
+ const status = !stands ? 'replaced' : closed ? 'remained closed' : 'declined';
4706
+
4707
+ // An audit record nothing reads back: the decline took effect above, so
4708
+ // a log that refuses it is the operator's to hear about, not a failure
4709
+ // of the decline.
4710
+ try {
4711
+ this.appendLifecycleEvent({
4712
+ kind: 'invitation-declined',
4713
+ serverId: entry.serverId,
4714
+ channelId: input.channelId,
4715
+ messageId: input.messageId,
4716
+ acknowledgment: representation ?? input.acknowledge,
4717
+ timestamp: new Date().toISOString(),
4718
+ });
4719
+ } catch (err) {
4720
+ console.error(
4721
+ `[channel-decline] server=${entry.serverId} channel=${logValue(input.channelId)}: the declined invitation wasn't ` +
4722
+ `recorded: ${err instanceof Error ? err.message : String(err)}`,
4723
+ );
4724
+ }
3412
4725
  return {
3413
4726
  success: true,
3414
4727
  data: {
3415
4728
  channelId: input.channelId,
3416
- status: 'remained closed',
4729
+ status,
3417
4730
  acknowledged,
3418
4731
  ...(representation ? { representation } : {}),
3419
4732
  ...(acknowledgmentError ? { acknowledgmentError } : {}),
3420
- ...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
4733
+ ...(closeError ? { closeError } : {}),
3421
4734
  },
3422
4735
  };
3423
4736
  }
@@ -3505,75 +4818,99 @@ export class ChannelRegistry {
3505
4818
  }
3506
4819
 
3507
4820
  /**
3508
- * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
3509
- * Publish the agent's plain-text speech to the current conversational locus
3510
- * (the most recent incoming channel, tracked cross-surface here in the host).
3511
- * Called by the framework on a text-only turn — replaces the per-surface
3512
- * sticky auto-post that used to live in discord-mcpl. Returns null when there
3513
- * is no locus / the channel or its server can't be resolved (in which case
3514
- * the speech simply stays in chronicle + module surfaces).
3515
- */
3516
- /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
3517
- * process-global default). Public so a multi-segment caller can snapshot it
3518
- * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
3519
- /**
3520
- * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the
3521
- * server owning the channel, AS THE MODEL GENERATES. Emitted only when that
3522
- * server declared `channels.streaming` in its initialize capabilities —
3523
- * servers that never opted in (the whole existing fleet) receive nothing.
3524
- * Fire-and-forget and never throws: streaming is an observer surface; the
3525
- * authoritative delivery is the eventual channels/publish.
4821
+ * MCPL Spec 14.3 outgoing streaming: forward a text delta to the server
4822
+ * owning the channel. The framework sends each piece of prose once its
4823
+ * publish succeeds, so a stream carries only what was published.
4824
+ * Emitted only when that server's grant has `channels.streaming` —
4825
+ * servers that never opted in receive nothing. Fire-and-forget and never
4826
+ * throws: streaming is an observer surface; the authoritative delivery is
4827
+ * channels/publish.
4828
+ *
4829
+ * `to` is the destination the publish went to, its server and channel.
4830
+ * The chunk goes there while that server registers that channel (Spec
4831
+ * 14.3: the recipient is "the server that registered the target
4832
+ * channel"). A stream doesn't start in a channel its server has declared
4833
+ * inbound-only (#350's rule), not even for a publish delivered there just
4834
+ * before the declaration. One that started there before it goes on, since
4835
+ * the declaration doesn't unregister the channel: a later chunk is for a
4836
+ * publish delivered before it. A bare channel id is resolved as before, to
4837
+ * the one server that registers it, at each call (streamingServerFor), and
4838
+ * keeps no record: a stream sent by destination ends by destination, since
4839
+ * only a destination's `complete` removes its record.
3526
4840
  */
3527
4841
  sendOutgoingChunk(
3528
- channelId: string,
4842
+ to: string | PublishDestination,
3529
4843
  conversationId: string,
3530
4844
  inferenceId: string,
3531
4845
  index: number,
3532
4846
  delta: string,
3533
- /** The server the channel resolves with, as the speech's locus does
3534
- * (deliverSpeech's locusServerId). */
3535
- serverId?: string,
3536
4847
  ): void {
3537
- const server = this.streamingServerFor(channelId, serverId);
4848
+ let server: McplServerConnection | null;
4849
+ if (typeof to === 'string') {
4850
+ server = this.streamingServerFor(to);
4851
+ } else {
4852
+ const key = destinationKey(to);
4853
+ let started = this.takenStreams.get(inferenceId);
4854
+ const continuing = started?.has(key) ?? false;
4855
+ server = this.streamingServerAt(to, !continuing);
4856
+ if (server && !continuing) {
4857
+ if (!started) this.takenStreams.set(inferenceId, started = new Set());
4858
+ started.add(key);
4859
+ }
4860
+ }
3538
4861
  if (!server) return;
4862
+ const channelId = typeof to === 'string' ? to : to.channelId;
3539
4863
  try {
3540
4864
  server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
3541
4865
  } catch { /* observer surface — never disturb the turn */ }
3542
4866
  }
3543
4867
 
3544
- /** Spec 14.3 companion: final moderated content per channel at stream end. */
4868
+ /**
4869
+ * Spec 14.3 companion: final moderated content per channel at stream end.
4870
+ * Given a destination, it goes only where the stream started
4871
+ * (sendOutgoingChunk), while that server still registers that channel,
4872
+ * inbound-only or not, and its grant has `channels.streaming`: once per
4873
+ * destination, whose consumer, such as a TTS utterance, finalizes on it
4874
+ * and reconciles any chunk it missed. A bare channel id is resolved as
4875
+ * before (streamingServerFor).
4876
+ */
3545
4877
  sendOutgoingComplete(
3546
- channelId: string,
4878
+ to: string | PublishDestination,
3547
4879
  conversationId: string,
3548
4880
  inferenceId: string,
3549
4881
  text: string,
3550
- /** As sendOutgoingChunk's. */
3551
- serverId?: string,
3552
4882
  ): void {
3553
- const server = this.streamingServerFor(channelId, serverId);
4883
+ let server: McplServerConnection | null;
4884
+ if (typeof to === 'string') {
4885
+ server = this.streamingServerFor(to);
4886
+ } else {
4887
+ const started = this.takenStreams.get(inferenceId);
4888
+ if (!started?.delete(destinationKey(to))) return;
4889
+ if (started.size === 0) this.takenStreams.delete(inferenceId);
4890
+ server = this.streamingServerAt(to, false);
4891
+ }
3554
4892
  if (!server) return;
3555
4893
  try {
3556
4894
  server.sendChannelsOutgoingComplete({
3557
4895
  inferenceId,
3558
4896
  conversationId,
3559
- channelId,
4897
+ channelId: typeof to === 'string' ? to : to.channelId,
3560
4898
  content: [{ type: 'text', text }],
3561
4899
  });
3562
4900
  } catch { /* observer surface — never disturb the turn */ }
3563
4901
  }
3564
4902
 
3565
- private streamingServerFor(channelId: string, serverId?: string) {
4903
+ private streamingServerFor(channelId: string) {
3566
4904
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
3567
- // bare descriptor ids (what resolveProseTarget returns). With the server
3568
- // the speech resolves with, only that server's entry; otherwise scan like
3569
- // the resolver does, and fail closed on cross-server ambiguity — the
3570
- // same never-guess rule that governs delivery.
4905
+ // bare descriptor ids (what resolveProseTarget returns). Scan like the
4906
+ // resolver does, and fail closed on cross-server ambiguity — the same
4907
+ // never-guess rule that governs delivery.
3571
4908
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
3572
- const entry = serverId !== undefined
3573
- ? matches.find((e) => e.serverId === serverId)
3574
- : matches.length === 1 ? matches[0] : undefined;
3575
- if (!entry) return null;
3576
- const server = this.serverRegistry.getServer(entry.serverId);
4909
+ if (matches.length !== 1) return null;
4910
+ // Nothing is published into an inbound-only channel, so nothing is
4911
+ // streamed there either.
4912
+ if (isInboundOnly(matches[0]!)) return null;
4913
+ const server = this.serverRegistry.getServer(matches[0]!.serverId);
3577
4914
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
3578
4915
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
3579
4916
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -3583,6 +4920,24 @@ export class ChannelRegistry {
3583
4920
  return server;
3584
4921
  }
3585
4922
 
4923
+ /**
4924
+ * A destination's server, for a stream message: while that server
4925
+ * registers that channel, and its grant has `channels.streaming` (as in
4926
+ * streamingServerFor). A stream doesn't start in an inbound-only channel.
4927
+ */
4928
+ private streamingServerAt(to: PublishDestination, starting: boolean): McplServerConnection | null {
4929
+ // The map's key joins the two ids with a colon, so another server's
4930
+ // entry can sit under a destination's key: it has to be this server's.
4931
+ const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
4932
+ if (!entry || entry.serverId !== to.serverId) return null;
4933
+ if (starting && isInboundOnly(entry)) return null;
4934
+ const server = this.serverRegistry.getServer(to.serverId);
4935
+ return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
4936
+ }
4937
+
4938
+ /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
4939
+ * process-global default). Public so a multi-segment caller can snapshot it
4940
+ * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
3586
4941
  resolveLocus(conversationId: string): string | null {
3587
4942
  const home = this.homeChannelResolver?.(conversationId);
3588
4943
  return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
@@ -3630,13 +4985,17 @@ export class ChannelRegistry {
3630
4985
  if (matches.length === 0) {
3631
4986
  return {
3632
4987
  error: target.serverId
3633
- ? `no registered channel "${target.channelId}" on server "${target.serverId}"`
3634
- : `no registered channel "${target.channelId}"`,
4988
+ ? `the "${target.serverId}" server has no channel "${target.channelId}" registered here`
4989
+ : `no channel "${target.channelId}" is registered here`,
3635
4990
  };
3636
4991
  }
3637
4992
  if (matches.length > 1) {
3638
4993
  return {
3639
- error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`,
4994
+ // Speech names no server, and neither its >> prefix nor
4995
+ // channel_publish can: whoever runs this host can. A caller that
4996
+ // can name one reads that none was.
4997
+ error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
4998
+ 'if you need them there, ask whoever runs this host',
3640
4999
  };
3641
5000
  }
3642
5001
  return { entry: matches[0]! };
@@ -3659,7 +5018,33 @@ export class ChannelRegistry {
3659
5018
  * possible to send into a closed channel: a closed but registered
3660
5019
  * destination is opened first (`openSource` names why), once its server
3661
5020
  * and that server's channels.publish grant are known to take the send, so
3662
- * a refused send opens nothing; and a failed open sends nothing.
5021
+ * a send refused before the open opens nothing; and a failed open sends
5022
+ * nothing.
5023
+ *
5024
+ * A channel its server declared inbound-only (isInboundOnly) takes nothing
5025
+ * the host would post, whichever path the words took, a drafts resend
5026
+ * included: it's refused before anything opens. The channel's queued
5027
+ * lifecycle work and the open await the server, and a channels/changed
5028
+ * update, a re-register or the open's own answer (applyOpenDeclaration)
5029
+ * may declare the channel inbound-only meanwhile, or a removal take it
5030
+ * away. So the check just before the publish, after every await, reads
5031
+ * the channel's registration as it is then, and refuses one that is gone
5032
+ * or inbound-only, or whose server's grant no longer has channels.publish.
5033
+ * A removal during a wait already fails it there (waitForOpenChannel); the
5034
+ * check also catches one made after the last wait, as the opening's notice
5035
+ * can. A registration nothing has confirmed open, such as one that
5036
+ * replaced the one waited for or a retarget of it, or one whose close now
5037
+ * stands (where the host directs the server's lifecycle), is treated as at
5038
+ * the first wait: the words wait for its queued lifecycle work, and the
5039
+ * check runs again on the registration that work leaves. Words refused
5040
+ * after an open for a declaration leave the channel open, and the agent
5041
+ * gets the failure for the words. An open by delivery (`openSource`
5042
+ * 'opened-by-delivery', the default) that turned a closed channel open
5043
+ * also gives the notice that it's open, which says how to close it to a
5044
+ * reader that can.
5045
+ * One that only reconfirmed the transport of a channel already decided
5046
+ * open or tuned out changes no decision, and gives none. When the
5047
+ * registration is gone, the failure says the channel was removed.
3663
5048
  *
3664
5049
  * Only the connector's `delivered: true` confirms a post. `failed` means
3665
5050
  * nothing was posted: the attempt stopped before the request was written,
@@ -3696,20 +5081,19 @@ export class ChannelRegistry {
3696
5081
 
3697
5082
  // A send that would be refused is refused before anything opens: a
3698
5083
  // closed channel opens only for words that can go out.
3699
- const server = this.serverRegistry.getServer(entry.serverId);
3700
- if (!server) {
3701
- return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() };
5084
+ if (isInboundOnly(entry)) {
5085
+ return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
3702
5086
  }
5087
+ const server = this.serverRegistry.getServer(entry.serverId);
3703
5088
  // §14.1: channels/publish requires channels.publish in the grant. The
3704
- // host not sending is the enforcement.
3705
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3706
- return {
3707
- status: 'failed',
3708
- destination,
3709
- reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`,
3710
- at: at(),
3711
- };
3712
- }
5089
+ // host not sending is the enforcement, here and at the last check, and
5090
+ // the server's state words the refusal (sendRefusal).
5091
+ const ungranted = (): PublishOutcome => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
5092
+ if (!server || !CapabilityGrant.of(server).has('channels.publish')) return ungranted();
5093
+
5094
+ const prevented = (err: unknown): PublishOutcome => ({
5095
+ status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
5096
+ });
3713
5097
 
3714
5098
  // Lifecycle work already queued for the channel settles first: words go
3715
5099
  // out only into the channel it leaves, and fail visibly if it leaves
@@ -3718,12 +5102,7 @@ export class ChannelRegistry {
3718
5102
  try {
3719
5103
  entry = await this.waitForOpenChannel(entry);
3720
5104
  } catch (err) {
3721
- return {
3722
- status: 'failed',
3723
- destination,
3724
- reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3725
- at: at(),
3726
- };
5105
+ return prevented(err);
3727
5106
  }
3728
5107
  }
3729
5108
 
@@ -3768,13 +5147,43 @@ export class ChannelRegistry {
3768
5147
  }
3769
5148
  }
3770
5149
  } catch (err) {
3771
- return {
3772
- status: 'failed',
3773
- destination,
3774
- reason: `channel is closed and open failed: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3775
- at: at(),
3776
- };
5150
+ return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
5151
+ }
5152
+ }
5153
+
5154
+ // Checked again here, after every await above, the lifecycle work's, the
5155
+ // open's or any other: a registration that is gone, or inbound-only, at
5156
+ // this moment takes nothing. The entry is read from the map rather than
5157
+ // taken from the last wait: the opening's notice runs in between, and
5158
+ // publish() resumes a microtask after the wait. A registration nothing
5159
+ // has confirmed open, such as one that replaced the one waited for
5160
+ // (registered with open: false) or a retarget of it (marked unconfirmed),
5161
+ // or one whose close now stands (closeStands), is treated as at the
5162
+ // first wait: the words wait for its queued lifecycle work, and
5163
+ // waitForOpenChannel refuses what that work leaves unconfirmed or with
5164
+ // its close standing. The loop opens nothing itself. The check then runs
5165
+ // again on the registration that stands. waitForOpenChannel returns
5166
+ // only a registration it found open and whose close doesn't stand, or
5167
+ // throws, so the loop ends unless the channel keeps changing.
5168
+ const key = `${entry.serverId}:${entry.descriptor.id}`;
5169
+ let current = this.channels.get(key);
5170
+ while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
5171
+ try {
5172
+ entry = await this.waitForOpenChannel(current);
5173
+ } catch (err) {
5174
+ return prevented(err);
3777
5175
  }
5176
+ current = this.channels.get(key);
5177
+ }
5178
+ if (!current) return { status: 'failed', destination, reason: CHANNEL_REMOVED_CLAUSE, at: at() };
5179
+ if (isInboundOnly(current)) {
5180
+ return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
5181
+ }
5182
+ // A grant reduced during any of the waits holds at once (§6.7): the
5183
+ // words already on their way take it too. Its refusal is worded for the
5184
+ // connection that refused (sendRefusal's `checked`).
5185
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
5186
+ return { status: 'failed', destination, reason: this.sendRefusal(entry.serverId, server), at: at() };
3778
5187
  }
3779
5188
 
3780
5189
  let result: unknown;
@@ -3794,9 +5203,13 @@ export class ChannelRegistry {
3794
5203
  * Plain speech for one turn segment: publish `text` to the turn's frozen
3795
5204
  * locus and report the PublishOutcome. A failure (or an uncertain outcome)
3796
5205
  * is surfaced as a trace and through onRouteFailure, so the agent learns
3797
- * its words did not (or may not have) reached anyone. An ephemeral run's
3798
- * speech with no locus is the exception: it is its caller's result, so it
3799
- * returns as not delivered and surfaces nothing.
5206
+ * its words did not (or may not have) reached anyone. A `failed` one whose
5207
+ * caller holds its words with a notice of its own (`heldOnFailure`) gets
5208
+ * the trace but not onRouteFailure. An ephemeral run's speech with no locus
5209
+ * (null) is its caller's result: it returns as not delivered and surfaces
5210
+ * nothing. This is host-owned output routing (see forking-knowledge-miner
5211
+ * LOCUS-ROUTING-DESIGN), which replaced the per-surface sticky auto-post
5212
+ * that used to live in discord-mcpl.
3800
5213
  */
3801
5214
  async deliverSpeech(
3802
5215
  conversationId: string,
@@ -3813,24 +5226,21 @@ export class ChannelRegistry {
3813
5226
  locusChannelId: string | null,
3814
5227
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3815
5228
  * draft with its own notice, so no failure marker is written for it.
3816
- * An `unknown` outcome still gets its marker. `locusServerId`: the
3817
- * server the locus resolves with, snapshotted by the caller with the
3818
- * locus: the server whose message triggered the turn, where the locus is
3819
- * that message's channel. The speech goes out through that server only,
3820
- * and fails where it no longer registers the channel. Without it, the
3821
- * locus is taken only where exactly one server registers it. */
3822
- opts?: { heldOnFailure?: boolean; locusServerId?: string },
5229
+ * An `unknown` outcome still gets its marker. */
5230
+ opts?: { heldOnFailure?: boolean },
3823
5231
  ): Promise<PublishOutcome> {
3824
- const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
5232
+ // `logged` is what the log line and the trace say, where it isn't the
5233
+ // agent's reason (the two no-locus reasons below).
5234
+ const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
3825
5235
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
3826
5236
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
3827
5237
  const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
3828
- console.error(`[routeSpeech] ${conversationId}: ${reason}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
5238
+ console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
3829
5239
  this.emitTraceFn({
3830
5240
  type: 'mcpl:speech-route-failed',
3831
5241
  conversationId,
3832
5242
  channelId: channelId ?? '',
3833
- reason,
5243
+ reason: logged,
3834
5244
  textLen: text.length,
3835
5245
  outcome: status,
3836
5246
  ...(detail !== undefined ? { detail } : {}),
@@ -3841,12 +5251,19 @@ export class ChannelRegistry {
3841
5251
  return outcome;
3842
5252
  };
3843
5253
 
5254
+ // These two reasons are the only ones whose log line and trace keep the
5255
+ // operator's words rather than the agent's: connectome-host's
5256
+ // connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
5257
+ // "no locus" in both logged texts. Each names no tool: whether one the
5258
+ // agent may call could post the words isn't known here, and the notice
5259
+ // calls it a routing or configuration situation.
3844
5260
  // Runtime backstop for JS callers the compiler can't see: an omitted
3845
5261
  // locus is a routing bug at the call site, never something to paper over
3846
5262
  // with a live re-resolution.
3847
5263
  if (locusChannelId === undefined) {
3848
- const reason = 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)';
3849
- return fail(null, reason, { status: 'failed', reason, at: Date.now() });
5264
+ const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
5265
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() },
5266
+ 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
3850
5267
  }
3851
5268
  if (!locusChannelId) {
3852
5269
  // An ephemeral run with no channel of its own: its plain speech is its
@@ -3858,15 +5275,14 @@ export class ChannelRegistry {
3858
5275
  // The turn froze with no locus (no home, no triggering channel, no
3859
5276
  // global inbound ever seen) — the agent was told its prose stays in
3860
5277
  // the archive; honor that.
3861
- const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)';
3862
- return fail(null, reason, { status: 'failed', reason, at: Date.now() });
5278
+ // The locus froze at the turn's start, so this says what the turn had.
5279
+ const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
5280
+ 'had one, ask whoever runs this host';
5281
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() },
5282
+ 'turn has no locus (no home/trigger channel; nothing to deliver into)');
3863
5283
  }
3864
5284
 
3865
- const outcome = await this.publish(conversationId, text, {
3866
- ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
3867
- channelId: locusChannelId,
3868
- routed: true,
3869
- });
5285
+ const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3870
5286
  const channelId = outcome.destination?.channelId ?? locusChannelId;
3871
5287
  if (outcome.status !== 'delivered') {
3872
5288
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -3889,96 +5305,97 @@ export class ChannelRegistry {
3889
5305
  return outcome;
3890
5306
  }
3891
5307
 
3892
- /** deliverSpeech, reduced to the confirmed delivery (null when there was
3893
- * none). `opts.locusServerId` is deliverSpeech's. */
5308
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
3894
5309
  async routeSpeech(
3895
5310
  conversationId: string,
3896
5311
  text: string,
3897
5312
  locusChannelId: string | null,
3898
- opts?: { locusServerId?: string },
3899
- ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
3900
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId,
3901
- opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
5313
+ ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
5314
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3902
5315
  if (outcome.status !== 'delivered') return null;
3903
5316
  return {
3904
5317
  delivered: true,
5318
+ serverId: outcome.destination!.serverId,
3905
5319
  channelId: outcome.destination!.channelId,
3906
5320
  ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
3907
5321
  };
3908
5322
  }
3909
5323
 
5324
+ /**
5325
+ * channel_publish, and the subconscious's speak_in_channel
5326
+ * (publishForAgent), which shares its delivery. Where an agent finds a
5327
+ * channel id differs: the subconscious isn't offered channel_list, and is
5328
+ * given speak_in_channel for the tuned-out channels whose messages it reads.
5329
+ */
3910
5330
  private async handleToolPublish(
3911
- input: { channelId?: string; serverId?: string; content?: string; text?: string },
5331
+ input: { channelId?: string; content?: string; text?: string },
3912
5332
  origin?: ChannelToolOrigin,
5333
+ /** Filled for publishForAgent: the publish's outcome once a request is
5334
+ * attempted, for its trace, or which check of the destination refused
5335
+ * it before anything went out, for its answer. */
5336
+ seen?: { outcome?: PublishOutcome; refusal?: PublishRefusalReason },
5337
+ tool: 'channel_publish' | 'speak_in_channel' = 'channel_publish',
3913
5338
  ): Promise<ToolResult> {
3914
- // Resolve content: accept both `content` and `text` (backward compat)
5339
+ const findIds = tool === 'channel_publish'
5340
+ ? 'channel_list shows the channel ids.'
5341
+ : 'Use the id of a tuned-out channel whose messages you are reading.';
5342
+ // Resolve content: accept both `content` and `text` (backward compat).
5343
+ // speak_in_channel checks its own text first, so only channel_publish
5344
+ // reads this refusal.
3915
5345
  const messageText = input.content ?? input.text;
3916
5346
  if (!messageText) {
3917
- return {
3918
- success: false,
3919
- error: 'Either content or text parameter is required',
3920
- isError: true,
3921
- };
5347
+ return { success: false, error: 'Nothing was posted: channel_publish needs the message in `content`.', isError: true };
3922
5348
  }
3923
5349
 
3924
- // Resolve channelId: default to the most recent incoming channel, which an
3925
- // ephemeral run never takes (takesGlobalFallback), so it names one.
5350
+ // An empty channelId names nothing, and isn't read as one left out.
5351
+ // speak_in_channel's caller passes one left out as empty, so there it
5352
+ // gets the refusal for one left out, as speak_in_channel has no default.
5353
+ if (input.channelId === '' && tool === 'channel_publish') {
5354
+ return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
5355
+ }
5356
+ // Resolve channelId: default to the channel of the most recent incoming
5357
+ // message that isn't a reaction, reaction removal, edit or deletion
5358
+ // marker, apart from an edit the coalescer delivers on acceptance as the
5359
+ // agent's first view. An ephemeral run never takes that default
5360
+ // (takesGlobalFallback), so it names one.
3926
5361
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
3927
5362
  const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
3928
5363
  if (!channelId) {
3929
5364
  return {
3930
5365
  success: false,
3931
- error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
3932
- ? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
3933
- + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
3934
- : 'No channelId specified and no default channel available',
5366
+ error: tool === 'speak_in_channel'
5367
+ ? `Nothing was posted: speak_in_channel needs a \`channelId\`. ${findIds}`
5368
+ : caller !== undefined && this.takesGlobalFallback?.(caller) === false
5369
+ ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
5370
+ + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
5371
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
3935
5372
  isError: true,
3936
5373
  };
3937
5374
  }
3938
5375
 
3939
- // A serverId the call gives names the exact pair, as the other channel
3940
- // tools read theirs (this tool's schema doesn't declare one); with no
3941
- // channelId, it's the default channel on that server. A call naming no
3942
- // server takes the calling turn's server for its trigger's or locus's
3943
- // channel; else, for the default channel, the server whose accepted
3944
- // message set it; else a channelId only where exactly one server
3945
- // registers it.
3946
- const serverId = input.serverId;
3947
- const trigger = !serverId && origin?.kind === 'agent'
3948
- ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
3949
- : undefined;
3950
- const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
3951
- ? this.fallbackServerFor(channelId)
3952
- : undefined;
3953
- const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
3954
- const entry = resolved.entry;
5376
+ const entry = this.findChannelEntry(channelId);
3955
5377
  if (!entry) {
3956
- return {
3957
- success: false,
3958
- error: trigger !== undefined
3959
- ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
3960
- : defaultServer !== undefined
3961
- ? `Channel ${channelId}, which this call took as its default, is no longer registered by the server whose message set that default, so nothing was published.`
3962
- : resolved.error!,
3963
- isError: true,
3964
- };
5378
+ if (seen) seen.refusal = 'channel-not-registered';
5379
+ return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true };
3965
5380
  }
3966
-
3967
- const server = this.serverRegistry.getServer(entry.serverId);
3968
- if (!server) {
5381
+ if (isInboundOnly(entry)) {
5382
+ if (seen) seen.refusal = 'inbound-only';
3969
5383
  return {
3970
5384
  success: false,
3971
- error: `Server not found: ${entry.serverId}`,
5385
+ error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`,
3972
5386
  isError: true,
3973
5387
  };
3974
5388
  }
3975
5389
 
3976
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3977
- return {
3978
- success: false,
3979
- error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
3980
- isError: true,
3981
- };
5390
+ // The same refusal, in the same words, as plain speech's (sendRefusal),
5391
+ // whose server state is the reason's: nothing awaits between the two.
5392
+ const server = this.serverRegistry.getServer(entry.serverId);
5393
+ if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
5394
+ if (seen) {
5395
+ const reach = this.serverReach(entry.serverId);
5396
+ seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
5397
+ }
5398
+ return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true };
3982
5399
  }
3983
5400
  const publishParams: ChannelsPublishParams = {
3984
5401
  conversationId: '', // Framework will fill this when wired
@@ -4000,15 +5417,19 @@ export class ChannelRegistry {
4000
5417
  outcome = publishReceiptOutcome(result, destination, Date.now());
4001
5418
  } catch (err) {
4002
5419
  outcome = publishErrorOutcome(err, destination, Date.now());
5420
+ // Refused before anything was written: nothing went out, as its words
5421
+ // say, so it's a refusal of the destination like the checks above.
5422
+ if (seen && err instanceof McplRequestError && err.outcome === 'not-sent') seen.refusal = 'not-sent';
4003
5423
  }
5424
+ if (seen) seen.outcome = outcome;
4004
5425
  if (outcome.status === 'delivered') return { success: true, data: result };
4005
5426
  // The connector's data may say what a multi-part send already posted.
4006
5427
  const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
4007
5428
  return {
4008
5429
  success: false,
4009
5430
  error: (outcome.status === 'failed'
4010
- ? `Publish to ${channelId} failed: ${outcome.reason}. Nothing was posted.`
4011
- : `Publish to ${channelId} wasn't confirmed: ${outcome.reason}. ` +
5431
+ ? `Nothing was posted to channel "${channelId}": ${outcome.reason}.`
5432
+ : `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` +
4012
5433
  'It may or may not have been posted, so sending it again could post it twice.') + detail,
4013
5434
  isError: true,
4014
5435
  };