@animalabs/agent-framework 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. package/README.md +340 -22
  2. package/dist/src/admission/admit.d.ts +18 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -1
  4. package/dist/src/admission/admit.js +16 -3
  5. package/dist/src/admission/admit.js.map +1 -1
  6. package/dist/src/agent.d.ts +208 -80
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +345 -113
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +50 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +4 -20
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/append-all.d.ts +5 -0
  17. package/dist/src/append-all.d.ts.map +1 -0
  18. package/dist/src/append-all.js +8 -0
  19. package/dist/src/append-all.js.map +1 -0
  20. package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
  21. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  22. package/dist/src/code-execution/isolate/scripts.js +24 -0
  23. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  24. package/dist/src/config-checks.d.ts +7 -0
  25. package/dist/src/config-checks.d.ts.map +1 -1
  26. package/dist/src/config-checks.js +14 -0
  27. package/dist/src/config-checks.js.map +1 -1
  28. package/dist/src/context-manager-errors.d.ts +25 -0
  29. package/dist/src/context-manager-errors.d.ts.map +1 -0
  30. package/dist/src/context-manager-errors.js +31 -0
  31. package/dist/src/context-manager-errors.js.map +1 -0
  32. package/dist/src/context-options.d.ts +70 -0
  33. package/dist/src/context-options.d.ts.map +1 -0
  34. package/dist/src/context-options.js +90 -0
  35. package/dist/src/context-options.js.map +1 -0
  36. package/dist/src/cut-off-round.js +4 -4
  37. package/dist/src/deferred-import.d.ts +93 -0
  38. package/dist/src/deferred-import.d.ts.map +1 -0
  39. package/dist/src/deferred-import.js +177 -0
  40. package/dist/src/deferred-import.js.map +1 -0
  41. package/dist/src/dendrite/audience.d.ts +223 -0
  42. package/dist/src/dendrite/audience.d.ts.map +1 -0
  43. package/dist/src/dendrite/audience.js +202 -0
  44. package/dist/src/dendrite/audience.js.map +1 -0
  45. package/dist/src/dendrite/index.d.ts +5 -3
  46. package/dist/src/dendrite/index.d.ts.map +1 -1
  47. package/dist/src/dendrite/index.js +2 -1
  48. package/dist/src/dendrite/index.js.map +1 -1
  49. package/dist/src/dendrite/presets.d.ts +10 -5
  50. package/dist/src/dendrite/presets.d.ts.map +1 -1
  51. package/dist/src/dendrite/presets.js +17 -5
  52. package/dist/src/dendrite/presets.js.map +1 -1
  53. package/dist/src/dendrite/registry.d.ts +62 -7
  54. package/dist/src/dendrite/registry.d.ts.map +1 -1
  55. package/dist/src/dendrite/registry.js +183 -12
  56. package/dist/src/dendrite/registry.js.map +1 -1
  57. package/dist/src/dendrite/types.d.ts +91 -8
  58. package/dist/src/dendrite/types.d.ts.map +1 -1
  59. package/dist/src/failure-text.d.ts +36 -0
  60. package/dist/src/failure-text.d.ts.map +1 -0
  61. package/dist/src/failure-text.js +89 -0
  62. package/dist/src/failure-text.js.map +1 -0
  63. package/dist/src/foreign-rounds.d.ts +69 -0
  64. package/dist/src/foreign-rounds.d.ts.map +1 -0
  65. package/dist/src/foreign-rounds.js +152 -0
  66. package/dist/src/foreign-rounds.js.map +1 -0
  67. package/dist/src/framework.d.ts +2064 -842
  68. package/dist/src/framework.d.ts.map +1 -1
  69. package/dist/src/framework.js +8586 -3739
  70. package/dist/src/framework.js.map +1 -1
  71. package/dist/src/gate/event-gate.d.ts +125 -21
  72. package/dist/src/gate/event-gate.d.ts.map +1 -1
  73. package/dist/src/gate/event-gate.js +487 -72
  74. package/dist/src/gate/event-gate.js.map +1 -1
  75. package/dist/src/gate/index.d.ts +1 -1
  76. package/dist/src/gate/index.d.ts.map +1 -1
  77. package/dist/src/gate/index.js.map +1 -1
  78. package/dist/src/gate/reply-judge.d.ts +4 -0
  79. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  80. package/dist/src/gate/reply-judge.js +12 -3
  81. package/dist/src/gate/reply-judge.js.map +1 -1
  82. package/dist/src/gate/rule-scope.d.ts +30 -0
  83. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  84. package/dist/src/gate/rule-scope.js +41 -0
  85. package/dist/src/gate/rule-scope.js.map +1 -0
  86. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  87. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  88. package/dist/src/gate/suppress-script-worker.js +103 -0
  89. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  90. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  91. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  92. package/dist/src/gate/suppress-scripts.js +403 -0
  93. package/dist/src/gate/suppress-scripts.js.map +1 -0
  94. package/dist/src/gate/suppression-review.d.ts +143 -0
  95. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  96. package/dist/src/gate/suppression-review.js +463 -0
  97. package/dist/src/gate/suppression-review.js.map +1 -0
  98. package/dist/src/gate/suppression-stats.d.ts +125 -0
  99. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  100. package/dist/src/gate/suppression-stats.js +214 -0
  101. package/dist/src/gate/suppression-stats.js.map +1 -0
  102. package/dist/src/gate/types.d.ts +142 -7
  103. package/dist/src/gate/types.d.ts.map +1 -1
  104. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  105. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  106. package/dist/src/gate/wake-onboarding.js +277 -0
  107. package/dist/src/gate/wake-onboarding.js.map +1 -0
  108. package/dist/src/heartbeat/heartbeat.js +1 -1
  109. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  110. package/dist/src/held-writes.d.ts +31 -23
  111. package/dist/src/held-writes.d.ts.map +1 -1
  112. package/dist/src/held-writes.js +44 -59
  113. package/dist/src/held-writes.js.map +1 -1
  114. package/dist/src/index.d.ts +6 -2
  115. package/dist/src/index.d.ts.map +1 -1
  116. package/dist/src/index.js +4 -0
  117. package/dist/src/index.js.map +1 -1
  118. package/dist/src/inference-bounds.d.ts +2 -1
  119. package/dist/src/inference-bounds.d.ts.map +1 -1
  120. package/dist/src/inference-bounds.js +9 -6
  121. package/dist/src/inference-bounds.js.map +1 -1
  122. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  123. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  124. package/dist/src/jsonrpc-envelope.js +66 -0
  125. package/dist/src/jsonrpc-envelope.js.map +1 -0
  126. package/dist/src/mcpl/channel-registry.d.ts +188 -28
  127. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  128. package/dist/src/mcpl/channel-registry.js +426 -70
  129. package/dist/src/mcpl/channel-registry.js.map +1 -1
  130. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  131. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  132. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  133. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  134. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  135. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  136. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  137. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  138. package/dist/src/mcpl/inbound-source.js +2 -1
  139. package/dist/src/mcpl/inbound-source.js.map +1 -1
  140. package/dist/src/mcpl/inference-router.d.ts +6 -6
  141. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  142. package/dist/src/mcpl/inference-router.js.map +1 -1
  143. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  144. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  145. package/dist/src/mcpl/modern-connection.js +593 -86
  146. package/dist/src/mcpl/modern-connection.js.map +1 -1
  147. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  148. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  149. package/dist/src/mcpl/protocol-family.js +15 -9
  150. package/dist/src/mcpl/protocol-family.js.map +1 -1
  151. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  152. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  153. package/dist/src/mcpl/push-coalescer.js +105 -15
  154. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  155. package/dist/src/mcpl/push-handler.d.ts +9 -7
  156. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  157. package/dist/src/mcpl/push-handler.js.map +1 -1
  158. package/dist/src/mcpl/server-connection.d.ts +50 -28
  159. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  160. package/dist/src/mcpl/server-connection.js +165 -72
  161. package/dist/src/mcpl/server-connection.js.map +1 -1
  162. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  163. package/dist/src/mcpl/tool-classes.js +3 -0
  164. package/dist/src/mcpl/tool-classes.js.map +1 -1
  165. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  166. package/dist/src/mcpl/tool-presence.js +10 -6
  167. package/dist/src/mcpl/tool-presence.js.map +1 -1
  168. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  169. package/dist/src/mcpl/toolset-host.js +7 -6
  170. package/dist/src/mcpl/toolset-host.js.map +1 -1
  171. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  172. package/dist/src/mcpl/toolsets-module.js +2 -1
  173. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  174. package/dist/src/mcpl/types.d.ts +33 -6
  175. package/dist/src/mcpl/types.d.ts.map +1 -1
  176. package/dist/src/mcpl/types.js.map +1 -1
  177. package/dist/src/module-registry.d.ts +24 -1
  178. package/dist/src/module-registry.d.ts.map +1 -1
  179. package/dist/src/module-registry.js +68 -18
  180. package/dist/src/module-registry.js.map +1 -1
  181. package/dist/src/modules/discord/index.d.ts +45 -8
  182. package/dist/src/modules/discord/index.d.ts.map +1 -1
  183. package/dist/src/modules/discord/index.js +238 -87
  184. package/dist/src/modules/discord/index.js.map +1 -1
  185. package/dist/src/modules/health/index.js +2 -2
  186. package/dist/src/modules/health/index.js.map +1 -1
  187. package/dist/src/modules/history/index.d.ts +59 -0
  188. package/dist/src/modules/history/index.d.ts.map +1 -1
  189. package/dist/src/modules/history/index.js +432 -67
  190. package/dist/src/modules/history/index.js.map +1 -1
  191. package/dist/src/modules/history/semantic.d.ts +6 -5
  192. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  193. package/dist/src/modules/history/semantic.js +18 -25
  194. package/dist/src/modules/history/semantic.js.map +1 -1
  195. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  196. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  197. package/dist/src/modules/workspace/index.d.ts +28 -7
  198. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  199. package/dist/src/modules/workspace/index.js +80 -25
  200. package/dist/src/modules/workspace/index.js.map +1 -1
  201. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  202. package/dist/src/modules/workspace/observe.js +2 -1
  203. package/dist/src/modules/workspace/observe.js.map +1 -1
  204. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/reconcile.js +3 -2
  206. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  207. package/dist/src/notice-tags.d.ts +20 -9
  208. package/dist/src/notice-tags.d.ts.map +1 -1
  209. package/dist/src/notice-tags.js +20 -9
  210. package/dist/src/notice-tags.js.map +1 -1
  211. package/dist/src/operator-log.d.ts +36 -3
  212. package/dist/src/operator-log.d.ts.map +1 -1
  213. package/dist/src/operator-log.js +2 -2
  214. package/dist/src/operator-log.js.map +1 -1
  215. package/dist/src/presentation.d.ts +747 -0
  216. package/dist/src/presentation.d.ts.map +1 -0
  217. package/dist/src/presentation.js +1437 -0
  218. package/dist/src/presentation.js.map +1 -0
  219. package/dist/src/prose-drafts.d.ts +15 -11
  220. package/dist/src/prose-drafts.d.ts.map +1 -1
  221. package/dist/src/prose-drafts.js +16 -12
  222. package/dist/src/prose-drafts.js.map +1 -1
  223. package/dist/src/read-marks.d.ts +192 -0
  224. package/dist/src/read-marks.d.ts.map +1 -0
  225. package/dist/src/read-marks.js +242 -0
  226. package/dist/src/read-marks.js.map +1 -0
  227. package/dist/src/reasoning-loss.d.ts +5 -0
  228. package/dist/src/reasoning-loss.d.ts.map +1 -0
  229. package/dist/src/reasoning-loss.js +8 -0
  230. package/dist/src/reasoning-loss.js.map +1 -0
  231. package/dist/src/recovery/offline-branch.d.ts +91 -6
  232. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  233. package/dist/src/recovery/offline-branch.js +277 -32
  234. package/dist/src/recovery/offline-branch.js.map +1 -1
  235. package/dist/src/recovery/recover-cli.js +24 -4
  236. package/dist/src/recovery/recover-cli.js.map +1 -1
  237. package/dist/src/send-tools.d.ts +5 -9
  238. package/dist/src/send-tools.d.ts.map +1 -1
  239. package/dist/src/send-tools.js +40 -7
  240. package/dist/src/send-tools.js.map +1 -1
  241. package/dist/src/sent-rows.js +1 -1
  242. package/dist/src/stored-injections.d.ts +2 -2
  243. package/dist/src/stored-injections.js +2 -2
  244. package/dist/src/stored-removal.d.ts +17 -4
  245. package/dist/src/stored-removal.d.ts.map +1 -1
  246. package/dist/src/stored-removal.js +29 -5
  247. package/dist/src/stored-removal.js.map +1 -1
  248. package/dist/src/supplied-text.d.ts +1 -1
  249. package/dist/src/supplied-text.js +3 -3
  250. package/dist/src/supplied-text.js.map +1 -1
  251. package/dist/src/thinking-drops.d.ts +2 -0
  252. package/dist/src/thinking-drops.d.ts.map +1 -1
  253. package/dist/src/thinking-drops.js.map +1 -1
  254. package/dist/src/tool-presentation.d.ts.map +1 -1
  255. package/dist/src/tool-presentation.js +38 -9
  256. package/dist/src/tool-presentation.js.map +1 -1
  257. package/dist/src/tool-result-guard.d.ts +12 -1
  258. package/dist/src/tool-result-guard.d.ts.map +1 -1
  259. package/dist/src/tool-result-guard.js +42 -8
  260. package/dist/src/tool-result-guard.js.map +1 -1
  261. package/dist/src/tune-out/coordinator.d.ts +1170 -81
  262. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  263. package/dist/src/tune-out/coordinator.js +3732 -369
  264. package/dist/src/tune-out/coordinator.js.map +1 -1
  265. package/dist/src/tune-out/tools.d.ts +114 -17
  266. package/dist/src/tune-out/tools.d.ts.map +1 -1
  267. package/dist/src/tune-out/tools.js +89 -28
  268. package/dist/src/tune-out/tools.js.map +1 -1
  269. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  270. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  271. package/dist/src/tune-out/window-anchor.js +202 -0
  272. package/dist/src/tune-out/window-anchor.js.map +1 -0
  273. package/dist/src/types/agent.d.ts +121 -25
  274. package/dist/src/types/agent.d.ts.map +1 -1
  275. package/dist/src/types/events.d.ts +2 -0
  276. package/dist/src/types/events.d.ts.map +1 -1
  277. package/dist/src/types/framework.d.ts +72 -17
  278. package/dist/src/types/framework.d.ts.map +1 -1
  279. package/dist/src/types/inference-log.d.ts +1 -0
  280. package/dist/src/types/inference-log.d.ts.map +1 -1
  281. package/dist/src/types/module.d.ts +180 -69
  282. package/dist/src/types/module.d.ts.map +1 -1
  283. package/dist/src/types/trace.d.ts +573 -39
  284. package/dist/src/types/trace.d.ts.map +1 -1
  285. package/package.json +4 -4
  286. package/src/admission/admit.ts +32 -3
  287. package/src/agent.ts +392 -132
  288. package/src/api/mcp-server-core.ts +54 -17
  289. package/src/api/server.ts +4 -23
  290. package/src/append-all.ts +6 -0
  291. package/src/code-execution/isolate/scripts.ts +28 -3
  292. package/src/config-checks.ts +14 -0
  293. package/src/context-manager-errors.ts +37 -0
  294. package/src/context-options.ts +106 -0
  295. package/src/cut-off-round.ts +4 -4
  296. package/src/deferred-import.ts +239 -0
  297. package/src/dendrite/audience.ts +315 -0
  298. package/src/dendrite/index.ts +14 -2
  299. package/src/dendrite/presets.ts +18 -6
  300. package/src/dendrite/registry.ts +213 -14
  301. package/src/dendrite/types.ts +92 -8
  302. package/src/failure-text.ts +88 -0
  303. package/src/foreign-rounds.ts +199 -0
  304. package/src/framework.ts +8796 -3813
  305. package/src/gate/event-gate.ts +518 -91
  306. package/src/gate/index.ts +3 -0
  307. package/src/gate/reply-judge.ts +12 -3
  308. package/src/gate/rule-scope.ts +45 -0
  309. package/src/gate/suppress-script-worker.ts +89 -0
  310. package/src/gate/suppress-scripts.ts +414 -0
  311. package/src/gate/suppression-review.ts +517 -0
  312. package/src/gate/suppression-stats.ts +314 -0
  313. package/src/gate/types.ts +148 -7
  314. package/src/gate/wake-onboarding.ts +309 -0
  315. package/src/heartbeat/heartbeat.ts +1 -1
  316. package/src/held-writes.ts +49 -60
  317. package/src/index.ts +10 -2
  318. package/src/inference-bounds.ts +11 -6
  319. package/src/jsonrpc-envelope.ts +81 -0
  320. package/src/mcpl/channel-registry.ts +513 -116
  321. package/src/mcpl/checkpoint-manager.ts +3 -2
  322. package/src/mcpl/feature-set-manager.ts +2 -1
  323. package/src/mcpl/inbound-source.ts +2 -1
  324. package/src/mcpl/inference-router.ts +7 -4
  325. package/src/mcpl/modern-connection.ts +629 -91
  326. package/src/mcpl/protocol-family.ts +15 -9
  327. package/src/mcpl/push-coalescer.ts +193 -34
  328. package/src/mcpl/push-handler.ts +10 -5
  329. package/src/mcpl/server-connection.ts +169 -75
  330. package/src/mcpl/tool-classes.ts +3 -0
  331. package/src/mcpl/tool-presence.ts +10 -5
  332. package/src/mcpl/toolset-host.ts +7 -6
  333. package/src/mcpl/toolsets-module.ts +2 -1
  334. package/src/mcpl/types.ts +33 -6
  335. package/src/module-registry.ts +73 -17
  336. package/src/modules/discord/index.ts +230 -82
  337. package/src/modules/health/index.ts +2 -2
  338. package/src/modules/history/index.ts +439 -70
  339. package/src/modules/history/semantic.ts +18 -24
  340. package/src/modules/workspace/disk-agreement.ts +1 -1
  341. package/src/modules/workspace/index.ts +89 -26
  342. package/src/modules/workspace/observe.ts +2 -1
  343. package/src/modules/workspace/reconcile.ts +3 -2
  344. package/src/notice-tags.ts +21 -9
  345. package/src/operator-log.ts +36 -3
  346. package/src/presentation.ts +1678 -0
  347. package/src/prose-drafts.ts +16 -12
  348. package/src/read-marks.ts +328 -0
  349. package/src/reasoning-loss.ts +7 -0
  350. package/src/recovery/offline-branch.ts +350 -35
  351. package/src/recovery/recover-cli.ts +23 -4
  352. package/src/send-tools.ts +40 -7
  353. package/src/sent-rows.ts +1 -1
  354. package/src/stored-injections.ts +2 -2
  355. package/src/stored-removal.ts +31 -7
  356. package/src/supplied-text.ts +3 -3
  357. package/src/thinking-drops.ts +2 -0
  358. package/src/tool-presentation.ts +34 -9
  359. package/src/tool-result-guard.ts +41 -7
  360. package/src/tune-out/coordinator.ts +4121 -472
  361. package/src/tune-out/tools.ts +162 -44
  362. package/src/tune-out/window-anchor.ts +277 -0
  363. package/src/types/agent.ts +122 -25
  364. package/src/types/events.ts +2 -0
  365. package/src/types/framework.ts +73 -17
  366. package/src/types/inference-log.ts +1 -0
  367. package/src/types/module.ts +181 -68
  368. package/src/types/trace.ts +607 -43
