@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
@@ -43,12 +43,15 @@ import { expandCoreTags, isMarkerTagged } from './tags.js';
43
43
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
44
44
  import { isVisiblyEmptyConversion } from './visible-content.js';
45
45
  import { logDetail, logValue } from './log-value.js';
46
+ import { jsonKind } from '../jsonrpc-envelope.js';
46
47
  import { RefusalLines } from './refusal-lines.js';
47
48
  import { CapabilityGrant } from './capability-grant.js';
48
49
  import type { InboundSource } from './inbound-source.js';
49
50
  import { McplRequestError, type McplServerConnection } from './server-connection.js';
50
51
  import { readSkipReplyWake, skipReplyNote, type SkipReplyEarlierWake } from './skip-reply-wake.js';
51
52
  import { isProseTargetToken } from './prose-grammar.js';
53
+ import { persistentReaderName, tuneOutHeldWords, tuneOutNoReaderAnswer, tuneOutReachWords, type TuneOutReader } from '../tune-out/tools.js';
54
+ import type { TraceEventInput } from '../types/trace.js';
52
55
 
53
56
  // ============================================================================
54
57
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
@@ -200,7 +203,7 @@ function substitutedChannel(
200
203
  */
201
204
  const CHANNEL_LABEL_HISTORY_LOG_ID = 'mcpl/channel-label-history';
202
205
 
203
- type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
206
+ export type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
204
207
 
205
208
  /**
206
209
  * Parameters of an active tune-out (issue #77), carried on the
@@ -275,8 +278,8 @@ function desiredStateRecord(item: unknown): {
275
278
 
276
279
  /**
277
280
  * The desired state `store`'s lifecycle log records for `channelId` on
278
- * `serverId`, for a reader with no registry yet, as a serving boot's
279
- * deferred-write flush is, before initializeMcpl: the last decision wins, as
281
+ * `serverId`, for a reader with no registry yet, as the boot's import of an
282
+ * earlier release's queued writes is, before initializeMcpl: the last decision wins, as
280
283
  * the registry's replay reads it (getDesiredState answers once there is a
281
284
  * registry). undefined when no decision names it.
282
285
  */
@@ -730,6 +733,34 @@ function channelOnServer(ref: ChannelRef): string {
730
733
  return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
731
734
  }
732
735
 
736
+ /** A channel_close's lead where its channel's server no longer registers it. */
737
+ function noLongerRegistered(ref: ChannelRef): string {
738
+ return `Server ${quotedValue(ref.serverId)} no longer registers channel ${quotedValue(ref.channelId)}, so no close was recorded or sent.`;
739
+ }
740
+
741
+ /** What a channel_close's bare name reached besides the channel it closed
742
+ * (ChannelRegistry.focusNamedBare): each by its id and server, which the
743
+ * bare name leaves out, and the channelId and serverId a call closes it
744
+ * with: its own id, which needn't be the name the call gave (a focus on
745
+ * `b:room` beside server b's `room`), so naming the server alone, the
746
+ * name kept, is no channel there. */
747
+ function notClosedText(refs: readonly ChannelRef[]): string {
748
+ const names = refs.map(channelOnServer);
749
+ const call = (ref: ChannelRef) => `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`;
750
+ return refs.length === 1
751
+ ? `Channel ${names[0]} was not closed: to close it, call again with ${call(refs[0]!)}.`
752
+ : `Channels ${names.slice(0, -1).join(', ')} and ${names.at(-1)} were not closed: to close one, call again with ` +
753
+ `${refs.slice(0, -1).map(call).join(', with ')}, or with ${call(refs.at(-1)!)}.`;
754
+ }
755
+
756
+ /** A tool result with a sentence added: a success's after its note (or as
757
+ * its note), a failure's after its error. */
758
+ function withSentence(result: ToolResult, sentence: string): ToolResult {
759
+ if (!result.success) return { ...result, error: result.error ? `${result.error} ${sentence}` : sentence };
760
+ const data = (result.data ?? {}) as Record<string, unknown>;
761
+ return { ...result, data: { ...data, note: typeof data.note === 'string' ? `${data.note} ${sentence}` : sentence } };
762
+ }
763
+
733
764
  const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
734
765
 
735
766
  /**
@@ -1011,16 +1042,31 @@ interface ChannelRegistryOptions {
1011
1042
  * this is ends through the cancel flow (release the backlog, record the
1012
1043
  * end) before the lifecycle state flips to closed. A
1013
1044
  * tuned-out channel's close is refused before this (its cancel ends it).
1014
- * Returns what it ended, in a sentence the close's answer adds when the
1015
- * close itself isn't recorded.
1045
+ * `unregistered`: the close names the channel after its server stopped
1046
+ * registering it, so no close is recorded or sent (handleToolClose).
1047
+ * Returns, where a hold excepts the channel, what became of that hold:
1048
+ * a function that says it in a sentence, read when the close's answer is
1049
+ * built, after its transport's round-trip, so the answer says what holds
1050
+ * when it arrives (a release that waited may have run by then). The
1051
+ * answer carries it whatever became of the close: as `note` on a success,
1052
+ * after the error on a failure. Throws, with the answer's text, when the
1053
+ * hold's release fails; one that ended the hold before it failed says so.
1016
1054
  */
1017
- onToolClose?: (serverId: string, channelId: string) => string | undefined;
1055
+ onToolClose?: ToolCloseHook;
1018
1056
  /**
1019
1057
  * The channel unprefixed speech lands in while an all-except hold is
1020
1058
  * active (its exception), else null. Consulted after a fork's home and
1021
1059
  * the turn's own delivery, before the legacy fallback.
1022
1060
  */
1023
1061
  focusChannelResolver?: () => ChannelRef | null;
1062
+ /**
1063
+ * Who reads a tuned-out channel's traffic (the framework's reader: the
1064
+ * persistent subconscious by its name, or reader forks), for the texts
1065
+ * that tell an agent where a tuned-out channel's messages go. Left out,
1066
+ * the texts name the persistent reader by its default name
1067
+ * (persistentReaderName): there is no hold without a reader (0.33.0).
1068
+ */
1069
+ tuneOutReader?: () => TuneOutReader;
1024
1070
  /**
1025
1071
  * Called when a text-only turn's speech was not delivered to its
1026
1072
  * conversational locus (`failed`), or may not have been (`unknown`), for
@@ -1048,6 +1094,9 @@ interface ChannelRegistryOptions {
1048
1094
  outcome?: 'failed' | 'unknown';
1049
1095
  /** Bounded display of the connector's structured error data, when present. */
1050
1096
  detail?: string;
1097
+ /** Set when the turn had no channel and the reason names the channels
1098
+ * its batch came from. */
1099
+ byDesign?: true;
1051
1100
  }) => void;
