@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
@@ -15,7 +15,7 @@
15
15
  import { convertBlock } from './convert-block.js';
16
16
  import { isDeepStrictEqual } from 'node:util';
17
17
  import { ensureStateRegistered } from '../module-registry.js';
18
- import { expandCoreTags } from './tags.js';
18
+ import { expandCoreTags, isMarkerTagged } from './tags.js';
19
19
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
20
20
  import { isVisiblyEmptyConversion } from './visible-content.js';
21
21
  import { logDetail, logValue } from './log-value.js';
@@ -28,16 +28,52 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
28
28
  // ============================================================================
29
29
  const TYPING_INTERVAL_MS = 7_000;
30
30
  const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
31
- /** A successful RPC can still fail to establish the latest channel target. */
32
- class ChannelLifecycleConvergenceError extends Error {
33
- }
34
- /** A channels/open answered for a channel other than the one it named. */
35
- class ChannelOpenSubstitutionError extends Error {
36
- }
37
- /** An operation queued for a server's registrations that were removed. */
38
- class ChannelLifecycleCancelledError extends Error {
31
+ /**
32
+ * A channel's lifecycle operation, or a delivery waiting on one, that didn't
33
+ * complete. Its message is a clause in the agent's words, which its traces
34
+ * carry too, so the two never drift apart; `kind` says what the agent can do
35
+ * next, and `reach` where the server stood, for `unreachable`.
36
+ * - `unreachable`: its server isn't up (ServerReach).
37
+ * - `not-permitted`: its server is up, and its grant lacks channels.lifecycle.
38
+ * - `cancelled`: queued for a server whose registrations were removed.
39
+ * - `unlisted`: the server no longer lists the channel.
40
+ * - `superseded`: a later decision about the channel came first.
41
+ * - `converge`: a successful RPC kept failing to establish the latest target.
42
+ * - `substituted`: a channels/open answered for a channel other than the one
43
+ * it named.
44
+ * - `removed`, `not-open`: a delivery's wait (waitForOpenChannel) ended with
45
+ * the channel's registration gone, or with its close standing
46
+ * (closeStands) or its open not confirmed. These are whole reasons for
47
+ * words that didn't go out, said as they are.
48
+ * - `unrecorded`: the decision couldn't be written to the lifecycle log
49
+ * (appendLifecycleEvent), so nothing changed and nothing was sent.
50
+ * - `overtaken`, `put-back-unrecorded`, `selected-again`: an open by speech
51
+ * or a reply that failed (openChannelNow), carrying its own failure's
52
+ * clause. `overtaken`: a decision made during its round-trip, such as the
53
+ * agent's own close or channel_open, replaced the one the open wrote; that
54
+ * decision stands, and nothing is put back. `put-back-unrecorded`: the
55
+ * decision it replaced couldn't be put back on record, so the decision to
56
+ * open stays, the channel stays selected for opening, and reconciliation
57
+ * may open it later. `selected-again`: the pure default it put back is
58
+ * lifted by the channel's current descriptor or the subscription policy,
59
+ * so the channel is selected for opening again; `by` says which.
60
+ */
61
+ class ChannelLifecycleError extends Error {
62
+ kind;
63
+ reach;
64
+ by;
65
+ constructor(message, kind, reach, options) {
66
+ super(message, options && 'cause' in options ? { cause: options.cause } : undefined);
67
+ this.kind = kind;
68
+ this.reach = reach;
69
+ this.name = 'ChannelLifecycleError';
70
+ this.by = options?.by;
71
+ }
39
72
  }
40
73
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
74
+ /** The sources of a decision the agent itself made: channel_open and
75
+ * channel_close, and channel_decline. */
76
+ const AGENT_DECISION_SOURCES = new Set(['agent-tool', 'invitation-declined']);
41
77
  /**
42
78
  * What a channels/open answer says about which channel the server opened:
43
79
  * its `channel.id`, or else the `channelId` of a history item. Each is
@@ -74,6 +110,43 @@ function substitutedChannel(result, requested) {
74
110
  * `resolveProseTargetDurable`.
75
111
  */
76
112
  const CHANNEL_LABEL_HISTORY_LOG_ID = 'mcpl/channel-label-history';
113
+ /** A lifecycle-log item as a desired-state decision, or null when it isn't
114
+ * one this build reads: the one rule the registry's replay
115
+ * (initializeLifecycleStore) and desiredStateInLog read the log by. */
116
+ function desiredStateRecord(item) {
117
+ const event = item;
118
+ if (event?.kind !== 'desired-state' || typeof event.serverId !== 'string' || typeof event.channelId !== 'string')
119
+ return null;
120
+ if (!(event.desired === 'open' || event.desired === 'closed' || (event.desired === 'tuned-out' && event.tuneOut)))
121
+ return null;
122
+ return {
123
+ serverId: event.serverId,
124
+ channelId: event.channelId,
125
+ state: event.desired,
126
+ source: typeof event.source === 'string' ? event.source : 'unknown',
127
+ ...(event.desired === 'tuned-out' ? { tuneOut: event.tuneOut } : {}),
128
+ };
129
+ }
130
+ /**
131
+ * The desired state `store`'s lifecycle log records for `channelId` on
132
+ * `serverId`, for a reader with no registry yet, as a serving boot's
133
+ * deferred-write flush is, before initializeMcpl: the last decision wins, as
134
+ * the registry's replay reads it (getDesiredState answers once there is a
135
+ * registry). undefined when no decision names it.
136
+ */
137
+ export function desiredStateInLog(store, serverId, channelId) {
138
+ ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
139
+ const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
140
+ if (!Array.isArray(raw))
141
+ return undefined;
142
+ let state;
143
+ for (const item of raw) {
144
+ const decision = desiredStateRecord(item);
145
+ if (decision?.serverId === serverId && decision.channelId === channelId)
146
+ state = decision.state;
147
+ }
148
+ return state;
149
+ }
77
150
  /** Extract a DM's identity/classification fields from a descriptor's
78
151
  * metadata, if present — mirrors `resolveProseTarget()`'s own local
79
152
  * `dmMeta()` read (`recipientId`, `recipientName`, `channelType`). Used
@@ -121,6 +194,25 @@ function shallowEqualRecord(a, b) {
121
194
  }
122
195
  return true;
123
196
  }
197
+ /**
198
+ * A channel its server declared inbound-only (SPEC §14.2 `direction`): its
199
+ * messages come in, and the host publishes nothing into it. No publish, from
200
+ * plain speech, `channel_publish` or `publishForAgent`, and no outgoing
201
+ * stream starts there, though one the channel took before the declaration
202
+ * ends there (sendOutgoingChunk). Typing and acknowledgements, which carry
203
+ * no content, still go (SPEC §14.5: "Delivery occurs only via
204
+ * `channels/publish`"). A channel declared `outbound` or `bidirectional`, or
205
+ * with no direction, isn't one.
206
+ */
207
+ function isInboundOnly(entry) {
208
+ return entry.descriptor.direction === 'inbound';
209
+ }
210
+ /** Why the host sends nothing into an inbound-only channel, as the resident
211
+ * reads it in a send-failure marker or a tool error. */
212
+ function inboundOnlyReason(entry) {
213
+ return (`inbound-only: server "${entry.serverId}" declared this channel's direction "inbound", ` +
214
+ 'so the host publishes nothing into it; to speak there, use that server\'s own tools');
215
+ }
124
216
  function sameChannelTarget(a, b) {
125
217
  return a.type === b.type && isDeepStrictEqual(a.address, b.address);
126
218
  }
@@ -140,6 +232,30 @@ export function journalToolDescription(canSkipReply) {
140
232
  'why, notes for later. It does not end your turn and does not affect where ordinary ' +
141
233
  'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
142
234
  }
235
+ /**
236
+ * channel_open's and channel_close's descriptions for an agent whose window doesn't hold a channel's traffic: an
237
+ * ephemeral run, whose store is its own unless it's where channel messages are stored (on a host with no residents,
238
+ * the first registered agent), and on a host that routes conversations every agent but a fork, since a channel's
239
+ * messages go to its fork or, unrouted, nowhere. The descriptions in CHANNEL_TOOL_DEFINITIONS, written for the agents
240
+ * that read a channel, promised such an agent what it never gets: "Messages that address you still reach you", and
241
+ * history "preceding the message that invited you" when nothing invites it. Given per agent (AgentFramework's
242
+ * getToolsForAgent), so a holder's cached tool list doesn't change.
243
+ *
244
+ * Both say the call is for every agent: the decision is recorded per server and channel (setDesiredState), so a run's
245
+ * close closes the channel for the agents whose windows hold it, which "its messages don't come to you" alone could
246
+ * read as costing nothing. They claim only what holds on both lanes: a push that names a channel is stored in the
247
+ * residents' shared slot whether or not the channel is open (handleMcplPushEvent), so on a routed host the residents
248
+ * get the channel's pushes, and a server may push while a channel is closed and send channels/incoming once it's
249
+ * open. So opening brings the caller none of the channel's messages, and closing stops none reaching it. The history
250
+ * channel_open asks for comes back in its result (handleToolOpen), so that clause is true for anyone.
251
+ */
252
+ export const NON_HOLDER_CHANNEL_DESCRIPTIONS = {
253
+ channel_open: 'Open a channel for every agent on this host. Opening it brings you none of its messages; for the agents they do ' +
254
+ 'come to, its server decides what it delivers. Optionally request some of its history, which comes back to you in ' +
255
+ 'this call.',
256
+ channel_close: 'Close a channel for every agent on this host. Closing it stops none of its messages reaching you; for the agents ' +
257
+ 'they do come to, its server decides what closing it stops.',
258
+ };
143
259
  const CHANNEL_TOOL_DEFINITIONS = [
144
260
  {
145
261
  name: 'channel_list',
@@ -201,11 +317,11 @@ const CHANNEL_TOOL_DEFINITIONS = [
201
317
  },
202
318
  {
203
319
  name: 'channel_publish',
204
- 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.',
320
+ 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.",
205
321
  inputSchema: {
206
322
  type: 'object',
207
323
  properties: {
208
- 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)' },
324
+ 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)" },
209
325
  content: { type: 'string', description: 'Text content to publish' },
210
326
  text: { type: 'string', description: 'Alias for content' },
211
327
  },
@@ -275,7 +391,8 @@ const CHANNEL_TOOL_DEFINITIONS = [
275
391
  // Constructor Options
276
392
  // ============================================================================
277
393
  /** Bound connector values for notices, logs and an outcome's reason. A
278
- * PublishOutcome keeps the connector's structured data (`detail`) whole. */
394
+ * PublishOutcome keeps the connector's structured data (`detail`) as the
395
+ * connection gives it, its strings redacted (redactedData). */
279
396
  export function connectorText(value) {
280
397
  let text;
281
398
  try {
@@ -288,6 +405,147 @@ export function connectorText(value) {
288
405
  const suffix = '… [truncated]';
289
406
  return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
290
407
  }
408
+ /** One key per destination, its server and channel: unlike joining the two
409
+ * with a separator, no two destinations share one, whatever their ids hold. */
410
+ export function destinationKey(to) {
411
+ return JSON.stringify([to.serverId, to.channelId]);
412
+ }
413
+ /**
414
+ * A failed request's words, bounded, for a reason that a caller composes.
415
+ * This host's own words, for a request that wasn't sent or wasn't answered
416
+ * or for an error of its own, such as its store's, are sentences, and every
417
+ * composition of a reason adds its own ending, so one final period comes
418
+ * off them. A server's error answer keeps its words as they came.
419
+ */
420
+ function requestErrorText(err) {
421
+ const text = connectorText(err instanceof Error ? err.message : String(err));
422
+ return err instanceof McplRequestError && err.outcome === 'error-response' ? text : text.replace(/(?<!\.)\.$/, '');
423
+ }
424
+ /** A server that isn't up (ServerReach), as a clause the agent reads. */
425
+ function unreachableClause(serverId, reach) {
426
+ switch (reach) {
427
+ case 'gone': return `the "${serverId}" server isn't connected, and this host won't reconnect to it on its own`;
428
+ case 'connecting': return `this host is connecting to the "${serverId}" server`;
429
+ case 'down': return `the "${serverId}" server isn't connected, and this host is reconnecting to it`;
430
+ }
431
+ }
432
+ /** What helps when a server isn't up (ServerReach), as the end of the
433
+ * clause that says so. */
434
+ function unreachableRemedy(reach) {
435
+ switch (reach) {
436
+ case 'gone': return '; if you need it, ask whoever runs this host';
437
+ case 'connecting': return ', so try again in a moment';
438
+ case 'down': return ', so try again later';
439
+ }
440
+ }
441
+ /** Whether an entry's descriptor carries the key a push's placeholder has
442
+ * (ensureChannelRegistered's lazyRegistered metadata), as main reads it for
443
+ * whether the server has declared the channel: a registration or a
444
+ * channels/changed update brings the server's own descriptor, which
445
+ * doesn't normally carry it. The traffic rule doesn't read it, since a
446
+ * server's descriptor can (untargetedPushEntries). */
447
+ function pushPlaceholder(entry) {
448
+ return entry.descriptor.metadata?.lazyRegistered === true;
449
+ }
450
+ /** When reconciling applies a recorded decision: at the server's next
451
+ * registration of the channel, where this host directs its channels'
452
+ * lifecycle then (reconcileChannels, §14.1). A channel made from a push
453
+ * may never be registered at all, so for one the text says "if". */
454
+ function laterClause(entry) {
455
+ return pushPlaceholder(entry)
456
+ ? `if the "${entry.serverId}" server registers this channel at a time this host may open and close its channels`
457
+ : `when the "${entry.serverId}" server next registers this channel, if this host may open and close its channels then`;
458
+ }
459
+ /** A channel decision the lifecycle log refused to append, as on a full
460
+ * disk: nothing changed (appendLifecycleEvent). */
461
+ function unrecordedDecision(err) {
462
+ return new ChannelLifecycleError(`this host couldn't record the decision: ${requestErrorText(err)}`, 'unrecorded');
463
+ }
464
+ /** A failure's clause, then more said of the same failure. A server's own
465
+ * words keep their final stop (requestErrorText), so after them the rest
466
+ * is a sentence of its own. */
467
+ function afterClause(clause, more) {
468
+ return /[.!?]$/.test(clause) ? `${clause} ${more.charAt(0).toUpperCase()}${more.slice(1)}` : `${clause}; ${more}`;
469
+ }
470
+ /** A lifecycle failure as a clause: its own words, or its request's. */
471
+ function lifecycleClause(err) {
472
+ return err instanceof ChannelLifecycleError ? err.message : requestErrorText(err);
473
+ }
474
+ /** Why words didn't go out when their channel's registration went away
475
+ * first, at a delivery's wait or at its last check. */
476
+ const CHANNEL_REMOVED_CLAUSE = 'the channel was removed before these words went out';
477
+ /** A tune-out's open answered for a tune-out that ended, or that a later
478
+ * decision or a later tune-out replaced, while the open was out: by
479
+ * openForTuneOut, and by a repeated tune_out whose tune-out isn't the one it
480
+ * read before it waited. */
481
+ export const TUNE_OUT_REPLACED = "The tune-out was recorded, but it isn't in effect any more: it ended, or a later " +
482
+ 'decision about the channel or a later tune-out replaced it, while its transport was being opened.';
483
+ /**
484
+ * Why words a send opened their channel for didn't go out. The delivery's
485
+ * wait after the open says it whole (`removed`, `not-open`). The open's own
486
+ * failure is said as one, with what helps where there's a step to take. It
487
+ * names no state of the channel's transport: one decided closed can still
488
+ * be open, after a close that failed.
489
+ */
490
+ function openFailedReason(err) {
491
+ if (err instanceof ChannelLifecycleError && (err.kind === 'removed' || err.kind === 'not-open'))
492
+ return err.message;
493
+ return `opening the channel for these words didn't complete: ${lifecycleClause(err)}${refusalRemedy(err)}`;
494
+ }
495
+ /** What helps after a lifecycle failure, as the end of the clause that says
496
+ * it: the server state's remedy, whom to ask for a grant, or another try
497
+ * where the channel's target kept changing or the decision couldn't be
498
+ * recorded. Nothing for any other failure. */
499
+ function refusalRemedy(err) {
500
+ if (!(err instanceof ChannelLifecycleError))
501
+ return '';
502
+ if (err.kind === 'unreachable' && err.reach)
503
+ return unreachableRemedy(err.reach);
504
+ if (err.kind === 'not-permitted')
505
+ return '; if you need it, ask whoever runs this host';
506
+ if (err.kind === 'converge')
507
+ return ', so try again';
508
+ if (err.kind === 'unrecorded')
509
+ return ', so try again; if it fails the same way, ask whoever runs this host';
510
+ return '';
511
+ }
512
+ /** Why an open or a close doesn't stand when a later decision about the
513
+ * channel came first (`now`, its desired state since). */
514
+ function supersededClause(action, now) {
515
+ if (now === 'tuned-out')
516
+ return 'it was tuned out meanwhile';
517
+ return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
518
+ }
519
+ /** What ends a tune-out, for a channel tool's answer about a tuned-out
520
+ * channel (`channel` as the answer shows it, quoted): its cancel, with the
521
+ * backlog it delivers, and, for a close, that channel_close closes the
522
+ * channel once the cancel has ended the tune-out. */
523
+ function tunedOutPointer(tool, channel) {
524
+ return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
525
+ 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
526
+ }
527
+ /** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
528
+ * the tune-out, with the backlog it delivers. As an error result the agent
529
+ * reads the pointer itself; `data` names the refusal for an in-process
530
+ * caller. */
531
+ function tunedOutRefusal(tool, channelId) {
532
+ return {
533
+ success: false,
534
+ isError: true,
535
+ error: `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
536
+ tunedOutPointer(tool, `"${channelId}"`),
537
+ data: { refusal: 'tuned-out', channelId },
538
+ };
539
+ }
540
+ /** A clause as the start of a sentence. */
541
+ function sentenceStart(clause) {
542
+ return clause.charAt(0).toUpperCase() + clause.slice(1);
543
+ }
544
+ /** Words as a sentence's end: a stop, unless they end in one already, as a
545
+ * server's own words may. */
546
+ function withStop(text) {
547
+ return /[.!?]$/.test(text) ? text : `${text}.`;
548
+ }
291
549
  /**
292
550
  * What a channels/publish request that threw establishes. Only a request
293
551
  * that never left the host proves nothing was posted. An error response may
@@ -297,12 +555,7 @@ export function connectorText(value) {
297
555
  * a failed request this way.
298
556
  */
299
557
  function publishErrorOutcome(err, destination, at) {
300
- // This host's own words for a request that wasn't sent or wasn't answered
301
- // are sentences, and every composition of the reason adds its own ending,
302
- // so one final period comes off them. A server's error answer keeps its
303
- // words as they came.
304
- const text = connectorText(err instanceof Error ? err.message : String(err));
305
- const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
558
+ const message = requestErrorText(err);
306
559
  const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
307
560
  const detail = err instanceof McplRequestError ? err.data : undefined;
308
561
  return {
@@ -330,16 +583,17 @@ function publishReceiptOutcome(result, destination, at) {
330
583
  }
331
584
  if (receipt.delivered === false) {
332
585
  if (receipt.messageId === undefined || receipt.messageId === null) {
333
- return { status: 'failed', destination, reason: `server "${destination.serverId}" reported delivered:false`, at };
586
+ return { status: 'failed', destination, reason: `the "${destination.serverId}" server said it didn't post the message`, at };
334
587
  }
335
588
  return {
336
589
  status: 'unknown',
337
590
  destination,
338
591
  ...(messageId ? { messageId } : {}),
339
592
  reason: messageId
340
- ? `server "${destination.serverId}" reported delivered:false but named posted message ${connectorText(messageId)} (delivery uncertain)`
341
- : `server "${destination.serverId}" reported delivered:false beside a malformed message id ` +
342
- `${connectorText(receipt.messageId)} (contradictory receipt; delivery uncertain)`,
593
+ ? `the "${destination.serverId}" server said it didn't post the message, yet named message ` +
594
+ `${connectorText(messageId)} as posted (delivery uncertain)`
595
+ : `the "${destination.serverId}" server said it didn't post the message, along with a message id that ` +
596
+ `isn't valid (${connectorText(receipt.messageId)}) (delivery uncertain)`,
343
597
  at,
344
598
  };
345
599
  }
@@ -347,7 +601,7 @@ function publishReceiptOutcome(result, destination, at) {
347
601
  status: 'unknown',
348
602
  destination,
349
603
  ...(messageId ? { messageId } : {}),
350
- reason: `server "${destination.serverId}" returned no valid delivery receipt (delivery uncertain)`,
604
+ reason: `the "${destination.serverId}" server's answer didn't say whether it posted the message (delivery uncertain)`,
351
605
  at,
352
606
  };
353
607
  }
@@ -367,9 +621,17 @@ export class ChannelRegistry {
367
621
  homeChannelResolver;
368
622
  activeChannelResolver;
369
623
  takesGlobalFallback;
624
+ serverConnecting;
370
625
  store;
371
626
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
372
627
  channels = new Map();
628
+ /**
629
+ * Each outgoing stream, by `inferenceId`, with the destinations it has
630
+ * started at (destinationKey): the rest of the stream there goes on there,
631
+ * and its `complete` goes only there (sendOutgoingChunk). An entry goes
632
+ * with the stream's `complete` for its destination.
633
+ */
634
+ takenStreams = new Map();
373
635
  /** Holds back repeats of the line for a message refused because its
374
636
  * channel isn't registered (RefusalLines). */
375
637
  unknownChannelLines = new RefusalLines();
@@ -418,11 +680,12 @@ export class ChannelRegistry {
418
680
  * sent at all.
419
681
  */
420
682
  dmClassified = new Map();
421
- /** Most recent incoming channel ID — used for speech routing / default publish. */
683
+ /** The channel of the most recent incoming message that isn't a reaction,
684
+ * reaction removal, edit or deletion marker, apart from an edit the
685
+ * coalescer delivers on acceptance as the agent's first view: used for
686
+ * speech routing and the default publish. */
422
687
  defaultPublishChannel = null;
423
- /** The server whose accepted message set defaultPublishChannel. */
424
- defaultPublishServer = null;
425
- /** Most recent incoming message metadata, used for buildChannelContext. */
688
+ /** The same message's id and thread, used for buildChannelContext. */
426
689
  defaultPublishMessageId = null;
427
690
  defaultPublishThreadId = undefined;
428
691
  /** Per-channel typing indicator timers. */
@@ -449,9 +712,31 @@ export class ChannelRegistry {
449
712
  // target before confirming anything. Unlike the tail, which settles a few
450
713
  // microtasks after its operation's last check, this drops at that check.
451
714
  lifecycleOutstanding = new Map();
715
+ // The removal count each channel's newest outstanding operation was queued
716
+ // under. Where it's older than its server's, every operation still
717
+ // outstanding for the channel cancels when it would run.
718
+ lifecycleQueuedRemovals = new Map();
452
719
  // Losing an open receipt does not confirm closure. Keep that distinction
453
720
  // private while preserving the public open:boolean availability projection.
454
721
  unconfirmedChannelTargets = new WeakSet();
722
+ // Marked entries a close failed for since a receipt or traffic last
723
+ // confirmed them, cleared where the mark is: a failed request's outcome is
724
+ // unknown, so such a close may have closed the channel, where an open,
725
+ // failed or not, can't have. Only a tune-out reads it (openForTuneOut),
726
+ // since its success says the channel's traffic will arrive.
727
+ failedCloseTargets = new WeakSet();
728
+ // Each tune-out's open in flight, by channel and epoch, so tune_out
729
+ // repeated meanwhile waits for that open's answer rather than send another.
730
+ tuneOutOpens = new Map();
731
+ // Entries ensureChannelRegistered made from a push, until a channels/changed
732
+ // update gives one a target (handleChanged's retarget). A registration makes
733
+ // a new entry, which isn't here. Kept by the host, not read from the
734
+ // descriptor, whose metadata a server's own registration supplies.
735
+ untargetedPushEntries = new WeakSet();
736
+ // How many times each server's transport has closed (transportClosed).
737
+ // handleIncoming reads it as a batch arrives, so traffic admitted after a
738
+ // close it arrived before confirms nothing.
739
+ transportCloses = new Map();
455
740
  invalidateTransportConfirmation(entry) {
456
741
  entry.open = false;
457
742
  this.unconfirmedChannelTargets.add(entry);
@@ -462,12 +747,201 @@ export class ChannelRegistry {
462
747
  directsLifecycle(serverId) {
463
748
  return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
464
749
  }
465
- /** A registration's entry: nothing about its transport is known until a
466
- * receipt for its target confirms it open or closed. */
750
+ /** Whether a decided close holds words back from a channel: only where
751
+ * the host directs the server's lifecycle. Without that grant the host
752
+ * can't close the channel, and words go into a transport that is open,
753
+ * as the open step's engagesDecidedClosed reads it too. */
754
+ closeStands(entry) {
755
+ return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
756
+ this.directsLifecycle(entry.serverId);
757
+ }
758
+ /** Where this server stands for a request this host would make of it
759
+ * (ServerReach). One without a connection's state reads as connected.
760
+ * A closed connection the host is restarting reads as connecting, as
761
+ * one being connected does. */
762
+ serverReach(serverId) {
763
+ const server = this.serverRegistry.getServer(serverId);
764
+ const connecting = this.serverConnecting?.(serverId) ?? false;
765
+ if (!server)
766
+ return connecting ? 'connecting' : 'gone';
767
+ if (server.isConnected === false)
768
+ return connecting ? 'connecting' : server.willReconnect ? 'down' : 'gone';
769
+ return connecting ? 'connecting' : 'up';
770
+ }
771
+ /**
772
+ * Why this host won't open or close this server's channels, for a caller
773
+ * that found its grant without channels.lifecycle (§14.1), which decides:
774
+ * the server's state says why. It isn't up, or it's up without that grant.
775
+ * Neither the grant's name, which is the operator's, nor an empty grant's
776
+ * refusal, which needn't be the server's, reaches the agent.
777
+ */
778
+ lifecycleRefusal(serverId) {
779
+ const reach = this.serverReach(serverId);
780
+ if (reach !== 'up')
781
+ return new ChannelLifecycleError(unreachableClause(serverId, reach), 'unreachable', reach);
782
+ return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
783
+ }
784
+ /**
785
+ * Why a send to this server's channels is refused before anything goes
786
+ * out, as one clause with what works from there, for a caller that found
787
+ * no server or a grant without channels.publish (§14.1), which decides:
788
+ * the server's state says why. Plain speech gives it as its reason, and
789
+ * channel_publish and speak_in_channel as theirs, so one refusal has one
790
+ * wording. `checked` is the connection whose grant refused, where that
791
+ * was read before the words waited: one the registry has replaced since
792
+ * says nothing about the server that stands now.
793
+ */
794
+ sendRefusal(serverId, checked) {
795
+ const now = this.serverRegistry.getServer(serverId);
796
+ const reach = this.serverReach(serverId);
797
+ // A replacement that would take the words is worth another try; one
798
+ // that wouldn't is worded as it stands.
799
+ if (checked && now && now !== checked && reach === 'up' && CapabilityGrant.of(now).has('channels.publish')) {
800
+ return `the "${serverId}" server's connection was replaced while these words waited, so try again`;
801
+ }
802
+ if (reach !== 'up')
803
+ return unreachableClause(serverId, reach) + unreachableRemedy(reach);
804
+ return `this host isn't permitted to post to the "${serverId}" server's channels, and trying again won't change that; ` +
805
+ 'if you need it, ask whoever runs this host';
806
+ }
807
+ /**
808
+ * What a failed channel_open or channel_close tells the agent. Its
809
+ * decision was recorded before the server was asked, and reconciling
810
+ * applies it when the server next registers the channel (for a push's
811
+ * placeholder, if it ever does), if this host directs their lifecycle then
812
+ * (laterClause). A decision the log refused changed nothing, so that comes
813
+ * first. A channel no longer registered
814
+ * can't be reached by any channel tool, so its text names none. A later
815
+ * decision that came first is what stands, so that comes next. Then the
816
+ * server's state: one that isn't up gets the change when it's back, though
817
+ * not a backscroll's history. For the request's own failure, its state is
818
+ * read now, since a request out when the server dropped may have taken
819
+ * effect, and reconciling settles it either way. A decision already in
820
+ * effect, with no other work for the channel under way, says so, as one
821
+ * whose backscroll alone failed is. Then the failure's kind, since each
822
+ * has its own next step (ChannelLifecycleError). A request's own failure
823
+ * is in the connection's words.
824
+ */
825
+ lifecycleFailureText(action, entry, err, opts) {
826
+ const channel = `"${entry.descriptor.id}"`;
827
+ const tool = action === 'open' ? 'channel_open' : 'channel_close';
828
+ const done = action === 'open' ? 'opened' : 'closed';
829
+ const later = laterClause(entry);
830
+ // A decision never recorded is no decision a later one overtook, and
831
+ // nothing about the channel changed, whatever its transport's state.
832
+ if (err instanceof ChannelLifecycleError && err.kind === 'unrecorded') {
833
+ return `Nothing changed for channel ${channel}: ${err.message}, so nothing was sent to the server. ` +
834
+ `Calling ${tool} again tries again; if it fails the same way, ask whoever runs this host.`;
835
+ }
836
+ const failure = err instanceof ChannelLifecycleError ? err : undefined;
837
+ const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
838
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
839
+ if (!current) {
840
+ // Its registration may have gone at the server's word or at this
841
+ // host's (removeServer). Only a failure that says which (cancelled,
842
+ // unlisted) names it; otherwise the server's state where it isn't up,
843
+ // and a request's own words where it failed with them. Another
844
+ // failure's words are about a request whose channel is gone.
845
+ const reach = this.serverReach(entry.serverId);
846
+ const why = failure?.kind === 'cancelled' || failure?.kind === 'unlisted' ? `: ${failure.message}`
847
+ : reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : '';
848
+ // A tune-out is in effect already: only its transport waits for the
849
+ // server's next registration.
850
+ const stays = now === 'tuned-out' ? 'It was tuned out meanwhile, and that stays recorded.'
851
+ : `Its ${now === 'closed' ? 'close' : 'open'} stays recorded, and happens ${later}.`;
852
+ return `Channel ${channel} isn't registered here any more${why}. ${stays}` +
853
+ (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
854
+ (failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
855
+ }
856
+ // A tune-out sends the channel's messages to the subconscious, and
857
+ // channel_open refuses a tuned-out channel.
858
+ if (action === 'open' ? now !== 'open' : now !== 'closed') {
859
+ if (action === 'open' && now === 'tuned-out') {
860
+ return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
861
+ `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
862
+ }
863
+ // channel_close refuses a tuned-out channel too, so calling it again
864
+ // isn't the way: the tune-out's cancel is, and it delivers the backlog.
865
+ if (action === 'close' && now === 'tuned-out') {
866
+ return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
867
+ `change that. ${tunedOutPointer('channel_close', channel)}`;
868
+ }
869
+ // The later decision's own work settles the transport, and its call
870
+ // says what became of it, so this says only that this call's change
871
+ // doesn't stand.
872
+ return `Channel ${channel}: ${supersededClause(action, now)}, so this ${action} doesn't stand. Call ${tool} again to ` +
873
+ `${action} it.`;
874
+ }
875
+ const reach = failure ? (failure.kind === 'unreachable' ? failure.reach : undefined) : this.serverReach(entry.serverId);
876
+ if (reach !== undefined && reach !== 'up') {
877
+ // Reconciling opens a channel, but asks for no history.
878
+ const history = action === 'open' && opts?.history;
879
+ return `${sentenceStart(unreachableClause(entry.serverId, reach))}, so channel ${channel} may not be ` +
880
+ `${action === 'open' ? 'open' : 'closed'} there yet. The ${action} is recorded and happens ${later}` +
881
+ (history
882
+ ? ', but the history you asked for doesn\'t come with it: calling channel_open with backscroll once the server is ' +
883
+ 'connected asks for it.'
884
+ : `, so there's no need to call ${tool} again.`) +
885
+ (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
886
+ (action === 'open' ? ' channel_close withdraws it.' : '');
887
+ }
888
+ const said = lifecycleClause(err);
889
+ // Other work for the channel still under way (lifecycleOutstanding,
890
+ // released as each operation's last look ends) may change its state.
891
+ const settled = !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
892
+ // The registry's own reading, as its checks make it (channel_open's
893
+ // already-open return, the decline's refusal, delivery): a request that
894
+ // failed leaves the open flag as it was, and the unconfirmed mark counts
895
+ // only while the channel isn't open (runLifecycleOperation).
896
+ const inEffect = current !== undefined && settled && (action === 'open'
897
+ ? current.open
898
+ : !current.open && !this.unconfirmedChannelTargets.has(current));
899
+ if (inEffect) {
900
+ // What was asked already holds. Without the grant, nothing more can be
901
+ // asked of the server, so calling again won't change anything.
902
+ const state = `Channel ${channel} is ${action === 'open' ? 'open' : 'closed'}`;
903
+ if (action === 'open' && opts?.history) {
904
+ return failure?.kind === 'not-permitted'
905
+ ? `${state}, but the history you asked for didn't come: ${failure.message}, so calling channel_open again ` +
906
+ "won't change that. If you need it, ask whoever runs this host."
907
+ : withStop(`${state}, but the history you asked for didn't come: ${said}`) +
908
+ ' Calling channel_open again with backscroll tries once more.';
909
+ }
910
+ return failure?.kind === 'not-permitted'
911
+ ? `${state}. ${sentenceStart(failure.message)}, so it sent the server nothing.`
912
+ : withStop(`${state}, though this call didn't complete: ${said}`);
913
+ }
914
+ switch (failure?.kind) {
915
+ case 'not-permitted':
916
+ // The decision was recorded before the refusal, and reconciling
917
+ // applies it at a registration whose grant allows it.
918
+ return `${sentenceStart(failure.message)}, so this ${action} of channel ${channel} didn't go through, and calling ${tool} ` +
919
+ `again won't change that. The ${action} stays recorded, and happens ${later}. If you need it, ask whoever runs this host.`;
920
+ case 'cancelled':
921
+ case 'unlisted':
922
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and happens ${later}.`;
923
+ case 'converge':
924
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and calling ${tool} again ` +
925
+ 'tries once more.';
926
+ case 'substituted':
927
+ // Only an open is answered for another channel.
928
+ return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and this host tries it ` +
929
+ `again ${later}; calling ${tool} again is likely to get the same answer. channel_close withdraws it.`;
930
+ }
931
+ return `${action === 'open' ? 'Opening' : 'Closing'} channel ${channel} didn't complete; the ${action} stays recorded, and this ` +
932
+ `host tries it again ${later}, or now if you call ${tool} again. ${withStop(said)}`;
933
+ }
934
+ /** A registration's entry, or a push's (ensureChannelRegistered): nothing
935
+ * about its transport is known until a receipt for its target confirms it
936
+ * open or closed, or the channel's traffic shows it open, which it does
937
+ * where the host doesn't direct the lifecycle, and for a push's entry
938
+ * decided open or tuned out that no update has given a target
939
+ * (handleIncoming). */
467
940
  registerEntry(key, serverId, descriptor) {
468
941
  const entry = { serverId, descriptor, open: false };
469
942
  this.channels.set(key, entry);
470
943
  this.unconfirmedChannelTargets.add(entry);
944
+ return entry;
471
945
  }
472
946
  hasPendingLifecycle(entry) {
473
947
  return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
@@ -500,6 +974,7 @@ export class ChannelRegistry {
500
974
  this.homeChannelResolver = options?.homeChannelResolver;
501
975
  this.activeChannelResolver = options?.activeChannelResolver;
502
976
  this.takesGlobalFallback = options?.takesGlobalFallback;
977
+ this.serverConnecting = options?.serverConnecting;
503
978
  this.store = options?.store;
504
979
  this.initializeLifecycleStore();
505
980
  this.initializeLabelHistoryStore();
@@ -536,7 +1011,7 @@ export class ChannelRegistry {
536
1011
  }
537
1012
  const key = `${serverId}:${channel.id}`;
538
1013
  this.registerEntry(key, serverId, channel);
539
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1014
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
540
1015
  registeredIds.push(channel.id);
541
1016
  results.push({ id: channel.id, accepted: true });
542
1017
  }
@@ -602,18 +1077,21 @@ export class ChannelRegistry {
602
1077
  // channel decided open (or tuned out) is opened at its new target.
603
1078
  if (!sameChannelTarget(existing.descriptor, channel)) {
604
1079
  this.invalidateTransportConfirmation(existing);
1080
+ this.untargetedPushEntries.delete(existing);
605
1081
  // An operation queued or running for this channel reads its target
606
1082
  // again before it confirms anything, and after a failed RPC, so it
607
- // opens the new one. With none, a channel decided open (or tuned
608
- // out) is reconciled here.
1083
+ // opens the new one, unless it was queued before its server's
1084
+ // registrations were last removed: that one cancels when it would
1085
+ // run. So a channel decided open (or tuned out) is reconciled here
1086
+ // unless work queued since then is outstanding.
609
1087
  const desired = this.getDesiredState(serverId, channel.id);
610
1088
  if ((desired === 'open' || desired === 'tuned-out') &&
611
- !this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
1089
+ !this.outstandingWorkCarriesOut(serverId, this.lifecycleKey(serverId, channel.id))) {
612
1090
  retargeted.push(channel);
613
1091
  }
614
1092
  }
615
1093
  existing.descriptor = channel;
616
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1094
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
617
1095
  addedResults.push({ id: channel.id, accepted: true });
618
1096
  }
619
1097
  }
@@ -629,7 +1107,7 @@ export class ChannelRegistry {
629
1107
  }
630
1108
  const key = `${serverId}:${channel.id}`;
631
1109
  this.registerEntry(key, serverId, channel);
632
- this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1110
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
633
1111
  addedResults.push({ id: channel.id, accepted: true });
634
1112
  accepted.push(channel);
635
1113
  }
@@ -691,6 +1169,9 @@ export class ChannelRegistry {
691
1169
  */
692
1170
  async handleIncoming(serverId, params, responder) {
693
1171
  const results = [];
1172
+ // The batch came on the transport open now: a close after this point
1173
+ // (transportClosed) leaves its traffic nothing to confirm.
1174
+ const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
694
1175
  for (const message of params.messages) {
695
1176
  if (!message || typeof message.channelId !== 'string' || !message.channelId
696
1177
  || typeof message.messageId !== 'string' || !message.messageId) {
@@ -716,17 +1197,6 @@ export class ChannelRegistry {
716
1197
  // conversion — decoding an unregistered sender's payload (including
717
1198
  // inline base64) is allocation and parsing work done for a message
718
1199
  // that must be rejected (Sol, PR #79 re-review blocker 2).
719
- // Lazy-register the channel if we've never seen it. A channel can deliver
720
- // an incoming message before its channels/register (boot enumeration) or
721
- // channels/changed (post-boot create / View-permission grant) round-trip
722
- // lands — or the registration event can be missed entirely (e.g. the bot
723
- // gains visibility in a way that fires neither `channelCreate` nor a
724
- // View-permission transition). Without a registry entry, routeSpeech()
725
- // can't resolve this channel as an outbound locus and the agent's reply
726
- // is silently dropped, even though this very message proves the channel
727
- // is reachable. The inbound message carries enough to make it publishable,
728
- // so register it here; a later authoritative channels/register or
729
- // channels/changed will overwrite this descriptor with the richer one.
730
1200
  // §14.5: channels/incoming is validated against the ACTUALLY
731
1201
  // REGISTERED channel. An unknown claimed channelId is rejected
732
1202
  // per-message with an itemized result — never minted by its first
@@ -734,8 +1204,8 @@ export class ChannelRegistry {
734
1204
  // self-attest a channel identity without ever passing
735
1205
  // channels/register authorization). A server whose channel genuinely
736
1206
  // exists registers it first; discord's DM case goes through
737
- // ensureChannelRegistered on push/event, which creates a CLOSED
738
- // routable entry rather than an open self-attested one.
1207
+ // ensureChannelRegistered on push/event, which creates a routable
1208
+ // entry that isn't open, rather than an open self-attested one.
739
1209
  const incomingKey = `${serverId}:${message.channelId}`;
740
1210
  if (!this.channels.has(incomingKey)) {
741
1211
  // Loud, coalesced or not, like every other refusal of a message, but
@@ -811,33 +1281,50 @@ export class ChannelRegistry {
811
1281
  // A coalesced item is "accepted" only once the coalescer admits it, so
812
1282
  // for those this runs after the hook (below).
813
1283
  const incomingEntry = this.channels.get(incomingKey);
814
- const markAccepted = () => {
815
- this.defaultPublishChannel = message.channelId;
816
- this.defaultPublishServer = serverId;
817
- this.defaultPublishMessageId = message.messageId;
818
- this.defaultPublishThreadId = message.threadId;
819
- // Where the host directs this server's lifecycle, only a receipt for
820
- // the current target confirms it: traffic shows the transport open
821
- // only for a confirmed target, and only for a channel still decided
822
- // open or tuned out. After a retarget, traffic for this id may still
823
- // come from the old subscription, and a close receipt that landed
824
- // first (or during the coalescer's await) stands. Where the host
825
- // doesn't direct it, the server opens its own channels and its
826
- // traffic is the only evidence the host gets, so it marks the
1284
+ const markAccepted = (firstView = false) => {
1285
+ // Non-conversational markers (a reaction added or removed, an edit, a
1286
+ // deletion) are ABOUT a message, not one the agent replies to: they
1287
+ // must not retarget the default publish locus or become
1288
+ // `context.incoming`'s reply anchor. An edit the coalescer delivered
1289
+ // as the agent's first view counts as the message it replaced.
1290
+ if (!isMarkerTagged(message.tags, firstView)) {
1291
+ this.defaultPublishChannel = message.channelId;
1292
+ this.defaultPublishMessageId = message.messageId;
1293
+ this.defaultPublishThreadId = message.threadId;
1294
+ }
1295
+ // Where the host directs this server's lifecycle, traffic shows the
1296
+ // transport open only for a channel still decided open or tuned out,
1297
+ // and, but for one exception, only for a confirmed target: after a
1298
+ // retarget, traffic for this id may still come from the old
1299
+ // subscription, and a close receipt that landed first (or during the
1300
+ // coalescer's await) stands. The exception is an entry made from a
1301
+ // push that no update has given a target (untargetedPushEntries):
1302
+ // there's no earlier target its traffic could come from, so for the
1303
+ // receipt rule's own reason its traffic shows the transport open. It
1304
+ // stops being one when a registration replaces it (registerEntry) or
1305
+ // a channels/changed update gives it a target (handleChanged's
1306
+ // retarget), and the receipt rule holds from then. Where the host
1307
+ // doesn't direct the lifecycle, the server opens its own channels and
1308
+ // its traffic is the only evidence the host gets, so it marks the
827
1309
  // channel open, as it always has. The entry is the one the message
828
1310
  // was accepted for: one a re-registration put in its place meanwhile
829
- // waits for its own evidence. Durable desired state still changes
830
- // exclusively through lifecycle operations.
1311
+ // waits for its own evidence. Traffic from a transport that has closed
1312
+ // since its batch arrived (here after the coalescer's await, or behind
1313
+ // a sibling's) is no evidence either: the close withdrew what that
1314
+ // transport confirmed, and its connection's grant is empty until the
1315
+ // next one's. Durable desired state still changes exclusively through
1316
+ // lifecycle operations. A marker is evidence like any other message.
1317
+ if ((this.transportCloses.get(serverId) ?? 0) !== closesAtArrival)
1318
+ return;
831
1319
  if (this.directsLifecycle(serverId)) {
832
- if (this.unconfirmedChannelTargets.has(incomingEntry))
1320
+ if (this.unconfirmedChannelTargets.has(incomingEntry) && !this.untargetedPushEntries.has(incomingEntry))
833
1321
  return;
834
1322
  const desired = this.getDesiredState(serverId, message.channelId);
835
1323
  if (desired !== 'open' && desired !== 'tuned-out')
836
1324
  return;
837
1325
  }
838
- else {
839
- this.unconfirmedChannelTargets.delete(incomingEntry);
840
- }
1326
+ this.unconfirmedChannelTargets.delete(incomingEntry);
1327
+ this.failedCloseTargets.delete(incomingEntry);
841
1328
  incomingEntry.open = true;
842
1329
  };
843
1330
  if (!coalesced)
@@ -884,9 +1371,9 @@ export class ChannelRegistry {
884
1371
  // the coalescer, which replaces, appends or withdraws. Malformed
885
1372
  // `coalesce` is a per-message failure; siblings are unaffected.
886
1373
  try {
887
- const result = await this.handleCoalescedIncoming(serverId, message, event);
1374
+ const { firstView, ...result } = await this.handleCoalescedIncoming(serverId, message, event);
888
1375
  if (result.accepted)
889
- markAccepted();
1376
+ markAccepted(firstView === true);
890
1377
  results.push(result);
891
1378
  }
892
1379
  catch (error) {
@@ -932,25 +1419,25 @@ export class ChannelRegistry {
932
1419
  * locus. A push event from a closed channel must never mutate lifecycle
933
1420
  * state: direct-address events are intentionally usable without subscribing.
934
1421
  *
935
- * Mirrors the lazy-registration inside handleIncoming(), but for channels
936
- * that only ever arrive as push/events rather than channels/incoming — the
937
- * motivating case is Discord DMs, which discord-mcpl forwards via push/event
938
- * with the channel closed (`channelIsOpen:false`). Such a channel is never
939
- * registered, so routeSpeech() can't resolve it and the agent's reply is
940
- * silently dropped even though the inbound message proves the channel is
941
- * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
942
- * channels/register or channels/changed overwrites this descriptor with the
943
- * richer one.
1422
+ * For channels that only ever arrive as push/events rather than
1423
+ * channels/incoming — the motivating case is Discord DMs, which
1424
+ * discord-mcpl forwards via push/event with the channel closed
1425
+ * (`channelIsOpen:false`). Such a channel is never registered, so
1426
+ * routeSpeech() can't resolve it and the agent's reply is silently dropped
1427
+ * even though the inbound message proves the channel is reachable (item-3
1428
+ * redux, DM sub-case). Its entry starts with its transport unknown, as a
1429
+ * registration's does. Its traffic shows it open, for a channel decided
1430
+ * open or tuned out, until an update gives it a target (handleIncoming).
1431
+ * Idempotent: a later authoritative channels/register or channels/changed
1432
+ * overwrites this descriptor with the richer one.
944
1433
  */
945
1434
  ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
946
- // The pushing server's own entry: another server's entry for the same id
947
- // is that server's, and a push leaves it as it was.
948
- const existing = this.channels.get(`${serverId}:${channelId}`);
1435
+ const existing = this.findChannelEntry(channelId);
949
1436
  if (existing) {
950
1437
  // Backfill identity onto a bare lazy registration: a DM that first
951
1438
  // arrived with no label/recipient info gets a usable name the next
952
1439
  // time a message reveals it (labels must show people, not ids).
953
- if (existing.descriptor.metadata?.lazyRegistered &&
1440
+ if (pushPlaceholder(existing) &&
954
1441
  label && existing.descriptor.label === channelId) {
955
1442
  const metadata = extraMetadata
956
1443
  ? { ...existing.descriptor.metadata, ...extraMetadata }
@@ -959,7 +1446,7 @@ export class ChannelRegistry {
959
1446
  // Recorded before the live descriptor takes it, since this branch's
960
1447
  // guard reads the descriptor: an append that throws leaves the
961
1448
  // placeholder, so the next push tries again.
962
- this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
1449
+ this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
963
1450
  existing.descriptor.label = label;
964
1451
  existing.descriptor.metadata = metadata;
965
1452
  }
@@ -979,19 +1466,20 @@ export class ChannelRegistry {
979
1466
  // label to record. A real label is recorded before the entry exists:
980
1467
  // an append that throws registers nothing, so the next push tries again.
981
1468
  if (label)
982
- this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
983
- const key = `${serverId}:${channelId}`;
984
- this.channels.set(key, {
985
- serverId,
986
- descriptor: {
987
- id: channelId,
988
- type: serverId,
989
- label: label ?? channelId,
990
- direction: 'bidirectional',
991
- metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
992
- },
993
- open: false,
1469
+ this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
1470
+ // Its transport is as unknown as a registration's (registerEntry). After
1471
+ // a restart, a push for a channel no registration has comes here, and
1472
+ // the server may still hold a channel an earlier process opened. Counting
1473
+ // it confirmed closed would answer a close or a decline as already done
1474
+ // and send nothing, even where the server still delivers.
1475
+ const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, {
1476
+ id: channelId,
1477
+ type: serverId,
1478
+ label: label ?? channelId,
1479
+ direction: 'bidirectional',
1480
+ metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
994
1481
  });
1482
+ this.untargetedPushEntries.add(entry);
995
1483
  this.emitTraceFn({
996
1484
  type: 'mcpl:channel-lazy-registered',
997
1485
  serverId,
@@ -1091,7 +1579,10 @@ export class ChannelRegistry {
1091
1579
  // Accessors
1092
1580
  // ==========================================================================
1093
1581
  /**
1094
- * Get the default publish channel ID (most recent incoming channel).
1582
+ * Get the default publish channel ID: the channel of the most recent
1583
+ * incoming message that isn't a reaction, reaction removal, edit or
1584
+ * deletion marker, apart from an edit the coalescer delivers on acceptance
1585
+ * as the agent's first view.
1095
1586
  */
1096
1587
  getDefaultPublishChannel() {
1097
1588
  return this.defaultPublishChannel;
@@ -1109,7 +1600,7 @@ export class ChannelRegistry {
1109
1600
  */
1110
1601
  isDeclaredChannel(serverId, channelId) {
1111
1602
  const entry = this.channels.get(`${serverId}:${channelId}`);
1112
- return !!entry && !entry.descriptor.metadata?.lazyRegistered;
1603
+ return !!entry && !pushPlaceholder(entry);
1113
1604
  }
1114
1605
  getDescriptor(channelId) {
1115
1606
  return this.findChannelEntry(channelId)?.descriptor;
@@ -1120,8 +1611,16 @@ export class ChannelRegistry {
1120
1611
  const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label;
1121
1612
  return typeof label === 'string' && label.length > 0 ? label : undefined;
1122
1613
  }
1123
- isChannelOpen(channelId) {
1124
- return this.findChannelEntry(channelId)?.open === true;
1614
+ /** Whether `channelId`'s transport is open: on `serverId` where the caller
1615
+ * knows the channel's server, and otherwise on the first server found to
1616
+ * register that id (findChannelEntry). The map's key joins the two ids
1617
+ * with a colon, so another server's entry can sit under a key: it has to
1618
+ * be this server's (as in streamingServerAt). */
1619
+ isChannelOpen(channelId, serverId) {
1620
+ if (serverId === undefined)
1621
+ return this.findChannelEntry(channelId)?.open === true;
1622
+ const entry = this.channels.get(`${serverId}:${channelId}`);
1623
+ return entry?.serverId === serverId && entry.open === true;
1125
1624
  }
1126
1625
  getDesiredState(serverId, channelId) {
1127
1626
  return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
@@ -1178,7 +1677,13 @@ export class ChannelRegistry {
1178
1677
  case 'skip_reply':
1179
1678
  return this.handleToolSkipReply(input);
1180
1679
  default:
1181
- return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
1680
+ // Not every agent is given every channel tool, so the refusal points
1681
+ // to the ones this agent has rather than listing them all.
1682
+ return {
1683
+ success: false,
1684
+ error: `There's no channel tool "${toolName}", so nothing ran. Use one of the channel tools you were given.`,
1685
+ isError: true,
1686
+ };
1182
1687
  }
1183
1688
  }
1184
1689
  // ==========================================================================
@@ -1192,8 +1697,9 @@ export class ChannelRegistry {
1192
1697
  buildChannelContext(agentName) {
1193
1698
  const openChannels = this.getOpenChannels();
1194
1699
  // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
1195
- // told the channel its speech will actually land in: a conversation fork's
1196
- // home channel, else this turn's channel (single trunk agent — item-3
1700
+ // told the channel its speech will actually land in (none, when its server
1701
+ // declared it inbound-only, since speech isn't posted there): a conversation
1702
+ // fork's home channel, else this turn's channel (single trunk agent — item-3
1197
1703
  // redux: its locus, else its triggering channel), else the global default
1198
1704
  // (heartbeats). Without this, a fork
1199
1705
  // was advertised the global locus but published somewhere else; and a trunk
@@ -1211,7 +1717,10 @@ export class ChannelRegistry {
1211
1717
  return undefined;
1212
1718
  }
1213
1719
  const context = {};
1214
- // Incoming: the most-recent inbound message (what the agent is replying to).
1720
+ // Incoming: the most recent inbound message that isn't a reaction,
1721
+ // reaction removal, edit or deletion marker, apart from an edit the
1722
+ // coalescer delivers on acceptance as the agent's first view (what the
1723
+ // agent is replying to).
1215
1724
  // Left process-global — per-channel inbound tracking (the right messageId for
1216
1725
  // a fork's own channel) is a separate concern from the outbound routing fix.
1217
1726
  if (fallback && this.defaultPublishMessageId) {
@@ -1221,8 +1730,10 @@ export class ChannelRegistry {
1221
1730
  threadId: this.defaultPublishThreadId,
1222
1731
  };
1223
1732
  }
1224
- // Default outgoing: the resolved outbound locus (home channel for forks).
1225
- if (outgoing) {
1733
+ // Default outgoing: the resolved outbound locus (home channel for forks),
1734
+ // unless its server declared it inbound-only: routeSpeech publishes nothing
1735
+ // there, so it isn't where the agent's speech goes.
1736
+ if (outgoing && !this.inboundOnlyServer(outgoing)) {
1226
1737
  context.defaultOutgoing = {
1227
1738
  channelId: outgoing,
1228
1739
  };
@@ -1243,7 +1754,7 @@ export class ChannelRegistry {
1243
1754
  */
1244
1755
  removeServer(serverId) {
1245
1756
  // Every lifecycle operation queued for this server is cancelled when it
1246
- // would run (ChannelLifecycleCancelledError), so none acts on a closing
1757
+ // would run (a ChannelLifecycleError of kind `cancelled`), so none acts on a closing
1247
1758
  // connection or on a later re-registration's entries.
1248
1759
  this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
1249
1760
  for (const [key, entry] of this.channels) {
@@ -1251,17 +1762,50 @@ export class ChannelRegistry {
1251
1762
  continue;
1252
1763
  this.channels.delete(key);
1253
1764
  this.stopTyping(entry.descriptor.id);
1254
- // The fallback goes with the server whose message set it, not with
1255
- // another server's entry for the same id.
1256
- if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
1765
+ if (this.defaultPublishChannel === entry.descriptor.id) {
1257
1766
  this.defaultPublishChannel = null;
1258
- this.defaultPublishServer = null;
1259
1767
  this.defaultPublishMessageId = null;
1260
1768
  this.defaultPublishThreadId = undefined;
1261
1769
  }
1262
1770
  }
1263
1771
  // Desired state and migration markers deliberately survive disconnects.
1264
1772
  }
1773
+ /**
1774
+ * The server's transport closed. Whatever it confirmed about a channel,
1775
+ * open or closed, held for that transport only, so each of the server's
1776
+ * channels reads as a registration's does: not open, and not confirmed
1777
+ * closed. Where the host directs the server's lifecycle, a receipt
1778
+ * confirms it again: a `channel_open` asks the server, and delivery opens
1779
+ * before it publishes (an untargeted push entry's traffic confirms it, as
1780
+ * before). Where the host doesn't, its traffic does, as after a
1781
+ * registration. The entries, their desired state, labels and tune-out
1782
+ * state stay, since the server is expected back (`removeServer` is for one
1783
+ * that isn't).
1784
+ *
1785
+ * What was under way here at the close confirms nothing afterwards
1786
+ * (transportCloses). Traffic that arrived before the close and is admitted
1787
+ * after it, behind the coalescer's await, marks nothing open
1788
+ * (handleIncoming). A lifecycle request sent before the close is answered
1789
+ * for an older transport: its failure withdraws the open flag, and its
1790
+ * receipt confirms nothing, and the operation tries again, which on the
1791
+ * closed connection is refused as unreachable, so the decision waits for
1792
+ * the next registration (runLifecycleOperation's sameTarget). What the
1793
+ * connection holds across the boundary is another matter: it reaches
1794
+ * handleIncoming after the next transport's grant, as that transport's
1795
+ * traffic (resetPolicyForTransportBoundary), as it does after a
1796
+ * registration.
1797
+ *
1798
+ * The framework calls this first in its handler for the connection's
1799
+ * `close`, so a registration on the next transport, whose entries are new,
1800
+ * can't come first and lose its confirmations to it.
1801
+ */
1802
+ transportClosed(serverId) {
1803
+ this.transportCloses.set(serverId, (this.transportCloses.get(serverId) ?? 0) + 1);
1804
+ for (const entry of this.channels.values()) {
1805
+ if (entry.serverId === serverId)
1806
+ this.invalidateTransportConfirmation(entry);
1807
+ }
1808
+ }
1265
1809
  /**
1266
1810
  * Stop all typing intervals and clear all channel registrations.
1267
1811
  */
@@ -1275,7 +1819,6 @@ export class ChannelRegistry {
1275
1819
  this.channels.clear();
1276
1820
  // Reset default publish tracking
1277
1821
  this.defaultPublishChannel = null;
1278
- this.defaultPublishServer = null;
1279
1822
  this.defaultPublishMessageId = null;
1280
1823
  this.defaultPublishThreadId = undefined;
1281
1824
  }
@@ -1298,16 +1841,9 @@ export class ChannelRegistry {
1298
1841
  const event = item;
1299
1842
  if (typeof event.serverId !== 'string')
1300
1843
  continue;
1301
- if (event.kind === 'desired-state' &&
1302
- typeof event.channelId === 'string' &&
1303
- (event.desired === 'open' || event.desired === 'closed' ||
1304
- (event.desired === 'tuned-out' && event.tuneOut))) {
1305
- this.desiredStates.set(this.lifecycleKey(event.serverId, event.channelId), {
1306
- state: event.desired,
1307
- source: typeof event.source === "string" ? event.source : "unknown",
1308
- tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
1309
- wakeCount: 0,
1310
- });
1844
+ const decision = desiredStateRecord(item);
1845
+ if (decision) {
1846
+ this.desiredStates.set(this.lifecycleKey(decision.serverId, decision.channelId), { state: decision.state, source: decision.source, tuneOut: decision.tuneOut, wakeCount: 0 });
1311
1847
  }
1312
1848
  else if (event.kind === 'tune-out-wake' &&
1313
1849
  typeof event.channelId === 'string' &&
@@ -1329,7 +1865,9 @@ export class ChannelRegistry {
1329
1865
  /**
1330
1866
  * Each writer appends its record here before it changes memory for it, so
1331
1867
  * an append that throws, as on a full disk, fails the operation with that
1332
- * change unmade, and memory and the log still agree. A retry then records
1868
+ * change unmade, and memory and the log still agree. A record that changes
1869
+ * no memory, as a decline's of the invitation it declined, is its
1870
+ * caller's to handle (handleToolDecline). A retry then records
1333
1871
  * the change rather than finding it in memory already, where
1334
1872
  * setDesiredState's same-state return would record nothing, and a restart
1335
1873
  * replays what memory held. appendLabelSighting keeps the same order for
@@ -1409,15 +1947,9 @@ export class ChannelRegistry {
1409
1947
  * `HistoryModule` tool call passes through unguarded via
1410
1948
  * `resolveProseTargetDurable()`.
1411
1949
  */
1412
- appendLabelSighting(serverId, channelId, label, dmMeta = {}) {
1950
+ appendLabelSighting(channelId, label, dmMeta = {}) {
1413
1951
  if (typeof label !== 'string' || !label)
1414
1952
  return;
1415
- // The log is kept by the bare id, and a durable lookup by label resolves
1416
- // to that id with no server. So a server's label for an id another
1417
- // server registers isn't recorded: it would name the other server's
1418
- // channel, across restarts.
1419
- if (this.registeredByAnotherServer(serverId, channelId))
1420
- return;
1421
1953
  const { recipientId, recipientName, isDm } = dmMeta;
1422
1954
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1423
1955
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -1675,8 +2207,20 @@ export class ChannelRegistry {
1675
2207
  }
1676
2208
  setDesiredState(serverId, channelId, desired, source) {
1677
2209
  const key = this.lifecycleKey(serverId, channelId);
1678
- if (this.desiredStates.get(key)?.state === desired)
2210
+ const current = this.desiredStates.get(key);
2211
+ // A decision that records the state already on record changes nothing,
2212
+ // so it writes nothing, unless it's the agent's own (channel_open,
2213
+ // channel_close or channel_decline) and the record isn't. The record
2214
+ // then says the agent decided, which is all handleToolClose's
2215
+ // explicit-open guard and ensureInitialDesiredState's lift of a default
2216
+ // ask of it. openChannelNow depends on it too: a failed open by speech
2217
+ // or a reply tells a decision made during its round-trip by the record
2218
+ // no longer being the object it wrote, and the agent's channel_open,
2219
+ // the only decision of the same state that can be made then, gets a new
2220
+ // record here.
2221
+ if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) {
1679
2222
  return;
2223
+ }
1680
2224
  this.appendLifecycleEvent({
1681
2225
  kind: 'desired-state',
1682
2226
  serverId,
@@ -1694,7 +2238,9 @@ export class ChannelRegistry {
1694
2238
  * Enter (or re-enter with fresh params) the tuned-out state for a channel.
1695
2239
  * Re-entering under a new epochId replaces the active epoch; the previous
1696
2240
  * epoch's stamped messages stay excluded (stamps are permanent) and its
1697
- * wake count is superseded. Transport stays open (see reconcile).
2241
+ * wake count is superseded. Its transport is kept open: tune_out opens a
2242
+ * closed one (openForTuneOut), and reconcile opens it at the channel's
2243
+ * registration.
1698
2244
  */
1699
2245
  enterTuneOut(serverId, channelId, params, source) {
1700
2246
  const key = this.lifecycleKey(serverId, channelId);
@@ -1714,6 +2260,179 @@ export class ChannelRegistry {
1714
2260
  wakeCount: 0,
1715
2261
  });
1716
2262
  }
2263
+ /**
2264
+ * Make a tuned-out channel's transport match its tune-out, which keeps it
2265
+ * open so its traffic reaches the subconscious (TuneOutParams). The
2266
+ * tune-out is recorded first (enterTuneOut), and this writes no decision,
2267
+ * so the tune-out stands whatever becomes of the open. Lifecycle work
2268
+ * queued for the channel goes first, until none is left, since each
2269
+ * operation reads the decision when it runs and so opens a channel tuned
2270
+ * out meanwhile; its own failure, such as a close the server refused,
2271
+ * isn't this open's. Then the state is read. Where this host directs the
2272
+ * lifecycle and the transport isn't confirmed open, the channel's
2273
+ * lifecycle queue opens it once (applyDesiredChannelState reads a
2274
+ * tune-out as open), and the answer waits for the work queued after it
2275
+ * too, such as a registration that came back, and comes from the state
2276
+ * that work leaves. Returns nothing where the transport is open, or, for
2277
+ * tune_out's answer, why the channel's messages don't or may not reach the
2278
+ * subconscious yet and when they will, or that the tune-out isn't in
2279
+ * effect any more. A call while this tune-out's open is in flight, as a
2280
+ * repeated tune_out makes, gets that open's answer rather than send
2281
+ * another.
2282
+ */
2283
+ async openForTuneOut(serverId, channelId) {
2284
+ // The tune-out this answer is for, by its epoch: one that ended, or that
2285
+ // a later decision or a later tune-out replaced, isn't this one.
2286
+ const epochId = this.getTuneOutState(serverId, channelId)?.params.epochId;
2287
+ const key = `${this.lifecycleKey(serverId, channelId)}\u0000${epochId ?? ''}`;
2288
+ let opening = this.tuneOutOpens.get(key);
2289
+ if (!opening) {
2290
+ const started = this.openTuneOutTransport(serverId, channelId, epochId);
2291
+ this.tuneOutOpens.set(key, started);
2292
+ const release = () => {
2293
+ if (this.tuneOutOpens.get(key) === started)
2294
+ this.tuneOutOpens.delete(key);
2295
+ };
2296
+ void started.then(release, release);
2297
+ opening = started;
2298
+ }
2299
+ return opening;
2300
+ }
2301
+ /**
2302
+ * Whether a tune_out repeated now has nothing to wait for and finds its
2303
+ * channel's transport open, as the tune-out reads it: no lifecycle work
2304
+ * queued for the channel, since the tune-out's own call may still be
2305
+ * waiting for the work queued after its open; its server up; and an open
2306
+ * flag the tune-out takes as open (tuneOutReadsOpen). Where it doesn't,
2307
+ * the repeat goes through openForTuneOut, which joins this tune-out's open
2308
+ * in flight, or waits for the queued work and, where this host can open
2309
+ * the channel, tries the open again.
2310
+ */
2311
+ tuneOutTransportOpen(serverId, channelId) {
2312
+ return this.getTuneOutState(serverId, channelId) !== null &&
2313
+ !this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
2314
+ this.serverReach(serverId) === 'up' &&
2315
+ this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`));
2316
+ }
2317
+ /**
2318
+ * Whether a tune-out takes an entry's transport as open: its open flag,
2319
+ * unless a close for it failed since it was confirmed (failedCloseTargets),
2320
+ * since that close may have taken effect. The other readers take any flag
2321
+ * a failed request left as it was (runLifecycleOperation), since a send
2322
+ * through it fails where it can be seen, and channel_open sends an open
2323
+ * after a failed close anyway, its decision having been closed. A
2324
+ * tune-out's success says traffic will arrive, nothing would show it
2325
+ * doesn't, and the decision it replaced isn't known here. An open that
2326
+ * failed, as a redundant one queued behind a confirming one may, can't
2327
+ * have closed the channel: one for the current target leaves the flag as
2328
+ * it was, so an open flag stays open (one for a target no longer current
2329
+ * withdraws it).
2330
+ */
2331
+ tuneOutReadsOpen(entry) {
2332
+ // A flag is set open only where both marks are cleared, so a failed
2333
+ // close on an open flag is one since its confirmation.
2334
+ return entry !== undefined && entry.open && !this.failedCloseTargets.has(entry);
2335
+ }
2336
+ async openTuneOutTransport(serverId, channelId, epochId) {
2337
+ const channelKey = `${serverId}:${channelId}`;
2338
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2339
+ const inEffect = () => epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
2340
+ // "May not": a failed request leaves its outcome unknown, and an open
2341
+ // that took effect would have its traffic diverted. `again` where calling
2342
+ // tune_out again tries the open now: the channel is registered here and
2343
+ // its server up.
2344
+ const notYet = (later, why, again = false) => withStop(`The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, ` +
2345
+ `which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`);
2346
+ const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
2347
+ 'close its channels then';
2348
+ // The work queued for the channel as it stands: its tail, waited for,
2349
+ // and its failure returned, if it failed. Each wait below takes it until
2350
+ // no work is left, as a delivery's wait does (waitForOpenChannel), so
2351
+ // work queued while earlier work ran, such as a registration that came
2352
+ // back, goes first too.
2353
+ let awaited;
2354
+ const settle = async () => {
2355
+ const pending = this.channelLifecycleTails.get(lifecycleKey);
2356
+ if (!pending)
2357
+ return undefined;
2358
+ awaited = pending;
2359
+ try {
2360
+ await pending;
2361
+ return undefined;
2362
+ }
2363
+ catch (error) {
2364
+ return error;
2365
+ }
2366
+ };
2367
+ // What the state answers now, or null where this host is to open the
2368
+ // transport itself. `missing` answers for a channel that isn't
2369
+ // registered here, where this host directs the lifecycle.
2370
+ const answered = (entry, missing) => {
2371
+ if (!inEffect())
2372
+ return TUNE_OUT_REPLACED;
2373
+ // A connection that dropped keeps its entries' open flags, and whether
2374
+ // this host or the server opens the channel isn't known until it's
2375
+ // back, since a reconnect empties the grant until the new one is
2376
+ // established.
2377
+ const reach = this.serverReach(serverId);
2378
+ if (reach !== 'up') {
2379
+ return withStop(`The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
2380
+ `connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
2381
+ (reach === 'gone' ? unreachableRemedy(reach) : ''));
2382
+ }
2383
+ const transportOpen = this.tuneOutReadsOpen(entry);
2384
+ if (!this.directsLifecycle(serverId)) {
2385
+ return transportOpen ? undefined
2386
+ : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
2387
+ 'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
2388
+ 'this host to open it, ask whoever runs this host.';
2389
+ }
2390
+ if (!entry)
2391
+ return missing;
2392
+ // A flag a close that may have taken effect left gets an open.
2393
+ return transportOpen ? undefined : null;
2394
+ };
2395
+ // Whether work is queued that settle hasn't waited for. It's looked at
2396
+ // in the turn that reads the state and queues the open, or answers, so
2397
+ // work queued after settle's look is waited for too. A settled tail
2398
+ // releases its own key before this runs, since its cleanup was
2399
+ // registered first (applyDesiredChannelState); one still standing isn't
2400
+ // waited for again, so the wait can't spin on it should that order ever
2401
+ // change, as waitForOpenChannel's guard keeps its own from doing.
2402
+ const queuedSince = () => {
2403
+ const tail = this.channelLifecycleTails.get(lifecycleKey);
2404
+ return tail !== undefined && tail !== awaited;
2405
+ };
2406
+ do
2407
+ await settle();
2408
+ while (queuedSince());
2409
+ const before = answered(this.channels.get(channelKey), notYet(nextRegistration, "it isn't registered here now"));
2410
+ if (before !== null)
2411
+ return before;
2412
+ let failure;
2413
+ try {
2414
+ await this.applyDesiredChannelState(serverId, channelId);
2415
+ }
2416
+ catch (error) {
2417
+ failure = error;
2418
+ }
2419
+ // A registration that came back while the open was out queued its own
2420
+ // reconcile, which the answer waits for too. The answer says why the
2421
+ // channel isn't open now, so where the last work failed, its failure,
2422
+ // the last attempt's, is the reason: this open's may concern a
2423
+ // registration that work replaced.
2424
+ let later;
2425
+ do
2426
+ later = await settle();
2427
+ while (queuedSince());
2428
+ failure = later ?? failure;
2429
+ const clause = failure !== undefined ? `: ${lifecycleClause(failure)}` : '';
2430
+ const after = this.channels.get(channelKey);
2431
+ const settled = answered(after, notYet(nextRegistration, `it isn't registered here any more${clause}`));
2432
+ if (settled !== null)
2433
+ return settled;
2434
+ return notYet(laterClause(after), failure !== undefined ? `opening it now didn't complete${clause}` : "it isn't open now", true);
2435
+ }
1717
2436
  /**
1718
2437
  * End the active tune-out, returning the channel to `nextState`.
1719
2438
  * The caller (tune-out coordinator) owns the dump/notice flow; this is
@@ -1768,6 +2487,19 @@ export class ChannelRegistry {
1768
2487
  return null;
1769
2488
  return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
1770
2489
  }
2490
+ /** Every tune-out on record, whether or not its channel is registered
2491
+ * here yet: the decisions the lifecycle log's replay restored, and any
2492
+ * made since. */
2493
+ tuneOutsOnRecord() {
2494
+ const found = [];
2495
+ for (const [key, record] of this.desiredStates) {
2496
+ if (record.state !== 'tuned-out' || !record.tuneOut)
2497
+ continue;
2498
+ const at = key.indexOf('\u0000');
2499
+ found.push({ serverId: key.slice(0, at), channelId: key.slice(at + 1), params: record.tuneOut, source: record.source });
2500
+ }
2501
+ return found;
2502
+ }
1771
2503
  /** Registered channel entries (read-only iteration for the coordinator). */
1772
2504
  listChannelsRaw() {
1773
2505
  return [...this.channels.values()].map((e) => ({
@@ -1794,12 +2526,49 @@ export class ChannelRegistry {
1794
2526
  }
1795
2527
  /**
1796
2528
  * Publish into a channel on behalf of a named non-resident agent (the
1797
- * subconscious's speak_in_channel). Same delivery path as the
1798
- * channel_publish tool; the agent name rides the speech-routed trace.
2529
+ * subconscious's speak_in_channel). Same delivery path, and the same result
2530
+ * as the channel_publish tool apart from `reason` below. Its traces carry the agent's name and follow
2531
+ * the publish's outcome as deliverSpeech's do: speech-routed after a
2532
+ * confirmed delivery, and speech-route-failed otherwise, `unknown` when the
2533
+ * words may have been posted and `failed` when nothing was, a refusal before
2534
+ * the publish included. The routed trace carries no message id, as before:
2535
+ * nothing here reads one, and a consumer that edits the newest traced
2536
+ * message in a channel would have this post to edit when an interruption of
2537
+ * the resident matches none. A refusal of the destination before anything
2538
+ * went out carries its `reason` (PublishRefusalReason), so a caller can act
2539
+ * on why nothing was posted without reading the words.
1799
2540
  */
1800
2541
  async publishForAgent(channelId, text, agentName) {
1801
- this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text });
1802
- return this.handleToolPublish({ channelId, content: text });
2542
+ // The server the publish goes to, as handleToolPublish finds it before
2543
+ // it awaits: the channel may be removed while the reply is pending.
2544
+ const serverId = this.findChannelEntry(channelId)?.serverId ?? '';
2545
+ const seen = {};
2546
+ const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel');
2547
+ if (seen.outcome?.status === 'delivered') {
2548
+ this.emitTraceFn({
2549
+ type: 'mcpl:speech-routed',
2550
+ conversationId: agentName,
2551
+ serverId,
2552
+ channelId,
2553
+ delivered: true,
2554
+ textLen: text.length,
2555
+ text,
2556
+ });
2557
+ }
2558
+ else {
2559
+ this.emitTraceFn({
2560
+ type: 'mcpl:speech-route-failed',
2561
+ conversationId: agentName,
2562
+ channelId,
2563
+ // The publish's own reason and the connector's detail, as
2564
+ // deliverSpeech's trace gives them, or the refusal before any publish.
2565
+ reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
2566
+ textLen: text.length,
2567
+ outcome: seen.outcome?.status === 'unknown' ? 'unknown' : 'failed',
2568
+ ...(seen.outcome?.detail !== undefined ? { detail: connectorText(seen.outcome.detail) } : {}),
2569
+ });
2570
+ }
2571
+ return seen.refusal !== undefined ? { ...result, reason: seen.refusal } : result;
1803
2572
  }
1804
2573
  /**
1805
2574
  * Consume the old recipe policy exactly once. It seeds Chronicle for
@@ -1884,6 +2653,7 @@ export class ChannelRegistry {
1884
2653
  const removals = this.serverRemovals.get(serverId) ?? 0;
1885
2654
  const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
1886
2655
  this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
2656
+ this.lifecycleQueuedRemovals.set(lifecycleKey, removals);
1887
2657
  const operation = previous.catch(() => { }).then(() => this.runLifecycleOperation(serverId, channelId, removals, options));
1888
2658
  // Keep the rejection visible to existing joiners, while observing it
1889
2659
  // immediately and letting each queued successor start after either result.
@@ -1905,17 +2675,14 @@ export class ChannelRegistry {
1905
2675
  let attempts = 0;
1906
2676
  for (;;) {
1907
2677
  if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
1908
- throw new ChannelLifecycleCancelledError(`Channel ${channelId}: its server's registrations were removed while this operation was queued`);
2678
+ throw new ChannelLifecycleError(`this host disconnected the "${serverId}" server while the change was being made`, 'cancelled');
1909
2679
  }
1910
2680
  const entry = this.channels.get(channelKey);
1911
2681
  if (!entry)
1912
- throw new Error(`Channel is no longer registered: ${channelId}`);
2682
+ throw new ChannelLifecycleError(`the "${serverId}" server no longer lists the channel`, 'unlisted');
1913
2683
  const server = this.serverRegistry.getServer(serverId);
1914
- if (!server)
1915
- throw new Error(`Server not found: ${serverId}`);
1916
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1917
- throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
1918
- }
2684
+ if (!server || !CapabilityGrant.of(server).has('channels.lifecycle'))
2685
+ throw this.lifecycleRefusal(serverId);
1919
2686
  const descriptor = entry.descriptor;
1920
2687
  const desired = this.getDesiredState(serverId, channelId);
1921
2688
  const open = desired === 'open' || desired === 'tuned-out';
@@ -1924,10 +2691,12 @@ export class ChannelRegistry {
1924
2691
  // release its queue slot and surface the failure to delivery joiners.
1925
2692
  // Its open flag needs no change here: every retry follows a replaced
1926
2693
  // registration, a retarget (already invalidated, whether the old
1927
- // target's RPC was answered or failed), or a receipt for this very
1928
- // target.
2694
+ // target's RPC was answered or failed), a receipt from before a
2695
+ // transport's close (invalidated by the close), or a receipt for this
2696
+ // very target.
1929
2697
  if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
1930
- const error = new ChannelLifecycleConvergenceError(`Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`);
2698
+ const error = new ChannelLifecycleError(`the channel's address at the "${serverId}" server, or the decision about it, kept changing, and ${attempts} tries ` +
2699
+ "didn't settle it", 'converge');
1931
2700
  this.emitTraceFn({
1932
2701
  type: 'mcpl:channel-reconcile-failed',
1933
2702
  serverId, channelId, desired, attempts, error: error.message,
@@ -1935,7 +2704,14 @@ export class ChannelRegistry {
1935
2704
  throw error;
1936
2705
  }
1937
2706
  attempts++;
2707
+ // A reconnect keeps the connection object, so the count of its
2708
+ // transport's closes (transportClosed) is what tells an answer from
2709
+ // the transport it was sent on apart from one on its successor: an
2710
+ // answer applied after a close it was sent before is from an older
2711
+ // transport, and confirms nothing.
2712
+ const closesAtSend = this.transportCloses.get(serverId) ?? 0;
1938
2713
  const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
2714
+ (this.transportCloses.get(serverId) ?? 0) === closesAtSend &&
1939
2715
  sameChannelTarget(entry.descriptor, descriptor);
1940
2716
  let result;
1941
2717
  try {
@@ -1957,8 +2733,11 @@ export class ChannelRegistry {
1957
2733
  // while not open). A failure from an older connection or target
1958
2734
  // also withdraws the open flag.
1959
2735
  if (this.channels.get(channelKey) === entry) {
1960
- if (sameTarget())
2736
+ if (sameTarget()) {
1961
2737
  this.unconfirmedChannelTargets.add(entry);
2738
+ if (!open)
2739
+ this.failedCloseTargets.add(entry);
2740
+ }
1962
2741
  else
1963
2742
  this.invalidateTransportConfirmation(entry);
1964
2743
  }
@@ -2006,12 +2785,15 @@ export class ChannelRegistry {
2006
2785
  // its sends either.
2007
2786
  if (substitute !== undefined) {
2008
2787
  this.invalidateTransportConfirmation(entry);
2009
- throw new ChannelOpenSubstitutionError(`Server "${serverId}" answered channels/open for ${channelId} with ` +
2010
- (substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
2011
- `; ${channelId} is not marked open, and nothing in the answer is used`);
2788
+ throw new ChannelLifecycleError(`the "${serverId}" server answered for ` +
2789
+ (substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
2790
+ ', so nothing in its answer is used', 'substituted');
2012
2791
  }
2792
+ if (open)
2793
+ this.applyOpenDeclaration(entry, result);
2013
2794
  entry.open = open;
2014
2795
  this.unconfirmedChannelTargets.delete(entry);
2796
+ this.failedCloseTargets.delete(entry);
2015
2797
  const now = this.getDesiredState(serverId, channelId);
2016
2798
  if (open !== (now === 'open' || now === 'tuned-out'))
2017
2799
  continue;
@@ -2024,10 +2806,34 @@ export class ChannelRegistry {
2024
2806
  const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
2025
2807
  if (left > 0)
2026
2808
  this.lifecycleOutstanding.set(lifecycleKey, left);
2027
- else
2809
+ else {
2028
2810
  this.lifecycleOutstanding.delete(lifecycleKey);
2811
+ this.lifecycleQueuedRemovals.delete(lifecycleKey);
2812
+ }
2029
2813
  }
2030
2814
  }
2815
+ /** Whether lifecycle work already outstanding for the channel will carry out
2816
+ * its current decision: some is, and the newest was queued since its
2817
+ * server's registrations were last removed, so it won't cancel when it
2818
+ * would run. Each operation reads the decision when it runs. It answers for
2819
+ * now: a removal after it cancels that work too, and takes the channel's
2820
+ * entry with it, so the channel's next registration by channels/register,
2821
+ * or as added by channels/changed, carries the decision out, where this
2822
+ * host directs the lifecycle then. A push's registration
2823
+ * (ensureChannelRegistered) queues no lifecycle work. So where a push
2824
+ * re-makes an entry that a channels/changed removal took while an RPC for
2825
+ * the old entry was out, the operation running that RPC carries the
2826
+ * decision out for the push's entry only if the RPC is answered: a failed
2827
+ * one rethrows, since its retry covers only a retarget of its own entry.
2828
+ * This answers true meanwhile, so a channels/changed update that retargets
2829
+ * the push's entry is left to that operation, and stays undone after a
2830
+ * failed RPC until lifecycle work is queued for the channel again
2831
+ * (applyDesiredChannelState). No server we know of sends `updated` for a
2832
+ * channel it has removed. */
2833
+ outstandingWorkCarriesOut(serverId, key) {
2834
+ return this.lifecycleOutstanding.has(key) &&
2835
+ this.lifecycleQueuedRemovals.get(key) === (this.serverRemovals.get(serverId) ?? 0);
2836
+ }
2031
2837
  /**
2032
2838
  * A server that answered an open with another channel really opened that
2033
2839
  * one. When it's registered here and the host wants it closed, close it
@@ -2042,40 +2848,57 @@ export class ChannelRegistry {
2042
2848
  return;
2043
2849
  if (this.getDesiredState(serverId, channelId) !== 'closed')
2044
2850
  return;
2851
+ this.applyThroughQueue(serverId, channelId);
2852
+ }
2853
+ /**
2854
+ * Carry out a channel's decision through its lifecycle queue, for a
2855
+ * transport the server may have opened without the host's knowing: one it
2856
+ * opened for another channel's request (closeSubstitutedChannel), or one a
2857
+ * failed open's request may have reached (openChannelNow). The decision is
2858
+ * a closed one, except after a failed open whose put-back the server's
2859
+ * latest announcement or the policy lifted. A failure is reported as a
2860
+ * trace; the decision stands.
2861
+ */
2862
+ applyThroughQueue(serverId, channelId) {
2045
2863
  this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2046
2864
  // Removal while queued is cancellation, as in reconcileChannels, and
2047
2865
  // exhausted convergence emitted its own diagnostic.
2048
2866
  if (!this.channels.has(`${serverId}:${channelId}`) ||
2049
- err instanceof ChannelLifecycleCancelledError ||
2050
- err instanceof ChannelLifecycleConvergenceError)
2867
+ (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge')))
2051
2868
  return;
2052
2869
  this.emitTraceFn({
2053
2870
  type: 'mcpl:channel-reconcile-failed',
2054
- serverId, channelId, desired: 'closed', error: err.message,
2871
+ serverId, channelId, desired: this.getDesiredState(serverId, channelId), error: err.message,
2055
2872
  });
2056
2873
  });
2057
2874
  }
2058
2875
  /**
2059
2876
  * Join outstanding lifecycle work without recording new intent. Retargeting
2060
2877
  * may have invalidated open confirmation while the decision remains pending.
2061
- * A close that settles during the wait remains authoritative; waiting for
2062
- * it is not an instruction to open the channel again.
2878
+ * A close that settles during the wait remains authoritative where the host
2879
+ * directs the server's lifecycle (closeStands); waiting for it is not an
2880
+ * instruction to open the channel again.
2063
2881
  */
2064
2882
  async waitForOpenChannel(entry) {
2065
2883
  const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
2884
+ // The failure of the last work waited on, if it failed, which says why a
2885
+ // channel it left unopened isn't open.
2886
+ let failed;
2066
2887
  for (;;) {
2067
2888
  const pending = this.channelLifecycleTails.get(lifecycleKey);
2068
2889
  if (!pending)
2069
2890
  break;
2070
2891
  try {
2071
2892
  await pending;
2893
+ failed = undefined;
2072
2894
  }
2073
2895
  catch (error) {
2074
2896
  // A redundant backscroll failure can leave a known-open channel
2075
2897
  // usable. Exhausted convergence is different: it never established
2076
2898
  // the current target, so joined delivery must fail visibly.
2077
- if (error instanceof ChannelLifecycleConvergenceError)
2899
+ if (error instanceof ChannelLifecycleError && error.kind === 'converge')
2078
2900
  throw error;
2901
+ failed = error;
2079
2902
  }
2080
2903
  // A settled tail releases its own key. One still current after it
2081
2904
  // settled would be awaited again without ever yielding to the event
@@ -2085,9 +2908,15 @@ export class ChannelRegistry {
2085
2908
  }
2086
2909
  const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2087
2910
  if (!current)
2088
- throw new Error('Channel registration was removed while delivery waited');
2089
- if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
2090
- throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
2911
+ throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
2912
+ // A close that stands is what the words met, whatever became of an open,
2913
+ // and whether or not the close itself took: one that failed may have
2914
+ // left the transport open.
2915
+ if (this.closeStands(current)) {
2916
+ throw new ChannelLifecycleError('a decision to close the channel came before these words went out', 'not-open');
2917
+ }
2918
+ if (!current.open) {
2919
+ throw new ChannelLifecycleError("the channel isn't confirmed open" + (failed !== undefined ? `: ${lifecycleClause(failed)}${refusalRemedy(failed)}` : ''), 'not-open');
2091
2920
  }
2092
2921
  return current;
2093
2922
  }
@@ -2147,9 +2976,8 @@ export class ChannelRegistry {
2147
2976
  // Exhausted convergence and a substituted answer already emitted
2148
2977
  // their own diagnostics.
2149
2978
  if (!this.channels.has(`${serverId}:${channel.id}`) ||
2150
- err instanceof ChannelLifecycleCancelledError ||
2151
- err instanceof ChannelLifecycleConvergenceError ||
2152
- err instanceof ChannelOpenSubstitutionError)
2979
+ (err instanceof ChannelLifecycleError &&
2980
+ (err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted')))
2153
2981
  return;
2154
2982
  this.emitTraceFn({
2155
2983
  type: 'mcpl:channel-reconcile-failed',
@@ -2191,25 +3019,19 @@ export class ChannelRegistry {
2191
3019
  .filter((e) => e.serverId === serverId)
2192
3020
  .map((e) => e.descriptor);
2193
3021
  }
2194
- /**
2195
- * The server whose accepted message set the global fallback, where
2196
- * `channelId` is the fallback's channel: a turn whose locus is the
2197
- * fallback resolves it with this server, taken with the locus.
2198
- */
2199
- fallbackServerFor(channelId) {
2200
- return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
2201
- }
2202
3022
  /** Server id owning a registered channel (by descriptor id), or null. */
2203
3023
  getChannelServerId(channelId) {
2204
3024
  return this.findChannelEntry(channelId)?.serverId ?? null;
2205
3025
  }
2206
- /** Whether a server other than `serverId` registers `channelId`. */
2207
- registeredByAnotherServer(serverId, channelId) {
2208
- for (const entry of this.channels.values()) {
2209
- if (entry.descriptor.id === channelId && entry.serverId !== serverId)
2210
- return true;
2211
- }
2212
- return false;
3026
+ /**
3027
+ * The server that declared this channel inbound-only (isInboundOnly), or
3028
+ * undefined when it isn't one. The notices name that server. A caller that
3029
+ * knows the channel's server passes it; otherwise the channel is found by
3030
+ * its id alone (findChannelEntry).
3031
+ */
3032
+ inboundOnlyServer(channelId, serverId) {
3033
+ const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId);
3034
+ return entry && isInboundOnly(entry) ? entry.serverId : undefined;
2213
3035
  }
2214
3036
  /**
2215
3037
  * Find a channel entry by channelId (searches across all servers).
@@ -2224,17 +3046,28 @@ export class ChannelRegistry {
2224
3046
  return undefined;
2225
3047
  }
2226
3048
  resolveToolChannelEntry(channelId, serverId) {
2227
- const matches = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId));
3049
+ const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId);
3050
+ const matches = withId.filter((entry) => !serverId || entry.serverId === serverId);
2228
3051
  if (matches.length === 0) {
3052
+ if (serverId && withId.length > 0) {
3053
+ // Leaving serverId out works only when one server has the id.
3054
+ const servers = withId.map((entry) => `"${entry.serverId}"`);
3055
+ return {
3056
+ error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1
3057
+ ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.`
3058
+ : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`),
3059
+ };
3060
+ }
2229
3061
  return {
2230
3062
  error: serverId
2231
- ? `Channel not found: ${channelId} on server ${serverId}`
2232
- : `Channel not found: ${channelId}`,
3063
+ ? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.`
3064
+ : `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`,
2233
3065
  };
2234
3066
  }
2235
3067
  if (matches.length > 1) {
2236
3068
  return {
2237
- error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
3069
+ error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` +
3070
+ 'so nothing ran. Call again with serverId set to one of them.',
2238
3071
  };
2239
3072
  }
2240
3073
  return { entry: matches[0] };
@@ -2274,15 +3107,31 @@ export class ChannelRegistry {
2274
3107
  /**
2275
3108
  * Record durable desired-open intent, then queue the channel's lifecycle
2276
3109
  * work (applyDesiredChannelState), which tells the server to subscribe and
2277
- * marks the live entry open on a receipt for its current target. Every
2278
- * open the agent or its speech asks for — the channel_open tool, delivery
3110
+ * marks the live entry open on a receipt for its current target, applying
3111
+ * an inbound-only declaration the answer carries (applyOpenDeclaration).
3112
+ * Every open the agent or its speech asks for — the channel_open tool, delivery
2279
3113
  * into a closed locus, an explicit send into a closed channel — comes
2280
3114
  * here. Reconciliation queues that work directly, as the closes do
2281
3115
  * (channel_close, a decline, and the close of a substituted channel).
2282
- * Desired state is recorded BEFORE the server round-trip: intent sticks
2283
- * even if the subscribe fails, and reconciliation retries later.
2284
- * Automatic delivery needs open transport, not a change of attention:
2285
- * preserve tune-out epochs both before and during the round-trip.
3116
+ * Desired state is recorded BEFORE the server round-trip. The agent's own
3117
+ * open is a decision about the channel: it sticks even if the subscribe
3118
+ * fails, and reconciliation retries later. An open by speech or a reply is
3119
+ * incidental to its words: when its round-trip fails, the decision it
3120
+ * wrote goes back to the one it replaced, so the agent, told the channel
3121
+ * is closed and the open failed, isn't later put into it by that decision.
3122
+ * That holds only while the record is still the one it wrote: a decision
3123
+ * made meanwhile stands, and the failure then comes as `overtaken`
3124
+ * (ChannelLifecycleError). Where the decision put back is a pure default,
3125
+ * nobody had decided, so the channel then follows its server's latest
3126
+ * announcement and the policy: a re-announcement during the round-trip
3127
+ * found the open's own record and lifted nothing. If they select it for
3128
+ * opening, the failure says so (`selected-again`). And since
3129
+ * a failed request may have reached the server, the decision that stands
3130
+ * is carried out on the wire too. An open by speech or a reply that can't
3131
+ * send its request, on a server without channels.lifecycle, writes
3132
+ * nothing. Automatic delivery needs open transport, not a change of
3133
+ * attention: preserve tune-out epochs both before and during the
3134
+ * round-trip.
2286
3135
  *
2287
3136
  * `opened` says whether this call turned a closed channel open, which is
2288
3137
  * what the delivery and reply notices tell the resident. Reconfirming the
@@ -2290,17 +3139,126 @@ export class ChannelRegistry {
2290
3139
  */
2291
3140
  async openChannelNow(entry, source, history) {
2292
3141
  const transportOnly = source !== 'agent-tool';
3142
+ const key = this.lifecycleKey(entry.serverId, entry.descriptor.id);
3143
+ if (transportOnly) {
3144
+ // Without channels.lifecycle the open fails before any request leaves
3145
+ // (§14.1), as it does without the server, so an incidental open would
3146
+ // only write a decision to put back. It writes none.
3147
+ const server = this.serverRegistry.getServer(entry.serverId);
3148
+ if (!server || !CapabilityGrant.of(server).has('channels.lifecycle'))
3149
+ throw this.lifecycleRefusal(entry.serverId);
3150
+ }
3151
+ // The decision this open replaces. A channel with none on record, as one
3152
+ // registered from a push, was closed by default: nobody had decided.
3153
+ const replaced = this.desiredStates.get(key) ?? { state: 'closed', source: 'default-closed' };
2293
3154
  const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
2294
3155
  const opened = before !== 'open' && before !== 'tuned-out';
2295
3156
  if (!transportOnly || before !== 'tuned-out') {
2296
- this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
3157
+ // A decision the log refused changed nothing, and its words say so
3158
+ // rather than read as one a later decision overtook.
3159
+ try {
3160
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
3161
+ }
3162
+ catch (err) {
3163
+ throw unrecordedDecision(err);
3164
+ }
2297
3165
  }
2298
- const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
2299
- const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2300
- if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
2301
- throw new Error('Channel open was superseded by a newer lifecycle decision');
3166
+ // The record an incidental open wrote, to put back if its round-trip fails.
3167
+ const wrote = transportOnly && opened ? this.desiredStates.get(key) : undefined;
3168
+ try {
3169
+ const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
3170
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
3171
+ if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
3172
+ throw new ChannelLifecycleError(supersededClause('open', desired), 'superseded');
3173
+ }
3174
+ return { result: applied.result, opened };
3175
+ }
3176
+ catch (err) {
3177
+ if (wrote && this.desiredStates.get(key) !== wrote) {
3178
+ // That decision stands, and the failure says so where its own clause
3179
+ // doesn't, as a superseded open's does. What helps turns on that
3180
+ // decision, so no step is offered.
3181
+ const own = lifecycleClause(err);
3182
+ throw new ChannelLifecycleError(err instanceof ChannelLifecycleError && err.kind === 'superseded'
3183
+ ? own : afterClause(own, 'a decision about the channel made while the open was out stands'), 'overtaken', undefined, { cause: err });
3184
+ }
3185
+ if (wrote) {
3186
+ try {
3187
+ this.setDesiredState(entry.serverId, entry.descriptor.id, replaced.state, replaced.source);
3188
+ }
3189
+ catch (putBackErr) {
3190
+ // The caller reports both: the channel isn't open, but it stays
3191
+ // selected for opening, so reconciliation may open it later.
3192
+ throw new ChannelLifecycleError(afterClause(lifecycleClause(err), 'the channel stays selected for opening, because this host couldn\'t record ' +
3193
+ `undoing that: ${requestErrorText(putBackErr)}`), 'put-back-unrecorded', undefined, { cause: err });
3194
+ }
3195
+ // The decision put back is a closed one: an open that turned the
3196
+ // channel open replaced no open decision. Where it's a pure default,
3197
+ // nobody had decided, so the channel follows its server's latest
3198
+ // announcement and the policy, not the ones from before the open: a
3199
+ // re-announcement during the round-trip found the open's own record
3200
+ // and lifted nothing. The lift is ensureInitialDesiredState's own,
3201
+ // which leaves every real decision alone. One that can't be recorded
3202
+ // leaves the default, closed.
3203
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3204
+ let selectedAgain;
3205
+ if (current) {
3206
+ try {
3207
+ this.ensureInitialDesiredState(entry.serverId, current.descriptor);
3208
+ }
3209
+ catch (liftErr) {
3210
+ console.error(`[channel] ${entry.serverId}:${logValue(entry.descriptor.id)}: the lift after a failed open couldn't be recorded:`, liftErr);
3211
+ }
3212
+ const now = this.desiredStates.get(key);
3213
+ if (now?.state === 'open' && (now.source === 'server-bootstrap' || now.source === 'subscription-policy')) {
3214
+ selectedAgain = now.source;
3215
+ }
3216
+ }
3217
+ // The failed request may have reached the server, so the decision
3218
+ // that stands is carried out through the queue, as a decline's close
3219
+ // is, unless other lifecycle work for the channel is queued that will
3220
+ // carry it out anyway: work queued before its server's registrations
3221
+ // were removed cancels instead. (An incidental open gets here only
3222
+ // where the host directed the lifecycle when it opened: see the grant
3223
+ // check above. Where the grant has lost channels.lifecycle since, as a
3224
+ // transport drop clears it, the queued work fails for it, and the
3225
+ // channel's next registration by channels/register, or as added by
3226
+ // channels/changed, carries the decision out, where this host directs
3227
+ // the lifecycle then.)
3228
+ if (current && !this.outstandingWorkCarriesOut(entry.serverId, key)) {
3229
+ this.applyThroughQueue(entry.serverId, entry.descriptor.id);
3230
+ }
3231
+ if (selectedAgain) {
3232
+ throw new ChannelLifecycleError(afterClause(lifecycleClause(err), 'the channel is selected for opening again, because ' +
3233
+ (selectedAgain === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it')), 'selected-again', undefined, { by: selectedAgain, cause: err });
3234
+ }
3235
+ }
3236
+ throw err;
3237
+ }
3238
+ }
3239
+ /**
3240
+ * An open's answer carries the server's descriptor for the channel
3241
+ * (§14.3), the first place a lazily registered channel's own declaration
3242
+ * can arrive. runLifecycleOperation hands it here, wherever the host opens
3243
+ * a channel, once the answer is known to be for the channel's current
3244
+ * entry and target. An answer that names another channel never arrives
3245
+ * here: it's a substitution, which fails the open. One whose channel names
3246
+ * no id is this channel's, as runLifecycleOperation takes it, so its
3247
+ * declaration applies too.
3248
+ *
3249
+ * Only an inbound-only declaration is applied. The answer isn't always the
3250
+ * server's latest word: a `channels/changed` that arrives in the same read
3251
+ * as the answer is handled before the open's operation resumes, so the
3252
+ * answer is applied over it. Applying only a refusal keeps that race
3253
+ * fail-closed: a stale answer can keep a channel refused until the
3254
+ * server's next update, but never lift a newer refusal. A server lifts its
3255
+ * declaration by changing or registering the channel again.
3256
+ */
3257
+ applyOpenDeclaration(entry, answer) {
3258
+ const declared = answer?.channel;
3259
+ if (declared?.direction === 'inbound') {
3260
+ entry.descriptor = { ...entry.descriptor, direction: 'inbound' };
2302
3261
  }
2303
- return { result: applied.result, opened };
2304
3262
  }
2305
3263
  /**
2306
3264
  * Resolve a `>>` prose-routing target (explicit prose routing —
@@ -2315,8 +3273,24 @@ export class ChannelRegistry {
2315
3273
  *
2316
3274
  * Ambiguity is an error carrying candidates — never a guess: this is the
2317
3275
  * mechanism that makes prose misdelivery structurally impossible.
3276
+ *
3277
+ * `forSpeech` is for a target the agent's speech will be posted to: a
3278
+ * channel its server declared inbound-only (isInboundOnly) then resolves
3279
+ * to an error that says so, since nothing is published there, and that
3280
+ * names the channel as `refusedChannelId`, so a caller can tell a refused
3281
+ * channel from a name that resolved to nothing. Without it, as for reading
3282
+ * a channel's history, any registered channel resolves.
2318
3283
  */
2319
- resolveProseTarget(spec) {
3284
+ resolveProseTarget(spec, opts) {
3285
+ const resolved = this.resolveTargetByName(spec);
3286
+ if (!opts?.forSpeech || 'error' in resolved)
3287
+ return resolved;
3288
+ const entry = this.findChannelEntry(resolved.channelId);
3289
+ return entry && isInboundOnly(entry)
3290
+ ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
3291
+ : resolved;
3292
+ }
3293
+ resolveTargetByName(spec) {
2320
3294
  const entries = [...this.channels.values()];
2321
3295
  const trimmed = spec.trim();
2322
3296
  if (!trimmed)
@@ -2462,7 +3436,7 @@ export class ChannelRegistry {
2462
3436
  for (const c of candidates) {
2463
3437
  if (/\s/.test(c) || c === '#')
2464
3438
  continue;
2465
- const r = this.resolveProseTarget(c);
3439
+ const r = this.resolveProseTarget(c, { forSpeech: true });
2466
3440
  if ('channelId' in r && r.channelId === d.id)
2467
3441
  return c;
2468
3442
  }
@@ -2474,7 +3448,17 @@ export class ChannelRegistry {
2474
3448
  * engaging a channel opens it, so typing indicators, reaction machinery,
2475
3449
  * and inbound forwarding all come alive with the first outbound message.
2476
3450
  * Accepts composite MCPL ids or raw server-internal ids (explicit send
2477
- * tools receive whatever the agent typed).
3451
+ * tools receive whatever the agent typed). An `open-failed` answer says
3452
+ * whether the open that failed was one that would have opened a channel
3453
+ * not selected open (`opening`), as opposed to one reconfirming the
3454
+ * transport of a channel already selected open or tuned out. It isn't one
3455
+ * either where a decision made during its round-trip replaced it
3456
+ * (`overtaken`), or where queued lifecycle work left the
3457
+ * channel closed before any open was sent. `selectedForOpening` says the
3458
+ * channel is selected for opening after the failure, and why: the put-back
3459
+ * couldn't be recorded (`put-back-unrecorded`), or the server's latest
3460
+ * announcement or the policy lifted the default it restored
3461
+ * (`selected-again`).
2478
3462
  */
2479
3463
  async openIfClosedForSend(rawChannelId, serverId) {
2480
3464
  let matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
@@ -2487,6 +3471,7 @@ export class ChannelRegistry {
2487
3471
  if (matches.length > 1)
2488
3472
  return { status: 'ambiguous' };
2489
3473
  let entry = matches[0];
3474
+ let opening = false;
2490
3475
  try {
2491
3476
  if (this.hasPendingLifecycle(entry)) {
2492
3477
  entry = await this.waitForOpenChannel(entry);
@@ -2495,6 +3480,7 @@ export class ChannelRegistry {
2495
3480
  if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
2496
3481
  return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2497
3482
  }
3483
+ opening = desired !== 'open' && desired !== 'tuned-out';
2498
3484
  const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
2499
3485
  entry = await this.waitForOpenChannel(entry);
2500
3486
  if (opened) {
@@ -2507,6 +3493,11 @@ export class ChannelRegistry {
2507
3493
  return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2508
3494
  }
2509
3495
  catch (err) {
3496
+ // Where a decision made during the open's round-trip replaced it, such
3497
+ // as the agent's own close or channel_open, the channel is as that
3498
+ // decision left it, and the agent isn't told the open failed.
3499
+ if (err instanceof ChannelLifecycleError && err.kind === 'overtaken')
3500
+ opening = false;
2510
3501
  this.emitTraceFn({
2511
3502
  type: 'mcpl:channel-open-failed',
2512
3503
  serverId: entry.serverId,
@@ -2514,7 +3505,11 @@ export class ChannelRegistry {
2514
3505
  error: err.message,
2515
3506
  });
2516
3507
  const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2517
- return { status: 'open-failed', channelId: entry.descriptor.id,
3508
+ return { status: 'open-failed', channelId: entry.descriptor.id, opening,
3509
+ // Where the channel is selected for opening after the failure, the
3510
+ // agent is told so and why, as speech's failure says.
3511
+ ...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' } : {}),
3512
+ ...(err instanceof ChannelLifecycleError && err.kind === 'selected-again' ? { selectedForOpening: err.by } : {}),
2518
3513
  ...(current ? { label: current.descriptor.label } : {}) };
2519
3514
  }
2520
3515
  }
@@ -2531,21 +3526,26 @@ export class ChannelRegistry {
2531
3526
  // A tuned-out channel is transport-open but attention-diverted; a plain
2532
3527
  // open would silently discard the epoch (and its pending backlog dump).
2533
3528
  // Cancelling is the tune-out coordinator's flow, reached via its own
2534
- // tool — refuse with the pointer rather than eat the state. As an error
2535
- // result, the agent reads the pointer itself; `data` names the refusal
2536
- // for an in-process caller.
3529
+ // tool — refuse with the pointer rather than eat the state.
2537
3530
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
2538
- return {
2539
- success: false,
2540
- isError: true,
2541
- error: `Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
2542
- `(tune_out with mode: 'cancel') to resume normal attention — ` +
2543
- `cancelling delivers the diverted backlog.`,
2544
- data: { refusal: 'tuned-out', channelId: input.channelId },
2545
- };
3531
+ return tunedOutRefusal('channel_open', input.channelId);
2546
3532
  }
2547
3533
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
2548
3534
  if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
3535
+ // Open already, perhaps because speech, a reply, the server or a
3536
+ // policy opened it: the agent's open is still its decision, and is
3537
+ // recorded as one (setDesiredState), so the explicit-open guard of
3538
+ // handleToolClose reads it.
3539
+ try {
3540
+ this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
3541
+ }
3542
+ catch (err) {
3543
+ return {
3544
+ success: false,
3545
+ error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${err.message}`,
3546
+ isError: true,
3547
+ };
3548
+ }
2549
3549
  return {
2550
3550
  success: true,
2551
3551
  data: { channelId: input.channelId, status: 'already open' },
@@ -2571,17 +3571,12 @@ export class ChannelRegistry {
2571
3571
  }
2572
3572
  catch (err) {
2573
3573
  // The decision was recorded before the round-trip and outlives its
2574
- // failure. Say so: otherwise a later delivery that opens the channel
2575
- // would be the resident's first sign that it was still wanted open.
2576
- const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
2577
- const stillSelected = current !== undefined && !current.open &&
2578
- this.getDesiredState(entry.serverId, input.channelId) === 'open';
3574
+ // failure. The text says so where it stands: otherwise a later
3575
+ // delivery that opens the channel would be the resident's first sign
3576
+ // that it was still wanted open.
2579
3577
  return {
2580
3578
  success: false,
2581
- error: `Failed to open channel: ${err.message}` +
2582
- (stillSelected
2583
- ? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
2584
- : ''),
3579
+ error: this.lifecycleFailureText('open', entry, err, { history: !!input.backscroll }),
2585
3580
  isError: true,
2586
3581
  };
2587
3582
  }
@@ -2596,14 +3591,6 @@ export class ChannelRegistry {
2596
3591
  isError: true,
2597
3592
  };
2598
3593
  }
2599
- const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
2600
- if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
2601
- !this.unconfirmedChannelTargets.has(entry)) {
2602
- return {
2603
- success: true,
2604
- data: { channelId: input.channelId, status: 'already closed' },
2605
- };
2606
- }
2607
3594
  // Provenance comes from trusted dispatch, not from self-description:
2608
3595
  // only module-origin calls may record a machine source, and only from
2609
3596
  // the closed vocabulary. Agent-origin (or origin-less — the safe
@@ -2622,6 +3609,27 @@ export class ChannelRegistry {
2622
3609
  }
2623
3610
  closeSource = input.source;
2624
3611
  }
3612
+ const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
3613
+ if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
3614
+ !this.unconfirmedChannelTargets.has(entry)) {
3615
+ // Closed already, perhaps only by default: the agent's close is still
3616
+ // its decision, and is recorded as one (setDesiredState), so a later
3617
+ // registration's lift of a default leaves the channel closed.
3618
+ try {
3619
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
3620
+ }
3621
+ catch (err) {
3622
+ return {
3623
+ success: false,
3624
+ error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${err.message}`,
3625
+ isError: true,
3626
+ };
3627
+ }
3628
+ return {
3629
+ success: true,
3630
+ data: { channelId: input.channelId, status: 'already closed' },
3631
+ };
3632
+ }
2625
3633
  // Housekeeping must not override stated intent (issue #5: GC closes wore
2626
3634
  // the agent's badge and reset explicitly-opened doors). A machine-sourced
2627
3635
  // close of a channel whose current desired state is an agent/operator
@@ -2642,39 +3650,42 @@ export class ChannelRegistry {
2642
3650
  };
2643
3651
  }
2644
3652
  }
2645
- this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
3653
+ // A tuned-out channel's decision is the tune-out, and a close recorded
3654
+ // over it would end it without its cancel, so the backlog it diverted
3655
+ // would never reach the agent. Its cancel is what ends it, as
3656
+ // channel_open's and channel_decline's refusals of a tuned-out channel
3657
+ // say, and the agent closes the channel after that. A module's close
3658
+ // that certified an idle lease, which the guard above lets through, is
3659
+ // refused too, as a refusal it stands down on rather than an error.
3660
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3661
+ const refusal = tunedOutRefusal('channel_close', input.channelId);
3662
+ return isModuleOrigin ? { ...refusal, isError: false } : refusal;
3663
+ }
3664
+ // A decision the log refused changed nothing, and the answer says so.
3665
+ try {
3666
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
3667
+ }
3668
+ catch (err) {
3669
+ return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
3670
+ }
2646
3671
  this.stopTyping(input.channelId);
2647
- const server = this.serverRegistry.getServer(entry.serverId);
2648
- if (!server) {
2649
- return {
2650
- success: false,
2651
- error: `Server not found: ${entry.serverId}`,
2652
- isError: true,
2653
- };
3672
+ // Refused now, the close stays recorded for the server's next
3673
+ // registration, and the text says so.
3674
+ if (!this.directsLifecycle(entry.serverId)) {
3675
+ return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
2654
3676
  }
2655
3677
  try {
2656
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2657
- return {
2658
- success: false,
2659
- error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
2660
- isError: true,
2661
- };
2662
- }
2663
3678
  const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
2664
- if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
2665
- throw new Error('Channel close was superseded by a newer lifecycle decision');
2666
- }
3679
+ const now = this.getDesiredState(entry.serverId, input.channelId);
3680
+ if (applied.open || now !== 'closed')
3681
+ throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
2667
3682
  return {
2668
3683
  success: true,
2669
3684
  data: { channelId: input.channelId, status: 'closed' },
2670
3685
  };
2671
3686
  }
2672
3687
  catch (err) {
2673
- return {
2674
- success: false,
2675
- error: `Failed to close channel: ${err.message}`,
2676
- isError: true,
2677
- };
3688
+ return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
2678
3689
  }
2679
3690
  }
2680
3691
  async handleToolDecline(input) {
@@ -2683,28 +3694,54 @@ export class ChannelRegistry {
2683
3694
  if (!entry) {
2684
3695
  return { success: false, error: resolved.error, isError: true };
2685
3696
  }
3697
+ // A tuned-out channel's decision is the tune-out, whatever its transport,
3698
+ // and a decline recorded over it would end it without its cancel, so the
3699
+ // backlog it diverted would never reach the agent. Its cancel is what ends
3700
+ // it, as channel_open's refusal of a tuned-out channel says.
3701
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3702
+ return tunedOutRefusal('channel_decline', input.channelId);
3703
+ }
2686
3704
  if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
3705
+ // channel_close is the call that closes it: its own answer says when,
3706
+ // as for a server that isn't up. A server that's up without that grant
3707
+ // keeps its channels as it chooses, so the text says so, and whom to
3708
+ // ask.
3709
+ const refusal = this.serverReach(entry.serverId) === 'up' && !this.directsLifecycle(entry.serverId)
3710
+ ? this.lifecycleRefusal(entry.serverId) : undefined;
2687
3711
  return {
2688
3712
  success: false,
2689
- error: `Channel is already open: ${input.channelId}. Use channel_close to close it.`,
3713
+ error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` +
3714
+ (refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
3715
+ : '. If you need it closed, call channel_close.'),
2690
3716
  isError: true,
2691
3717
  };
2692
3718
  }
2693
3719
  // The lifecycle decision is authoritative even if the optional public
2694
- // acknowledgment cannot be rendered by the surface.
2695
- this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
3720
+ // acknowledgment cannot be rendered by the surface. One the log refused
3721
+ // changed nothing, and nothing after it runs.
3722
+ try {
3723
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
3724
+ }
3725
+ catch (err) {
3726
+ return {
3727
+ success: false,
3728
+ error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` +
3729
+ 'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
3730
+ 'way, ask whoever runs this host.',
3731
+ isError: true,
3732
+ };
3733
+ }
2696
3734
  let acknowledged = false;
2697
3735
  let representation;
2698
- let acknowledgmentError;
3736
+ // What became of an acknowledgment that wasn't posted, worded once the
3737
+ // decline's own outcome is known: each says the decline is recorded,
3738
+ // since it is whatever becomes of the acknowledgment, and a repeated
3739
+ // decline records it again and posts, but only where another decline
3740
+ // would be taken (`retry`).
3741
+ let ackFailure;
2699
3742
  if (input.acknowledge) {
2700
3743
  const server = this.serverRegistry.getServer(entry.serverId);
2701
- if (!server) {
2702
- acknowledgmentError = `Server not found: ${entry.serverId}`;
2703
- }
2704
- else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
2705
- acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
2706
- }
2707
- else {
3744
+ if (server && CapabilityGrant.of(server).has('channels.acknowledge')) {
2708
3745
  try {
2709
3746
  const result = await server.sendChannelsAcknowledge({
2710
3747
  channelId: input.channelId,
@@ -2712,53 +3749,206 @@ export class ChannelRegistry {
2712
3749
  intent: 'seen-not-opening',
2713
3750
  value: input.acknowledge,
2714
3751
  });
2715
- acknowledged = result.acknowledged;
2716
- representation = result.representation;
2717
- if (!acknowledged) {
2718
- acknowledgmentError = result.reason ??
2719
- 'The channel integration could not post the acknowledgment.';
3752
+ // An answer counts by #163's rule, as a publish's does: only
3753
+ // acknowledged: true confirms a post, and only false denies one.
3754
+ const receipt = (result ?? {});
3755
+ if (receipt.acknowledged === true) {
3756
+ acknowledged = true;
3757
+ if (typeof receipt.representation === 'string')
3758
+ representation = receipt.representation;
3759
+ }
3760
+ else if (receipt.acknowledged === false) {
3761
+ // A reason that isn't a non-empty string is none.
3762
+ const reason = typeof receipt.reason === 'string' && receipt.reason !== '' ? connectorText(receipt.reason) : undefined;
3763
+ ackFailure = (retry) => reason !== undefined
3764
+ ? withStop(`The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment: ${reason}`)
3765
+ : `The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment, and gave no reason.` +
3766
+ (retry ? ` To try once more, ${retry}.` : '');
3767
+ }
3768
+ else {
3769
+ ackFailure = (retry) => `The decline is recorded, but the "${entry.serverId}" server's answer didn't say whether it ` +
3770
+ 'posted the acknowledgment.' + (retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
2720
3771
  }
2721
3772
  }
2722
3773
  catch (error) {
2723
- acknowledgmentError = error.message;
3774
+ // Only a request that never left the host proves nothing was
3775
+ // posted, as for a publish (publishErrorOutcome).
3776
+ const words = requestErrorText(error);
3777
+ ackFailure = error instanceof McplRequestError && error.outcome === 'not-sent'
3778
+ ? (retry) => withStop(`The decline is recorded, but the acknowledgment wasn't posted: ${words}`) +
3779
+ (retry ? ` To try again, ${retry}.` : '')
3780
+ : (retry) => withStop(`The decline is recorded, but whether the acknowledgment was posted isn't known: ${words}`) +
3781
+ (retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
2724
3782
  }
2725
3783
  }
3784
+ else {
3785
+ const reach = this.serverReach(entry.serverId);
3786
+ // Read when the acknowledgment was attempted. Where the server is up,
3787
+ // the decline's close can await past a change to its grant, so that
3788
+ // text says what kept the acknowledgment back then.
3789
+ ackFailure = (retry) => reach === 'up'
3790
+ ? `The decline is recorded, but this host wasn't permitted to post acknowledgments to the "${entry.serverId}" server, ` +
3791
+ 'so none was posted. If you need it, ask whoever runs this host.'
3792
+ : `The decline is recorded, but the acknowledgment wasn't posted: ${unreachableClause(entry.serverId, reach)}.` +
3793
+ (reach === 'gone' ? ' If you need it, ask whoever runs this host.'
3794
+ : retry ? ` To post it, ${retry} ${reach === 'connecting' ? 'in a moment' : "once it's back"}.` : '');
3795
+ }
2726
3796
  }
2727
3797
  // Where the host directs the lifecycle, a decline goes through the
2728
3798
  // lifecycle queue like any closed decision, unless a receipt confirmed
2729
- // the transport closed with nothing pending: an unconfirmed transport may
2730
- // be open, and pending work must settle on this decision. A failed close
2731
- // is reported; the decision stands.
2732
- let closeError;
3799
+ // the transport closed with nothing pending: an open or unconfirmed
3800
+ // transport may be open, and pending work must settle on this decision.
3801
+ // The decline refused an open channel before its acknowledgment, but
3802
+ // traffic can show one open meanwhile where this host didn't direct the
3803
+ // server's channels, before a grant that now lets it. A decline acts
3804
+ // only on its own decision: one made meanwhile, to open the channel or
3805
+ // tune it out, isn't the decline's to apply. A failed close is reported
3806
+ // below; the decision stands.
3807
+ let closeFailure;
3808
+ let closeFailed = false;
2733
3809
  const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
2734
3810
  if (current && this.directsLifecycle(entry.serverId) &&
2735
- (this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
3811
+ this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
3812
+ (current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
2736
3813
  try {
2737
- const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
2738
- if (applied.open)
2739
- closeError = 'the close was superseded by a newer lifecycle decision';
3814
+ await this.applyDesiredChannelState(entry.serverId, input.channelId);
2740
3815
  }
2741
3816
  catch (error) {
2742
- closeError = error.message;
3817
+ closeFailure = error;
3818
+ closeFailed = true;
2743
3819
  }
2744
3820
  }
2745
- this.appendLifecycleEvent({
2746
- kind: 'invitation-declined',
2747
- serverId: entry.serverId,
2748
- channelId: input.channelId,
2749
- messageId: input.messageId,
2750
- acknowledgment: representation ?? input.acknowledge,
2751
- timestamp: new Date().toISOString(),
2752
- });
3821
+ // A decision made while the decline ran, to open the channel or tune it
3822
+ // out, replaced it, and that is what stands.
3823
+ const decided = this.getDesiredState(entry.serverId, input.channelId);
3824
+ const stands = decided === 'closed';
3825
+ // The channel as the decline leaves it: closed for certain only where
3826
+ // it is registered, not open, its target confirmed, and no lifecycle
3827
+ // work for it outstanding, as the in-effect texts read it. A close that
3828
+ // didn't complete, a channel this host never confirmed closed, or a
3829
+ // registration that went leaves that unknown. A server without
3830
+ // channels.lifecycle keeps its channels as it chooses, and this host
3831
+ // asks it to open or close none of them, so a channel there is confirmed
3832
+ // closed only by the receipt of a request sent while the server had the
3833
+ // grant, whether the channel was registered or known only from a push.
3834
+ const after = this.channels.get(`${entry.serverId}:${input.channelId}`);
3835
+ const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
3836
+ !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
3837
+ // `closeError` is the result's sentence for the agent: what became of
3838
+ // the channel, and what follows. Every status but `remained closed`
3839
+ // carries one, and that one none, by this tree; `status` is the word a
3840
+ // caller's code reads. Reconciling closes a channel decided closed when
3841
+ // the server next registers it (for a push's placeholder, if it ever
3842
+ // does), if this host directs their lifecycle then (§14.1), which
3843
+ // laterClause says.
3844
+ let closeError;
3845
+ const reach = this.serverReach(entry.serverId);
3846
+ const serverBack = reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '';
3847
+ // What helps now with a registered channel the decline didn't leave
3848
+ // closed for certain, read from the state the reads above found, not
3849
+ // from how it came to be: whom to ask where this host doesn't direct the
3850
+ // server's channels, work still under way where some will carry the
3851
+ // decision out (outstandingWorkCarriesOut, not work a removal cancels),
3852
+ // and otherwise channel_close, which asks for the close now.
3853
+ const stepNow = () => !this.directsLifecycle(entry.serverId)
3854
+ ? (reach === 'up' ? ' If you need it closed sooner, ask whoever runs this host.' : serverBack)
3855
+ : this.outstandingWorkCarriesOut(entry.serverId, this.lifecycleKey(entry.serverId, input.channelId))
3856
+ ? ' Other work for it that\'s still under way applies the decline when its turn comes, so there\'s no need to ' +
3857
+ 'decline again to close it.'
3858
+ : ' If you need it closed now, call channel_close.';
3859
+ if (!stands) {
3860
+ // channel_close closes it after all while the channel is registered,
3861
+ // over an open, or once a tune-out's cancel has ended the tune-out,
3862
+ // since it refuses a tuned-out channel; a server that's up without the
3863
+ // grant keeps its channels as it chooses, as the decline's refusal
3864
+ // says.
3865
+ const step = after === undefined
3866
+ ? `. It isn't registered here any more, so no channel tool can reach it until it's registered here again.${serverBack}`
3867
+ : reach === 'up' && !this.directsLifecycle(entry.serverId)
3868
+ ? `, and ${this.lifecycleRefusal(entry.serverId).message}; if you need it closed, ask whoever runs this host.`
3869
+ // channel_close refuses a tuned-out channel, so the cancel comes first.
3870
+ : decided === 'tuned-out'
3871
+ ? `. If you still want it closed, cancel the tune-out with tune_out mode "cancel", which delivers what it ` +
3872
+ `diverted, and then call channel_close.${serverBack}`
3873
+ : `. If you still want it closed, call channel_close.${serverBack}`;
3874
+ closeError = decided === 'tuned-out'
3875
+ ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
3876
+ `the subconscious${step}`
3877
+ : `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
3878
+ }
3879
+ else if (closed) {
3880
+ // Closed for certain, whatever became of the decline's own close: one
3881
+ // refused for a grant that went after an earlier operation confirmed
3882
+ // the close changes nothing the agent needs.
3883
+ }
3884
+ else if (closeFailed) {
3885
+ // What happened comes from the failure: a registration that went
3886
+ // while the close was out says so, since the server may have closed it
3887
+ // first, and one that came back since says that; a request that failed
3888
+ // while the registration went says both. What helps comes from the
3889
+ // state now.
3890
+ const failure = closeFailure instanceof ChannelLifecycleError ? closeFailure : undefined;
3891
+ const went = failure?.kind === 'cancelled' || failure?.kind === 'unlisted';
3892
+ closeError = withStop(`The decline is recorded, and this host closes the channel ${laterClause(entry)}; ` +
3893
+ (!went ? `closing it now didn't complete: ${lifecycleClause(closeFailure)}`
3894
+ : after !== undefined
3895
+ ? 'its registration went and came back while the close was out'
3896
+ : `it isn't registered here any more: ${failure.message}`)) + (after === undefined ? (went ? '' : ' It isn\'t registered here any more.') + serverBack : stepNow());
3897
+ }
3898
+ else if (after === undefined) {
3899
+ // The registration went while the acknowledgment was out, so no close
3900
+ // was tried.
3901
+ closeError = withStop(`The decline is recorded, and this host closes the channel ${laterClause(entry)}; it isn't registered here any more` +
3902
+ (reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : '')) + serverBack;
3903
+ }
3904
+ else if (!this.directsLifecycle(entry.serverId)) {
3905
+ closeError = reach === 'up'
3906
+ ? `The decline is recorded, but ${this.lifecycleRefusal(entry.serverId).message}, so the server keeps this one open ` +
3907
+ `or closed as it chooses, and declining again won't change that. This host closes it ${laterClause(entry)}. If you ` +
3908
+ 'need it closed sooner, ask whoever runs this host.'
3909
+ : `The decline is recorded, but ${unreachableClause(entry.serverId, reach)}, so the channel may not be closed there ` +
3910
+ `yet. This host closes it ${laterClause(entry)}, so there's no need to decline again to close it.` + serverBack;
3911
+ }
3912
+ else {
3913
+ // This host directs the channel's lifecycle and its close didn't fail,
3914
+ // so what remains is other work for it, or a change since that close's
3915
+ // receipt that left it unconfirmed; the step says which.
3916
+ closeError = 'The decline is recorded, but the channel isn\'t closed for certain yet.' + stepNow();
3917
+ }
3918
+ // Another decline is taken only where this one was: the channel
3919
+ // registered, not open, and not decided open.
3920
+ const again = stands && after !== undefined && !after.open;
3921
+ const acknowledgmentError = ackFailure?.(again ? 'call channel_decline again with the same arguments' : undefined);
3922
+ // What the decline found, in a word: the channel remained closed, the
3923
+ // decline stands though the channel may not be closed, or a later
3924
+ // decision replaced it.
3925
+ const status = !stands ? 'replaced' : closed ? 'remained closed' : 'declined';
3926
+ // An audit record nothing reads back: the decline took effect above, so
3927
+ // a log that refuses it is the operator's to hear about, not a failure
3928
+ // of the decline.
3929
+ try {
3930
+ this.appendLifecycleEvent({
3931
+ kind: 'invitation-declined',
3932
+ serverId: entry.serverId,
3933
+ channelId: input.channelId,
3934
+ messageId: input.messageId,
3935
+ acknowledgment: representation ?? input.acknowledge,
3936
+ timestamp: new Date().toISOString(),
3937
+ });
3938
+ }
3939
+ catch (err) {
3940
+ console.error(`[channel-decline] server=${entry.serverId} channel=${logValue(input.channelId)}: the declined invitation wasn't ` +
3941
+ `recorded: ${err instanceof Error ? err.message : String(err)}`);
3942
+ }
2753
3943
  return {
2754
3944
  success: true,
2755
3945
  data: {
2756
3946
  channelId: input.channelId,
2757
- status: 'remained closed',
3947
+ status,
2758
3948
  acknowledged,
2759
3949
  ...(representation ? { representation } : {}),
2760
3950
  ...(acknowledgmentError ? { acknowledgmentError } : {}),
2761
- ...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
3951
+ ...(closeError ? { closeError } : {}),
2762
3952
  },
2763
3953
  };
2764
3954
  }
@@ -2841,67 +4031,97 @@ export class ChannelRegistry {
2841
4031
  };
2842
4032
  }
2843
4033
  /**
2844
- * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
2845
- * Publish the agent's plain-text speech to the current conversational locus
2846
- * (the most recent incoming channel, tracked cross-surface here in the host).
2847
- * Called by the framework on a text-only turn — replaces the per-surface
2848
- * sticky auto-post that used to live in discord-mcpl. Returns null when there
2849
- * is no locus / the channel or its server can't be resolved (in which case
2850
- * the speech simply stays in chronicle + module surfaces).
4034
+ * MCPL Spec 14.3 outgoing streaming: forward a text delta to the server
4035
+ * owning the channel. The framework sends each piece of prose once its
4036
+ * publish succeeds, so a stream carries only what was published.
4037
+ * Emitted only when that server's grant has `channels.streaming` —
4038
+ * servers that never opted in receive nothing. Fire-and-forget and never
4039
+ * throws: streaming is an observer surface; the authoritative delivery is
4040
+ * channels/publish.
4041
+ *
4042
+ * `to` is the destination the publish went to, its server and channel.
4043
+ * The chunk goes there while that server registers that channel (Spec
4044
+ * 14.3: the recipient is "the server that registered the target
4045
+ * channel"). A stream doesn't start in a channel its server has declared
4046
+ * inbound-only (#350's rule), not even for a publish delivered there just
4047
+ * before the declaration. One that started there before it goes on, since
4048
+ * the declaration doesn't unregister the channel: a later chunk is for a
4049
+ * publish delivered before it. A bare channel id is resolved as before, to
4050
+ * the one server that registers it, at each call (streamingServerFor), and
4051
+ * keeps no record: a stream sent by destination ends by destination, since
4052
+ * only a destination's `complete` removes its record.
2851
4053
  */
2852
- /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
2853
- * process-global default). Public so a multi-segment caller can snapshot it
2854
- * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
2855
- /**
2856
- * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the
2857
- * server owning the channel, AS THE MODEL GENERATES. Emitted only when that
2858
- * server declared `channels.streaming` in its initialize capabilities —
2859
- * servers that never opted in (the whole existing fleet) receive nothing.
2860
- * Fire-and-forget and never throws: streaming is an observer surface; the
2861
- * authoritative delivery is the eventual channels/publish.
2862
- */
2863
- sendOutgoingChunk(channelId, conversationId, inferenceId, index, delta,
2864
- /** The server the channel resolves with, as the speech's locus does
2865
- * (deliverSpeech's locusServerId). */
2866
- serverId) {
2867
- const server = this.streamingServerFor(channelId, serverId);
4054
+ sendOutgoingChunk(to, conversationId, inferenceId, index, delta) {
4055
+ let server;
4056
+ if (typeof to === 'string') {
4057
+ server = this.streamingServerFor(to);
4058
+ }
4059
+ else {
4060
+ const key = destinationKey(to);
4061
+ let started = this.takenStreams.get(inferenceId);
4062
+ const continuing = started?.has(key) ?? false;
4063
+ server = this.streamingServerAt(to, !continuing);
4064
+ if (server && !continuing) {
4065
+ if (!started)
4066
+ this.takenStreams.set(inferenceId, started = new Set());
4067
+ started.add(key);
4068
+ }
4069
+ }
2868
4070
  if (!server)
2869
4071
  return;
4072
+ const channelId = typeof to === 'string' ? to : to.channelId;
2870
4073
  try {
2871
4074
  server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
2872
4075
  }
2873
4076
  catch { /* observer surface — never disturb the turn */ }
2874
4077
  }
2875
- /** Spec 14.3 companion: final moderated content per channel at stream end. */
2876
- sendOutgoingComplete(channelId, conversationId, inferenceId, text,
2877
- /** As sendOutgoingChunk's. */
2878
- serverId) {
2879
- const server = this.streamingServerFor(channelId, serverId);
4078
+ /**
4079
+ * Spec 14.3 companion: final moderated content per channel at stream end.
4080
+ * Given a destination, it goes only where the stream started
4081
+ * (sendOutgoingChunk), while that server still registers that channel,
4082
+ * inbound-only or not, and its grant has `channels.streaming`: once per
4083
+ * destination, whose consumer, such as a TTS utterance, finalizes on it
4084
+ * and reconciles any chunk it missed. A bare channel id is resolved as
4085
+ * before (streamingServerFor).
4086
+ */
4087
+ sendOutgoingComplete(to, conversationId, inferenceId, text) {
4088
+ let server;
4089
+ if (typeof to === 'string') {
4090
+ server = this.streamingServerFor(to);
4091
+ }
4092
+ else {
4093
+ const started = this.takenStreams.get(inferenceId);
4094
+ if (!started?.delete(destinationKey(to)))
4095
+ return;
4096
+ if (started.size === 0)
4097
+ this.takenStreams.delete(inferenceId);
4098
+ server = this.streamingServerAt(to, false);
4099
+ }
2880
4100
  if (!server)
2881
4101
  return;
2882
4102
  try {
2883
4103
  server.sendChannelsOutgoingComplete({
2884
4104
  inferenceId,
2885
4105
  conversationId,
2886
- channelId,
4106
+ channelId: typeof to === 'string' ? to : to.channelId,
2887
4107
  content: [{ type: 'text', text }],
2888
4108
  });
2889
4109
  }
2890
4110
  catch { /* observer surface — never disturb the turn */ }
2891
4111
  }
2892
- streamingServerFor(channelId, serverId) {
4112
+ streamingServerFor(channelId) {
2893
4113
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
2894
- // bare descriptor ids (what resolveProseTarget returns). With the server
2895
- // the speech resolves with, only that server's entry; otherwise scan like
2896
- // the resolver does, and fail closed on cross-server ambiguity — the
2897
- // same never-guess rule that governs delivery.
4114
+ // bare descriptor ids (what resolveProseTarget returns). Scan like the
4115
+ // resolver does, and fail closed on cross-server ambiguity — the same
4116
+ // never-guess rule that governs delivery.
2898
4117
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
2899
- const entry = serverId !== undefined
2900
- ? matches.find((e) => e.serverId === serverId)
2901
- : matches.length === 1 ? matches[0] : undefined;
2902
- if (!entry)
4118
+ if (matches.length !== 1)
2903
4119
  return null;
2904
- const server = this.serverRegistry.getServer(entry.serverId);
4120
+ // Nothing is published into an inbound-only channel, so nothing is
4121
+ // streamed there either.
4122
+ if (isInboundOnly(matches[0]))
4123
+ return null;
4124
+ const server = this.serverRegistry.getServer(matches[0].serverId);
2905
4125
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
2906
4126
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
2907
4127
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -2911,6 +4131,25 @@ export class ChannelRegistry {
2911
4131
  return null;
2912
4132
  return server;
2913
4133
  }
4134
+ /**
4135
+ * A destination's server, for a stream message: while that server
4136
+ * registers that channel, and its grant has `channels.streaming` (as in
4137
+ * streamingServerFor). A stream doesn't start in an inbound-only channel.
4138
+ */
4139
+ streamingServerAt(to, starting) {
4140
+ // The map's key joins the two ids with a colon, so another server's
4141
+ // entry can sit under a destination's key: it has to be this server's.
4142
+ const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
4143
+ if (!entry || entry.serverId !== to.serverId)
4144
+ return null;
4145
+ if (starting && isInboundOnly(entry))
4146
+ return null;
4147
+ const server = this.serverRegistry.getServer(to.serverId);
4148
+ return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
4149
+ }
4150
+ /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
4151
+ * process-global default). Public so a multi-segment caller can snapshot it
4152
+ * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
2914
4153
  resolveLocus(conversationId) {
2915
4154
  const home = this.homeChannelResolver?.(conversationId);
2916
4155
  return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
@@ -2950,13 +4189,17 @@ export class ChannelRegistry {
2950
4189
  if (matches.length === 0) {
2951
4190
  return {
2952
4191
  error: target.serverId
2953
- ? `no registered channel "${target.channelId}" on server "${target.serverId}"`
2954
- : `no registered channel "${target.channelId}"`,
4192
+ ? `the "${target.serverId}" server has no channel "${target.channelId}" registered here`
4193
+ : `no channel "${target.channelId}" is registered here`,
2955
4194
  };
2956
4195
  }
2957
4196
  if (matches.length > 1) {
2958
4197
  return {
2959
- error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`,
4198
+ // Speech names no server, and neither its >> prefix nor
4199
+ // channel_publish can: whoever runs this host can. A caller that
4200
+ // can name one reads that none was.
4201
+ error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
4202
+ 'if you need them there, ask whoever runs this host',
2960
4203
  };
2961
4204
  }
2962
4205
  return { entry: matches[0] };
@@ -2978,7 +4221,33 @@ export class ChannelRegistry {
2978
4221
  * possible to send into a closed channel: a closed but registered
2979
4222
  * destination is opened first (`openSource` names why), once its server
2980
4223
  * and that server's channels.publish grant are known to take the send, so
2981
- * a refused send opens nothing; and a failed open sends nothing.
4224
+ * a send refused before the open opens nothing; and a failed open sends
4225
+ * nothing.
4226
+ *
4227
+ * A channel its server declared inbound-only (isInboundOnly) takes nothing
4228
+ * the host would post, whichever path the words took, a drafts resend
4229
+ * included: it's refused before anything opens. The channel's queued
4230
+ * lifecycle work and the open await the server, and a channels/changed
4231
+ * update, a re-register or the open's own answer (applyOpenDeclaration)
4232
+ * may declare the channel inbound-only meanwhile, or a removal take it
4233
+ * away. So the check just before the publish, after every await, reads
4234
+ * the channel's registration as it is then, and refuses one that is gone
4235
+ * or inbound-only, or whose server's grant no longer has channels.publish.
4236
+ * A removal during a wait already fails it there (waitForOpenChannel); the
4237
+ * check also catches one made after the last wait, as the opening's notice
4238
+ * can. A registration nothing has confirmed open, such as one that
4239
+ * replaced the one waited for or a retarget of it, or one whose close now
4240
+ * stands (where the host directs the server's lifecycle), is treated as at
4241
+ * the first wait: the words wait for its queued lifecycle work, and the
4242
+ * check runs again on the registration that work leaves. Words refused
4243
+ * after an open for a declaration leave the channel open, and the agent
4244
+ * gets the failure for the words. An open by delivery (`openSource`
4245
+ * 'opened-by-delivery', the default) that turned a closed channel open
4246
+ * also gives the notice that it's open, which says how to close it to a
4247
+ * reader that can.
4248
+ * One that only reconfirmed the transport of a channel already decided
4249
+ * open or tuned out changes no decision, and gives none. When the
4250
+ * registration is gone, the failure says the channel was removed.
2982
4251
  *
2983
4252
  * Only the connector's `delivered: true` confirms a post. `failed` means
2984
4253
  * nothing was posted: the attempt stopped before the request was written,
@@ -3003,20 +4272,19 @@ export class ChannelRegistry {
3003
4272
  };
3004
4273
  // A send that would be refused is refused before anything opens: a
3005
4274
  // closed channel opens only for words that can go out.
3006
- const server = this.serverRegistry.getServer(entry.serverId);
3007
- if (!server) {
3008
- return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() };
4275
+ if (isInboundOnly(entry)) {
4276
+ return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
3009
4277
  }
4278
+ const server = this.serverRegistry.getServer(entry.serverId);
3010
4279
  // §14.1: channels/publish requires channels.publish in the grant. The
3011
- // host not sending is the enforcement.
3012
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3013
- return {
3014
- status: 'failed',
3015
- destination,
3016
- reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`,
3017
- at: at(),
3018
- };
3019
- }
4280
+ // host not sending is the enforcement, here and at the last check, and
4281
+ // the server's state words the refusal (sendRefusal).
4282
+ const ungranted = () => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
4283
+ if (!server || !CapabilityGrant.of(server).has('channels.publish'))
4284
+ return ungranted();
4285
+ const prevented = (err) => ({
4286
+ status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
4287
+ });
3020
4288
  // Lifecycle work already queued for the channel settles first: words go
3021
4289
  // out only into the channel it leaves, and fail visibly if it leaves
3022
4290
  // none open.
@@ -3025,12 +4293,7 @@ export class ChannelRegistry {
3025
4293
  entry = await this.waitForOpenChannel(entry);
3026
4294
  }
3027
4295
  catch (err) {
3028
- return {
3029
- status: 'failed',
3030
- destination,
3031
- reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3032
- at: at(),
3033
- };
4296
+ return prevented(err);
3034
4297
  }
3035
4298
  }
3036
4299
  // INVARIANT: it is not possible to send into a closed channel. A send
@@ -3074,13 +4337,44 @@ export class ChannelRegistry {
3074
4337
  }
3075
4338
  }
3076
4339
  catch (err) {
3077
- return {
3078
- status: 'failed',
3079
- destination,
3080
- reason: `channel is closed and open failed: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3081
- at: at(),
3082
- };
4340
+ return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
4341
+ }
4342
+ }
4343
+ // Checked again here, after every await above, the lifecycle work's, the
4344
+ // open's or any other: a registration that is gone, or inbound-only, at
4345
+ // this moment takes nothing. The entry is read from the map rather than
4346
+ // taken from the last wait: the opening's notice runs in between, and
4347
+ // publish() resumes a microtask after the wait. A registration nothing
4348
+ // has confirmed open, such as one that replaced the one waited for
4349
+ // (registered with open: false) or a retarget of it (marked unconfirmed),
4350
+ // or one whose close now stands (closeStands), is treated as at the
4351
+ // first wait: the words wait for its queued lifecycle work, and
4352
+ // waitForOpenChannel refuses what that work leaves unconfirmed or with
4353
+ // its close standing. The loop opens nothing itself. The check then runs
4354
+ // again on the registration that stands. waitForOpenChannel returns
4355
+ // only a registration it found open and whose close doesn't stand, or
4356
+ // throws, so the loop ends unless the channel keeps changing.
4357
+ const key = `${entry.serverId}:${entry.descriptor.id}`;
4358
+ let current = this.channels.get(key);
4359
+ while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
4360
+ try {
4361
+ entry = await this.waitForOpenChannel(current);
4362
+ }
4363
+ catch (err) {
4364
+ return prevented(err);
3083
4365
  }
4366
+ current = this.channels.get(key);
4367
+ }
4368
+ if (!current)
4369
+ return { status: 'failed', destination, reason: CHANNEL_REMOVED_CLAUSE, at: at() };
4370
+ if (isInboundOnly(current)) {
4371
+ return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
4372
+ }
4373
+ // A grant reduced during any of the waits holds at once (§6.7): the
4374
+ // words already on their way take it too. Its refusal is worded for the
4375
+ // connection that refused (sendRefusal's `checked`).
4376
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
4377
+ return { status: 'failed', destination, reason: this.sendRefusal(entry.serverId, server), at: at() };
3084
4378
  }
3085
4379
  let result;
3086
4380
  try {
@@ -3099,9 +4393,13 @@ export class ChannelRegistry {
3099
4393
  * Plain speech for one turn segment: publish `text` to the turn's frozen
3100
4394
  * locus and report the PublishOutcome. A failure (or an uncertain outcome)
3101
4395
  * is surfaced as a trace and through onRouteFailure, so the agent learns
3102
- * its words did not (or may not have) reached anyone. An ephemeral run's
3103
- * speech with no locus is the exception: it is its caller's result, so it
3104
- * returns as not delivered and surfaces nothing.
4396
+ * its words did not (or may not have) reached anyone. A `failed` one whose
4397
+ * caller holds its words with a notice of its own (`heldOnFailure`) gets
4398
+ * the trace but not onRouteFailure. An ephemeral run's speech with no locus
4399
+ * (null) is its caller's result: it returns as not delivered and surfaces
4400
+ * nothing. This is host-owned output routing (see forking-knowledge-miner
4401
+ * LOCUS-ROUTING-DESIGN), which replaced the per-surface sticky auto-post
4402
+ * that used to live in discord-mcpl.
3105
4403
  */
3106
4404
  async deliverSpeech(conversationId, text,
3107
4405
  /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
@@ -3116,23 +4414,20 @@ export class ChannelRegistry {
3116
4414
  locusChannelId,
3117
4415
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3118
4416
  * draft with its own notice, so no failure marker is written for it.
3119
- * An `unknown` outcome still gets its marker. `locusServerId`: the
3120
- * server the locus resolves with, snapshotted by the caller with the
3121
- * locus: the server whose message triggered the turn, where the locus is
3122
- * that message's channel. The speech goes out through that server only,
3123
- * and fails where it no longer registers the channel. Without it, the
3124
- * locus is taken only where exactly one server registers it. */
4417
+ * An `unknown` outcome still gets its marker. */
3125
4418
  opts) {
3126
- const fail = (channelId, reason, outcome) => {
4419
+ // `logged` is what the log line and the trace say, where it isn't the
4420
+ // agent's reason (the two no-locus reasons below).
4421
+ const fail = (channelId, reason, outcome, logged = reason) => {
3127
4422
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
3128
4423
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
3129
4424
  const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
3130
- console.error(`[routeSpeech] ${conversationId}: ${reason}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
4425
+ console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
3131
4426
  this.emitTraceFn({
3132
4427
  type: 'mcpl:speech-route-failed',
3133
4428
  conversationId,
3134
4429
  channelId: channelId ?? '',
3135
- reason,
4430
+ reason: logged,
3136
4431
  textLen: text.length,
3137
4432
  outcome: status,
3138
4433
  ...(detail !== undefined ? { detail } : {}),
@@ -3142,12 +4437,18 @@ export class ChannelRegistry {
3142
4437
  }
3143
4438
  return outcome;
3144
4439
  };
4440
+ // These two reasons are the only ones whose log line and trace keep the
4441
+ // operator's words rather than the agent's: connectome-host's
4442
+ // connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
4443
+ // "no locus" in both logged texts. Each names no tool: whether one the
4444
+ // agent may call could post the words isn't known here, and the notice
4445
+ // calls it a routing or configuration situation.
3145
4446
  // Runtime backstop for JS callers the compiler can't see: an omitted
3146
4447
  // locus is a routing bug at the call site, never something to paper over
3147
4448
  // with a live re-resolution.
3148
4449
  if (locusChannelId === undefined) {
3149
- const reason = 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)';
3150
- return fail(null, reason, { status: 'failed', reason, at: Date.now() });
4450
+ const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
4451
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
3151
4452
  }
3152
4453
  if (!locusChannelId) {
3153
4454
  // An ephemeral run with no channel of its own: its plain speech is its
@@ -3159,14 +4460,12 @@ export class ChannelRegistry {
3159
4460
  // The turn froze with no locus (no home, no triggering channel, no
3160
4461
  // global inbound ever seen) — the agent was told its prose stays in
3161
4462
  // the archive; honor that.
3162
- const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)';
3163
- return fail(null, reason, { status: 'failed', reason, at: Date.now() });
4463
+ // The locus froze at the turn's start, so this says what the turn had.
4464
+ const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
4465
+ 'had one, ask whoever runs this host';
4466
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
3164
4467
  }
3165
- const outcome = await this.publish(conversationId, text, {
3166
- ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
3167
- channelId: locusChannelId,
3168
- routed: true,
3169
- });
4468
+ const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3170
4469
  const channelId = outcome.destination?.channelId ?? locusChannelId;
3171
4470
  if (outcome.status !== 'delivered') {
3172
4471
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -3187,83 +4486,88 @@ export class ChannelRegistry {
3187
4486
  });
3188
4487
  return outcome;
3189
4488
  }
3190
- /** deliverSpeech, reduced to the confirmed delivery (null when there was
3191
- * none). `opts.locusServerId` is deliverSpeech's. */
3192
- async routeSpeech(conversationId, text, locusChannelId, opts) {
3193
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId, opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
4489
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
4490
+ async routeSpeech(conversationId, text, locusChannelId) {
4491
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3194
4492
  if (outcome.status !== 'delivered')
3195
4493
  return null;
3196
4494
  return {
3197
4495
  delivered: true,
4496
+ serverId: outcome.destination.serverId,
3198
4497
  channelId: outcome.destination.channelId,
3199
4498
  ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
3200
4499
  };
3201
4500
  }
3202
- async handleToolPublish(input, origin) {
3203
- // Resolve content: accept both `content` and `text` (backward compat)
4501
+ /**
4502
+ * channel_publish, and the subconscious's speak_in_channel
4503
+ * (publishForAgent), which shares its delivery. Where an agent finds a
4504
+ * channel id differs: the subconscious isn't offered channel_list, and is
4505
+ * given speak_in_channel for the tuned-out channels whose messages it reads.
4506
+ */
4507
+ async handleToolPublish(input, origin,
4508
+ /** Filled for publishForAgent: the publish's outcome once a request is
4509
+ * attempted, for its trace, or which check of the destination refused
4510
+ * it before anything went out, for its answer. */
4511
+ seen, tool = 'channel_publish') {
4512
+ const findIds = tool === 'channel_publish'
4513
+ ? 'channel_list shows the channel ids.'
4514
+ : 'Use the id of a tuned-out channel whose messages you are reading.';
4515
+ // Resolve content: accept both `content` and `text` (backward compat).
4516
+ // speak_in_channel checks its own text first, so only channel_publish
4517
+ // reads this refusal.
3204
4518
  const messageText = input.content ?? input.text;
3205
4519
  if (!messageText) {
3206
- return {
3207
- success: false,
3208
- error: 'Either content or text parameter is required',
3209
- isError: true,
3210
- };
4520
+ return { success: false, error: 'Nothing was posted: channel_publish needs the message in `content`.', isError: true };
4521
+ }
4522
+ // An empty channelId names nothing, and isn't read as one left out.
4523
+ // speak_in_channel's caller passes one left out as empty, so there it
4524
+ // gets the refusal for one left out, as speak_in_channel has no default.
4525
+ if (input.channelId === '' && tool === 'channel_publish') {
4526
+ return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
3211
4527
  }
3212
- // Resolve channelId: default to the most recent incoming channel, which an
3213
- // ephemeral run never takes (takesGlobalFallback), so it names one.
4528
+ // Resolve channelId: default to the channel of the most recent incoming
4529
+ // message that isn't a reaction, reaction removal, edit or deletion
4530
+ // marker, apart from an edit the coalescer delivers on acceptance as the
4531
+ // agent's first view. An ephemeral run never takes that default
4532
+ // (takesGlobalFallback), so it names one.
3214
4533
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
3215
4534
  const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
3216
4535
  if (!channelId) {
3217
4536
  return {
3218
4537
  success: false,
3219
- error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
3220
- ? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
3221
- + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
3222
- : 'No channelId specified and no default channel available',
4538
+ error: tool === 'speak_in_channel'
4539
+ ? `Nothing was posted: speak_in_channel needs a \`channelId\`. ${findIds}`
4540
+ : caller !== undefined && this.takesGlobalFallback?.(caller) === false
4541
+ ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
4542
+ + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
4543
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
3223
4544
  isError: true,
3224
4545
  };
3225
4546
  }
3226
- // A serverId the call gives names the exact pair, as the other channel
3227
- // tools read theirs (this tool's schema doesn't declare one); with no
3228
- // channelId, it's the default channel on that server. A call naming no
3229
- // server takes the calling turn's server for its trigger's or locus's
3230
- // channel; else, for the default channel, the server whose accepted
3231
- // message set it; else a channelId only where exactly one server
3232
- // registers it.
3233
- const serverId = input.serverId;
3234
- const trigger = !serverId && origin?.kind === 'agent'
3235
- ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
3236
- : undefined;
3237
- const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
3238
- ? this.fallbackServerFor(channelId)
3239
- : undefined;
3240
- const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
3241
- const entry = resolved.entry;
4547
+ const entry = this.findChannelEntry(channelId);
3242
4548
  if (!entry) {
3243
- return {
3244
- success: false,
3245
- error: trigger !== undefined
3246
- ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
3247
- : defaultServer !== undefined
3248
- ? `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.`
3249
- : resolved.error,
3250
- isError: true,
3251
- };
4549
+ if (seen)
4550
+ seen.refusal = 'channel-not-registered';
4551
+ return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true };
3252
4552
  }
3253
- const server = this.serverRegistry.getServer(entry.serverId);
3254
- if (!server) {
4553
+ if (isInboundOnly(entry)) {
4554
+ if (seen)
4555
+ seen.refusal = 'inbound-only';
3255
4556
  return {
3256
4557
  success: false,
3257
- error: `Server not found: ${entry.serverId}`,
4558
+ error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`,
3258
4559
  isError: true,
3259
4560
  };
3260
4561
  }
3261
- if (!CapabilityGrant.of(server).has('channels.publish')) {
3262
- return {
3263
- success: false,
3264
- error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
3265
- isError: true,
3266
- };
4562
+ // The same refusal, in the same words, as plain speech's (sendRefusal),
4563
+ // whose server state is the reason's: nothing awaits between the two.
4564
+ const server = this.serverRegistry.getServer(entry.serverId);
4565
+ if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
4566
+ if (seen) {
4567
+ const reach = this.serverReach(entry.serverId);
4568
+ seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
4569
+ }
4570
+ return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true };
3267
4571
  }
3268
4572
  const publishParams = {
3269
4573
  conversationId: '', // Framework will fill this when wired
@@ -3285,7 +4589,13 @@ export class ChannelRegistry {
3285
4589
  }
3286
4590
  catch (err) {
3287
4591
  outcome = publishErrorOutcome(err, destination, Date.now());
4592
+ // Refused before anything was written: nothing went out, as its words
4593
+ // say, so it's a refusal of the destination like the checks above.
4594
+ if (seen && err instanceof McplRequestError && err.outcome === 'not-sent')
4595
+ seen.refusal = 'not-sent';
3288
4596
  }
4597
+ if (seen)
4598
+ seen.outcome = outcome;
3289
4599
  if (outcome.status === 'delivered')
3290
4600
  return { success: true, data: result };
3291
4601
  // The connector's data may say what a multi-part send already posted.
@@ -3293,8 +4603,8 @@ export class ChannelRegistry {
3293
4603
  return {
3294
4604
  success: false,
3295
4605
  error: (outcome.status === 'failed'
3296
- ? `Publish to ${channelId} failed: ${outcome.reason}. Nothing was posted.`
3297
- : `Publish to ${channelId} wasn't confirmed: ${outcome.reason}. ` +
4606
+ ? `Nothing was posted to channel "${channelId}": ${outcome.reason}.`
4607
+ : `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` +
3298
4608
  'It may or may not have been posted, so sending it again could post it twice.') + detail,
3299
4609
  isError: true,
3300
4610
  };