@@ -21,11 +21,13 @@ import { expandCoreTags, isMarkerTagged } from './tags.js';
21
21
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
22
22
  import { isVisiblyEmptyConversion } from './visible-content.js';
23
23
  import { logDetail, logValue } from './log-value.js';
24
+ import { jsonKind } from '../jsonrpc-envelope.js';
24
25
  import { RefusalLines } from './refusal-lines.js';
25
26
  import { CapabilityGrant } from './capability-grant.js';
26
27
  import { McplRequestError } from './server-connection.js';
27
28
  import { readSkipReplyWake, skipReplyNote } from './skip-reply-wake.js';
28
29
  import { isProseTargetToken } from './prose-grammar.js';
30
+ import { persistentReaderName, tuneOutHeldWords, tuneOutNoReaderAnswer, tuneOutReachWords } from '../tune-out/tools.js';
29
31
  // ============================================================================
30
32
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
31
33
  // ============================================================================
@@ -146,8 +148,8 @@ function desiredStateRecord(item) {
146
148
  }
147
149
  /**
148
150
  * The desired state `store`'s lifecycle log records for `channelId` on
149
- * `serverId`, for a reader with no registry yet, as a serving boot's
150
- * deferred-write flush is, before initializeMcpl: the last decision wins, as
151
+ * `serverId`, for a reader with no registry yet, as the boot's import of an
152
+ * earlier release's queued writes is, before initializeMcpl: the last decision wins, as
151
153
  * the registry's replay reads it (getDesiredState answers once there is a
152
154
  * registry). undefined when no decision names it.
153
155
  */