1052
1101
  /**
1053
1102
  * Announces either a durable subscription-policy admission (before its
@@ -1090,15 +1139,38 @@ interface ChannelRegistryOptions {
1090
1139
  * message across ALL channels (markers aside) and misroutes a reply to
1091
1140
  * whichever channel last spoke under concurrency (item-3 redux). The
1092
1141
  * framework keeps both on the live turn's routing state and exposes them
1093
- * here. For a heartbeat / no-trigger turn it resolves to undefined until the
1094
- * turn's locus is resolved, so that
1095
- * resolution takes the global fallback, which the turn then keeps.
1096
- * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
1097
- * `defaultPublishChannel`. Null where the turn deliberately has no channel,
1098
- * as one woken for a channel id two servers register, given without its
1099
- * server: that takes no fallback either.
1142
+ * here. For a no-trigger turn it resolves to undefined until the turn's
1143
+ * locus is resolved, so that resolution takes the global fallback, which the
1144
+ * turn then keeps. Consulted AFTER `homeChannelResolver` (a fork's home
1145
+ * always wins), BEFORE the global fallback. Null where the turn deliberately
1146
+ * has no channel, as a heartbeat's turn, or one woken for a channel id two
1147
+ * servers register, given without its server: that takes no fallback
1148
+ * either.
1100
1149
  */
1101
1150
  activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1151