@@ -478,6 +480,32 @@ function withEmbeddedTextMarked(block) {
478
480
  function channelOnServer(ref) {
479
481
  return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
480
482
  }
483
+ /** A channel_close's lead where its channel's server no longer registers it. */
484
+ function noLongerRegistered(ref) {
485
+ return `Server ${quotedValue(ref.serverId)} no longer registers channel ${quotedValue(ref.channelId)}, so no close was recorded or sent.`;
486
+ }
487
+ /** What a channel_close's bare name reached besides the channel it closed
488
+ * (ChannelRegistry.focusNamedBare): each by its id and server, which the
489
+ * bare name leaves out, and the channelId and serverId a call closes it
490
+ * with: its own id, which needn't be the name the call gave (a focus on
491
+ * `b:room` beside server b's `room`), so naming the server alone, the
492
+ * name kept, is no channel there. */
493
+ function notClosedText(refs) {
494
+ const names = refs.map(channelOnServer);
495
+ const call = (ref) => `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`;
496
+ return refs.length === 1
497
+ ? `Channel ${names[0]} was not closed: to close it, call again with ${call(refs[0])}.`
498
+ : `Channels ${names.slice(0, -1).join(', ')} and ${names.at(-1)} were not closed: to close one, call again with ` +
499
+ `${refs.slice(0, -1).map(call).join(', with ')}, or with ${call(refs.at(-1))}.`;
500
+ }
501
+ /** A tool result with a sentence added: a success's after its note (or as
502
+ * its note), a failure's after its error. */
503
+ function withSentence(result, sentence) {
504
+ if (!result.success)
505
+ return { ...result, error: result.error ? `${result.error} ${sentence}` : sentence };
506
+ const data = (result.data ?? {});
507
+ return { ...result, data: { ...data, note: typeof data.note === 'string' ? `${data.note} ${sentence}` : sentence } };
508
+ }
481
509
  const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
482
510
  /**
483
511
  * A failed request's words, bounded, for a reason that a caller composes.
@@ -680,12 +708,17 @@ export class ChannelRegistry {
680
708
  latestInboundLocus;
681
709
  onToolClose;
682
710
  focusChannelResolver;
711
+ tuneOutReaderFn;
683
712
  onRouteFailure;
684
713
  onChannelAutoOpened;
685
714
  homeChannelResolver;
686
715
  activeChannelResolver;
716
+ channellessTurn;
687
717
  takesGlobalFallback;
688
718
  serverConnecting;
719
+ unpromptedChannel;
720
+ unpromptedChannelWarned = false;
721
+ takesUnpromptedChannel;
689
722
  store;
690
723
  /**
691
724
  * Registered channels, keyed by their server and id (destinationKey): a
@@ -861,6 +894,10 @@ export class ChannelRegistry {
861
894
  return new ChannelLifecycleError(unreachableClause(serverId, reach), 'unreachable', reach);
862
895
  return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
863
896
  }
897
+ publishingServer(serverId) {
898
+ const server = this.serverRegistry.getServer(serverId);
899
+ return server && CapabilityGrant.of(server).has('channels.publish') ? server : undefined;
900
+ }
864
901
  /**
865
902
  * Why a send to this server's channels is refused before anything goes
866
903
  * out, as one clause with what works from there, for a caller that found
@@ -934,17 +971,17 @@ export class ChannelRegistry {
934
971
  (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
935
972
  (failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
936
973
  }
937
- // A tune-out sends the channel's messages to the subconscious, and
974
+ // A tune-out holds the channel's messages for its reader, if any, and
938
975
  // channel_open refuses a tuned-out channel.
939
976
  if (action === 'open' ? now !== 'open' : now !== 'closed') {
940
977
  if (action === 'open' && now === 'tuned-out') {
941
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
978
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_open doesn't ` +
942
979
  `change that. ${this.tuneOutEndText(current)}`;
943
980
  }
944
981
  // channel_close refuses a tuned-out channel too, so calling it again
945
982
  // isn't the way: the tune-out's cancel is, and it delivers the backlog.
946
983
  if (action === 'close' && now === 'tuned-out') {
947
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
984
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_close doesn't ` +
948
985
  `change that. ${this.tuneOutEndText(current, 'channel_close')}`;
949
986
  }
950
987
  // The later decision's own work settles the transport, and its call
@@ -1034,6 +1071,16 @@ export class ChannelRegistry {
1034
1071
  this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
1035
1072
  this.acceptInbound = options?.acceptInbound;
1036
1073
  this.channelTools = CHANNEL_TOOL_DEFINITIONS.map((tool) => {
1074
+ if (tool.name === 'channel_publish') {
1075
+ if (options?.unpromptedChannel === undefined || options.eventGatePresent !== true)
1076
+ return tool;
1077
+ return {
1078
+ ...tool,
1079
+ description: tool.description
1080
+ .replace('otherwise, while a focus holds, the channel it keeps.', 'otherwise, while a focus holds, the channel it keeps; otherwise, on a turn your own skip_reply wake or the end of your sleep started (or a turn continuing one, when your plain speech isn\'t routed), the channel this host names for such turns.')
1081
+ .replace('A turn with no channel of its own, such as', 'A turn with no channel by design, such as'),
1082
+ };
1083
+ }
1037
1084
  if (tool.name !== 'skip_reply' || options?.eventGatePresent === true)
1038
1085
  return tool;
1039
1086
  const schema = tool.inputSchema;
@@ -1053,11 +1100,15 @@ export class ChannelRegistry {
1053
1100
  this.latestInboundLocus = options?.latestInboundLocus === true;
1054
1101
  this.onToolClose = options?.onToolClose;
1055
1102
  this.focusChannelResolver = options?.focusChannelResolver;
1103
+ this.tuneOutReaderFn = options?.tuneOutReader;
1056
1104
  this.onRouteFailure = options?.onRouteFailure;
1057
1105
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1058
1106
  this.homeChannelResolver = options?.homeChannelResolver;
1059
1107
  this.activeChannelResolver = options?.activeChannelResolver;
1108
+ this.channellessTurn = options?.channellessTurn;
1060
1109
  this.takesGlobalFallback = options?.takesGlobalFallback;
1110
+ this.unpromptedChannel = options?.unpromptedChannel;
1111
+ this.takesUnpromptedChannel = options?.takesUnpromptedChannel;
1061
1112
  this.serverConnecting = options?.serverConnecting;
1062
1113
  this.store = options?.store;
1063
1114
  this.initializeLifecycleStore();
@@ -1686,8 +1737,10 @@ export class ChannelRegistry {
1686
1737
  }
1687
1738
  }
1688
1739
  /** The explicit 'stop' for a channel whose typing ended, while its
1689
- * server still registers it. */
1740
+ * server still registers it and holds the grant its start needed. */
1690
1741
  sendTypingStop(ref) {
1742
+ if (!this.typingGranted(ref))
1743
+ return;
1691
1744
  if (this.sendTypingFn && this.entryAt(ref)) {
1692
1745
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
1693
1746
  }
@@ -1796,6 +1849,30 @@ export class ChannelRegistry {
1796
1849
  getChannelTools() {
1797
1850
  return this.channelTools;
1798
1851
  }
1852
+ /**
1853
+ * The refusal of a channel tool's call that gives a parameter its schema
1854
+ * declares a string as something else, before anything changes; absent and
1855
+ * `null` pass, as each tool reads them. A number written past 2^53 has
1856
+ * usually lost its last digits by the time the call is parsed.
1857
+ */
1858
+ nonStringRefusal(toolName, input) {
1859
+ if (input === null || typeof input !== 'object')
1860
+ return undefined;
1861
+ const properties = this.channelTools.find((tool) => tool.name === toolName)?.inputSchema.properties ?? {};
1862
+ for (const [field, value] of Object.entries(input)) {
1863
+ if (properties[field]?.type !== 'string')
1864
+ continue;
1865
+ if (value === undefined || value === null || typeof value === 'string')
1866
+ continue;
1867
+ return {
1868
+ success: false,
1869
+ error: `Nothing changed: ${toolName}'s ${field} must be a string, and this call gave ${jsonKind(value)}. Give it in quotes`
1870
+ + (typeof value === 'number' ? ': a number can lose its last digits as the call is read.' : '.'),
1871
+ isError: true,
1872
+ };
1873
+ }
1874
+ return undefined;
1875
+ }
1799
1876
  /**
1800
1877
  * Handle a call to one of the synthesized channel tools.
1801
1878
  *
@@ -1812,6 +1889,9 @@ export class ChannelRegistry {
1812
1889
  /** Filled with a channel_publish's outcome once its request is
1813
1890
  * attempted, so the caller learns where it was delivered. */
1814
1891
  seen) {
1892
+ const refused = this.nonStringRefusal(toolName, input);
1893
+ if (refused)
1894
+ return refused;
1815
1895
  switch (toolName) {
1816
1896
  case 'channel_list':
1817
1897
  return this.handleToolList();
@@ -1858,7 +1938,9 @@ export class ChannelRegistry {
1858
1938
  * its home.
1859
1939
  */
1860
1940
  incomingFor(agentName) {
1861
- if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
1941
+ const channelless = agentName === undefined ? false
1942
+ : this.channellessTurn ? this.channellessTurn(agentName) : this.activeChannelResolver?.(agentName) === null;
1943
+ if (channelless && !this.homeChannelResolver?.(agentName)) {
1862
1944
  return undefined;
1863
1945
  }
1864
1946
  // The latest delivered inbound itself, not the speech fallback
@@ -1885,7 +1967,9 @@ export class ChannelRegistry {
1885
1967
  // declared it inbound-only, since speech isn't posted there): a conversation
1886
1968
  // fork's home channel, else this turn's channel (single trunk agent — item-3
1887
1969
  // redux: its locus, else its triggering channel), else the global default
1888
- // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
1970
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none), with
1971
+ // the configured unprompted channel in it where the turn takes it: the
1972
+ // channel channel_publish naming none publishes to, in every prose mode.
1889
1973
  // Without this, a fork
1890
1974
  // was advertised the global locus but published somewhere else; and a trunk
1891
1975
  // agent was told the wrong channel under concurrency.
@@ -1896,9 +1980,11 @@ export class ChannelRegistry {
1896
1980
  // An ephemeral run takes no global fallback (takesGlobalFallback): not the
1897
1981
  // all-except hold's channel, not the latest inbound message's channel as
1898
1982
  // its outgoing one, nor that message as the one it is replying to.
1899
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
1983
+ const fallback = () => agentName !== undefined
1984
+ ? this.globalFallbackFor(agentName, () => this.configuredUnprompted(agentName))
1985
+ : this.sharedFallback();
1900
1986
  // A turn that deliberately has no channel (null) takes no fallback.
1901
- const outgoing = home ?? (active !== undefined ? active : fallback);
1987
+ const outgoing = home ?? (active !== undefined ? active : fallback());
1902
1988
  const context = {};
1903
1989
  // Incoming: the message the agent is replying to (incomingFor). The
1904
1990
  // anchor is independent of the speech rule: under `speech.locus:
@@ -2466,7 +2552,7 @@ export class ChannelRegistry {
2466
2552
  }
2467
2553
  /**
2468
2554
  * Make a tuned-out channel's transport match its tune-out, which keeps it
2469
- * open so its traffic reaches the subconscious (TuneOutParams). The
2555
+ * open so its traffic reaches its reader, or is held (TuneOutParams). The
2470
2556
  * tune-out is recorded first (enterTuneOut), and this writes no decision,
2471
2557
  * so the tune-out stands whatever becomes of the open. Lifecycle work
2472
2558
  * queued for the channel goes first, until none is left, since each
@@ -2478,8 +2564,8 @@ export class ChannelRegistry {
2478
2564
  * tune-out as open), and the answer waits for the work queued after it
2479
2565
  * too, such as a registration that came back, and comes from the state
2480
2566
  * that work leaves. Returns nothing where the transport is open, or, for
2481
- * tune_out's answer, why the channel's messages don't or may not reach the
2482
- * subconscious yet and when they will, or that the tune-out isn't in
2567
+ * tune_out's answer, why the channel's messages don't or may not reach its
2568
+ * reader yet, or be received, and when they will, or that the tune-out isn't in
2483
2569
  * effect any more. A call while this tune-out's open is in flight, as a
2484
2570
  * repeated tune_out makes, gets that open's answer rather than send
2485
2571
  * another.
@@ -2545,8 +2631,9 @@ export class ChannelRegistry {
2545
2631
  // that took effect would have its traffic diverted. `again` where calling
2546
2632
  // tune_out again tries the open now: the channel is registered here and
2547
2633
  // its server up.
2548
- 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, ` +
2549
- `which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`);
2634
+ // Where no reader runs, each answer is that alone (tuneOutNoReaderAnswer).
2635
+ const notYet = (later, why, again = false) => tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(`The tune-out is recorded, but its messages ${this.tuneOutReach('may not reach')} until this host opens the ` +
2636
+ `channel, which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`);
2550
2637
  const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
2551
2638
  'close its channels then';
2552
2639
  // The work queued for the channel as it stands: its tail, waited for,
@@ -2580,16 +2667,16 @@ export class ChannelRegistry {
2580
2667
  // established.
2581
2668
  const reach = this.serverReach(serverId);
2582
2669
  if (reach !== 'up') {
2583
- return withStop(`The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
2584
- `connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
2670
+ return tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(`The tune-out is recorded, but its messages ${this.tuneOutReach("don't reach")} until the "${serverId}" server ` +
2671
+ `is connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
2585
2672
  (reach === 'gone' ? unreachableRemedy(reach) : ''));
2586
2673
  }
2587
2674
  const transportOpen = this.tuneOutReadsOpen(entry);
2588
2675
  if (!this.directsLifecycle(serverId)) {
2589
2676
  return transportOpen ? undefined
2590
- : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
2591
- 'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
2592
- 'this host to open it, ask whoever runs this host.';
2677
+ : tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages ` +
2678
+ `${this.tuneOutReach('reach')} only as that server sends them, and calling tune_out again won't change ` +
2679
+ 'that. If you need this host to open it, ask whoever runs this host.';
2593
2680
  }
2594
2681
  if (!entry)
2595
2682
  return missing;
@@ -3222,10 +3309,9 @@ export class ChannelRegistry {
3222
3309
  * (typing timer lifecycle is still managed for when the callback is wired).
3223
3310
  */
3224
3311
  sendTypingNotification(ref) {
3225
- // §14.1: channels/typing requires channels.typing in the grant. Silent
3226
- // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
3227
- // tick would be noise.
3228
- if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing'))
3312
+ // Silent skip — a typing indicator is cosmetic and per-7s, so a
3313
+ // diagnostic per tick would be noise.
3314
+ if (!this.typingGranted(ref))
3229
3315
  return;
3230
3316
  if (this.sendTypingFn) {
3231
3317
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
@@ -3233,6 +3319,10 @@ export class ChannelRegistry {
3233
3319
  // TODO: When server-connection exposes a public sendNotification or
3234
3320
  // sendTyping method, wire it here directly instead of using a callback.
3235
3321
  }
3322
+ /** §14.1: channels/typing, start and stop alike, requires channels.typing in the grant. */
3323
+ typingGranted(ref) {
3324
+ return CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing');
3325
+ }
3236
3326
  // ==========================================================================
3237
3327
  // Private: Channel Lookup
3238
3328
  // ==========================================================================
@@ -3305,6 +3395,18 @@ export class ChannelRegistry {
3305
3395
  }
3306
3396
  return channelOnServer(ref);
3307
3397
  }
3398
+ /** The reader of a tuned-out channel's traffic, as the host says (tuneOutReader), or the default persistent reader. */
3399
+ tuneOutReaderNow() {
3400
+ return this.tuneOutReaderFn?.() ?? { kind: 'persistent', name: persistentReaderName({}) };
3401
+ }
3402
+ /** What a tuned-out channel's messages are, after "its messages" (tuneOutHeldWords). */
3403
+ tuneOutHeld() {
3404
+ return tuneOutHeldWords(this.tuneOutReaderNow());
3405
+ }
3406
+ /** Whether a tuned-out channel's messages get to its reader, after "its messages" (tuneOutReachWords). */
3407
+ tuneOutReach(form) {
3408
+ return tuneOutReachWords(this.tuneOutReaderNow(), form);
3409
+ }
3308
3410
  /**
3309
3411
  * A tool's refusal of a tuned-out channel, whose decision is the tune-out:
3310
3412
  * its cancel is what ends it (tuneOutEndText), and a decision recorded over
@@ -3315,7 +3417,7 @@ export class ChannelRegistry {
3315
3417
  return {
3316
3418
  success: false,
3317
3419
  isError: true,
3318
- error: `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
3420
+ error: `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages ${this.tuneOutHeld()}, and ${tool} ` +
3319
3421
  `doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
3320
3422
  data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
3321
3423
  };
@@ -3334,7 +3436,7 @@ export class ChannelRegistry {
3334
3436
  const entry = this.entryAt(ref);
3335
3437
  return entry ? this.tuneOutEndText(entry) : undefined;
3336
3438
  }
3337
- tuneOutEndText(entry, tool) {
3439
+ tuneOutEndText(entry, tool, closeWith) {
3338
3440
  const close = tool === 'channel_close';
3339
3441
  // A name is written as a literal tune_out's channelId takes back
3340
3442
  // (quotedValue, main's #852); one no name picks alone, by its id and
@@ -3344,7 +3446,7 @@ export class ChannelRegistry {
3344
3446
  ? `channelId ${quotedValue(name)}`
3345
3447
  : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
3346
3448
  return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
3347
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
3449
+ `attention; cancelling delivers the diverted backlog${close ? `, and channel_close${closeWith ? ` with ${closeWith}` : ''} closes it after that.` : '.'}`;
3348
3450
  }
3349
3451
  /**
3350
3452
  * A channel's name as nameShown gives it, quoted for a sentence: the name
@@ -4336,15 +4438,38 @@ export class ChannelRegistry {
4336
4438
  }
4337
4439
  }
4338
4440
  async handleToolClose(input, origin) {
4339
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
4340
- const entry = resolved.entry;
4341
- if (!entry) {
4342
- return {
4343
- success: false,
4344
- error: resolved.error,
4345
- isError: true,
4346
- };
4441
+ const own = origin?.kind === 'agent' ? origin.own : undefined;
4442
+ // An agent's bare name for the channel the all-except hold excepts means
4443
+ // that channel (focusNamedBare), whether its server still registers it
4444
+ // or not: another server's channel with the id, which the name would
4445
+ // reach otherwise, isn't closed, and the answer says so. A module's
4446
+ // close resolves its name as any other: housekeeping doesn't end the
4447
+ // resident's focus.
4448
+ const focused = origin?.kind === 'module' ? undefined : this.focusNamedBare(input.channelId, input.serverId, own);
4449
+ if (focused && 'refusal' in focused)
4450
+ return { success: false, error: focused.refusal, isError: true };
4451
+ if (focused) {
4452
+ const entry = this.entryAt(focused.focus);
4453
+ const result = entry
4454
+ ? await this.closeToolEntry(entry, input, origin)
4455
+ : this.unregisteredFocusClose(focused.focus) ?? { success: false, error: noLongerRegistered(focused.focus), isError: true };
4456
+ return focused.notClosed ? withSentence(result, focused.notClosed) : result;
4347
4457
  }
4458
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
4459
+ if (resolved.entry)
4460
+ return this.closeToolEntry(resolved.entry, input, origin);
4461
+ // The name, with its serverId or as the turn's own channel, means the
4462
+ // channel the all-except hold excepts, which its server no longer
4463
+ // registers (unregisteredFocusNamed). A module's close of such a name is
4464
+ // refused as any name no channel answers to.
4465
+ const gone = origin?.kind === 'module' ? undefined : this.unregisteredFocusNamed(input.channelId, input.serverId, own);
4466
+ return (gone && this.unregisteredFocusClose(gone)) || { success: false, error: resolved.error, isError: true };
4467
+ }
4468
+ /**
4469
+ * A tool close of the channel `entry`, which its name resolved to
4470
+ * (handleToolClose).
4471
+ */
4472
+ async closeToolEntry(entry, input, origin) {
4348
4473
  // From here, the channel's own id, whichever name the call gave for it
4349
4474
  // (a server-qualified one included).
4350
4475
  input = { ...input, channelId: entry.descriptor.id };
@@ -4375,20 +4500,23 @@ export class ChannelRegistry {
4375
4500
  // Closed already, perhaps only by default: the agent's close is still
4376
4501
  // its decision, and is recorded as one (setDesiredState), so a later
4377
4502
  // registration's lift of a default leaves the channel closed.
4378
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
4503
+ const hook = this.toolCloseHook(refOf(entry));
4504
+ if ('error' in hook)
4505
+ return { success: false, error: hook.error, isError: true };
4506
+ const ended = hook.text;
4379
4507
  try {
4380
4508
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4381
4509
  }
4382
4510
  catch (err) {
4383
4511
  return {
4384
4512
  success: false,
4385
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}${ended ? ` ${ended}` : ''}`,
4513
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}${ended ? ` ${ended()}` : ''}`,
4386
4514
  isError: true,
4387
4515
  };
4388
4516
  }
4389
4517
  return {
4390
4518
  success: true,
4391
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
4519
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed', ...(ended ? { note: ended() } : {}) },
4392
4520
  };
4393
4521
  }
4394
4522
  // Housekeeping must not override stated intent (issue #5: GC closes wore
@@ -4425,15 +4553,23 @@ export class ChannelRegistry {
4425
4553
  // A hold around this channel — the all-except scope it is the excepted
4426
4554
  // channel of — ends through the cancel flow: backlog released, end
4427
4555
  // recorded, never by overwriting state (#232 gap 9).
4428
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
4556
+ const hook = this.toolCloseHook(refOf(entry));
4557
+ if ('error' in hook)
4558
+ return { success: false, error: hook.error, isError: true };
4559
+ const ended = hook.text;
4429
4560
  // A decision the log refused changed nothing for the channel, and the
4430
- // answer says so, and what the hook ended all the same.
4561
+ // answer says so, and what the hook ended all the same. Every answer
4562
+ // carries the hook's sentence, a success's as `note`: whether the close
4563
+ // went through says nothing of the focus, whose end may wait (an
4564
+ // operator's change to the store in progress). The sentence is read as
4565
+ // the answer is built, after the transport's round-trip, during which a
4566
+ // release that waited may have run.
4567
+ const withEnded = (text) => (ended ? `${text} ${ended()}` : text);
4431
4568
  try {
4432
4569
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4433
4570
  }
4434
4571
  catch (err) {
4435
- const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
4436
- return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
4572
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, unrecordedDecision(err))), isError: true };
4437
4573
  }
4438
4574
  // This channel's typing, not every channel with its id (stopTyping's
4439
4575
  // fan-out of a bare name).
@@ -4441,7 +4577,7 @@ export class ChannelRegistry {
4441
4577
  // Refused now, the close stays recorded for the server's next
4442
4578
  // registration, and the text says so.
4443
4579
  if (!this.directsLifecycle(entry.serverId)) {
4444
- return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
4580
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId))), isError: true };
4445
4581
  }
4446
4582
  try {
4447
4583
  const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
@@ -4450,13 +4586,167 @@ export class ChannelRegistry {
4450
4586
  throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
4451
4587
  return {
4452
4588
  success: true,
4453
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
4589
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed', ...(ended ? { note: ended() } : {}) },
4454
4590
  };
4455
4591
  }
4456
4592
  catch (err) {
4457
- return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
4593
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, err)), isError: true };
4594
+ }
4595
+ }
4596
+ /**
4597
+ * The tool-close hook (onToolClose) for a channel, its throw taken as the
4598
+ * answer's text: a release of the hold that failed is the close's answer,
4599
+ * as the registry's other failures are, never a rejected call.
4600
+ */
4601
+ toolCloseHook(ref, opts) {
4602
+ try {
4603
+ return { text: this.onToolClose?.(ref.serverId, ref.channelId, opts) };
4604
+ }
4605
+ catch (err) {
4606
+ return { error: err instanceof Error ? err.message : String(err) };
4458
4607
  }
4459
4608
  }