+ /**
1152
+ * FrameworkConfig.unpromptedChannel: where a turn that takes it
1153
+ * (takesUnpromptedChannel) and has no home and no channel of its own is
1154
+ * placed, within the process-global fallback, after an all-except hold's
1155
+ * channel (sharedFallback). A name, as entriesNamed reads one.
1156
+ */
1157
+ unpromptedChannel?: string;
1158
+ /**
1159
+ * Whether the conversation's current turn takes `unpromptedChannel` where it
1160
+ * has no channel of its own: the framework answers true only for a turn
1161
+ * the agent's own timer started, as FrameworkConfig.unpromptedChannel
1162
+ * says. Absent, every turn does.
1163
+ */
1164
+ takesUnpromptedChannel?: (conversationId: string) => boolean;
1165
+ /**
1166
+ * Whether the agent's current turn has no channel of its own — a
1167
+ * heartbeat's, or one woken for a channel that names none — so it replies
1168
+ * to no incoming message (incomingFor). Left out, a null from
1169
+ * activeChannelResolver means that, as it always did; given, it separates
1170
+ * a turn with nowhere to speak (a locked turn pointed at drafts) from one
1171
+ * with nothing to answer.
1172
+ */
1173
+ channellessTurn?: (agentName: string) => boolean;
1102
1174
  /**
1103
1175
  * Whether a conversation takes the process-global fallback, the channel of
1104
1176
  * the most recent delivered inbound message: as where its plain speech lands
@@ -1127,6 +1199,33 @@ interface ChannelRegistryOptions {
1127
1199
  // ChannelRegistry
1128
1200
  // ============================================================================
1129
1201
 
1202
+ /** A channel_close call's input (ChannelRegistry.handleToolClose). */
1203
+ type ToolCloseInput = {
1204
+ channelId: string;
1205
+ serverId?: string;
1206
+ /** Machine callers name their decision source ('subscription-gc',
1207
+ * 'housekeeping') so the durable record carries honest provenance.
1208
+ * Honored ONLY for module-origin dispatch — tool input is untrusted,
1209
+ * so a model-origin call carrying this field is still recorded as
1210
+ * 'agent-tool' (the resident may close their own channel; they may
1211
+ * not attribute the act to housekeeping). A module-origin call
1212
+ * without it is the module's own close, MODULE_CLOSE_SOURCE. */
1213
+ source?: string;
1214
+ /** A machine close aimed at an explicitly-opened channel is refused
1215
+ * unless the caller certifies an explicit idle lease (a configured
1216
+ * per-channel budget is consent to close at that budget). A tuned-out
1217
+ * channel is refused even so, since its tune-out's cancel is what
1218
+ * ends it. Module origin only, like `source`. */
1219
+ overrideExplicitOpen?: boolean;
1220
+ };
1221
+
1222
+ /** ChannelRegistryOptions.onToolClose: a tool close's end of the all-except hold that excepts its channel. */
1223
+ export type ToolCloseHook = (
1224
+ serverId: string,
1225
+ channelId: string,
1226
+ opts?: { unregistered?: true },
1227
+ ) => (() => string) | undefined;
1228
+
1130
1229
  /**
1131
1230
  * Who is actually making a channel tool call, as established by the
1132
1231
  * framework's own dispatch — model-origin calls come through
@@ -1177,7 +1276,7 @@ export class ChannelRegistry {
1177
1276
  private serverRegistry: McplServerRegistry;
1178
1277
  private featureSetManager: FeatureSetManager;
1179
1278
  private pushEventFn: (event: ProcessEvent) => void;
1180
- private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
1279
+ private emitTraceFn: (event: TraceEventInput) => void;
1181
1280
  private sendTypingFn?: (
1182
1281
  serverId: string,
1183
1282
  channelId: string,
@@ -1186,8 +1285,9 @@ export class ChannelRegistry {
1186
1285
  ) => void;
1187
1286
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1188
1287
  private readonly latestInboundLocus: boolean;
1189
- private onToolClose?: (serverId: string, channelId: string) => string | undefined;
1288
+ private onToolClose?: ToolCloseHook;
1190
1289
  private focusChannelResolver?: () => ChannelRef | null;
1290
+ private readonly tuneOutReaderFn?: () => TuneOutReader;
1191
1291
  private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
1192
1292
  private onChannelAutoOpened?: (info: {
1193
1293
  conversationId?: string;
@@ -1197,8 +1297,12 @@ export class ChannelRegistry {
1197
1297
  }) => void;
1198
1298
  private homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
1199
1299
  private activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1300
+ private channellessTurn?: (agentName: string) => boolean;
1200
1301
  private takesGlobalFallback?: (conversationId: string) => boolean;
1201
1302
  private serverConnecting?: (serverId: string) => boolean;
1303
+ private unpromptedChannel?: string;
1304
+ private unpromptedChannelWarned = false;
1305
+ private takesUnpromptedChannel?: (conversationId: string) => boolean;
1202
1306
  private store?: JsStore;
1203
1307
 
1204
1308
  /**
@@ -1400,6 +1504,11 @@ export class ChannelRegistry {
1400
1504
  return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
1401
1505
  }
1402
1506
 
1507
+ private publishingServer(serverId: string): McplServerConnection | undefined {
1508
+ const server = this.serverRegistry.getServer(serverId);
1509
+ return server && CapabilityGrant.of(server).has('channels.publish') ? server : undefined;
1510
+ }
1511
+
1403
1512
  /**
1404
1513
  * Why a send to this server's channels is refused before anything goes
1405
1514
  * out, as one clause with what works from there, for a caller that found
@@ -1478,17 +1587,17 @@ export class ChannelRegistry {
1478
1587
  (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
1479
1588
  (failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
1480
1589
  }
1481
- // A tune-out sends the channel's messages to the subconscious, and
1590
+ // A tune-out holds the channel's messages for its reader, if any, and
1482
1591
  // channel_open refuses a tuned-out channel.
1483
1592
  if (action === 'open' ? now !== 'open' : now !== 'closed') {
1484
1593
  if (action === 'open' && now === 'tuned-out') {
1485
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
1594
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_open doesn't ` +
1486
1595
  `change that. ${this.tuneOutEndText(current)}`;
1487
1596
  }
1488
1597
  // channel_close refuses a tuned-out channel too, so calling it again
1489
1598
  // isn't the way: the tune-out's cancel is, and it delivers the backlog.
1490
1599
  if (action === 'close' && now === 'tuned-out') {
1491
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
1600
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_close doesn't ` +
1492
1601
  `change that. ${this.tuneOutEndText(current, 'channel_close')}`;
1493
1602
  }
1494
1603
  // The later decision's own work settles the transport, and its call
@@ -1581,7 +1690,7 @@ export class ChannelRegistry {
1581
1690
  serverRegistry: McplServerRegistry,
1582
1691
  featureSetManager: FeatureSetManager,
1583
1692
  pushEventFn: (event: ProcessEvent) => void,
1584
- emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
1693
+ emitTraceFn: (event: TraceEventInput) => void,
1585
1694
  options?: ChannelRegistryOptions & {
1586
1695
  sendTypingFn?: (
1587
1696
  serverId: string,
@@ -1594,6 +1703,18 @@ export class ChannelRegistry {
1594
1703
  this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
1595
1704
  this.acceptInbound = options?.acceptInbound;
1596
1705
  this.channelTools = CHANNEL_TOOL_DEFINITIONS.map((tool) => {
1706
+ if (tool.name === 'channel_publish') {
1707
+ if (options?.unpromptedChannel === undefined || options.eventGatePresent !== true) return tool;
1708
+ return {
1709
+ ...tool,
1710
+ description: tool.description
1711
+ .replace(
1712
+ 'otherwise, while a focus holds, the channel it keeps.',
1713
+ '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.',
1714
+ )
1715
+ .replace('A turn with no channel of its own, such as', 'A turn with no channel by design, such as'),
1716
+ };
1717
+ }
1597
1718
  if (tool.name !== 'skip_reply' || options?.eventGatePresent === true) return tool;
1598
1719
  const schema = tool.inputSchema as {
1599
1720
  type: 'object';
@@ -1619,11 +1740,15 @@ export class ChannelRegistry {
1619
1740
  this.latestInboundLocus = options?.latestInboundLocus === true;
1620
1741
  this.onToolClose = options?.onToolClose;
1621
1742
  this.focusChannelResolver = options?.focusChannelResolver;
1743
+ this.tuneOutReaderFn = options?.tuneOutReader;
1622
1744
  this.onRouteFailure = options?.onRouteFailure;
1623
1745
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1624
1746
  this.homeChannelResolver = options?.homeChannelResolver;
1625
1747
  this.activeChannelResolver = options?.activeChannelResolver;
1748
+ this.channellessTurn = options?.channellessTurn;
1626
1749
  this.takesGlobalFallback = options?.takesGlobalFallback;
1750
+ this.unpromptedChannel = options?.unpromptedChannel;
1751
+ this.takesUnpromptedChannel = options?.takesUnpromptedChannel;
1627
1752
  this.serverConnecting = options?.serverConnecting;
1628
1753
  this.store = options?.store;
1629
1754
  this.initializeLifecycleStore();
@@ -2303,8 +2428,9 @@ export class ChannelRegistry {
2303
2428
  }
2304
2429
 
2305
2430
  /** The explicit 'stop' for a channel whose typing ended, while its
2306
- * server still registers it. */
2431
+ * server still registers it and holds the grant its start needed. */
2307
2432
  private sendTypingStop(ref: ChannelRef): void {
2433
+ if (!this.typingGranted(ref)) return;
2308
2434
  if (this.sendTypingFn && this.entryAt(ref)) {
2309
2435
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
2310
2436
  }
@@ -2423,6 +2549,28 @@ export class ChannelRegistry {
2423
2549
  return this.channelTools;
2424
2550
  }
2425
2551
 
2552
+ /**
2553
+ * The refusal of a channel tool's call that gives a parameter its schema
2554
+ * declares a string as something else, before anything changes; absent and
2555
+ * `null` pass, as each tool reads them. A number written past 2^53 has
2556
+ * usually lost its last digits by the time the call is parsed.
2557
+ */
2558
+ nonStringRefusal(toolName: string, input: unknown): ToolResult | undefined {
2559
+ if (input === null || typeof input !== 'object') return undefined;
2560
+ const properties = this.channelTools.find((tool) => tool.name === toolName)?.inputSchema.properties ?? {};
2561
+ for (const [field, value] of Object.entries(input)) {
2562
+ if ((properties[field] as { type?: unknown } | undefined)?.type !== 'string') continue;
2563
+ if (value === undefined || value === null || typeof value === 'string') continue;
2564
+ return {
2565
+ success: false,
2566
+ error: `Nothing changed: ${toolName}'s ${field} must be a string, and this call gave ${jsonKind(value)}. Give it in quotes`
2567
+ + (typeof value === 'number' ? ': a number can lose its last digits as the call is read.' : '.'),
2568
+ isError: true,
2569
+ };
2570
+ }
2571
+ return undefined;
2572
+ }
2573
+
2426
2574
  /**
2427
2575
  * Handle a call to one of the synthesized channel tools.
2428
2576
  *
@@ -2443,6 +2591,8 @@ export class ChannelRegistry {
2443
2591
  * attempted, so the caller learns where it was delivered. */
2444
2592
  seen?: { outcome?: PublishOutcome },
2445
2593
  ): Promise<ToolResult> {
2594
+ const refused = this.nonStringRefusal(toolName, input);
2595
+ if (refused) return refused;
2446
2596
  switch (toolName) {
2447
2597
  case 'channel_list':
2448
2598
  return this.handleToolList();
@@ -2456,15 +2606,7 @@ export class ChannelRegistry {
2456
2606
  }, origin?.kind === 'agent' ? origin.own : undefined);
2457
2607
 
2458
2608
  case 'channel_close':
2459
- return this.handleToolClose(
2460
- input as {
2461
- channelId: string;
2462
- serverId?: string;
2463
- source?: string;
2464
- overrideExplicitOpen?: boolean;
2465
- },
2466
- origin,
2467
- );
2609
+ return this.handleToolClose(input as ToolCloseInput, origin);
2468
2610
 
2469
2611
  case 'channel_decline':
2470
2612
  return this.handleToolDecline(input as {
@@ -2520,7 +2662,9 @@ export class ChannelRegistry {
2520
2662
  * its home.
2521
2663
  */
2522
2664
  incomingFor(agentName?: string): { channel: ChannelRef; messageId: string; threadId?: string } | undefined {
2523
- if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2665
+ const channelless = agentName === undefined ? false
2666
+ : this.channellessTurn ? this.channellessTurn(agentName) : this.activeChannelResolver?.(agentName) === null;
2667
+ if (channelless && !this.homeChannelResolver?.(agentName!)) {
2524
2668
  return undefined;
2525
2669
  }
2526
2670
  // The latest delivered inbound itself, not the speech fallback
@@ -2547,7 +2691,9 @@ export class ChannelRegistry {
2547
2691
  // declared it inbound-only, since speech isn't posted there): a conversation
2548
2692
  // fork's home channel, else this turn's channel (single trunk agent — item-3
2549
2693
  // redux: its locus, else its triggering channel), else the global default
2550
- // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
2694
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none), with
2695
+ // the configured unprompted channel in it where the turn takes it: the
2696
+ // channel channel_publish naming none publishes to, in every prose mode.
2551
2697
  // Without this, a fork
2552
2698
  // was advertised the global locus but published somewhere else; and a trunk
2553
2699
  // agent was told the wrong channel under concurrency.
@@ -2558,9 +2704,11 @@ export class ChannelRegistry {
2558
2704
  // An ephemeral run takes no global fallback (takesGlobalFallback): not the
2559
2705
  // all-except hold's channel, not the latest inbound message's channel as
2560
2706
  // its outgoing one, nor that message as the one it is replying to.
2561
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
2707
+ const fallback = (): ChannelRef | null => agentName !== undefined
2708
+ ? this.globalFallbackFor(agentName, () => this.configuredUnprompted(agentName))
2709
+ : this.sharedFallback();
2562
2710
  // A turn that deliberately has no channel (null) takes no fallback.
2563
- const outgoing = home ?? (active !== undefined ? active : fallback);
2711
+ const outgoing = home ?? (active !== undefined ? active : fallback());
2564
2712
 
2565
2713
  const context: ChannelContext = {};
2566
2714
 
@@ -3159,7 +3307,7 @@ export class ChannelRegistry {
3159
3307
 
3160
3308
  /**
3161
3309
  * Make a tuned-out channel's transport match its tune-out, which keeps it
3162
- * open so its traffic reaches the subconscious (TuneOutParams). The
3310
+ * open so its traffic reaches its reader, or is held (TuneOutParams). The
3163
3311
  * tune-out is recorded first (enterTuneOut), and this writes no decision,
3164
3312
  * so the tune-out stands whatever becomes of the open. Lifecycle work
3165
3313
  * queued for the channel goes first, until none is left, since each
@@ -3171,8 +3319,8 @@ export class ChannelRegistry {
3171
3319
  * tune-out as open), and the answer waits for the work queued after it
3172
3320
  * too, such as a registration that came back, and comes from the state
3173
3321
  * that work leaves. Returns nothing where the transport is open, or, for
3174
- * tune_out's answer, why the channel's messages don't or may not reach the
3175
- * subconscious yet and when they will, or that the tune-out isn't in
3322
+ * tune_out's answer, why the channel's messages don't or may not reach its
3323
+ * reader yet, or be received, and when they will, or that the tune-out isn't in
3176
3324
  * effect any more. A call while this tune-out's open is in flight, as a
3177
3325
  * repeated tune_out makes, gets that open's answer rather than send
3178
3326
  * another.
@@ -3245,9 +3393,10 @@ export class ChannelRegistry {
3245
3393
  // that took effect would have its traffic diverted. `again` where calling
3246
3394
  // tune_out again tries the open now: the channel is registered here and
3247
3395
  // its server up.
3248
- const notYet = (later: string, why: string, again = false): string => withStop(
3249
- `The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, ` +
3250
- `which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
3396
+ // Where no reader runs, each answer is that alone (tuneOutNoReaderAnswer).
3397
+ const notYet = (later: string, why: string, again = false): string => tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(
3398
+ `The tune-out is recorded, but its messages ${this.tuneOutReach('may not reach')} until this host opens the ` +
3399
+ `channel, which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
3251
3400
  );
3252
3401
  const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
3253
3402
  'close its channels then';
@@ -3279,18 +3428,18 @@ export class ChannelRegistry {
3279
3428
  // established.
3280
3429
  const reach = this.serverReach(serverId);
3281
3430
  if (reach !== 'up') {
3282
- return withStop(
3283
- `The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
3284
- `connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
3431
+ return tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(
3432
+ `The tune-out is recorded, but its messages ${this.tuneOutReach("don't reach")} until the "${serverId}" server ` +
3433
+ `is connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
3285
3434
  (reach === 'gone' ? unreachableRemedy(reach) : ''),
3286
3435
  );
3287
3436
  }
3288
3437
  const transportOpen = this.tuneOutReadsOpen(entry);
3289
3438
  if (!this.directsLifecycle(serverId)) {
3290
3439
  return transportOpen ? undefined
3291
- : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
3292
- 'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
3293
- 'this host to open it, ask whoever runs this host.';
3440
+ : tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages ` +
3441
+ `${this.tuneOutReach('reach')} only as that server sends them, and calling tune_out again won't change ` +
3442
+ 'that. If you need this host to open it, ask whoever runs this host.';
3294
3443
  }
3295
3444
  if (!entry) return missing;
3296
3445
  // A flag a close that may have taken effect left gets an open.
@@ -3957,10 +4106,9 @@ export class ChannelRegistry {
3957
4106
  * (typing timer lifecycle is still managed for when the callback is wired).
3958
4107
  */
3959
4108
  private sendTypingNotification(ref: ChannelRef): void {
3960
- // §14.1: channels/typing requires channels.typing in the grant. Silent
3961
- // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
3962
- // tick would be noise.
3963
- if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing')) return;
4109
+ // Silent skip — a typing indicator is cosmetic and per-7s, so a
4110
+ // diagnostic per tick would be noise.
4111
+ if (!this.typingGranted(ref)) return;
3964
4112
  if (this.sendTypingFn) {
3965
4113
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
3966
4114
  }
@@ -3968,6 +4116,11 @@ export class ChannelRegistry {
3968
4116
  // sendTyping method, wire it here directly instead of using a callback.
3969
4117
  }
3970
4118
 
4119
+ /** §14.1: channels/typing, start and stop alike, requires channels.typing in the grant. */
4120
+ private typingGranted(ref: ChannelRef): boolean {
4121
+ return CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing');
4122
+ }
4123
+
3971
4124
  // ==========================================================================
3972
4125
  // Private: Channel Lookup
3973
4126
  // ==========================================================================
@@ -4048,6 +4201,21 @@ export class ChannelRegistry {
4048
4201
  return channelOnServer(ref);
4049
4202
  }
4050
4203
 
4204
+ /** The reader of a tuned-out channel's traffic, as the host says (tuneOutReader), or the default persistent reader. */
4205
+ private tuneOutReaderNow(): TuneOutReader {
4206
+ return this.tuneOutReaderFn?.() ?? { kind: 'persistent', name: persistentReaderName({}) };
4207
+ }
4208
+
4209
+ /** What a tuned-out channel's messages are, after "its messages" (tuneOutHeldWords). */
4210
+ private tuneOutHeld(): string {
4211
+ return tuneOutHeldWords(this.tuneOutReaderNow());
4212
+ }
4213
+
4214
+ /** Whether a tuned-out channel's messages get to its reader, after "its messages" (tuneOutReachWords). */
4215
+ private tuneOutReach(form: Parameters<typeof tuneOutReachWords>[1]): string {
4216
+ return tuneOutReachWords(this.tuneOutReaderNow(), form);
4217
+ }
4218
+
4051
4219
  /**
4052
4220
  * A tool's refusal of a tuned-out channel, whose decision is the tune-out:
4053
4221
  * its cancel is what ends it (tuneOutEndText), and a decision recorded over
@@ -4059,7 +4227,7 @@ export class ChannelRegistry {
4059
4227
  success: false,
4060
4228
  isError: true,
4061
4229
  error:
4062
- `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
4230
+ `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages ${this.tuneOutHeld()}, and ${tool} ` +
4063
4231
  `doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
4064
4232
  data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
4065
4233
  };
@@ -4080,7 +4248,7 @@ export class ChannelRegistry {
4080
4248
  return entry ? this.tuneOutEndText(entry) : undefined;
4081
4249
  }
4082
4250
 
4083
- private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
4251
+ private tuneOutEndText(entry: ChannelEntry, tool?: string, closeWith?: string): string {
4084
4252
  const close = tool === 'channel_close';
4085
4253
  // A name is written as a literal tune_out's channelId takes back
4086
4254
  // (quotedValue, main's #852); one no name picks alone, by its id and
@@ -4090,7 +4258,7 @@ export class ChannelRegistry {
4090
4258
  ? `channelId ${quotedValue(name)}`
4091
4259
  : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
4092
4260
  return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
4093
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
4261
+ `attention; cancelling delivers the diverted backlog${close ? `, and channel_close${closeWith ? ` with ${closeWith}` : ''} closes it after that.` : '.'}`;
4094
4262
  }
4095
4263
 
4096
4264
  /**
@@ -5170,36 +5338,38 @@ export class ChannelRegistry {
5170
5338
  }
5171
5339
  }
5172
5340
 
5173
- private async handleToolClose(
5174
- input: {
5175
- channelId: string;
5176
- serverId?: string;
5177
- /** Machine callers name their decision source ('subscription-gc',
5178
- * 'housekeeping') so the durable record carries honest provenance.
5179
- * Honored ONLY for module-origin dispatch — tool input is untrusted,
5180
- * so a model-origin call carrying this field is still recorded as
5181
- * 'agent-tool' (the resident may close their own channel; they may
5182
- * not attribute the act to housekeeping). A module-origin call
5183
- * without it is the module's own close, MODULE_CLOSE_SOURCE. */
5184
- source?: string;
5185
- /** A machine close aimed at an explicitly-opened channel is refused
5186
- * unless the caller certifies an explicit idle lease (a configured
5187
- * per-channel budget is consent to close at that budget). A tuned-out
5188
- * channel is refused even so, since its tune-out's cancel is what
5189
- * ends it. Module origin only, like `source`. */
5190
- overrideExplicitOpen?: boolean;
5191
- },
5192
- origin?: ChannelToolOrigin,
5193
- ): Promise<ToolResult> {
5194
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
5195
- const entry = resolved.entry;
5196
- if (!entry) {
5197
- return {
5198
- success: false,
5199
- error: resolved.error,
5200
- isError: true,
5201
- };
5341
+ private async handleToolClose(input: ToolCloseInput, origin?: ChannelToolOrigin): Promise<ToolResult> {
5342
+ const own = origin?.kind === 'agent' ? origin.own : undefined;
5343
+ // An agent's bare name for the channel the all-except hold excepts means
5344
+ // that channel (focusNamedBare), whether its server still registers it
5345
+ // or not: another server's channel with the id, which the name would
5346
+ // reach otherwise, isn't closed, and the answer says so. A module's
5347
+ // close resolves its name as any other: housekeeping doesn't end the
5348
+ // resident's focus.
5349
+ const focused = origin?.kind === 'module' ? undefined : this.focusNamedBare(input.channelId, input.serverId, own);
5350
+ if (focused && 'refusal' in focused) return { success: false, error: focused.refusal, isError: true };
5351
+ if (focused) {
5352
+ const entry = this.entryAt(focused.focus);
5353
+ const result = entry
5354
+ ? await this.closeToolEntry(entry, input, origin)
5355
+ : this.unregisteredFocusClose(focused.focus) ?? { success: false, error: noLongerRegistered(focused.focus), isError: true };
5356
+ return focused.notClosed ? withSentence(result, focused.notClosed) : result;
5202
5357
  }
5358
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
5359
+ if (resolved.entry) return this.closeToolEntry(resolved.entry, input, origin);
5360
+ // The name, with its serverId or as the turn's own channel, means the
5361
+ // channel the all-except hold excepts, which its server no longer
5362
+ // registers (unregisteredFocusNamed). A module's close of such a name is
5363
+ // refused as any name no channel answers to.
5364
+ const gone = origin?.kind === 'module' ? undefined : this.unregisteredFocusNamed(input.channelId, input.serverId, own);
5365
+ return (gone && this.unregisteredFocusClose(gone)) || { success: false, error: resolved.error, isError: true };
5366
+ }
5367
+
5368
+ /**
5369
+ * A tool close of the channel `entry`, which its name resolved to
5370
+ * (handleToolClose).
5371
+ */
5372
+ private async closeToolEntry(entry: ChannelEntry, input: ToolCloseInput, origin?: ChannelToolOrigin): Promise<ToolResult> {
5203
5373
  // From here, the channel's own id, whichever name the call gave for it
5204
5374
  // (a server-qualified one included).
5205
5375
  input = { ...input, channelId: entry.descriptor.id };
@@ -5233,19 +5403,21 @@ export class ChannelRegistry {
5233
5403
  // Closed already, perhaps only by default: the agent's close is still
5234
5404
  // its decision, and is recorded as one (setDesiredState), so a later
5235
5405
  // registration's lift of a default leaves the channel closed.
5236
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
5406
+ const hook = this.toolCloseHook(refOf(entry));
5407
+ if ('error' in hook) return { success: false, error: hook.error, isError: true };
5408
+ const ended = hook.text;
5237
5409
  try {
5238
5410
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5239
5411
  } catch (err) {
5240
5412
  return {
5241
5413
  success: false,
5242
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`,
5414
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended()}` : ''}`,
5243
5415
  isError: true,
5244
5416
  };
5245
5417
  }
5246
5418
  return {
5247
5419
  success: true,
5248
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
5420
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed', ...(ended ? { note: ended() } : {}) },
5249
5421
  };
5250
5422
  }
5251
5423
 
@@ -5285,15 +5457,22 @@ export class ChannelRegistry {
5285
5457
  // A hold around this channel — the all-except scope it is the excepted
5286
5458
  // channel of — ends through the cancel flow: backlog released, end
5287
5459
  // recorded, never by overwriting state (#232 gap 9).
5288
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
5460
+ const hook = this.toolCloseHook(refOf(entry));
5461
+ if ('error' in hook) return { success: false, error: hook.error, isError: true };
5462
+ const ended = hook.text;
5289
5463
 
5290
5464
  // A decision the log refused changed nothing for the channel, and the
5291
- // answer says so, and what the hook ended all the same.
5465
+ // answer says so, and what the hook ended all the same. Every answer
5466
+ // carries the hook's sentence, a success's as `note`: whether the close
5467
+ // went through says nothing of the focus, whose end may wait (an
5468
+ // operator's change to the store in progress). The sentence is read as
5469
+ // the answer is built, after the transport's round-trip, during which a
5470
+ // release that waited may have run.
5471
+ const withEnded = (text: string): string => (ended ? `${text} ${ended()}` : text);
5292
5472
  try {
5293
5473
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5294
5474
  } catch (err) {
5295
- const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
5296
- return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
5475
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, unrecordedDecision(err))), isError: true };
5297
5476
  }
5298
5477
  // This channel's typing, not every channel with its id (stopTyping's
5299
5478
  // fan-out of a bare name).
@@ -5302,7 +5481,7 @@ export class ChannelRegistry {
5302
5481
  // Refused now, the close stays recorded for the server's next
5303
5482
  // registration, and the text says so.
5304
5483
  if (!this.directsLifecycle(entry.serverId)) {
5305
- return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
5484
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId))), isError: true };
5306
5485
  }
5307
5486
 
5308
5487
  try {
@@ -5311,11 +5490,163 @@ export class ChannelRegistry {
5311
5490
  if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
5312
5491
  return {
5313
5492
  success: true,
5314
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
5493
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed', ...(ended ? { note: ended() } : {}) },
5315
5494
  };
5316
5495
  } catch (err) {
5317
- return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
5496
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, err)), isError: true };
5497
+ }
5498
+ }
5499
+
5500
+ /**
5501
+ * The tool-close hook (onToolClose) for a channel, its throw taken as the
5502
+ * answer's text: a release of the hold that failed is the close's answer,
5503
+ * as the registry's other failures are, never a rejected call.
5504
+ */
5505
+ private toolCloseHook(ref: ChannelRef, opts?: { unregistered?: true }): { text?: () => string } | { error: string } {
5506
+ try {
5507
+ return { text: this.onToolClose?.(ref.serverId, ref.channelId, opts) };
5508
+ } catch (err) {
5509
+ return { error: err instanceof Error ? err.message : String(err) };
5510
+ }
5511
+ }
5512
+
5513
+ /**
5514
+ * A close naming the channel the all-except hold excepts, which its server
5515
+ * no longer registers: the close has nothing left to close, and the hold
5516
+ * nothing left to speak into, so it ends as a close of the registered
5517
+ * channel ends it, what it held released, and the answer says so, as a
5518
+ * failure, since no close ran. Undefined where no hold excepts the channel
5519
+ * (onToolClose answers nothing).
5520
+ */
5521
+ private unregisteredFocusClose(gone: ChannelRef): ToolResult | undefined {
5522
+ const lead = noLongerRegistered(gone);
5523
+ const ended = this.toolCloseHook(gone, { unregistered: true });
5524
+ if ('error' in ended) return { success: false, error: `${lead} ${ended.error}`, isError: true };
5525
+ if (ended.text) return { success: false, error: `${lead} ${ended.text()}`, isError: true };
5526
+ return undefined;
5527
+ }
5528
+
5529
+ /**
5530
+ * The channel the all-except hold excepts (focusChannelResolver), where a
5531
+ * channel_close names it by its id or its server-qualified name alone: no
5532
+ * serverId, and no channel of the calling turn's own answering to the name
5533
+ * (ownChannelNamed, which a turn's name means first). Such a name means
5534
+ * the hold's channel, registered or not, and not another server's channel
5535
+ * with the id, as when the hold's channel's server stopped registering it
5536
+ * and another server registered the id: that one is closed only by a close
5537
+ * that names its server. With it, where other registered channels answer
5538
+ * to the name, the sentence saying they weren't closed (`notClosed`).
5539
+ *
5540
+ * Where the name is the hold channel's and a channel of the calling turn's
5541
+ * own, another channel, answers to it too, the name means neither: a
5542
+ * resident repeating a close that failed before its focus ended, in a turn
5543
+ * that has since opened another server's channel with the id, would
5544
+ * otherwise close that channel and leave the focus in force. That close is
5545
+ * refused, naming both (`refusal`, focusOrOwnRefusal), and a close naming
5546
+ * either's server reaches it. A turn's name for the hold's channel itself,
5547
+ * and a name the turn's channels on more than one server answer to
5548
+ * (ownNeitherRefusal), resolve as any other.
5549
+ *
5550
+ * Undefined where no hold is in force, where the name isn't the hold
5551
+ * channel's, and where the hold's record can't be read, which leaves the
5552
+ * name to resolve as any other (resolveToolChannelEntry).
5553
+ *
5554
+ * Another channel the name reaches that has a tune-out of its own is
5555
+ * refused by a close until that tune-out is cancelled (tunedOutRefusal),
5556
+ * so its sentence says it is tuned out, and names the cancel first, then
5557
+ * the close by its id and server.
5558
+ */
5559
+ private focusNamedBare(
5560
+ name: string,
5561
+ serverId: string | undefined,
5562
+ own: readonly ChannelRef[] | undefined,
5563
+ ): { focus: ChannelRef; notClosed?: string } | { refusal: string } | undefined {
5564
+ if (serverId) return undefined;
5565
+ const mine = this.ownChannelNamed(name, own);
5566
+ if (mine && 'neither' in mine) return undefined;
5567
+ let focus: ChannelRef | null | undefined;
5568
+ try {
5569
+ focus = this.focusChannelResolver?.();
5570
+ } catch {
5571
+ return undefined;
5318
5572
  }
5573
+ if (!focus || (name !== focus.channelId && name !== qualifiedChannelName(focus))) return undefined;
5574
+ if (mine) return sameChannel(mine.channel, focus) ? undefined : { refusal: this.focusOrOwnRefusal(name, focus, mine.channel, own) };
5575
+ const held = focus;
5576
+ const others = this.entriesNamed(name).filter((entry) => !sameChannel(refOf(entry), held));
5577
+ const tunedOut = others.filter((entry) => this.getDesiredState(entry.serverId, entry.descriptor.id) === 'tuned-out');
5578
+ const closable = others.filter((entry) => !tunedOut.includes(entry)).map(refOf);
5579
+ const sentences = [
5580
+ ...(closable.length > 0 ? [notClosedText(closable)] : []),
5581
+ ...tunedOut.map((entry) => {
5582
+ const ref = refOf(entry);
5583
+ return `Channel ${channelOnServer(ref)} was not closed: it is tuned out, and a close doesn't change that. ` +
5584
+ this.tuneOutEndText(entry, 'channel_close', `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`);
5585
+ }),
5586
+ ];
5587
+ return { focus: held, ...(sentences.length > 0 ? { notClosed: sentences.join(' ') } : {}) };
5588
+ }
5589
+
5590
+ /**
5591
+ * The refusal of a channel_close's bare name (focusNamedBare) that both the
5592
+ * channel the all-except hold excepts (`focus`) and another channel of the
5593
+ * calling turn's own (`mine`, ownChannelNamed) answer to, each named by
5594
+ * what reaches it alone. The hold's channel is reached by its
5595
+ * server-qualified name, registered or not (focusNamedBare), unless a
5596
+ * channel of the turn's answers to that too; the turn's channel as a
5597
+ * refusal of the turn's names shows it (ownCandidates), unless another
5598
+ * channel of the turn's has its qualified name for its id. A serverId goes
5599
+ * with the channel's own id, so it's offered only where the name is the id
5600
+ * of both, for the hold's server (registered or not, the close reaches its
5601
+ * channel: unregisteredFocusNamed) and for the turn's channel's where that
5602
+ * server still registers it. Where the name is the hold channel's
5603
+ * server-qualified name, which another of the turn's channels has for its
5604
+ * id, the hold's channel is reached by its id and server.
5605
+ */
5606
+ private focusOrOwnRefusal(name: string, focus: ChannelRef, mine: ChannelRef, own: readonly ChannelRef[] | undefined): string {
5607
+ const qualified = qualifiedChannelName(focus);
5608
+ const focusReaches = this.ownChannelNamed(qualified, own) === undefined;
5609
+ const [theirs] = this.ownCandidates([mine]);
5610
+ // The turn's channel's name, as the focus's: a bare id answers to the
5611
+ // turn's own channels first, so one of them with that id catches it.
5612
+ const theirsReaches = theirs!.reaches && this.ownChannelNamed(theirs!.text, own) === undefined;
5613
+ const parts = [
5614
+ { text: focusReaches ? quotedValue(qualified) : channelOnServer(focus), reaches: focusReaches },
5615
+ { text: theirsReaches ? quotedValue(theirs!.text) : theirs!.reaches ? channelOnServer(mine) : theirs!.text, reaches: theirsReaches },
5616
+ ];
5617
+ const named = parts.filter((p) => p.reaches).map((p) => p.text);
5618
+ const byName = named.length === 2 ? 'channelId set to one of them' : named.length === 1 ? `channelId ${named[0]}` : '';
5619
+ const servers = name === focus.channelId ? [focus.serverId, ...(this.entryAt(mine) ? [mine.serverId] : [])] : [];
5620
+ const byServer = servers.length > 0 ? `serverId set to ${servers.map((id) => JSON.stringify(id)).join(' or ')}` : '';
5621
+ const how = [byName, byServer].filter((h) => h !== '').join(', or with ');
5622
+ const focusRoute = name !== focus.channelId && !focusReaches
5623
+ ? ` For the channel the focus excepts, call again with channelId ${quotedValue(focus.channelId)} and serverId ${JSON.stringify(focus.serverId)}.`
5624
+ : '';
5625
+ return `Channel ${JSON.stringify(name)} names both the channel the all-except focus excepts (${parts[0]!.text}) ` +
5626
+ `and this turn's channel (${parts[1]!.text}), so nothing ran.${how ? ` Call again with ${how}.` : ''}${focusRoute}`;
5627
+ }
5628
+
5629
+ /**
5630
+ * The channel the all-except hold excepts (focusChannelResolver), where a
5631
+ * channel tool's name with a serverId, or the calling turn's own channel's
5632
+ * name, means it though its server no longer registers it: with a
5633
+ * serverId, that server's channel with the id; inside the calling turn,
5634
+ * the turn's own channel the name answers to by its id (ownChannelNamed).
5635
+ * A name given alone is focusNamedBare's. Undefined where no hold is in
5636
+ * force, where its channel is still registered, and where the hold's
5637
+ * record can't be read, which leaves the name's refusal as it was.
5638
+ */
5639
+ private unregisteredFocusNamed(name: string, serverId: string | undefined, own: readonly ChannelRef[] | undefined): ChannelRef | undefined {
5640
+ let focus: ChannelRef | null | undefined;
5641
+ try {
5642
+ focus = this.focusChannelResolver?.();
5643
+ } catch {
5644
+ return undefined;
5645
+ }
5646
+ if (!focus || this.entryAt(focus)) return undefined;
5647
+ if (serverId) return sameChannel(focus, { serverId, channelId: name }) ? focus : undefined;
5648
+ const mine = this.ownChannelNamed(name, own);
5649
+ return mine && 'channel' in mine && sameChannel(mine.channel, focus) ? focus : undefined;
5319
5650
  }
5320
5651
 
5321
5652
  private async handleToolDecline(input: {
@@ -5513,8 +5844,8 @@ export class ChannelRegistry {
5513
5844
  `diverted, and then call channel_close.${serverBack}`
5514
5845
  : `. If you still want it closed, call channel_close.${serverBack}`;
5515
5846
  closeError = decided === 'tuned-out'
5516
- ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
5517
- `the subconscious${step}`
5847
+ ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages ` +
5848
+ `${this.tuneOutHeld()}${step}`
5518
5849
  : `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
5519
5850
  } else if (closed) {
5520
5851
  // Closed for certain, whatever became of the decline's own close: one
@@ -5759,7 +6090,8 @@ export class ChannelRegistry {
5759
6090
  }
5760
6091
 
5761
6092
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
5762
- * process-global default), with its server. Public so a multi-segment
6093
+ * process-global default, with the configured unprompted channel in it
6094
+ * where the turn takes it), with its server. Public so a multi-segment
5763
6095
  * caller can snapshot it ONCE and pin every segment to it via
5764
6096
  * routeSpeech's locus. */
5765
6097
  resolveLocus(conversationId: string): ChannelRef | null {
@@ -5767,7 +6099,33 @@ export class ChannelRegistry {
5767
6099
  if (home) return home;
5768
6100
  // A turn that deliberately has no channel (null) takes no fallback.
5769
6101
  const active = this.activeChannelResolver?.(conversationId);
5770
- return active !== undefined ? active : this.globalFallbackFor(conversationId);
6102
+ return active !== undefined
6103
+ ? active
6104
+ : this.globalFallbackFor(conversationId, () => this.configuredUnprompted(conversationId));
6105
+ }
6106
+
6107
+ private configuredUnprompted(conversationId: string | undefined): ChannelRef | null {
6108
+ if (!this.unpromptedChannel) return null;
6109
+ if (conversationId !== undefined && this.takesUnpromptedChannel?.(conversationId) === false) return null;
6110
+ const named = this.entriesNamed(this.unpromptedChannel);
6111
+ if (named.length === 1 && !isInboundOnly(named[0]!)) {
6112
+ this.unpromptedChannelWarned = false;
6113
+ return refOf(named[0]!);
6114
+ }
6115
+ if (!this.unpromptedChannelWarned) {
6116
+ this.unpromptedChannelWarned = true;
6117
+ const why = named.length === 0
6118
+ ? 'names no registered channel'
6119
+ : named.length === 1
6120
+ ? 'names a channel its server declared inbound-only'
6121
+ : `names ${named.length} registered channels (name one with its server, as "serverId:channelId")`;
6122
+ console.error(
6123
+ `[routing] unpromptedChannel ${logValue(this.unpromptedChannel)} ${why}, so a turn the agent's own timer starts `
6124
+ + 'takes the fallback it takes without the setting until the setting names a channel it can speak in. '
6125
+ + 'Not logged again until a turn has used it.',
6126
+ );
6127
+ }
6128
+ return null;
5771
6129
  }
5772
6130
 
5773
6131
  /**
@@ -5776,19 +6134,21 @@ export class ChannelRegistry {
5776
6134
  * result, so neither the all-except hold's channel nor the latest inbound
5777
6135
  * message's is ever its destination.
5778
6136
  */
5779
- private globalFallbackFor(conversationId: string): ChannelRef | null {
5780
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
6137
+ private globalFallbackFor(conversationId: string, unprompted?: () => ChannelRef | null): ChannelRef | null {
6138
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback(unprompted);
5781
6139
  }
5782
6140
 
5783
6141
  /**
5784
6142
  * What every conversation that takes the global fallback falls back to when
5785
6143
  * neither a home channel nor its own delivery names one: the all-except
5786
- * hold's channel while one is open, else the latest delivered inbound
5787
- * channel under the legacy locus rule, else none — the prose stays private
5788
- * (architecture channel §3 carve-out, 2026-10-06).
6144
+ * hold's channel while one is open, else, for a turn's locus, the
6145
+ * configured unprompted channel where the turn takes it (resolveLocus),
6146
+ * else the latest delivered inbound channel under the legacy locus rule,
6147
+ * else none — the prose stays private (architecture channel §3 carve-out,
6148
+ * 2026-10-06).
5789
6149
  */
5790
- private sharedFallback(): ChannelRef | null {
5791
- return this.focusChannelResolver?.() ?? this.fallbackLocus();
6150
+ private sharedFallback(unprompted?: () => ChannelRef | null): ChannelRef | null {
6151
+ return this.focusChannelResolver?.() ?? unprompted?.() ?? this.fallbackLocus();
5792
6152
  }
5793
6153
 
5794
6154
  private fallbackLocus(): ChannelRef | null {
@@ -5941,12 +6301,12 @@ export class ChannelRegistry {
5941
6301
  if (isInboundOnly(entry)) {
5942
6302
  return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
5943
6303
  }
5944
- const server = this.serverRegistry.getServer(entry.serverId);
6304
+ const server = this.publishingServer(entry.serverId);
5945
6305
  // §14.1: channels/publish requires channels.publish in the grant. The
5946
6306
  // host not sending is the enforcement, here and at the last check, and
5947
6307
  // the server's state words the refusal (sendRefusal).
5948
6308
  const ungranted = (): PublishOutcome => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
5949
- if (!server || !CapabilityGrant.of(server).has('channels.publish')) return ungranted();
6309
+ if (!server) return ungranted();
5950
6310
 
5951
6311
  const prevented = (err: unknown): PublishOutcome => ({
5952
6312
  status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
@@ -6109,12 +6469,15 @@ export class ChannelRegistry {
6109
6469
  locus: ChannelRef | null,
6110
6470
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
6111
6471
  * draft with its own notice, so no failure marker is written for it.
6112
- * An `unknown` outcome still gets its marker. */
6113
- opts?: { heldOnFailure?: boolean },
6472
+ * An `unknown` outcome still gets its marker. `cameFrom`: with no
6473
+ * locus, the channels the turn's batch came from, each with how the
6474
+ * agent is shown it; the failure's reason names them, and how to reply
6475
+ * in those registered that take speech. */
6476
+ opts?: { heldOnFailure?: boolean; cameFrom?: ReadonlyArray<{ channel: ChannelRef; shown: string }> },
6114
6477
  ): Promise<PublishOutcome> {
6115
6478
  // `logged` is what the log line and the trace say, where it isn't the
6116
- // agent's reason (the two no-locus reasons below).
6117
- const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
6479
+ // agent's reason (the three no-locus reasons below).
6480
+ const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason, byDesign = false): PublishOutcome => {
6118
6481
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
6119
6482
  const channelId = channel?.channelId ?? null;
6120
6483
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
@@ -6134,17 +6497,19 @@ export class ChannelRegistry {
6134
6497
  this.onRouteFailure?.({
6135
6498
  conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
6136
6499
  reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
6500
+ ...(byDesign ? { byDesign: true as const } : {}),
6137
6501
  });
6138
6502
  }
6139
6503
  return outcome;
6140
6504
  };
6141
6505
 
6142
- // These two reasons are the only ones whose log line and trace keep the
6506
+ // These three reasons are the only ones whose log line and trace keep the
6143
6507
  // operator's words rather than the agent's: connectome-host's
6144
6508
  // connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
6145
- // "no locus" in both logged texts. Each names no tool: whether one the
6509
+ // "no locus" in each logged text. Each names no tool: whether one the
6146
6510
  // agent may call could post the words isn't known here, and the notice
6147
- // calls it a routing or configuration situation.
6511
+ // calls it a routing or configuration situation, or for a turn whose
6512
+ // batch named the channels it came from a routing one (byDesign).
6148
6513
  // Runtime backstop for JS callers the compiler can't see: an omitted
6149
6514
  // locus is a routing bug at the call site, never something to paper over
6150
6515
  // with a live re-resolution.
@@ -6164,6 +6529,32 @@ export class ChannelRegistry {
6164
6529
  // global inbound ever seen) — the agent was told its prose stays in
6165
6530
  // the archive; honor that.
6166
6531
  // The locus froze at the turn's start, so this says what the turn had.
6532
+ const from = opts?.cameFrom ?? [];
6533
+ if (from.length > 0) {
6534
+ const list = (names: readonly string[]) =>
6535
+ names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
6536
+ const closed: string[] = [];
6537
+ const open: Array<{ channel: ChannelRef; shown: string }> = [];
6538
+ for (const came of from) {
6539
+ const entry = this.entryAt(came.channel);
6540
+ if (!entry) closed.push(`${came.shown} isn't registered with this host now, so this host has no channel there to send to`);
6541
+ else if (isInboundOnly(entry)) closed.push(`${came.shown} is ${inboundOnlyReason(entry)}`);
6542
+ else if (!this.publishingServer(entry.serverId)) {
6543
+ const reach = this.serverReach(entry.serverId);
6544
+ closed.push(reach === 'connecting' || reach === 'down'
6545
+ ? `${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`
6546
+ : `${came.shown} can't take a send: ${this.sendRefusal(entry.serverId)}`);
6547
+ } else open.push(came);
6548
+ }
6549
+ const reply = open.length === 0 ? ''
6550
+ : open.length === from.length
6551
+ ? `; to reply ${from.length === 1 ? 'there' : 'in one of them'}, send your reply to that channel explicitly`
6552
+ : `; to reply in ${open.length === 1 ? open[0].shown : `one of ${list(open.map((c) => c.shown))}`}, send your reply to that channel explicitly`;
6553
+ const reason = `this turn came from ${list(from.map((c) => c.shown))}, and no channel was chosen for its reply` +
6554
+ `${reply}${closed.map((c) => `; ${c}`).join('')}`;
6555
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() },
6556
+ "turn has no locus (no channel was chosen for its batch's reply)", true);
6557
+ }
6167
6558
  const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
6168
6559
  'had one, ask whoever runs this host';
6169
6560
  return fail(null, reason, { status: 'failed', reason, at: Date.now() },
@@ -6198,8 +6589,9 @@ export class ChannelRegistry {
6198
6589
  conversationId: string,
6199
6590
  text: string,
6200
6591
  locus: ChannelRef | null,
6592
+ opts?: { cameFrom?: ReadonlyArray<{ channel: ChannelRef; shown: string }> },
6201
6593
  ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
6202
- const outcome = await this.deliverSpeech(conversationId, text, locus);
6594
+ const outcome = await this.deliverSpeech(conversationId, text, locus, opts);
6203
6595
  if (outcome.status !== 'delivered') return null;
6204
6596
  return {
6205
6597
  delivered: true,
@@ -6267,8 +6659,13 @@ export class ChannelRegistry {
6267
6659
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
6268
6660
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
6269
6661
  : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
6270
- ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6271
- + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
6662
+ ? (this.channellessTurn && !this.channellessTurn(caller)
6663
+ // The turn came from a channel, but its speech goes to none
6664
+ // (a locked turn held as drafts).
6665
+ ? `Nothing was posted: name the channel with channelId. Your plain speech goes to no channel now, so `
6666
+ + `channel_publish sends only to a channel it names. ${findIds}`
6667
+ : `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6668
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`)
6272
6669
  : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6273
6670
  isError: true,
6274
6671
  };
@@ -6353,8 +6750,8 @@ export class ChannelRegistry {
6353
6750
 
6354
6751
  // The same refusal, in the same words, as plain speech's (sendRefusal),
6355
6752
  // whose server state is the reason's: nothing awaits between the two.
6356
- const server = this.serverRegistry.getServer(entry.serverId);
6357
- if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
6753
+ const server = this.publishingServer(entry.serverId);
6754
+ if (!server) {
6358
6755
  if (seen) {
6359
6756
  const reach = this.serverReach(entry.serverId);
6360
6757
  seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;