4609
+ /**
4610
+ * A close naming the channel the all-except hold excepts, which its server
4611
+ * no longer registers: the close has nothing left to close, and the hold
4612
+ * nothing left to speak into, so it ends as a close of the registered
4613
+ * channel ends it, what it held released, and the answer says so, as a
4614
+ * failure, since no close ran. Undefined where no hold excepts the channel
4615
+ * (onToolClose answers nothing).
4616
+ */
4617
+ unregisteredFocusClose(gone) {
4618
+ const lead = noLongerRegistered(gone);
4619
+ const ended = this.toolCloseHook(gone, { unregistered: true });
4620
+ if ('error' in ended)
4621
+ return { success: false, error: `${lead} ${ended.error}`, isError: true };
4622
+ if (ended.text)
4623
+ return { success: false, error: `${lead} ${ended.text()}`, isError: true };
4624
+ return undefined;
4625
+ }
4626
+ /**
4627
+ * The channel the all-except hold excepts (focusChannelResolver), where a
4628
+ * channel_close names it by its id or its server-qualified name alone: no
4629
+ * serverId, and no channel of the calling turn's own answering to the name
4630
+ * (ownChannelNamed, which a turn's name means first). Such a name means
4631
+ * the hold's channel, registered or not, and not another server's channel
4632
+ * with the id, as when the hold's channel's server stopped registering it
4633
+ * and another server registered the id: that one is closed only by a close
4634
+ * that names its server. With it, where other registered channels answer
4635
+ * to the name, the sentence saying they weren't closed (`notClosed`).
4636
+ *
4637
+ * Where the name is the hold channel's and a channel of the calling turn's
4638
+ * own, another channel, answers to it too, the name means neither: a
4639
+ * resident repeating a close that failed before its focus ended, in a turn
4640
+ * that has since opened another server's channel with the id, would
4641
+ * otherwise close that channel and leave the focus in force. That close is
4642
+ * refused, naming both (`refusal`, focusOrOwnRefusal), and a close naming
4643
+ * either's server reaches it. A turn's name for the hold's channel itself,
4644
+ * and a name the turn's channels on more than one server answer to
4645
+ * (ownNeitherRefusal), resolve as any other.
4646
+ *
4647
+ * Undefined where no hold is in force, where the name isn't the hold
4648
+ * channel's, and where the hold's record can't be read, which leaves the
4649
+ * name to resolve as any other (resolveToolChannelEntry).
4650
+ *
4651
+ * Another channel the name reaches that has a tune-out of its own is
4652
+ * refused by a close until that tune-out is cancelled (tunedOutRefusal),
4653
+ * so its sentence says it is tuned out, and names the cancel first, then
4654
+ * the close by its id and server.
4655
+ */
4656
+ focusNamedBare(name, serverId, own) {
4657
+ if (serverId)
4658
+ return undefined;
4659
+ const mine = this.ownChannelNamed(name, own);
4660
+ if (mine && 'neither' in mine)
4661
+ return undefined;
4662
+ let focus;
4663
+ try {
4664
+ focus = this.focusChannelResolver?.();
4665
+ }
4666
+ catch {
4667
+ return undefined;
4668
+ }
4669
+ if (!focus || (name !== focus.channelId && name !== qualifiedChannelName(focus)))
4670
+ return undefined;
4671
+ if (mine)
4672
+ return sameChannel(mine.channel, focus) ? undefined : { refusal: this.focusOrOwnRefusal(name, focus, mine.channel, own) };
4673
+ const held = focus;
4674
+ const others = this.entriesNamed(name).filter((entry) => !sameChannel(refOf(entry), held));
4675
+ const tunedOut = others.filter((entry) => this.getDesiredState(entry.serverId, entry.descriptor.id) === 'tuned-out');
4676
+ const closable = others.filter((entry) => !tunedOut.includes(entry)).map(refOf);
4677
+ const sentences = [
4678
+ ...(closable.length > 0 ? [notClosedText(closable)] : []),
4679
+ ...tunedOut.map((entry) => {
4680
+ const ref = refOf(entry);
4681
+ return `Channel ${channelOnServer(ref)} was not closed: it is tuned out, and a close doesn't change that. ` +
4682
+ this.tuneOutEndText(entry, 'channel_close', `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`);
4683
+ }),
4684
+ ];
4685
+ return { focus: held, ...(sentences.length > 0 ? { notClosed: sentences.join(' ') } : {}) };
4686
+ }
4687
+ /**
4688
+ * The refusal of a channel_close's bare name (focusNamedBare) that both the
4689
+ * channel the all-except hold excepts (`focus`) and another channel of the
4690
+ * calling turn's own (`mine`, ownChannelNamed) answer to, each named by
4691
+ * what reaches it alone. The hold's channel is reached by its
4692
+ * server-qualified name, registered or not (focusNamedBare), unless a
4693
+ * channel of the turn's answers to that too; the turn's channel as a
4694
+ * refusal of the turn's names shows it (ownCandidates), unless another
4695
+ * channel of the turn's has its qualified name for its id. A serverId goes
4696
+ * with the channel's own id, so it's offered only where the name is the id
4697
+ * of both, for the hold's server (registered or not, the close reaches its
4698
+ * channel: unregisteredFocusNamed) and for the turn's channel's where that
4699
+ * server still registers it. Where the name is the hold channel's
4700
+ * server-qualified name, which another of the turn's channels has for its
4701
+ * id, the hold's channel is reached by its id and server.
4702
+ */
4703
+ focusOrOwnRefusal(name, focus, mine, own) {
4704
+ const qualified = qualifiedChannelName(focus);
4705
+ const focusReaches = this.ownChannelNamed(qualified, own) === undefined;
4706
+ const [theirs] = this.ownCandidates([mine]);
4707
+ // The turn's channel's name, as the focus's: a bare id answers to the
4708
+ // turn's own channels first, so one of them with that id catches it.
4709
+ const theirsReaches = theirs.reaches && this.ownChannelNamed(theirs.text, own) === undefined;
4710
+ const parts = [
4711
+ { text: focusReaches ? quotedValue(qualified) : channelOnServer(focus), reaches: focusReaches },
4712
+ { text: theirsReaches ? quotedValue(theirs.text) : theirs.reaches ? channelOnServer(mine) : theirs.text, reaches: theirsReaches },
4713
+ ];
4714
+ const named = parts.filter((p) => p.reaches).map((p) => p.text);
4715
+ const byName = named.length === 2 ? 'channelId set to one of them' : named.length === 1 ? `channelId ${named[0]}` : '';
4716
+ const servers = name === focus.channelId ? [focus.serverId, ...(this.entryAt(mine) ? [mine.serverId] : [])] : [];
4717
+ const byServer = servers.length > 0 ? `serverId set to ${servers.map((id) => JSON.stringify(id)).join(' or ')}` : '';
4718
+ const how = [byName, byServer].filter((h) => h !== '').join(', or with ');
4719
+ const focusRoute = name !== focus.channelId && !focusReaches
4720
+ ? ` For the channel the focus excepts, call again with channelId ${quotedValue(focus.channelId)} and serverId ${JSON.stringify(focus.serverId)}.`
4721
+ : '';
4722
+ return `Channel ${JSON.stringify(name)} names both the channel the all-except focus excepts (${parts[0].text}) ` +
4723
+ `and this turn's channel (${parts[1].text}), so nothing ran.${how ? ` Call again with ${how}.` : ''}${focusRoute}`;
4724
+ }
4725
+ /**
4726
+ * The channel the all-except hold excepts (focusChannelResolver), where a
4727
+ * channel tool's name with a serverId, or the calling turn's own channel's
4728
+ * name, means it though its server no longer registers it: with a
4729
+ * serverId, that server's channel with the id; inside the calling turn,
4730
+ * the turn's own channel the name answers to by its id (ownChannelNamed).
4731
+ * A name given alone is focusNamedBare's. Undefined where no hold is in
4732
+ * force, where its channel is still registered, and where the hold's
4733
+ * record can't be read, which leaves the name's refusal as it was.
4734
+ */
4735
+ unregisteredFocusNamed(name, serverId, own) {
4736
+ let focus;
4737
+ try {
4738
+ focus = this.focusChannelResolver?.();
4739
+ }
4740
+ catch {
4741
+ return undefined;
4742
+ }
4743
+ if (!focus || this.entryAt(focus))
4744
+ return undefined;
4745
+ if (serverId)
4746
+ return sameChannel(focus, { serverId, channelId: name }) ? focus : undefined;
4747
+ const mine = this.ownChannelNamed(name, own);
4748
+ return mine && 'channel' in mine && sameChannel(mine.channel, focus) ? focus : undefined;
4749
+ }
4460
4750
  async handleToolDecline(input, own) {
4461
4751
  const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
4462
4752
  const entry = resolved.entry;
@@ -4651,8 +4941,8 @@ export class ChannelRegistry {
4651
4941
  `diverted, and then call channel_close.${serverBack}`
4652
4942
  : `. If you still want it closed, call channel_close.${serverBack}`;
4653
4943
  closeError = decided === 'tuned-out'
4654
- ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
4655
- `the subconscious${step}`
4944
+ ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages ` +
4945
+ `${this.tuneOutHeld()}${step}`
4656
4946
  : `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
4657
4947
  }
4658
4948
  else if (closed) {
@@ -4886,7 +5176,8 @@ export class ChannelRegistry {
4886
5176
  return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
4887
5177
  }
4888
5178
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
4889
- * process-global default), with its server. Public so a multi-segment
5179
+ * process-global default, with the configured unprompted channel in it
5180
+ * where the turn takes it), with its server. Public so a multi-segment
4890
5181
  * caller can snapshot it ONCE and pin every segment to it via
4891
5182
  * routeSpeech's locus. */
4892
5183
  resolveLocus(conversationId) {
@@ -4895,7 +5186,32 @@ export class ChannelRegistry {
4895
5186
  return home;
4896
5187
  // A turn that deliberately has no channel (null) takes no fallback.
4897
5188
  const active = this.activeChannelResolver?.(conversationId);
4898
- return active !== undefined ? active : this.globalFallbackFor(conversationId);
5189
+ return active !== undefined
5190
+ ? active
5191
+ : this.globalFallbackFor(conversationId, () => this.configuredUnprompted(conversationId));
5192
+ }
5193
+ configuredUnprompted(conversationId) {
5194
+ if (!this.unpromptedChannel)
5195
+ return null;
5196
+ if (conversationId !== undefined && this.takesUnpromptedChannel?.(conversationId) === false)
5197
+ return null;
5198
+ const named = this.entriesNamed(this.unpromptedChannel);
5199
+ if (named.length === 1 && !isInboundOnly(named[0])) {
5200
+ this.unpromptedChannelWarned = false;
5201
+ return refOf(named[0]);
5202
+ }
5203
+ if (!this.unpromptedChannelWarned) {
5204
+ this.unpromptedChannelWarned = true;
5205
+ const why = named.length === 0
5206
+ ? 'names no registered channel'
5207
+ : named.length === 1
5208
+ ? 'names a channel its server declared inbound-only'
5209
+ : `names ${named.length} registered channels (name one with its server, as "serverId:channelId")`;
5210
+ console.error(`[routing] unpromptedChannel ${logValue(this.unpromptedChannel)} ${why}, so a turn the agent's own timer starts `
5211
+ + 'takes the fallback it takes without the setting until the setting names a channel it can speak in. '
5212
+ + 'Not logged again until a turn has used it.');
5213
+ }
5214
+ return null;
4899
5215
  }
4900
5216
  /**
4901
5217
  * The process-global fallback for this conversation, or null when it takes
@@ -4903,18 +5219,20 @@ export class ChannelRegistry {
4903
5219
  * result, so neither the all-except hold's channel nor the latest inbound
4904
5220
  * message's is ever its destination.
4905
5221
  */
4906
- globalFallbackFor(conversationId) {
4907
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
5222
+ globalFallbackFor(conversationId, unprompted) {
5223
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback(unprompted);
4908
5224
  }
4909
5225
  /**
4910
5226
  * What every conversation that takes the global fallback falls back to when
4911
5227
  * neither a home channel nor its own delivery names one: the all-except
4912
- * hold's channel while one is open, else the latest delivered inbound
4913
- * channel under the legacy locus rule, else none — the prose stays private
4914
- * (architecture channel §3 carve-out, 2026-10-06).
5228
+ * hold's channel while one is open, else, for a turn's locus, the
5229
+ * configured unprompted channel where the turn takes it (resolveLocus),
5230
+ * else the latest delivered inbound channel under the legacy locus rule,
5231
+ * else none — the prose stays private (architecture channel §3 carve-out,
5232
+ * 2026-10-06).
4915
5233
  */
4916
- sharedFallback() {
4917
- return this.focusChannelResolver?.() ?? this.fallbackLocus();
5234
+ sharedFallback(unprompted) {
5235
+ return this.focusChannelResolver?.() ?? unprompted?.() ?? this.fallbackLocus();
4918
5236
  }
4919
5237
  fallbackLocus() {
4920
5238
  return this.latestInboundLocus ? this.defaultPublishChannel : null;
@@ -5048,12 +5366,12 @@ export class ChannelRegistry {
5048
5366
  if (isInboundOnly(entry)) {
5049
5367
  return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
5050
5368
  }
5051
- const server = this.serverRegistry.getServer(entry.serverId);
5369
+ const server = this.publishingServer(entry.serverId);
5052
5370
  // §14.1: channels/publish requires channels.publish in the grant. The
5053
5371
  // host not sending is the enforcement, here and at the last check, and
5054
5372
  // the server's state words the refusal (sendRefusal).
5055
5373
  const ungranted = () => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
5056
- if (!server || !CapabilityGrant.of(server).has('channels.publish'))
5374
+ if (!server)
5057
5375
  return ungranted();
5058
5376
  const prevented = (err) => ({
5059
5377
  status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
@@ -5215,11 +5533,14 @@ export class ChannelRegistry {
5215
5533
  locus,
5216
5534
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
5217
5535
  * draft with its own notice, so no failure marker is written for it.
5218
- * An `unknown` outcome still gets its marker. */
5536
+ * An `unknown` outcome still gets its marker. `cameFrom`: with no
5537
+ * locus, the channels the turn's batch came from, each with how the
5538
+ * agent is shown it; the failure's reason names them, and how to reply
5539
+ * in those registered that take speech. */
5219
5540
  opts) {
5220
5541
  // `logged` is what the log line and the trace say, where it isn't the
5221
- // agent's reason (the two no-locus reasons below).
5222
- const fail = (channel, reason, outcome, logged = reason) => {
5542
+ // agent's reason (the three no-locus reasons below).
5543
+ const fail = (channel, reason, outcome, logged = reason, byDesign = false) => {
5223
5544
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
5224
5545
  const channelId = channel?.channelId ?? null;
5225
5546
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
@@ -5239,16 +5560,18 @@ export class ChannelRegistry {
5239
5560
  this.onRouteFailure?.({
5240
5561
  conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
5241
5562
  reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
5563
+ ...(byDesign ? { byDesign: true } : {}),
5242
5564
  });
5243
5565
  }
5244
5566
  return outcome;
5245
5567
  };
5246
- // These two reasons are the only ones whose log line and trace keep the
5568
+ // These three reasons are the only ones whose log line and trace keep the
5247
5569
  // operator's words rather than the agent's: connectome-host's
5248
5570
  // connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
5249
- // "no locus" in both logged texts. Each names no tool: whether one the
5571
+ // "no locus" in each logged text. Each names no tool: whether one the
5250
5572
  // agent may call could post the words isn't known here, and the notice
5251
- // calls it a routing or configuration situation.
5573
+ // calls it a routing or configuration situation, or for a turn whose
5574
+ // batch named the channels it came from a routing one (byDesign).
5252
5575
  // Runtime backstop for JS callers the compiler can't see: an omitted
5253
5576
  // locus is a routing bug at the call site, never something to paper over
5254
5577
  // with a live re-resolution.
@@ -5267,6 +5590,34 @@ export class ChannelRegistry {
5267
5590
  // global inbound ever seen) — the agent was told its prose stays in
5268
5591
  // the archive; honor that.
5269
5592
  // The locus froze at the turn's start, so this says what the turn had.
5593
+ const from = opts?.cameFrom ?? [];
5594
+ if (from.length > 0) {
5595
+ const list = (names) => names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
5596
+ const closed = [];
5597
+ const open = [];
5598
+ for (const came of from) {
5599
+ const entry = this.entryAt(came.channel);
5600
+ if (!entry)
5601
+ closed.push(`${came.shown} isn't registered with this host now, so this host has no channel there to send to`);
5602
+ else if (isInboundOnly(entry))
5603
+ closed.push(`${came.shown} is ${inboundOnlyReason(entry)}`);
5604
+ else if (!this.publishingServer(entry.serverId)) {
5605
+ const reach = this.serverReach(entry.serverId);
5606
+ closed.push(reach === 'connecting' || reach === 'down'
5607
+ ? `${came.shown} can't take a send: ${unreachableClause(entry.serverId, reach)}, so to reply there, send your reply to that channel explicitly once it's back`
5608
+ : `${came.shown} can't take a send: ${this.sendRefusal(entry.serverId)}`);
5609
+ }
5610
+ else
5611
+ open.push(came);
5612
+ }
5613
+ const reply = open.length === 0 ? ''
5614
+ : open.length === from.length
5615
+ ? `; to reply ${from.length === 1 ? 'there' : 'in one of them'}, send your reply to that channel explicitly`
5616
+ : `; to reply in ${open.length === 1 ? open[0].shown : `one of ${list(open.map((c) => c.shown))}`}, send your reply to that channel explicitly`;
5617
+ const reason = `this turn came from ${list(from.map((c) => c.shown))}, and no channel was chosen for its reply` +
5618
+ `${reply}${closed.map((c) => `; ${c}`).join('')}`;
5619
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() }, "turn has no locus (no channel was chosen for its batch's reply)", true);
5620
+ }
5270
5621
  const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
5271
5622
  'had one, ask whoever runs this host';
5272
5623
  return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
@@ -5293,8 +5644,8 @@ export class ChannelRegistry {
5293
5644
  return outcome;
5294
5645
  }
5295
5646
  /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
5296
- async routeSpeech(conversationId, text, locus) {
5297
- const outcome = await this.deliverSpeech(conversationId, text, locus);
5647
+ async routeSpeech(conversationId, text, locus, opts) {
5648
+ const outcome = await this.deliverSpeech(conversationId, text, locus, opts);
5298
5649
  if (outcome.status !== 'delivered')
5299
5650
  return null;
5300
5651
  return {
@@ -5358,8 +5709,13 @@ export class ChannelRegistry {
5358
5709
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
5359
5710
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
5360
5711
  : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
5361
- ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
5362
- + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
5712
+ ? (this.channellessTurn && !this.channellessTurn(caller)
5713
+ // The turn came from a channel, but its speech goes to none
5714
+ // (a locked turn held as drafts).
5715
+ ? `Nothing was posted: name the channel with channelId. Your plain speech goes to no channel now, so `
5716
+ + `channel_publish sends only to a channel it names. ${findIds}`
5717
+ : `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
5718
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`)
5363
5719
  : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
5364
5720
  isError: true,
5365
5721
  };
@@ -5453,8 +5809,8 @@ export class ChannelRegistry {
5453
5809
  }
5454
5810
  // The same refusal, in the same words, as plain speech's (sendRefusal),
5455
5811
  // whose server state is the reason's: nothing awaits between the two.
5456
- const server = this.serverRegistry.getServer(entry.serverId);
5457
- if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
5812
+ const server = this.publishingServer(entry.serverId);
5813
+ if (!server) {
5458
5814
  if (seen) {
5459
5815
  const reach = this.serverReach(entry.serverId);
5460
5816
  seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;