@animalabs/agent-framework 0.26.1 → 0.27.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 (276) hide show
  1. package/README.md +57 -7
  2. package/dist/src/agent.d.ts +263 -22
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +531 -54
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +1 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +33 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +35 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/framework.d.ts +829 -156
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3906 -1008
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +309 -46
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +2117 -395
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -2
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -1
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  30. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  31. package/dist/src/gate/recipe-reconcile.js +325 -0
  32. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +68 -8
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/index.d.ts +6 -4
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -2
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +1152 -434
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  44. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  45. package/dist/src/mcpl/conversation-router.js +29 -15
  46. package/dist/src/mcpl/conversation-router.js.map +1 -1
  47. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  48. package/dist/src/mcpl/convert-block.js +14 -2
  49. package/dist/src/mcpl/convert-block.js.map +1 -1
  50. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  51. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  52. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  53. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  54. package/dist/src/mcpl/index.d.ts +5 -1
  55. package/dist/src/mcpl/index.d.ts.map +1 -1
  56. package/dist/src/mcpl/index.js +4 -0
  57. package/dist/src/mcpl/index.js.map +1 -1
  58. package/dist/src/mcpl/inference-router.d.ts +14 -2
  59. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  60. package/dist/src/mcpl/inference-router.js +73 -28
  61. package/dist/src/mcpl/inference-router.js.map +1 -1
  62. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  63. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-grammar.js +6 -0
  65. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  66. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  67. package/dist/src/mcpl/protocol-family.js +3 -0
  68. package/dist/src/mcpl/protocol-family.js.map +1 -1
  69. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  70. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  71. package/dist/src/mcpl/push-coalescer.js +33 -11
  72. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  73. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  74. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  75. package/dist/src/mcpl/reference-fetcher.js +177 -145
  76. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  77. package/dist/src/mcpl/references.d.ts +380 -46
  78. package/dist/src/mcpl/references.d.ts.map +1 -1
  79. package/dist/src/mcpl/references.js +737 -78
  80. package/dist/src/mcpl/references.js.map +1 -1
  81. package/dist/src/mcpl/server-connection.d.ts +82 -3
  82. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  83. package/dist/src/mcpl/server-connection.js +132 -7
  84. package/dist/src/mcpl/server-connection.js.map +1 -1
  85. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  86. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  87. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  88. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  89. package/dist/src/mcpl/tool-presence.d.ts +259 -0
  90. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  91. package/dist/src/mcpl/tool-presence.js +628 -0
  92. package/dist/src/mcpl/tool-presence.js.map +1 -0
  93. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  94. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  95. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  96. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  97. package/dist/src/mcpl/tool-signature.d.ts +39 -0
  98. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  99. package/dist/src/mcpl/tool-signature.js +292 -0
  100. package/dist/src/mcpl/tool-signature.js.map +1 -0
  101. package/dist/src/mcpl/toolset-host.d.ts +200 -0
  102. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  103. package/dist/src/mcpl/toolset-host.js +621 -0
  104. package/dist/src/mcpl/toolset-host.js.map +1 -0
  105. package/dist/src/mcpl/toolsets-module.d.ts +80 -0
  106. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  107. package/dist/src/mcpl/toolsets-module.js +431 -0
  108. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  109. package/dist/src/mcpl/transport.d.ts +2 -2
  110. package/dist/src/mcpl/transport.d.ts.map +1 -1
  111. package/dist/src/mcpl/transport.js +3 -2
  112. package/dist/src/mcpl/transport.js.map +1 -1
  113. package/dist/src/mcpl/types.d.ts +21 -1
  114. package/dist/src/mcpl/types.d.ts.map +1 -1
  115. package/dist/src/mcpl/types.js.map +1 -1
  116. package/dist/src/mcpl/visible-content.d.ts +7 -9
  117. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  118. package/dist/src/mcpl/visible-content.js +21 -9
  119. package/dist/src/mcpl/visible-content.js.map +1 -1
  120. package/dist/src/module-registry.d.ts +23 -8
  121. package/dist/src/module-registry.d.ts.map +1 -1
  122. package/dist/src/module-registry.js +68 -15
  123. package/dist/src/module-registry.js.map +1 -1
  124. package/dist/src/modules/discord/index.js +1 -1
  125. package/dist/src/modules/discord/index.js.map +1 -1
  126. package/dist/src/modules/history/index.d.ts +2 -1
  127. package/dist/src/modules/history/index.d.ts.map +1 -1
  128. package/dist/src/modules/history/index.js +51 -17
  129. package/dist/src/modules/history/index.js.map +1 -1
  130. package/dist/src/modules/history/semantic.d.ts +108 -6
  131. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  132. package/dist/src/modules/history/semantic.js +273 -57
  133. package/dist/src/modules/history/semantic.js.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.d.ts +181 -0
  135. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  136. package/dist/src/modules/workspace/direct-write.js +384 -0
  137. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  138. package/dist/src/modules/workspace/edit-text.d.ts +26 -12
  139. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  140. package/dist/src/modules/workspace/edit-text.js +58 -28
  141. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  142. package/dist/src/modules/workspace/effects.d.ts +10 -0
  143. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  144. package/dist/src/modules/workspace/effects.js +1 -1
  145. package/dist/src/modules/workspace/effects.js.map +1 -1
  146. package/dist/src/modules/workspace/index.d.ts +246 -27
  147. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  148. package/dist/src/modules/workspace/index.js +1216 -236
  149. package/dist/src/modules/workspace/index.js.map +1 -1
  150. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  151. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  152. package/dist/src/modules/workspace/read-page.js +213 -0
  153. package/dist/src/modules/workspace/read-page.js.map +1 -0
  154. package/dist/src/modules/workspace/result-text.d.ts +67 -0
  155. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  156. package/dist/src/modules/workspace/result-text.js +94 -0
  157. package/dist/src/modules/workspace/result-text.js.map +1 -0
  158. package/dist/src/modules/workspace/text.d.ts +29 -12
  159. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/text.js +35 -33
  161. package/dist/src/modules/workspace/text.js.map +1 -1
  162. package/dist/src/modules/workspace/types.d.ts +41 -17
  163. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/types.js.map +1 -1
  165. package/dist/src/sent-rows.d.ts +59 -0
  166. package/dist/src/sent-rows.d.ts.map +1 -0
  167. package/dist/src/sent-rows.js +145 -0
  168. package/dist/src/sent-rows.js.map +1 -0
  169. package/dist/src/silent-heartbeat.d.ts +17 -10
  170. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  171. package/dist/src/silent-heartbeat.js.map +1 -1
  172. package/dist/src/stable-hash.d.ts +3 -0
  173. package/dist/src/stable-hash.d.ts.map +1 -0
  174. package/dist/src/stable-hash.js +15 -0
  175. package/dist/src/stable-hash.js.map +1 -0
  176. package/dist/src/stored-injections.d.ts +99 -0
  177. package/dist/src/stored-injections.d.ts.map +1 -0
  178. package/dist/src/stored-injections.js +106 -0
  179. package/dist/src/stored-injections.js.map +1 -0
  180. package/dist/src/text-as-stored.d.ts +4 -3
  181. package/dist/src/text-as-stored.d.ts.map +1 -1
  182. package/dist/src/text-as-stored.js +4 -3
  183. package/dist/src/text-as-stored.js.map +1 -1
  184. package/dist/src/thinking-drops.d.ts +81 -0
  185. package/dist/src/thinking-drops.d.ts.map +1 -0
  186. package/dist/src/thinking-drops.js +103 -0
  187. package/dist/src/thinking-drops.js.map +1 -0
  188. package/dist/src/tool-image-ledger.d.ts +9 -8
  189. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  190. package/dist/src/tool-image-ledger.js +9 -8
  191. package/dist/src/tool-image-ledger.js.map +1 -1
  192. package/dist/src/tool-presentation.d.ts.map +1 -1
  193. package/dist/src/tool-presentation.js +3 -1
  194. package/dist/src/tool-presentation.js.map +1 -1
  195. package/dist/src/tool-result-guard.d.ts +4 -2
  196. package/dist/src/tool-result-guard.d.ts.map +1 -1
  197. package/dist/src/tool-result-guard.js +21 -6
  198. package/dist/src/tool-result-guard.js.map +1 -1
  199. package/dist/src/tool-result-history.d.ts +186 -21
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +428 -89
  202. package/dist/src/tool-result-history.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +17 -9
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +84 -37
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/types/agent.d.ts +186 -7
  208. package/dist/src/types/agent.d.ts.map +1 -1
  209. package/dist/src/types/events.d.ts +19 -2
  210. package/dist/src/types/events.d.ts.map +1 -1
  211. package/dist/src/types/framework.d.ts +24 -3
  212. package/dist/src/types/framework.d.ts.map +1 -1
  213. package/dist/src/types/module.d.ts +90 -7
  214. package/dist/src/types/module.d.ts.map +1 -1
  215. package/dist/src/types/trace.d.ts +57 -2
  216. package/dist/src/types/trace.d.ts.map +1 -1
  217. package/package.json +4 -3
  218. package/src/agent.ts +593 -60
  219. package/src/api/server.ts +1 -2
  220. package/src/auto-continue.ts +42 -0
  221. package/src/code-execution/isolate/scripts.ts +11 -3
  222. package/src/framework.ts +4004 -1026
  223. package/src/gate/event-gate.ts +2315 -389
  224. package/src/gate/index.ts +2 -1
  225. package/src/gate/recipe-reconcile.ts +355 -0
  226. package/src/gate/types.ts +70 -8
  227. package/src/index.ts +7 -4
  228. package/src/mcpl/channel-registry.ts +1220 -467
  229. package/src/mcpl/conversation-router.ts +34 -16
  230. package/src/mcpl/convert-block.ts +14 -1
  231. package/src/mcpl/hook-orchestrator.ts +33 -6
  232. package/src/mcpl/index.ts +6 -1
  233. package/src/mcpl/inference-router.ts +79 -34
  234. package/src/mcpl/prose-grammar.ts +7 -0
  235. package/src/mcpl/protocol-family.ts +2 -0
  236. package/src/mcpl/push-coalescer.ts +51 -13
  237. package/src/mcpl/reference-fetcher.ts +201 -162
  238. package/src/mcpl/references.ts +852 -78
  239. package/src/mcpl/server-connection.ts +145 -7
  240. package/src/mcpl/tool-lifecycle.ts +21 -0
  241. package/src/mcpl/tool-presence.ts +650 -0
  242. package/src/mcpl/tool-result-normalize.ts +18 -6
  243. package/src/mcpl/tool-signature.ts +285 -0
  244. package/src/mcpl/toolset-host.ts +637 -0
  245. package/src/mcpl/toolsets-module.ts +451 -0
  246. package/src/mcpl/transport.ts +3 -2
  247. package/src/mcpl/types.ts +22 -1
  248. package/src/mcpl/visible-content.ts +22 -9
  249. package/src/module-registry.ts +77 -24
  250. package/src/modules/discord/index.ts +1 -1
  251. package/src/modules/history/index.ts +48 -18
  252. package/src/modules/history/semantic.ts +308 -54
  253. package/src/modules/workspace/direct-write.ts +425 -0
  254. package/src/modules/workspace/edit-text.ts +82 -35
  255. package/src/modules/workspace/effects.ts +1 -1
  256. package/src/modules/workspace/index.ts +1274 -241
  257. package/src/modules/workspace/read-page.ts +222 -0
  258. package/src/modules/workspace/result-text.ts +119 -0
  259. package/src/modules/workspace/text.ts +58 -32
  260. package/src/modules/workspace/types.ts +41 -17
  261. package/src/sent-rows.ts +162 -0
  262. package/src/silent-heartbeat.ts +17 -10
  263. package/src/stable-hash.ts +14 -0
  264. package/src/stored-injections.ts +131 -0
  265. package/src/text-as-stored.ts +4 -3
  266. package/src/thinking-drops.ts +141 -0
  267. package/src/tool-image-ledger.ts +9 -8
  268. package/src/tool-presentation.ts +3 -1
  269. package/src/tool-result-guard.ts +19 -6
  270. package/src/tool-result-history.ts +488 -88
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +19 -2
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -23,6 +23,7 @@ import { RefusalLines } from './refusal-lines.js';
23
23
  import { CapabilityGrant } from './capability-grant.js';
24
24
  import { McplRequestError } from './server-connection.js';
25
25
  import { readSkipReplyWake } from './skip-reply-wake.js';
26
+ import { isProseTargetToken } from './prose-grammar.js';
26
27
  // ============================================================================
27
28
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
28
29
  // ============================================================================
@@ -70,6 +71,20 @@ class ChannelLifecycleError extends Error {
70
71
  this.by = options?.by;
71
72
  }
72
73
  }
74
+ /** Whether a lifecycle failure, or one it wraps (an overtaken open's
75
+ * `cause`), is convergence that never established the channel's target.
76
+ * Words don't go into such a channel on the strength of a close the agent
77
+ * decided meanwhile (refusalGivesWay): the address they'd go to is the one
78
+ * that kept changing. A later operation that settles on the current target
79
+ * establishes it, so this is the failure's, not the channel's (compare
80
+ * answeredElsewhere). */
81
+ function convergeFailure(err) {
82
+ for (let e = err; e instanceof Error; e = e.cause) {
83
+ if (e instanceof ChannelLifecycleError && e.kind === 'converge')
84
+ return true;
85
+ }
86
+ return false;
87
+ }
73
88
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
74
89
  /** The sources of a decision the agent itself made: channel_open and
75
90
  * channel_close, and channel_decline. */
@@ -194,6 +209,10 @@ function shallowEqualRecord(a, b) {
194
209
  }
195
210
  return true;
196
211
  }
212
+ /** The ref naming a registered channel's entry. */
213
+ function refOf(entry) {
214
+ return { serverId: entry.serverId, channelId: entry.descriptor.id };
215
+ }
197
216
  /**
198
217
  * A channel its server declared inbound-only (SPEC §14.2 `direction`): its
199
218
  * messages come in, and the host publishes nothing into it. No publish, from
@@ -305,7 +324,8 @@ const CHANNEL_TOOL_DEFINITIONS = [
305
324
  {
306
325
  name: 'channel_close',
307
326
  description: 'Close a channel. Messages that address you still reach you; what else stops reaching you ' +
308
- 'depends on its server.',
327
+ 'depends on its server. You can still speak there. It stays closed until you open it with ' +
328
+ 'channel_open: neither speaking there nor subscription policy reopens it.',
309
329
  inputSchema: {
310
330
  type: 'object',
311
331
  properties: {
@@ -405,11 +425,33 @@ export function connectorText(value) {
405
425
  const suffix = '… [truncated]';
406
426
  return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
407
427
  }
408
- /** One key per destination, its server and channel: unlike joining the two
409
- * with a separator, no two destinations share one, whatever their ids hold. */
428
+ /** One key per channel, its server and id: unlike joining the two with a
429
+ * separator, no two channels share one, whatever their ids hold. */
410
430
  export function destinationKey(to) {
411
431
  return JSON.stringify([to.serverId, to.channelId]);
412
432
  }
433
+ /** Whether two refs name the same channel. Two absent refs are the same:
434
+ * neither names a channel. */
435
+ export function sameChannel(a, b) {
436
+ if (!a || !b)
437
+ return !a && !b;
438
+ return a.serverId === b.serverId && a.channelId === b.channelId;
439
+ }
440
+ /**
441
+ * A channel's server-qualified name, `serverId:channelId`. A name given as a
442
+ * string (a tool's channelId, a `>>` target, a host's call) may take this
443
+ * form to pick one server's channel where several servers register the id.
444
+ * It is looked up among the registered pairs, never split at a colon, since
445
+ * ids hold colons (ChannelRegistry.entriesNamed).
446
+ */
447
+ export function qualifiedChannelName(ref) {
448
+ return `${ref.serverId}:${ref.channelId}`;
449
+ }
450
+ /** A channel told apart by its id and its server, for one no string picks
451
+ * alone: `"b:room" on server "a"`. No input takes it for a name. */
452
+ function channelOnServer(ref) {
453
+ return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
454
+ }
413
455
  /**
414
456
  * A failed request's words, bounded, for a reason that a caller composes.
415
457
  * This host's own words, for a request that wasn't sent or wasn't answered
@@ -516,27 +558,6 @@ function supersededClause(action, now) {
516
558
  return 'it was tuned out meanwhile';
517
559
  return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
518
560
  }
519
- /** What ends a tune-out, for a channel tool's answer about a tuned-out
520
- * channel (`channel` as the answer shows it, quoted): its cancel, with the
521
- * backlog it delivers, and, for a close, that channel_close closes the
522
- * channel once the cancel has ended the tune-out. */
523
- function tunedOutPointer(tool, channel) {
524
- return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
525
- 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
526
- }
527
- /** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
528
- * the tune-out, with the backlog it delivers. As an error result the agent
529
- * reads the pointer itself; `data` names the refusal for an in-process
530
- * caller. */
531
- function tunedOutRefusal(tool, channelId) {
532
- return {
533
- success: false,
534
- isError: true,
535
- error: `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
536
- tunedOutPointer(tool, `"${channelId}"`),
537
- data: { refusal: 'tuned-out', channelId },
538
- };
539
- }
540
561
  /** A clause as the start of a sentence. */
541
562
  function sentenceStart(clause) {
542
563
  return clause.charAt(0).toUpperCase() + clause.slice(1);
@@ -609,6 +630,19 @@ function publishReceiptOutcome(result, destination, at) {
609
630
  * Closed on purpose: honest provenance means a small, auditable vocabulary,
610
631
  * not arbitrary self-description. */
611
632
  export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
633
+ /** A close a module made through its own tool call (ctx.callTool) without a
634
+ * machine source, such as connectome-host's mentions mode: a decision the
635
+ * module owns, with its own way back. Speech leaves it closed, as it does a
636
+ * channel the agent closed (speechOpens), but the agent isn't told how to
637
+ * open it, since channel_open alone would undo only part of what the
638
+ * module did. */
639
+ export const MODULE_CLOSE_SOURCE = 'module-tool';
640
+ /** Whether a closed record is a close the agent or a module decided
641
+ * (channel_close, channel_decline, or a module's own close), which speech
642
+ * never undoes. */
643
+ function isCloseDecision(source) {
644
+ return AGENT_DECISION_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE;
645
+ }
612
646
  export class ChannelRegistry {
613
647
  serverRegistry;
614
648
  featureSetManager;
@@ -623,7 +657,11 @@ export class ChannelRegistry {
623
657
  takesGlobalFallback;
624
658
  serverConnecting;
625
659
  store;
626
- /** Registered channels, keyed by `{serverId}:{channelId}`. */
660
+ /**
661
+ * Registered channels, keyed by their server and id (destinationKey): a
662
+ * key that joined the two with a separator would let server `a`'s channel
663
+ * `b:room` and server `a:b`'s channel `room` share one entry.
664
+ */
627
665
  channels = new Map();
628
666
  /**
629
667
  * Each outgoing stream, by `inferenceId`, with the destinations it has
@@ -688,10 +726,12 @@ export class ChannelRegistry {
688
726
  /** The same message's id and thread, used for buildChannelContext. */
689
727
  defaultPublishMessageId = null;
690
728
  defaultPublishThreadId = undefined;
691
- /** Per-channel typing indicator timers. */
729
+ /** Per-channel typing indicator timers, by destinationKey, with the
730
+ * channel each one types in. */
692
731
  typingIntervals = new Map();
693
- /** Per-channel typing metadata — carried on the 7s refresh so the target
694
- * server keeps getting the same routing hints (e.g. Zulip topic). */
732
+ /** Per-channel typing metadata, by destinationKey — carried on the 7s
733
+ * refresh so the target server keeps getting the same routing hints (e.g.
734
+ * Zulip topic). */
695
735
  typingMetadata = new Map();
696
736
  /** Chronicle-projected desired lifecycle state, keyed by server + channel.
697
737
  * Provenance is kept so reconcile can tell a pure default (nobody ever
@@ -719,6 +759,16 @@ export class ChannelRegistry {
719
759
  // Losing an open receipt does not confirm closure. Keep that distinction
720
760
  // private while preserving the public open:boolean availability projection.
721
761
  unconfirmedChannelTargets = new WeakSet();
762
+ // Registrations whose last open the server answered for another channel,
763
+ // until an open is answered for this one, or the server registers the
764
+ // channel again, which makes a new registration: a server that doesn't
765
+ // resolve this id may not resolve a publish by it either. A close's answer
766
+ // names no channel, so it doesn't clear the mark, and a retarget keeps the
767
+ // registration and its mark. Read where speech would put words into such a
768
+ // channel without an open (publish). Where the grant loses
769
+ // channels.lifecycle while the connection stays (§6.7), no open can clear
770
+ // the mark until the server reconnects and registers the channel again.
771
+ answeredElsewhere = new WeakSet();
722
772
  // Marked entries a close failed for since a receipt or traffic last
723
773
  // confirmed them, cleared where the mark is: a failed request's outcome is
724
774
  // unknown, so such a close may have closed the channel, where an open,
@@ -750,7 +800,7 @@ export class ChannelRegistry {
750
800
  /** Whether a decided close holds words back from a channel: only where
751
801
  * the host directs the server's lifecycle. Without that grant the host
752
802
  * can't close the channel, and words go into a transport that is open,
753
- * as the open step's engagesDecidedClosed reads it too. */
803
+ * as the open step reads it too (speechOpens). */
754
804
  closeStands(entry) {
755
805
  return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
756
806
  this.directsLifecycle(entry.serverId);
@@ -823,7 +873,8 @@ export class ChannelRegistry {
823
873
  * is in the connection's words.
824
874
  */
825
875
  lifecycleFailureText(action, entry, err, opts) {
826
- const channel = `"${entry.descriptor.id}"`;
876
+ // As the agent names it back (nameShown), where another server shares its id.
877
+ const channel = this.quotedShown(refOf(entry));
827
878
  const tool = action === 'open' ? 'channel_open' : 'channel_close';
828
879
  const done = action === 'open' ? 'opened' : 'closed';
829
880
  const later = laterClause(entry);
@@ -835,7 +886,7 @@ export class ChannelRegistry {
835
886
  }
836
887
  const failure = err instanceof ChannelLifecycleError ? err : undefined;
837
888
  const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
838
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
889
+ const current = this.entryAt(refOf(entry));
839
890
  if (!current) {
840
891
  // Its registration may have gone at the server's word or at this
841
892
  // host's (removeServer). Only a failure that says which (cancelled,
@@ -858,13 +909,13 @@ export class ChannelRegistry {
858
909
  if (action === 'open' ? now !== 'open' : now !== 'closed') {
859
910
  if (action === 'open' && now === 'tuned-out') {
860
911
  return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
861
- `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
912
+ `change that. ${this.tuneOutEndText(current)}`;
862
913
  }
863
914
  // channel_close refuses a tuned-out channel too, so calling it again
864
915
  // isn't the way: the tune-out's cancel is, and it delivers the backlog.
865
916
  if (action === 'close' && now === 'tuned-out') {
866
917
  return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
867
- `change that. ${tunedOutPointer('channel_close', channel)}`;
918
+ `change that. ${this.tuneOutEndText(current, 'channel_close')}`;
868
919
  }
869
920
  // The later decision's own work settles the transport, and its call
870
921
  // says what became of it, so this says only that this call's change
@@ -1009,7 +1060,7 @@ export class ChannelRegistry {
1009
1060
  results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
1010
1061
  continue;
1011
1062
  }
1012
- const key = `${serverId}:${channel.id}`;
1063
+ const key = destinationKey({ serverId, channelId: channel.id });
1013
1064
  this.registerEntry(key, serverId, channel);
1014
1065
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1015
1066
  registeredIds.push(channel.id);
@@ -1048,9 +1099,11 @@ export class ChannelRegistry {
1048
1099
  // we never had is reported, not silently accepted.
1049
1100
  if (params.removed) {
1050
1101
  for (const channelId of params.removed) {
1051
- const key = `${serverId}:${channelId}`;
1052
- const existed = this.channels.delete(key);
1053
- this.stopTyping(channelId);
1102
+ // A server may name the channel by a number: it means the channel
1103
+ // whose id is that number as a string, as it always has.
1104
+ const ref = { serverId, channelId: String(channelId) };
1105
+ const existed = this.channels.delete(destinationKey(ref));
1106
+ this.stopTyping(ref);
1054
1107
  addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
1055
1108
  }
1056
1109
  }
@@ -1065,7 +1118,7 @@ export class ChannelRegistry {
1065
1118
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
1066
1119
  continue;
1067
1120
  }
1068
- const key = `${serverId}:${channel.id}`;
1121
+ const key = destinationKey({ serverId, channelId: channel.id });
1069
1122
  const existing = this.channels.get(key);
1070
1123
  if (!existing) {
1071
1124
  addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
@@ -1105,7 +1158,7 @@ export class ChannelRegistry {
1105
1158
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
1106
1159
  continue;
1107
1160
  }
1108
- const key = `${serverId}:${channel.id}`;
1161
+ const key = destinationKey({ serverId, channelId: channel.id });
1109
1162
  this.registerEntry(key, serverId, channel);
1110
1163
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1111
1164
  addedResults.push({ id: channel.id, accepted: true });
@@ -1175,6 +1228,9 @@ export class ChannelRegistry {
1175
1228
  // The batch came on the transport open now: a close after this point
1176
1229
  // (transportClosed) leaves its traffic nothing to confirm.
1177
1230
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1231
+ // And came from a server registered now: one removed since (removeServer)
1232
+ // gives no message the agent replies to (markAccepted).
1233
+ const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1178
1234
  for (const message of params.messages) {
1179
1235
  if (!message || typeof message.channelId !== 'string' || !message.channelId
1180
1236
  || typeof message.messageId !== 'string' || !message.messageId) {
@@ -1209,7 +1265,7 @@ export class ChannelRegistry {
1209
1265
  // exists registers it first; discord's DM case goes through
1210
1266
  // ensureChannelRegistered on push/event, which creates a routable
1211
1267
  // entry that isn't open, rather than an open self-attested one.
1212
- const incomingKey = `${serverId}:${message.channelId}`;
1268
+ const incomingKey = destinationKey({ serverId, channelId: message.channelId });
1213
1269
  if (!this.channels.has(incomingKey)) {
1214
1270
  // Loud, coalesced or not, like every other refusal of a message, but
1215
1271
  // at most once a minute per channel (RefusalLines): a server that
@@ -1290,8 +1346,11 @@ export class ChannelRegistry {
1290
1346
  // must not retarget the default publish locus or become
1291
1347
  // `context.incoming`'s reply anchor. An edit the coalescer delivered
1292
1348
  // as the agent's first view counts as the message it replaced.
1293
- if (!isMarkerTagged(message.tags, firstView)) {
1294
- this.defaultPublishChannel = message.channelId;
1349
+ // A message the coalescer admits after its server was removed sets no
1350
+ // default: the removal cleared that server's message, and a server
1351
+ // that's gone mustn't displace a live one's default.
1352
+ if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1353
+ this.defaultPublishChannel = { serverId, channelId: message.channelId };
1295
1354
  this.defaultPublishMessageId = message.messageId;
1296
1355
  this.defaultPublishThreadId = message.threadId;
1297
1356
  }
@@ -1453,7 +1512,9 @@ export class ChannelRegistry {
1453
1512
  * overwrites this descriptor with the richer one.
1454
1513
  */
1455
1514
  ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
1456
- const existing = this.findChannelEntry(channelId);
1515
+ // This server's own registration: another server's channel with the same
1516
+ // id is another channel (ChannelRef), which this one doesn't make.
1517
+ const existing = this.entryAt({ serverId, channelId });
1457
1518
  if (existing) {
1458
1519
  // Backfill identity onto a bare lazy registration: a DM that first
1459
1520
  // arrived with no label/recipient info gets a usable name the next
@@ -1493,7 +1554,7 @@ export class ChannelRegistry {
1493
1554
  // the server may still hold a channel an earlier process opened. Counting
1494
1555
  // it confirmed closed would answer a close or a decline as already done
1495
1556
  // and send nothing, even where the server still delivers.
1496
- const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, {
1557
+ const entry = this.registerEntry(destinationKey({ serverId, channelId }), serverId, {
1497
1558
  id: channelId,
1498
1559
  type: serverId,
1499
1560
  label: label ?? channelId,
@@ -1512,88 +1573,101 @@ export class ChannelRegistry {
1512
1573
  // Typing Indicator Management
1513
1574
  // ==========================================================================
1514
1575
  /**
1515
- * Start sending typing indicators for a channel.
1576
+ * Start sending typing indicators for a channel: the channel a ref names,
1577
+ * or each channel a name given as a string means (entriesNamed). A host
1578
+ * names channels by the ids it keeps (connectome-host's activity module),
1579
+ * and typing posts nothing, so a name two servers' channels answer to
1580
+ * shows typing in both, rather than in neither or in whichever registered
1581
+ * first. Where two servers reach one platform channel, two bots can then
1582
+ * show typing there.
1516
1583
  *
1517
1584
  * Sends a typing notification immediately and every 7 seconds thereafter.
1518
1585
  * Discord typing indicators last ~10s, so 7s keeps them alive.
1519
1586
  *
1520
- * No-op if already typing on this channel.
1587
+ * No-op for a channel already typing, or not registered.
1521
1588
  */
1522
- startTyping(channelId, metadata) {
1589
+ startTyping(channel, metadata) {
1590
+ const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
1591
+ for (const ref of refs)
1592
+ this.startTypingAt(ref, metadata);
1593
+ }
1594
+ startTypingAt(ref, metadata) {
1595
+ // A channel types only while it's registered, and keeps no metadata
1596
+ // otherwise: each removal of a channel or a server stops its typing
1597
+ // (stopTyping), which drops the metadata too, and stopAll clears both.
1598
+ if (!this.entryAt(ref))
1599
+ return;
1600
+ const key = destinationKey(ref);
1523
1601
  const metadataChanged = metadata !== undefined &&
1524
- !shallowEqualRecord(this.typingMetadata.get(channelId), metadata);
1602
+ !shallowEqualRecord(this.typingMetadata.get(key), metadata);
1525
1603
  if (metadata) {
1526
- this.typingMetadata.set(channelId, metadata);
1604
+ this.typingMetadata.set(key, metadata);
1527
1605
  }
1528
- if (this.typingIntervals.has(channelId)) {
1606
+ if (this.typingIntervals.has(key)) {
1529
1607
  // Already typing. If the caller supplied new routing metadata (e.g. the
1530
1608
  // relevant Zulip topic just moved because a newer message arrived),
1531
1609
  // dispatch an immediate refresh so the server sees the new routing
1532
1610
  // within this request instead of waiting up to TYPING_INTERVAL_MS for
1533
1611
  // the next tick.
1534
- if (metadataChanged) {
1535
- const entry = this.findChannelEntry(channelId);
1536
- if (entry) {
1537
- this.sendTypingNotification(entry.serverId, channelId);
1538
- }
1539
- }
1540
- return;
1541
- }
1542
- // Find the channel entry and its server
1543
- const entry = this.findChannelEntry(channelId);
1544
- if (!entry) {
1612
+ if (metadataChanged)
1613
+ this.sendTypingNotification(ref);
1545
1614
  return;
1546
1615
  }
1547
1616
  // Send typing immediately
1548
- this.sendTypingNotification(entry.serverId, channelId);
1617
+ this.sendTypingNotification(ref);
1549
1618
  // Set up interval — pulls the latest metadata on each tick so mid-stream
1550
1619
  // updates (e.g. a newer incoming message switching the relevant topic)
1551
1620
  // take effect on the next refresh.
1552
1621
  const interval = setInterval(() => {
1553
- this.sendTypingNotification(entry.serverId, channelId);
1622
+ this.sendTypingNotification(ref);
1554
1623
  }, TYPING_INTERVAL_MS);
1555
- this.typingIntervals.set(channelId, interval);
1624
+ this.typingIntervals.set(key, { ref, interval });
1556
1625
  }
1557
1626
  /**
1558
1627
  * Stop sending typing indicators.
1559
1628
  *
1560
- * If channelId is specified, stops typing on that channel only.
1561
- * If no channelId, stops all typing indicators.
1629
+ * Given a ref, stops typing in that channel. Given a name as a string,
1630
+ * stops typing in each channel the name means, by its id or its
1631
+ * server-qualified name (entriesNamed), as startTyping started it: a
1632
+ * channel types only while it's registered, since startTypingAt starts
1633
+ * none that isn't and each removal stops its channel's typing. Given
1634
+ * nothing, stops all typing indicators.
1562
1635
  */
1563
- stopTyping(channelId) {
1564
- if (channelId !== undefined) {
1565
- const interval = this.typingIntervals.get(channelId);
1566
- if (interval) {
1636
+ stopTyping(channel) {
1637
+ if (channel === undefined) {
1638
+ // Clear all typing intervals and dispatch stop for each known channel
1639
+ const running = [...this.typingIntervals.values()];
1640
+ for (const { interval } of running)
1567
1641
  clearInterval(interval);
1568
- this.typingIntervals.delete(channelId);
1642
+ this.typingIntervals.clear();
1643
+ for (const { ref } of running)
1644
+ this.sendTypingStop(ref);
1645
+ this.typingMetadata.clear();
1646
+ return;
1647
+ }
1648
+ const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
1649
+ for (const ref of refs) {
1650
+ const key = destinationKey(ref);
1651
+ const typing = this.typingIntervals.get(key);
1652
+ if (typing) {
1653
+ clearInterval(typing.interval);
1654
+ this.typingIntervals.delete(key);
1569
1655
  // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
1570
1656
  // clear the indicator immediately rather than waiting for auto-expire.
1571
1657
  // Metadata still carries the routing hint so the stop hits the same
1572
- // topic/thread as the start. Guarded by `interval`: matches the
1658
+ // topic/thread as the start. Guarded by the interval: matches the
1573
1659
  // global-clear branch's semantics, and keeps defensive stopTyping(ch)
1574
1660
  // calls from spamming stops at a server that never saw a start.
1575
- const entry = this.findChannelEntry(channelId);
1576
- if (entry && this.sendTypingFn) {
1577
- this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
1578
- }
1661
+ this.sendTypingStop(ref);
1579
1662
  }
1580
- this.typingMetadata.delete(channelId);
1663
+ this.typingMetadata.delete(key);
1581
1664
  }
1582
- else {
1583
- // Clear all typing intervals and dispatch stop for each known channel
1584
- const channels = Array.from(this.typingIntervals.keys());
1585
- for (const interval of this.typingIntervals.values()) {
1586
- clearInterval(interval);
1587
- }
1588
- this.typingIntervals.clear();
1589
- if (this.sendTypingFn) {
1590
- for (const id of channels) {
1591
- const entry = this.findChannelEntry(id);
1592
- if (entry)
1593
- this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
1594
- }
1595
- }
1596
- this.typingMetadata.clear();
1665
+ }
1666
+ /** The explicit 'stop' for a channel whose typing ended, while its
1667
+ * server still registers it. */
1668
+ sendTypingStop(ref) {
1669
+ if (this.sendTypingFn && this.entryAt(ref)) {
1670
+ this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
1597
1671
  }
1598
1672
  }
1599
1673
  // ==========================================================================
@@ -1608,10 +1682,6 @@ export class ChannelRegistry {
1608
1682
  getDefaultPublishChannel() {
1609
1683
  return this.defaultPublishChannel;
1610
1684
  }
1611
- /**
1612
- * Get the descriptor for a channel by its channelId (first match across
1613
- * servers). Used by the conversation router for DM classification.
1614
- */
1615
1685
  /**
1616
1686
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
1617
1687
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -1620,28 +1690,22 @@ export class ChannelRegistry {
1620
1690
  * registration.
1621
1691
  */
1622
1692
  isDeclaredChannel(serverId, channelId) {
1623
- const entry = this.channels.get(`${serverId}:${channelId}`);
1693
+ const entry = this.entryAt({ serverId, channelId });
1624
1694
  return !!entry && !pushPlaceholder(entry);
1625
1695
  }
1626
- getDescriptor(channelId) {
1627
- return this.findChannelEntry(channelId)?.descriptor;
1696
+ /** A registered channel's descriptor. Used by the conversation router for
1697
+ * DM classification. */
1698
+ getDescriptor(ref) {
1699
+ return this.entryAt(ref)?.descriptor;
1628
1700
  }
1629
1701
  /** The registered label of `channelId` on exactly `serverId` (no
1630
1702
  * cross-server first match), or undefined when it has none. */
1631
1703
  getChannelLabel(serverId, channelId) {
1632
- const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label;
1704
+ const label = this.entryAt({ serverId, channelId })?.descriptor.label;
1633
1705
  return typeof label === 'string' && label.length > 0 ? label : undefined;
1634
1706
  }
1635
- /** Whether `channelId`'s transport is open: on `serverId` where the caller
1636
- * knows the channel's server, and otherwise on the first server found to
1637
- * register that id (findChannelEntry). The map's key joins the two ids
1638
- * with a colon, so another server's entry can sit under a key: it has to
1639
- * be this server's (as in streamingServerAt). */
1640
- isChannelOpen(channelId, serverId) {
1641
- if (serverId === undefined)
1642
- return this.findChannelEntry(channelId)?.open === true;
1643
- const entry = this.channels.get(`${serverId}:${channelId}`);
1644
- return entry?.serverId === serverId && entry.open === true;
1707
+ isChannelOpen(ref) {
1708
+ return this.entryAt(ref)?.open === true;
1645
1709
  }
1646
1710
  getDesiredState(serverId, channelId) {
1647
1711
  return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
@@ -1679,18 +1743,21 @@ export class ChannelRegistry {
1679
1743
  * decision it actually is. Absent origin is treated as agent-origin (the
1680
1744
  * untrusted-safe default).
1681
1745
  */
1682
- async handleChannelToolCall(toolName, input, origin) {
1746
+ async handleChannelToolCall(toolName, input, origin,
1747
+ /** Filled with a channel_publish's outcome once its request is
1748
+ * attempted, so the caller learns where it was delivered. */
1749
+ seen) {
1683
1750
  switch (toolName) {
1684
1751
  case 'channel_list':
1685
1752
  return this.handleToolList();
1686
1753
  case 'channel_open':
1687
- return this.handleToolOpen(input);
1754
+ return this.handleToolOpen(input, origin?.kind === 'agent' ? origin.own : undefined);
1688
1755
  case 'channel_close':
1689
1756
  return this.handleToolClose(input, origin);
1690
1757
  case 'channel_decline':
1691
- return this.handleToolDecline(input);
1758
+ return this.handleToolDecline(input, origin?.kind === 'agent' ? origin.own : undefined);
1692
1759
  case 'channel_publish':
1693
- return this.handleToolPublish(input, origin);
1760
+ return this.handleToolPublish(input, origin, seen);
1694
1761
  case 'think':
1695
1762
  return this.handleToolThink(input);
1696
1763
  case 'journal':
@@ -1711,12 +1778,39 @@ export class ChannelRegistry {
1711
1778
  // Channel Context for beforeInference
1712
1779
  // ==========================================================================
1713
1780
  /**
1714
- * Build channel context for inclusion in beforeInference params.
1715
- *
1716
- * Returns undefined if no channels are active.
1781
+ * The message the agent is replying to, with its channel: the most recent
1782
+ * inbound message that isn't a reaction, reaction removal, edit or deletion
1783
+ * marker, apart from an edit the coalescer delivers on acceptance as the
1784
+ * agent's first view. Left process-global: per-channel inbound tracking
1785
+ * (the right messageId for a fork's own channel) is a separate concern from
1786
+ * the outbound routing fix. An ephemeral run has none (takesGlobalFallback),
1787
+ * so a subagent's refusal marks no one else's channel. Nor has a turn that
1788
+ * deliberately has no channel (activeChannelResolver's null): woken for a
1789
+ * channel id two servers register, it isn't replying to the latest inbound
1790
+ * message, which may be anywhere. A fork's turn always has a channel, its
1791
+ * home, so it's taken to be replying to the latest inbound message as every
1792
+ * fork's turn is, in explicit mode as in locus mode, where its locus is
1793
+ * its home.
1717
1794
  */
1718
- buildChannelContext(agentName) {
1719
- const openChannels = this.getOpenChannels();
1795
+ incomingFor(agentName) {
1796
+ if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
1797
+ return undefined;
1798
+ }
1799
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1800
+ if (!fallback || !this.defaultPublishMessageId)
1801
+ return undefined;
1802
+ return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
1803
+ }
1804
+ /**
1805
+ * The channel context one server is told for an agent's inference
1806
+ * (context/beforeInference, §14.4). Each server is told only of its own
1807
+ * channels: ids are unique only within one server's connection (§14.2), so
1808
+ * another server's channel named by its id alone could read as this
1809
+ * server's own, and §14.4 leaves the field to the host. So `incoming` and
1810
+ * `defaultOutgoing` appear only where that channel is `serverId`'s, and
1811
+ * `candidates` lists only its open channels. Undefined when none applies.
1812
+ */
1813
+ buildChannelContext(agentName, serverId) {
1720
1814
  // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
1721
1815
  // told the channel its speech will actually land in (none, when its server
1722
1816
  // declared it inbound-only, since speech isn't posted there): a conversation
@@ -1733,37 +1827,32 @@ export class ChannelRegistry {
1733
1827
  // the latest inbound message's channel as its outgoing one, nor that
1734
1828
  // message as the one it is replying to.
1735
1829
  const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1736
- const outgoing = home ?? active ?? fallback;
1737
- if (openChannels.length === 0 && !outgoing) {
1738
- return undefined;
1739
- }
1830
+ // A turn that deliberately has no channel (null) takes no fallback.
1831
+ const outgoing = home ?? (active !== undefined ? active : fallback);
1740
1832
  const context = {};
1741
- // Incoming: the most recent inbound message that isn't a reaction,
1742
- // reaction removal, edit or deletion marker, apart from an edit the
1743
- // coalescer delivers on acceptance as the agent's first view (what the
1744
- // agent is replying to).
1745
- // Left process-global — per-channel inbound tracking (the right messageId for
1746
- // a fork's own channel) is a separate concern from the outbound routing fix.
1747
- if (fallback && this.defaultPublishMessageId) {
1833
+ // Incoming: the message the agent is replying to (incomingFor).
1834
+ const incoming = this.incomingFor(agentName);
1835
+ if (incoming && incoming.channel.serverId === serverId) {
1748
1836
  context.incoming = {
1749
- channelId: fallback,
1750
- messageId: this.defaultPublishMessageId,
1751
- threadId: this.defaultPublishThreadId,
1837
+ channelId: incoming.channel.channelId,
1838
+ messageId: incoming.messageId,
1839
+ threadId: incoming.threadId,
1752
1840
  };
1753
1841
  }
1754
1842
  // Default outgoing: the resolved outbound locus (home channel for forks),
1755
1843
  // unless its server declared it inbound-only: routeSpeech publishes nothing
1756
1844
  // there, so it isn't where the agent's speech goes.
1757
- if (outgoing && !this.inboundOnlyServer(outgoing)) {
1845
+ if (outgoing && outgoing.serverId === serverId && !this.inboundOnlyServer(outgoing)) {
1758
1846
  context.defaultOutgoing = {
1759
- channelId: outgoing,
1847
+ channelId: outgoing.channelId,
1760
1848
  };
1761
1849
  }
1762
- // Candidates: all open channel IDs
1763
- if (openChannels.length > 0) {
1764
- context.candidates = openChannels.map((e) => e.descriptor.id);
1850
+ // Candidates: this server's open channels.
1851
+ const candidates = this.getOpenChannels().filter((e) => e.serverId === serverId).map((e) => e.descriptor.id);
1852
+ if (candidates.length > 0) {
1853
+ context.candidates = candidates;
1765
1854
  }
1766
- return context;
1855
+ return Object.keys(context).length > 0 ? context : undefined;
1767
1856
  }
1768
1857
  // ==========================================================================
1769
1858
  // Lifecycle
@@ -1782,12 +1871,18 @@ export class ChannelRegistry {
1782
1871
  if (entry.serverId !== serverId)
1783
1872
  continue;
1784
1873
  this.channels.delete(key);
1785
- this.stopTyping(entry.descriptor.id);
1786
- if (this.defaultPublishChannel === entry.descriptor.id) {
1787
- this.defaultPublishChannel = null;
1788
- this.defaultPublishMessageId = null;
1789
- this.defaultPublishThreadId = undefined;
1790
- }
1874
+ this.stopTyping(refOf(entry));
1875
+ }
1876
+ // The default channel's ref stays, as a channels/changed removal leaves
1877
+ // it: a call that would default to it is refused as the default gone,
1878
+ // naming it, rather than told there's no default yet, and another
1879
+ // server's channel with its id isn't taken for it. Its message and
1880
+ // thread go, as on main, so no message on a server that's gone is given
1881
+ // as the one an agent replies to (incomingFor), to that server added
1882
+ // back or to a refusal's reaction.
1883
+ if (this.defaultPublishChannel?.serverId === serverId) {
1884
+ this.defaultPublishMessageId = null;
1885
+ this.defaultPublishThreadId = undefined;
1791
1886
  }
1792
1887
  // Desired state and migration markers deliberately survive disconnects.
1793
1888
  }
@@ -1831,11 +1926,13 @@ export class ChannelRegistry {
1831
1926
  * Stop all typing intervals and clear all channel registrations.
1832
1927
  */
1833
1928
  stopAll() {
1834
- // Clear all typing intervals
1835
- for (const interval of this.typingIntervals.values()) {
1929
+ // Clear all typing intervals, and their metadata, which a channel
1930
+ // registered again later would otherwise send
1931
+ for (const { interval } of this.typingIntervals.values()) {
1836
1932
  clearInterval(interval);
1837
1933
  }
1838
1934
  this.typingIntervals.clear();
1935
+ this.typingMetadata.clear();
1839
1936
  // Clear channels map
1840
1937
  this.channels.clear();
1841
1938
  // Reset default publish tracking
@@ -2229,17 +2326,30 @@ export class ChannelRegistry {
2229
2326
  setDesiredState(serverId, channelId, desired, source) {
2230
2327
  const key = this.lifecycleKey(serverId, channelId);
2231
2328
  const current = this.desiredStates.get(key);
2329
+ // A machine's close isn't a decision: of a channel with no record (one
2330
+ // registered lazily, or on a server whose grant lacks channels.lifecycle),
2331
+ // it records the default that channel already had, so policy and
2332
+ // bootstrap can still lift it (#417).
2333
+ const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source;
2232
2334
  // A decision that records the state already on record changes nothing,
2233
- // so it writes nothing, unless it's the agent's own (channel_open,
2234
- // channel_close or channel_decline) and the record isn't. The record
2235
- // then says the agent decided, which is all handleToolClose's
2236
- // explicit-open guard and ensureInitialDesiredState's lift of a default
2237
- // ask of it. openChannelNow depends on it too: a failed open by speech
2238
- // or a reply tells a decision made during its round-trip by the record
2239
- // no longer being the object it wrote, and the agent's channel_open,
2240
- // the only decision of the same state that can be made then, gets a new
2241
- // record here.
2242
- if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) {
2335
+ // so it writes nothing, with two exceptions. The agent's own decision
2336
+ // (channel_open, channel_close or channel_decline) records itself over a
2337
+ // record that isn't the agent's. The record then says the agent decided,
2338
+ // which is all handleToolClose's explicit-open guard and
2339
+ // ensureInitialDesiredState's lift of a default ask of it. openChannelNow
2340
+ // depends on it too: a failed open by speech or a reply tells a decision
2341
+ // made during its round-trip by the record no longer being the object it
2342
+ // wrote, and the agent's channel_open, the only decision of the same
2343
+ // state that can be made then, gets a new record here. And the latest
2344
+ // deliberate close wins: the agent's close or decline, or a module's
2345
+ // close, records itself over any closed record from another source,
2346
+ // another deliberate close included, so a decline after a close is the
2347
+ // decision on record. Speech opens no channel whose close was decided
2348
+ // (speechOpens). A machine's close never replaces a closed record, so
2349
+ // what nobody decided stays liftable.
2350
+ const agentOverOther = AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current?.source ?? '');
2351
+ const latestClose = desired === 'closed' && isCloseDecision(source) && current?.source !== source;
2352
+ if (current?.state === desired && !agentOverOther && !latestClose) {
2243
2353
  return;
2244
2354
  }
2245
2355
  this.appendLifecycleEvent({
@@ -2247,10 +2357,10 @@ export class ChannelRegistry {
2247
2357
  serverId,
2248
2358
  channelId,
2249
2359
  desired,
2250
- source,
2360
+ source: recorded,
2251
2361
  timestamp: new Date().toISOString(),
2252
2362
  });
2253
- this.desiredStates.set(key, { state: desired, source });
2363
+ this.desiredStates.set(key, { state: desired, source: recorded });
2254
2364
  }
2255
2365
  // ==========================================================================
2256
2366
  // Tune-out state (issue #77) — durable in the lifecycle log
@@ -2333,7 +2443,7 @@ export class ChannelRegistry {
2333
2443
  return this.getTuneOutState(serverId, channelId) !== null &&
2334
2444
  !this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
2335
2445
  this.serverReach(serverId) === 'up' &&
2336
- this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`));
2446
+ this.tuneOutReadsOpen(this.channels.get(destinationKey({ serverId, channelId })));
2337
2447
  }
2338
2448
  /**
2339
2449
  * Whether a tune-out takes an entry's transport as open: its open flag,
@@ -2355,7 +2465,7 @@ export class ChannelRegistry {
2355
2465
  return entry !== undefined && entry.open && !this.failedCloseTargets.has(entry);
2356
2466
  }
2357
2467
  async openTuneOutTransport(serverId, channelId, epochId) {
2358
- const channelKey = `${serverId}:${channelId}`;
2468
+ const channelKey = destinationKey({ serverId, channelId });
2359
2469
  const lifecycleKey = this.lifecycleKey(serverId, channelId);
2360
2470
  const inEffect = () => epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
2361
2471
  // "May not": a failed request leaves its outcome unknown, and an open
@@ -2558,29 +2668,45 @@ export class ChannelRegistry {
2558
2668
  * the resident matches none. A refusal of the destination before anything
2559
2669
  * went out carries its `reason` (PublishRefusalReason), so a caller can act
2560
2670
  * on why nothing was posted without reading the words.
2671
+ *
2672
+ * The channel is a ref, or a name given as a string, which resolves as
2673
+ * channel_publish's channelId does: one channel the name means, which may
2674
+ * be server-qualified, and a name two servers' channels answer to is
2675
+ * refused with them as candidates (`shared-id`). A host keeps channels by
2676
+ * their ids (connectome-host's notices module). Where several servers
2677
+ * register an id, a ref picks one server's channel, as a qualified name does
2678
+ * where it names one channel alone.
2561
2679
  */
2562
- async publishForAgent(channelId, text, agentName) {
2563
- // The server the publish goes to, as handleToolPublish finds it before
2564
- // it awaits: the channel may be removed while the reply is pending.
2565
- const serverId = this.findChannelEntry(channelId)?.serverId ?? '';
2680
+ async publishForAgent(channel, text, agentName) {
2681
+ const input = typeof channel === 'string'
2682
+ ? { channelId: channel, content: text }
2683
+ : { channelId: channel.channelId, serverId: channel.serverId, content: text };
2566
2684
  const seen = {};
2567
- const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel');
2568
- if (seen.outcome?.status === 'delivered') {
2685
+ const result = await this.handleToolPublish(input, undefined, seen, 'speak_in_channel');
2686
+ // The destination the publish resolved before it awaited, which the
2687
+ // outcome carries: the channel may be removed while the reply is pending.
2688
+ const destination = seen.outcome?.destination;
2689
+ if (seen.outcome?.status === 'delivered' && destination) {
2569
2690
  this.emitTraceFn({
2570
2691
  type: 'mcpl:speech-routed',
2571
2692
  conversationId: agentName,
2572
- serverId,
2573
- channelId,
2693
+ serverId: destination.serverId,
2694
+ channelId: destination.channelId,
2574
2695
  delivered: true,
2575
2696
  textLen: text.length,
2576
2697
  text,
2577
2698
  });
2578
2699
  }
2579
2700
  else {
2701
+ // The channel the publish went to, or that the call named before a
2702
+ // refusal, or, where nothing resolved, the server the caller gave, with
2703
+ // the name it gave.
2704
+ const named = destination ?? seen.resolved;
2580
2705
  this.emitTraceFn({
2581
2706
  type: 'mcpl:speech-route-failed',
2582
2707
  conversationId: agentName,
2583
- channelId,
2708
+ ...(named ? { serverId: named.serverId } : 'serverId' in input ? { serverId: input.serverId } : {}),
2709
+ channelId: named?.channelId ?? input.channelId,
2584
2710
  // The publish's own reason and the connector's detail, as
2585
2711
  // deliverSpeech's trace gives them, or the refusal before any publish.
2586
2712
  reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
@@ -2690,7 +2816,7 @@ export class ChannelRegistry {
2690
2816
  }
2691
2817
  /** One queued lifecycle operation, run when its turn in the queue comes. */
2692
2818
  async runLifecycleOperation(serverId, channelId, removals, options) {
2693
- const channelKey = `${serverId}:${channelId}`;
2819
+ const channelKey = destinationKey({ serverId, channelId });
2694
2820
  const lifecycleKey = this.lifecycleKey(serverId, channelId);
2695
2821
  try {
2696
2822
  let attempts = 0;
@@ -2806,6 +2932,7 @@ export class ChannelRegistry {
2806
2932
  // its sends either.
2807
2933
  if (substitute !== undefined) {
2808
2934
  this.invalidateTransportConfirmation(entry);
2935
+ this.answeredElsewhere.add(entry);
2809
2936
  throw new ChannelLifecycleError(`the "${serverId}" server answered for ` +
2810
2937
  (substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
2811
2938
  ', so nothing in its answer is used', 'substituted');
@@ -2813,6 +2940,8 @@ export class ChannelRegistry {
2813
2940
  if (open)
2814
2941
  this.applyOpenDeclaration(entry, result);
2815
2942
  entry.open = open;
2943
+ if (open)
2944
+ this.answeredElsewhere.delete(entry);
2816
2945
  this.unconfirmedChannelTargets.delete(entry);
2817
2946
  this.failedCloseTargets.delete(entry);
2818
2947
  const now = this.getDesiredState(serverId, channelId);
@@ -2865,7 +2994,7 @@ export class ChannelRegistry {
2865
2994
  * doesn't know needs nothing: §14.5 refuses its traffic.
2866
2995
  */
2867
2996
  closeSubstitutedChannel(serverId, channelId) {
2868
- if (!this.channels.has(`${serverId}:${channelId}`))
2997
+ if (!this.channels.has(destinationKey({ serverId, channelId })))
2869
2998
  return;
2870
2999
  if (this.getDesiredState(serverId, channelId) !== 'closed')
2871
3000
  return;
@@ -2884,7 +3013,7 @@ export class ChannelRegistry {
2884
3013
  this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2885
3014
  // Removal while queued is cancellation, as in reconcileChannels, and
2886
3015
  // exhausted convergence emitted its own diagnostic.
2887
- if (!this.channels.has(`${serverId}:${channelId}`) ||
3016
+ if (!this.channels.has(destinationKey({ serverId, channelId })) ||
2888
3017
  (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge')))
2889
3018
  return;
2890
3019
  this.emitTraceFn({
@@ -2927,7 +3056,7 @@ export class ChannelRegistry {
2927
3056
  if (this.channelLifecycleTails.get(lifecycleKey) === pending)
2928
3057
  break;
2929
3058
  }
2930
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3059
+ const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
2931
3060
  if (!current)
2932
3061
  throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
2933
3062
  // A close that stands is what the words met, whatever became of an open,
@@ -2964,7 +3093,7 @@ export class ChannelRegistry {
2964
3093
  // Record all admissions before waiting on any channel. A slow first RPC
2965
3094
  // must not delay intent for the rest of this registration batch.
2966
3095
  for (const channel of channels) {
2967
- const entry = this.channels.get(`${serverId}:${channel.id}`);
3096
+ const entry = this.entryAt({ serverId, channelId: channel.id });
2968
3097
  if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
2969
3098
  policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
2970
3099
  }
@@ -2987,7 +3116,7 @@ export class ChannelRegistry {
2987
3116
  // Each channel has its own lifecycle queue, so a held RPC for one (up to
2988
3117
  // its timeout, times its attempts) doesn't delay its siblings.
2989
3118
  await Promise.allSettled(channels.map(async (channel) => {
2990
- if (!this.channels.has(`${serverId}:${channel.id}`))
3119
+ if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })))
2991
3120
  return;
2992
3121
  try {
2993
3122
  await this.applyDesiredChannelState(serverId, channel.id);
@@ -2996,7 +3125,7 @@ export class ChannelRegistry {
2996
3125
  // Removal while queued is cancellation, not a failed subscription.
2997
3126
  // Exhausted convergence and a substituted answer already emitted
2998
3127
  // their own diagnostics.
2999
- if (!this.channels.has(`${serverId}:${channel.id}`) ||
3128
+ if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })) ||
3000
3129
  (err instanceof ChannelLifecycleError &&
3001
3130
  (err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted')))
3002
3131
  return;
@@ -3019,14 +3148,14 @@ export class ChannelRegistry {
3019
3148
  * Uses the sendTypingFn callback if provided. If not, this is a no-op
3020
3149
  * (typing timer lifecycle is still managed for when the callback is wired).
3021
3150
  */
3022
- sendTypingNotification(serverId, channelId) {
3151
+ sendTypingNotification(ref) {
3023
3152
  // §14.1: channels/typing requires channels.typing in the grant. Silent
3024
3153
  // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
3025
3154
  // tick would be noise.
3026
- if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing'))
3155
+ if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing'))
3027
3156
  return;
3028
3157
  if (this.sendTypingFn) {
3029
- this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
3158
+ this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
3030
3159
  }
3031
3160
  // TODO: When server-connection exposes a public sendNotification or
3032
3161
  // sendTyping method, wire it here directly instead of using a callback.
@@ -3040,78 +3169,277 @@ export class ChannelRegistry {
3040
3169
  .filter((e) => e.serverId === serverId)
3041
3170
  .map((e) => e.descriptor);
3042
3171
  }
3043
- /** Server id owning a registered channel (by descriptor id), or null. */
3044
- getChannelServerId(channelId) {
3045
- return this.findChannelEntry(channelId)?.serverId ?? null;
3046
- }
3047
3172
  /**
3048
3173
  * The server that declared this channel inbound-only (isInboundOnly), or
3049
- * undefined when it isn't one. The notices name that server. A caller that
3050
- * knows the channel's server passes it; otherwise the channel is found by
3051
- * its id alone (findChannelEntry).
3174
+ * undefined when it isn't one, or isn't registered. The notices name that
3175
+ * server.
3052
3176
  */
3053
- inboundOnlyServer(channelId, serverId) {
3054
- const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId);
3177
+ inboundOnlyServer(ref) {
3178
+ const entry = this.entryAt(ref);
3055
3179
  return entry && isInboundOnly(entry) ? entry.serverId : undefined;
3056
3180
  }
3181
+ /** A channel's entry, by its server and id: the map is keyed by destinationKey. */
3182
+ entryAt(ref) {
3183
+ return this.channels.get(destinationKey(ref));
3184
+ }
3057
3185
  /**
3058
- * Find a channel entry by channelId (searches across all servers).
3059
- * Returns the first match.
3186
+ * The registered channels a name given as a string can mean (a tool's
3187
+ * channelId, a `>>` target, a host's call): those whose id is the name and
3188
+ * those whose server-qualified name (qualifiedChannelName) is the name,
3189
+ * taken together. The qualified form is matched against the registered
3190
+ * pairs, never split at a colon, since ids hold colons (discord-mcpl's are
3191
+ * `discord:<guild>:<channel>`). So a name that two entries answer to, in
3192
+ * either form, means both of them, and a caller that has to pick one
3193
+ * refuses it with both as candidates rather than taking the first.
3060
3194
  */
3061
- findChannelEntry(channelId) {
3062
- for (const [key, entry] of this.channels) {
3063
- if (entry.descriptor.id === channelId) {
3064
- return entry;
3065
- }
3195
+ entriesNamed(name) {
3196
+ return [...this.channels.values()].filter((e) => e.descriptor.id === name || qualifiedChannelName(refOf(e)) === name);
3197
+ }
3198
+ /** The channels a name given as a string means (entriesNamed), by ref:
3199
+ * for a caller that has to settle a host's or a tool's name itself. */
3200
+ channelsNamed(name) {
3201
+ return this.entriesNamed(name).map(refOf);
3202
+ }
3203
+ /**
3204
+ * The name to show an agent for a channel: one that picks it alone, which
3205
+ * a channel tool's channelId takes back, and a `>>` target too where the
3206
+ * name is one (isProseTargetToken: a single token other than `skip_reply`,
3207
+ * which a `>>` line reads as private speech); proseTargetFor gives the
3208
+ * target to write. A registered
3209
+ * channel's is nameFor's: its id, or, where another server registers the
3210
+ * same id, its server-qualified name. A channel no string picks alone, as
3211
+ * where qualified names collide too, is shown by its id and its server, as
3212
+ * the refusals show it (channelOnServer), which no input takes for a name.
3213
+ * A channel no longer registered is shown by its id, or by its qualified
3214
+ * name, only where no registered channel takes that name, as a channel
3215
+ * tool reads it or a `>>` target does (by label or raw id too), so the
3216
+ * name can't send anything to another channel; else by its id and server.
3217
+ */
3218
+ nameShown(ref) {
3219
+ const entry = this.entryAt(ref);
3220
+ if (entry)
3221
+ return this.nameFor(entry) ?? channelOnServer(ref);
3222
+ for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
3223
+ if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name)))
3224
+ return name;
3225
+ }
3226
+ return channelOnServer(ref);
3227
+ }
3228
+ /**
3229
+ * A tool's refusal of a tuned-out channel, whose decision is the tune-out:
3230
+ * its cancel is what ends it (tuneOutEndText), and a decision recorded over
3231
+ * it would end it without that cancel, so the backlog it diverted would
3232
+ * never reach the agent.
3233
+ */
3234
+ tunedOutRefusal(tool, entry) {
3235
+ return {
3236
+ success: false,
3237
+ isError: true,
3238
+ error: `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
3239
+ `doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
3240
+ data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
3241
+ };
3242
+ }
3243
+ /**
3244
+ * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3245
+ * channel_open doesn't change it: a tune_out cancel, by the name that
3246
+ * picks the channel (nameFor), or, where no name picks it alone, its own
3247
+ * end, since tune_out takes a name.
3248
+ * For channel_close, which refuses a tuned-out channel too, it adds that
3249
+ * the close comes after that.
3250
+ */
3251
+ /** How this channel's tune-out ends, as tune_out's own refusals say it
3252
+ * (tuneOutEndText), or undefined for a channel no longer registered. */
3253
+ tuneOutEnd(ref) {
3254
+ const entry = this.entryAt(ref);
3255
+ return entry ? this.tuneOutEndText(entry) : undefined;
3256
+ }
3257
+ tuneOutEndText(entry, tool) {
3258
+ const close = tool === 'channel_close';
3259
+ return this.nameFor(entry) !== undefined
3260
+ ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
3261
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3262
+ : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3263
+ 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
3264
+ (close ? ' Once it has ended, channel_close can close it.' : '');
3265
+ }
3266
+ /**
3267
+ * A channel's name as nameShown gives it, quoted for a sentence: the name
3268
+ * in quotes, or the id-and-server form, whose parts are quoted already.
3269
+ */
3270
+ quotedShown(ref) {
3271
+ const shown = this.nameShown(ref);
3272
+ return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
3273
+ }
3274
+ /**
3275
+ * The string that names exactly this entry by entriesNamed: its id when no
3276
+ * other entry answers to that (the id is unique), else its server-qualified
3277
+ * name when no other entry answers to that, else undefined. The last can
3278
+ * happen only where qualified names collide, as server `a`'s channel
3279
+ * `b:room` and server `a:b`'s channel `room` do; such a channel is named
3280
+ * only by its server and id apart.
3281
+ */
3282
+ nameFor(entry) {
3283
+ for (const name of [entry.descriptor.id, qualifiedChannelName(refOf(entry))]) {
3284
+ const named = this.entriesNamed(name);
3285
+ if (named.length === 1 && named[0] === entry)
3286
+ return name;
3066
3287
  }
3067
3288
  return undefined;
3068
3289
  }
3069
- resolveToolChannelEntry(channelId, serverId) {
3070
- const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId);
3071
- const matches = withId.filter((entry) => !serverId || entry.serverId === serverId);
3072
- if (matches.length === 0) {
3073
- if (serverId && withId.length > 0) {
3074
- // Leaving serverId out works only when one server has the id.
3075
- const servers = withId.map((entry) => `"${entry.serverId}"`);
3076
- return {
3077
- error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1
3078
- ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.`
3079
- : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`),
3080
- };
3290
+ /**
3291
+ * The refusal for a name that more than one registered channel answers
3292
+ * to (entriesNamed). Where each one has a name that picks it alone
3293
+ * (nameFor), as two servers' channels with one id do, it lists those.
3294
+ * Where one has none, the name reads two ways, as server `a`'s `b:room`
3295
+ * and server `a:b`'s `room` both answer to `a:b:room`, and the refusal
3296
+ * gives a line per channel, each with its own route, or, where the tool
3297
+ * has none to it, saying that no channelId reaches it alone.
3298
+ * `takesServerId` says whether the tool also takes a serverId to pick
3299
+ * with; `nothing` says what didn't happen, "nothing ran" unless given.
3300
+ */
3301
+ sharedNameRefusal(name, entries, opts) {
3302
+ const nothing = opts.nothing ?? 'nothing ran';
3303
+ const names = entries.map((e) => this.nameFor(e));
3304
+ if (names.every((n) => n !== undefined)) {
3305
+ const listed = names.map((n) => JSON.stringify(n)).join(', ');
3306
+ const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
3307
+ // One server's two channels can answer to one name too, as `s`'s
3308
+ // `s:room` and `room` both do to `s:room`; its server picks neither.
3309
+ if (servers.length === 1) {
3310
+ return `Channel ${JSON.stringify(name)} could mean more than one channel on server ${servers[0]} ` +
3311
+ `(${listed}), so ${nothing}. Call again with channelId set to one of them.`;
3081
3312
  }
3082
- return {
3083
- error: serverId
3084
- ? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.`
3085
- : `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`,
3086
- };
3087
- }
3088
- if (matches.length > 1) {
3089
- return {
3090
- error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` +
3091
- 'so nothing ran. Call again with serverId set to one of them.',
3092
- };
3313
+ // A serverId goes with the channel's own id, so it's offered only
3314
+ // where the name is every candidate's own id.
3315
+ const byServer = opts.takesServerId && entries.every((e) => e.descriptor.id === name)
3316
+ ? `, or with serverId set to ${servers.join(' or ')}`
3317
+ : '';
3318
+ return `Channel ${JSON.stringify(name)} is on more than one server (${listed}), so ${nothing}.` +
3319
+ ` Call again with channelId set to one of them${byServer}.`;
3320
+ }
3321
+ const lines = entries.map((e, i) => {
3322
+ const where = channelOnServer(refOf(e));
3323
+ const own = names[i];
3324
+ if (own === undefined && !opts.takesServerId)
3325
+ return `- ${where}: no channelId reaches it alone.`;
3326
+ return own !== undefined
3327
+ ? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
3328
+ : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
3329
+ });
3330
+ const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
3331
+ return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
3332
+ }
3333
+ /**
3334
+ * The refusal for a name more than one registered channel answers to, for
3335
+ * a tool outside the registry whose schema has no serverId (tune_out, the
3336
+ * subconscious's cancel_tuneout and deliver_summary), or undefined when the
3337
+ * name means one channel or none. `nothing` says what didn't happen,
3338
+ * "nothing ran" unless given.
3339
+ */
3340
+ sharedNameRefusalFor(name, nothing) {
3341
+ const entries = this.entriesNamed(name);
3342
+ return entries.length > 1 ? this.sharedNameRefusal(name, entries, { takesServerId: false, nothing }) : undefined;
3343
+ }
3344
+ /**
3345
+ * Where a channel id given with a server that doesn't have it is, for that
3346
+ * refusal: the servers that register the id, each a serverId to call again
3347
+ * with, and leaving serverId out only where the id alone then names one
3348
+ * channel (entriesNamed), since another channel's qualified name can be
3349
+ * the same string. For a publish, whose schema has no serverId
3350
+ * (`takesServerId` false: a channel_publish given one the agent added, or
3351
+ * a host's publishForAgent given a ref), where the id is, by the names its
3352
+ * channelId takes (nameFor), and no advice to post there, since each is
3353
+ * another destination, perhaps another community's.
3354
+ */
3355
+ elsewhereText(channelId, takesServerId = true) {
3356
+ const elsewhere = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
3357
+ const named = this.entriesNamed(channelId);
3358
+ const alone = named.length === 1;
3359
+ if (elsewhere.length === 0) {
3360
+ // Such as a server-qualified name given with serverId too: alone, it
3361
+ // names its channel, which may be another server's than the one given,
3362
+ // so the text says whose.
3363
+ if (!alone)
3364
+ return 'channel_list shows the channels and their servers.';
3365
+ const one = named[0];
3366
+ const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
3367
+ + JSON.stringify(one.serverId);
3368
+ return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
3369
+ }
3370
+ if (!takesServerId) {
3371
+ // Each is another destination, perhaps another community's, so the
3372
+ // text names where the id is, each place by the name its channelId
3373
+ // takes, and leaves the choice to the caller. A place no channelId
3374
+ // reaches alone says so.
3375
+ const places = elsewhere.map((e) => {
3376
+ const at = `on server ${JSON.stringify(e.serverId)}`;
3377
+ const name = this.nameFor(e);
3378
+ return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
3379
+ });
3380
+ return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
3093
3381
  }
3094
- return { entry: matches[0] };
3382
+ const servers = elsewhere.map((e) => JSON.stringify(e.serverId));
3383
+ const leaveOut = alone ? ', or leave serverId out' : '';
3384
+ return servers.length === 1
3385
+ ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}${leaveOut}.`
3386
+ : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`;
3095
3387
  }
3096
3388
  /**
3097
- * Find the composite key for a channel by its channelId.
3389
+ * The channel a channel tool names: the channel with id `channelId` on
3390
+ * `serverId` when the call gives a serverId, the exact pair, as every
3391
+ * selector that gives a server reads it; otherwise the one channel its
3392
+ * name means (entriesNamed), which may be server-qualified. A name more
3393
+ * than one channel answers to is refused with them as candidates, never
3394
+ * resolved to the first. Its callers are the tools that take a serverId
3395
+ * (channel_open, channel_close, channel_decline), so the refusal offers
3396
+ * one. `own` is the calling turn's own channels, for an agent's call made
3397
+ * inside it (ChannelToolOrigin.own).
3098
3398
  */
3099
- findChannelKey(channelId) {
3100
- for (const [key, entry] of this.channels) {
3101
- if (entry.descriptor.id === channelId) {
3102
- return key;
3103
- }
3399
+ resolveToolChannelEntry(channelId, serverId, own) {
3400
+ // Inside the calling turn, a name one of the turn's own channels answers
3401
+ // to by its id means that channel, as a `>>` target does (ownChannelNamed):
3402
+ // once its server no longer registers it, the call is refused, with no
3403
+ // route to another server's channel with the id.
3404
+ const mine = serverId ? undefined : this.ownChannelNamed(channelId, own);
3405
+ if (mine && 'neither' in mine)
3406
+ return { error: this.ownNeitherRefusal(channelId, mine.neither, 'ran') };
3407
+ if (mine) {
3408
+ const entry = this.entryAt(mine.channel);
3409
+ return entry ? { entry } : { error: this.ownChannelGoneRefusal(channelId, mine.channel, 'other') };
3410
+ }
3411
+ if (serverId) {
3412
+ const entry = this.entryAt({ serverId, channelId });
3413
+ if (entry)
3414
+ return { entry };
3415
+ return { error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ${this.elsewhereText(channelId)}` };
3416
+ }
3417
+ const matches = this.entriesNamed(channelId);
3418
+ if (matches.length === 0) {
3419
+ return { error: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.` };
3104
3420
  }
3105
- return undefined;
3421
+ if (matches.length > 1)
3422
+ return { error: this.sharedNameRefusal(channelId, matches, { takesServerId: true }) };
3423
+ return { entry: matches[0] };
3106
3424
  }
3107
3425
  // ==========================================================================
3108
3426
  // Private: Tool Handlers
3109
3427
  // ==========================================================================
3110
3428
  handleToolList() {
3111
3429
  const allChannels = [];
3430
+ // How many registered channels answer to each id and server-qualified
3431
+ // id, in one pass, so each entry's name is nameFor's (the one of those
3432
+ // that picks it alone) without a scan of every channel per entry.
3433
+ const answering = new Map();
3434
+ for (const e of this.channels.values()) {
3435
+ for (const n of [e.descriptor.id, qualifiedChannelName(refOf(e))])
3436
+ answering.set(n, (answering.get(n) ?? 0) + 1);
3437
+ }
3112
3438
  for (const entry of this.channels.values()) {
3439
+ const name = [entry.descriptor.id, qualifiedChannelName(refOf(entry))].find((n) => answering.get(n) === 1);
3113
3440
  allChannels.push({
3114
3441
  id: entry.descriptor.id,
3442
+ ...(name !== undefined && name !== entry.descriptor.id ? { name } : {}),
3115
3443
  type: entry.descriptor.type,
3116
3444
  label: entry.descriptor.label,
3117
3445
  direction: entry.descriptor.direction,
@@ -3221,7 +3549,7 @@ export class ChannelRegistry {
3221
3549
  // and lifted nothing. The lift is ensureInitialDesiredState's own,
3222
3550
  // which leaves every real decision alone. One that can't be recorded
3223
3551
  // leaves the default, closed.
3224
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3552
+ const current = this.entryAt(refOf(entry));
3225
3553
  let selectedAgain;
3226
3554
  if (current) {
3227
3555
  try {
@@ -3286,7 +3614,10 @@ export class ChannelRegistry {
3286
3614
  * docs/explicit-prose-routing.md) to a registered channel.
3287
3615
  *
3288
3616
  * Accepted spellings, tried in order:
3289
- * 1. exact descriptor id (`discord:guild:123`, always unambiguous)
3617
+ * 1. exact descriptor id (`discord:guild:123`), or its server-qualified
3618
+ * name (`a:discord:guild:123`), as entriesNamed matches them; a name
3619
+ * two channels answer to, such as an id two servers register, is
3620
+ * refused with both as candidates
3290
3621
  * 2. exact raw server-internal id (`address.channelId`)
3291
3622
  * 3. `#label` / bare label — case-insensitive match on descriptor label
3292
3623
  * with any leading '#' stripped from both sides
@@ -3301,39 +3632,170 @@ export class ChannelRegistry {
3301
3632
  * names the channel as `refusedChannelId`, so a caller can tell a refused
3302
3633
  * channel from a name that resolved to nothing. Without it, as for reading
3303
3634
  * a channel's history, any registered channel resolves.
3635
+ *
3636
+ * `own` is the turn's own channels, for a target its speech names: a name
3637
+ * one of them answers to by its id means that channel, on its server,
3638
+ * before any other spelling (ownChannelNamed).
3304
3639
  */
3305
3640
  resolveProseTarget(spec, opts) {
3306
- const resolved = this.resolveTargetByName(spec);
3641
+ const own = this.ownChannelNamed(spec.trim(), opts?.own);
3642
+ let resolved;
3643
+ if (own === undefined) {
3644
+ resolved = this.resolveTargetByName(spec);
3645
+ }
3646
+ else if ('neither' in own) {
3647
+ resolved = { error: `"${spec.trim()}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.text) };
3648
+ }
3649
+ else {
3650
+ const entry = this.entryAt(own.channel);
3651
+ resolved = entry
3652
+ ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
3653
+ : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
3654
+ }
3307
3655
  if (!opts?.forSpeech || 'error' in resolved)
3308
3656
  return resolved;
3309
- const entry = this.findChannelEntry(resolved.channelId);
3657
+ const entry = this.entryAt(resolved);
3310
3658
  return entry && isInboundOnly(entry)
3311
- ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
3659
+ ? { error: `${this.nameShown(resolved)} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
3312
3660
  : resolved;
3313
3661
  }
3662
+ /**
3663
+ * The turn's own channel a name means: where the name is the id of one of
3664
+ * the turn's own channels, a channel that has been its trigger or its
3665
+ * locus, that channel, on its server, whether or not that server still
3666
+ * registers it. A message's
3667
+ * reply goes where the message came from, so another server that
3668
+ * registers the id, or takes it once the message's server withdrew it,
3669
+ * isn't reached through the name. Where the turn's channels with that id
3670
+ * are on more than one server, the name means neither. Undefined where no
3671
+ * channel of the turn's has that id.
3672
+ */
3673
+ ownChannelNamed(name, own) {
3674
+ const named = [];
3675
+ for (const c of own ?? []) {
3676
+ if (c.channelId === name && !named.some((n) => sameChannel(n, c)))
3677
+ named.push(c);
3678
+ }
3679
+ if (named.length === 0)
3680
+ return undefined;
3681
+ return named.length === 1 ? { channel: named[0] } : { neither: named };
3682
+ }
3683
+ /** Why a name the turn's own channel answers to (ownChannelNamed) reaches
3684
+ * nothing once that channel's server no longer registers it. It offers no
3685
+ * other channel with the id, since that's another server's. */
3686
+ ownChannelGoneText(name, channel) {
3687
+ return `"${name}" is this turn's channel on server "${channel.serverId}", which no longer registers it`;
3688
+ }
3689
+ /**
3690
+ * A name the calling turn's own channels answer to by their id, for a tool
3691
+ * outside the registry that takes a channel's name (tune_out, and the
3692
+ * subconscious's cancel_tuneout, speak_in_channel and deliver_summary),
3693
+ * read as the registry's channel tools read it: the turn's channel, or a
3694
+ * refusal where its server no longer registers it, or where the name is
3695
+ * the id of the turn's channels on more than one server. Undefined where
3696
+ * none of the turn's channels has that id. `tool` words the refusal for
3697
+ * what the tool would have done: posted (speak_in_channel), delivered
3698
+ * (deliver_summary) or run (the others).
3699
+ */
3700
+ ownChannelFor(name, own, tool = 'other') {
3701
+ const mine = this.ownChannelNamed(name, own);
3702
+ if (!mine)
3703
+ return undefined;
3704
+ if ('neither' in mine) {
3705
+ const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
3706
+ return { error: this.ownNeitherRefusal(name, mine.neither, done) };
3707
+ }
3708
+ return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
3709
+ }
3710
+ /** A refusal of a name the turn's own channel answers to, once its server
3711
+ * no longer registers it: nothing was posted (channel_publish,
3712
+ * speak_in_channel), delivered (deliver_summary) or ran (the other channel
3713
+ * tools, tune_out, cancel_tuneout), and nothing can be until it does
3714
+ * again. */
3715
+ ownChannelGoneRefusal(name, channel, tool) {
3716
+ const outcome = tool === 'publish' ? 'nothing was posted, and nothing can be'
3717
+ : tool === 'deliver' ? 'nothing was delivered, and nothing can be' : 'nothing ran, and nothing can run on it';
3718
+ return `${this.ownChannelGoneText(name, channel)}, so ${outcome} until that server registers it again.`;
3719
+ }
3720
+ /**
3721
+ * The turn's channels a name means neither of (ownChannelNamed), each by a
3722
+ * string that reaches it outside the turn's rule: its server-qualified
3723
+ * name, where that picks it alone, since its id is the name refused. One
3724
+ * no string picks alone, and one its server no longer registers, is named
3725
+ * by its id and server, saying so; `reaches` says which a name reaches.
3726
+ */
3727
+ ownCandidates(channels) {
3728
+ return channels.map((c) => {
3729
+ const entry = this.entryAt(c);
3730
+ if (!entry)
3731
+ return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
3732
+ const qualified = qualifiedChannelName(c);
3733
+ const picked = this.entriesNamed(qualified);
3734
+ return picked.length === 1 && picked[0] === entry
3735
+ ? { text: qualified, reaches: true }
3736
+ : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
3737
+ });
3738
+ }
3739
+ /** A tool's refusal of a name the turn's channels on more than one server
3740
+ * answer to by their id (ownChannelNamed's neither). */
3741
+ ownNeitherRefusal(name, channels, done) {
3742
+ const candidates = this.ownCandidates(channels);
3743
+ const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
3744
+ const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
3745
+ : named.length === 1 ? ` Call again with channelId ${named[0]}.`
3746
+ : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
3747
+ const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
3748
+ return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
3749
+ }
3314
3750
  resolveTargetByName(spec) {
3315
3751
  const entries = [...this.channels.values()];
3316
3752
  const trimmed = spec.trim();
3317
3753
  if (!trimmed)
3318
3754
  return { error: 'empty target' };
3319
- const exact = entries.filter((e) => e.descriptor.id === trimmed);
3755
+ const hit = (e) => ({ channelId: e.descriptor.id, serverId: e.serverId, label: e.descriptor.label });
3756
+ // A candidate is named by the token that picks it alone (nameFor), so
3757
+ // two servers' channels with one id or one label can be told apart, or,
3758
+ // where no string picks it alone, by its id and its server.
3759
+ const token = (e) => this.nameFor(e) ?? channelOnServer(refOf(e));
3760
+ // A DM is shown by its label, the person, where that label, given as a
3761
+ // target, picks that DM. It doesn't where another channel answers to that
3762
+ // label too, as two servers' DMs with one person do (a label is matched
3763
+ // ignoring case, so "DM: Antra" and "DM: antra" both answer), nor where
3764
+ // an id, a raw id or another label outranks it, so a bounce's example or
3765
+ // a resend's destination never names another channel. Those are shown by
3766
+ // their tokens instead. A label read as a mention or an @name would come
3767
+ // back here, so it's shown by its token too.
3768
+ const labelPicks = (e, label) => {
3769
+ if (/^@|^<@!?\d+>$/.test(label.trim()))
3770
+ return false;
3771
+ const r = this.resolveTargetByName(label);
3772
+ return 'channelId' in r && sameChannel(r, refOf(e));
3773
+ };
3774
+ const dmShown = (list) => list.map((e) => {
3775
+ const label = e.descriptor.label ?? e.descriptor.id;
3776
+ return labelPicks(e, label) ? label : token(e);
3777
+ });
3778
+ // A channel id, or its server-qualified name (entriesNamed).
3779
+ const exact = this.entriesNamed(trimmed);
3320
3780
  if (exact.length === 1) {
3321
- return { channelId: exact[0].descriptor.id, label: exact[0].descriptor.label };
3781
+ return hit(exact[0]);
3322
3782
  }
3323
3783
  if (exact.length > 1) {
3324
3784
  return {
3325
- error: `channel id "${trimmed}" is ambiguous across servers`,
3326
- candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
3785
+ error: new Set(exact.map((e) => e.serverId)).size > 1
3786
+ ? `channel id "${trimmed}" is ambiguous across servers`
3787
+ : `channel id "${trimmed}" names more than one channel on server "${exact[0].serverId}"`,
3788
+ candidates: exact.map(token),
3327
3789
  };
3328
3790
  }
3329
3791
  const raw = entries.filter((e) => e.descriptor.address?.channelId === trimmed);
3330
3792
  if (raw.length === 1) {
3331
- return { channelId: raw[0].descriptor.id, label: raw[0].descriptor.label };
3793
+ return hit(raw[0]);
3332
3794
  }
3333
3795
  if (raw.length > 1) {
3334
3796
  return {
3335
3797
  error: `raw id "${trimmed}" is ambiguous`,
3336
- candidates: raw.map((e) => e.descriptor.id),
3798
+ candidates: raw.map(token),
3337
3799
  };
3338
3800
  }
3339
3801
  const norm = normalizeChannelLabel;
@@ -3360,11 +3822,15 @@ export class ChannelRegistry {
3360
3822
  const id = mention[1];
3361
3823
  const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
3362
3824
  if (byId.length === 1) {
3363
- return { channelId: byId[0].descriptor.id, label: byId[0].descriptor.label };
3825
+ return hit(byId[0]);
3826
+ }
3827
+ if (byId.length > 1) {
3828
+ // One person's DMs on more than one server.
3829
+ return { error: `"${trimmed}" matches several DMs`, candidates: dmShown(byId) };
3364
3830
  }
3365
3831
  return {
3366
3832
  error: `no registered DM matches the mention <@${id}>`,
3367
- candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
3833
+ candidates: dmShown(dms.slice(0, 6)),
3368
3834
  };
3369
3835
  }
3370
3836
  const name = trimmed.slice(1).toLowerCase();
@@ -3376,27 +3842,27 @@ export class ChannelRegistry {
3376
3842
  return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
3377
3843
  });
3378
3844
  if (pool.length === 1) {
3379
- return { channelId: pool[0].descriptor.id, label: pool[0].descriptor.label };
3845
+ return hit(pool[0]);
3380
3846
  }
3381
3847
  if (pool.length > 1) {
3382
3848
  return {
3383
3849
  error: `"${trimmed}" matches several DMs`,
3384
- candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
3850
+ candidates: dmShown(pool),
3385
3851
  };
3386
3852
  }
3387
3853
  return {
3388
3854
  error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
3389
- ...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
3855
+ ...(dms.length ? { candidates: dmShown(dms.slice(0, 6)) } : {}),
3390
3856
  };
3391
3857
  }
3392
3858
  const byLabel = entries.filter((e) => norm(e.descriptor.label ?? '') === norm(trimmed));
3393
3859
  if (byLabel.length === 1) {
3394
- return { channelId: byLabel[0].descriptor.id, label: byLabel[0].descriptor.label };
3860
+ return hit(byLabel[0]);
3395
3861
  }
3396
3862
  if (byLabel.length > 1) {
3397
3863
  return {
3398
3864
  error: `label "${trimmed}" matches several channels`,
3399
- candidates: byLabel.map((e) => e.descriptor.id),
3865
+ candidates: byLabel.map(token),
3400
3866
  };
3401
3867
  }
3402
3868
  // Name-segment match: server labels carry a disambiguating suffix
@@ -3407,12 +3873,12 @@ export class ChannelRegistry {
3407
3873
  const nameOf = normalizeChannelLabelName;
3408
3874
  const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
3409
3875
  if (byName.length === 1) {
3410
- return { channelId: byName[0].descriptor.id, label: byName[0].descriptor.label };
3876
+ return hit(byName[0]);
3411
3877
  }
3412
3878
  if (byName.length > 1) {
3413
3879
  return {
3414
3880
  error: `"${trimmed}" matches several channels`,
3415
- candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
3881
+ candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
3416
3882
  };
3417
3883
  }
3418
3884
  // No match: offer near-candidates (labels containing the name) so the
@@ -3420,7 +3886,7 @@ export class ChannelRegistry {
3420
3886
  const near = entries
3421
3887
  .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
3422
3888
  .slice(0, 5)
3423
- .map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
3889
+ .map((e) => `${e.descriptor.label} = ${token(e)}`);
3424
3890
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
3425
3891
  }
3426
3892
  /**
@@ -3430,18 +3896,19 @@ export class ChannelRegistry {
3430
3896
  * a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
3431
3897
  * plus body `alice …`, and `>>#fable (antra's server)` delivers
3432
3898
  * `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
3433
- * `#name` (label without its server suffix), the descriptor id.
3899
+ * `#name` (label without its server suffix), the name that picks the
3900
+ * channel alone (nameFor): its id, or, where another server registers the
3901
+ * same id, its server-qualified name; and last its server-qualified name,
3902
+ * for a channel whose id is no target (isProseTargetToken), as
3903
+ * `skip_reply` isn't.
3434
3904
  *
3435
- * Undefined when there is no safe token: the channel isn't registered (on
3436
- * `serverId`, when given), no candidate is whitespace-free and resolves back,
3437
- * or the id is registered by more than one server. A resolved target names a
3438
- * channel by id alone, and ids are unique only within a connection, so a
3439
- * shared id could route the reply through the wrong server.
3905
+ * Undefined when there is no safe token: the channel isn't registered, or
3906
+ * no candidate is whitespace-free and resolves back to this channel, on
3907
+ * this server.
3440
3908
  */
3441
- proseTargetFor(channelId, serverId) {
3442
- const sameId = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
3443
- const entry = serverId ? sameId.find((e) => e.serverId === serverId) : sameId[0];
3444
- if (!entry || sameId.length > 1)
3909
+ proseTargetFor(ref) {
3910
+ const entry = this.entryAt(ref);
3911
+ if (!entry)
3445
3912
  return undefined;
3446
3913
  const d = entry.descriptor;
3447
3914
  const label = d.label ?? '';
@@ -3452,24 +3919,136 @@ export class ChannelRegistry {
3452
3919
  const candidates = [
3453
3920
  ...(isDm && dmName ? [`@${dmName}`] : []),
3454
3921
  ...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
3455
- d.id,
3922
+ ...(this.nameFor(entry) !== undefined ? [this.nameFor(entry)] : []),
3923
+ qualifiedChannelName(ref),
3456
3924
  ];
3457
3925
  for (const c of candidates) {
3458
- if (/\s/.test(c) || c === '#')
3926
+ if (!isProseTargetToken(c) || c === '#')
3459
3927
  continue;
3460
3928
  const r = this.resolveProseTarget(c, { forSpeech: true });
3461
- if ('channelId' in r && r.channelId === d.id)
3929
+ if ('channelId' in r && sameChannel(r, ref))
3462
3930
  return c;
3463
3931
  }
3464
3932
  return undefined;
3465
3933
  }
3934
+ /** Whether the decision on record is a close the agent or a module made
3935
+ * (isCloseDecision). */
3936
+ closeDecided(ref) {
3937
+ const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
3938
+ return current?.state === 'closed' && isCloseDecision(current.source);
3939
+ }
3940
+ /**
3941
+ * Whether the agent's words delivered into this channel open it first, by
3942
+ * publish() or after an explicit send (openIfClosedForSend): one that isn't
3943
+ * open, or whose close stands (closeStands: a close that failed may have
3944
+ * left its transport open), unless speech leaves it as it stands
3945
+ * (speechLeavesAsItStands). Where nobody decided (no record, a pure
3946
+ * default) or a machine closed the channel (subscription-gc,
3947
+ * housekeeping), it opens, as it always did, and so does one decided open
3948
+ * whose transport isn't confirmed. A tuned-out channel whose transport
3949
+ * isn't open is opened for its traffic, and its tune-out stays
3950
+ * (openChannelNow). A server that isn't up isn't one this host can't
3951
+ * direct, though a drop empties its grant until its next exchange, so
3952
+ * speech there is refused as it always was: by the open's failure, which
3953
+ * says the server is connecting or reconnecting, or, where its grant is
3954
+ * empty, by publish()'s grant check before any open.
3955
+ */
3956
+ speechOpens(ref) {
3957
+ const entry = this.entryAt(ref);
3958
+ return !!entry && (!entry.open || this.closeStands(entry)) && !this.speechLeavesAsItStands(entry);
3959
+ }
3960
+ /** Whether speech leaves this registration as it stands, never opening
3961
+ * it, and words go into it as it is: the decision on record is a close
3962
+ * the agent made itself (channel_close, channel_decline) or a module made
3963
+ * (MODULE_CLOSE_SOURCE), which stands against the agent's own speech
3964
+ * whatever the channel's transport is; or its server is up and this host
3965
+ * doesn't direct its lifecycle (lifecycleWithheld), so nothing opens
3966
+ * there. A wait or an open that failed gives way to such a registration,
3967
+ * as when the agent closed the channel while the words waited, since
3968
+ * there's nothing for the words to wait for (refusalGivesWay). */
3969
+ speechLeavesAsItStands(entry) {
3970
+ if (!entry)
3971
+ return false;
3972
+ return this.closeDecided(refOf(entry)) || this.lifecycleWithheld(entry.serverId);
3973
+ }
3974
+ /** Whether words a wait or an open refused go into the channel after all:
3975
+ * its registration `now` is one speech leaves as it stands
3976
+ * (speechLeavesAsItStands), such as one the agent closed or declined
3977
+ * while the words waited, and the failure isn't convergence that never
3978
+ * established its target (convergeFailure). A registration that went
3979
+ * still fails them, and publish() refuses one the server last answered
3980
+ * for another channel (answeredElsewhere), whenever that happened. */
3981
+ refusalGivesWay(err, now) {
3982
+ return this.speechLeavesAsItStands(now) && !convergeFailure(err);
3983
+ }
3984
+ /** Whether this host doesn't direct the server's channel lifecycle: the
3985
+ * server is up, and its grant lacks channels.lifecycle, so nothing opens
3986
+ * or closes its channels. A server that isn't up has an empty grant from
3987
+ * a drop until its next exchange, which says nothing yet of what it
3988
+ * grants, so it isn't counted. */
3989
+ lifecycleWithheld(serverId) {
3990
+ return !this.directsLifecycle(serverId) && this.serverReach(serverId) === 'up';
3991
+ }
3992
+ /** Whether the agent's words go into this channel and leave it closed: it
3993
+ * isn't open at the transport, and speech leaves it as it stands
3994
+ * (speechLeavesAsItStands: the agent's or a module's close, or a server
3995
+ * that's up without channels.lifecycle). False for a channel open at the
3996
+ * transport, whose traffic still flows, and for one speech opens. */
3997
+ speechLeavesClosed(ref) {
3998
+ const entry = this.entryAt(ref);
3999
+ return !!entry && !entry.open && this.speechLeavesAsItStands(entry);
4000
+ }
3466
4001
  /**
3467
- * Open a channel because something was DELIVERED into it (explicit send
3468
- * tool or routed speech). Sending into a closed channel is not a thing:
3469
- * engaging a channel opens it, so typing indicators, reaction machinery,
3470
- * and inbound forwarding all come alive with the first outbound message.
3471
- * Accepts composite MCPL ids or raw server-internal ids (explicit send
3472
- * tools receive whatever the agent typed). An `open-failed` answer says
4002
+ * How the agent kept this registered channel closed itself, if it did:
4003
+ * 'closed' for its channel_close, 'declined' for its channel_decline. Its
4004
+ * speech there leaves such a channel as it stands (speechOpens). A
4005
+ * channel no longer registered has nothing to keep closed, whatever its
4006
+ * record says, so it answers undefined.
4007
+ */
4008
+ closedByAgent(ref) {
4009
+ if (!this.entryAt(ref))
4010
+ return undefined;
4011
+ const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
4012
+ if (current?.state !== 'closed')
4013
+ return undefined;
4014
+ if (current.source === 'agent-tool')
4015
+ return 'closed';
4016
+ if (current.source === 'invitation-declined')
4017
+ return 'declined';
4018
+ return undefined;
4019
+ }
4020
+ /** The registered channels an explicit send tool may have delivered into,
4021
+ * from the id the tool reported or was given: a composite MCPL id, or the
4022
+ * raw server-internal id (`address.channelId`), since send tools receive
4023
+ * whatever the agent typed. Matches on `serverId` when it is given,
4024
+ * across every server otherwise. */
4025
+ sendTargets(rawChannelId, serverId) {
4026
+ const matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
4027
+ if (matches.length > 0)
4028
+ return matches;
4029
+ return [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
4030
+ (!serverId || e.serverId === serverId));
4031
+ }
4032
+ /** The one registered channel an explicit send tool delivered into
4033
+ * (sendTargets), or undefined when none or more than one matches. A
4034
+ * lookup only, for what's said beside the send's answer before it's
4035
+ * queued; openIfClosedForSend decides whether the send opens it. */
4036
+ resolveSendTarget(rawChannelId, serverId) {
4037
+ const matches = this.sendTargets(rawChannelId, serverId);
4038
+ return matches.length === 1 ? refOf(matches[0]) : undefined;
4039
+ }
4040
+ /**
4041
+ * Open a channel because something was DELIVERED into it by an explicit
4042
+ * send tool, so typing indicators, reaction machinery and inbound
4043
+ * forwarding all come alive with the first outbound message, unless
4044
+ * speech leaves it as it stands (speechLeavesAsItStands: the agent closed
4045
+ * or declined it itself, a module closed it, or its server is up and this
4046
+ * host doesn't direct its lifecycle). Such a channel opens nothing, and
4047
+ * answers `stays-closed`, or `already-open` where its transport is open
4048
+ * with no close standing, whether the send found it so or it became so
4049
+ * while the send's wait or open was out.
4050
+ * Accepts composite MCPL ids or raw server-internal ids (sendTargets). An
4051
+ * `open-failed` answer says
3473
4052
  * whether the open that failed was one that would have opened a channel
3474
4053
  * not selected open (`opening`), as opposed to one reconfirming the
3475
4054
  * transport of a channel already selected open or tuned out. It isn't one
@@ -3482,11 +4061,7 @@ export class ChannelRegistry {
3482
4061
  * (`selected-again`).
3483
4062
  */
3484
4063
  async openIfClosedForSend(rawChannelId, serverId) {
3485
- let matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
3486
- if (matches.length === 0) {
3487
- matches = [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
3488
- (!serverId || e.serverId === serverId));
3489
- }
4064
+ const matches = this.sendTargets(rawChannelId, serverId);
3490
4065
  if (matches.length === 0)
3491
4066
  return { status: 'unknown-channel' };
3492
4067
  if (matches.length > 1)
@@ -3499,8 +4074,14 @@ export class ChannelRegistry {
3499
4074
  }
3500
4075
  const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
3501
4076
  if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
3502
- return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
4077
+ return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
3503
4078
  }
4079
+ // A channel speech leaves as it stands opens nothing. Every other one
4080
+ // is opened, as it always was: one open at the transport with no
4081
+ // decision on record too, so the send's engagement is recorded as an
4082
+ // open decision, which a retarget or a restart carries out.
4083
+ if (this.speechLeavesAsItStands(entry))
4084
+ return this.sendLeavesChannel(entry);
3504
4085
  opening = desired !== 'open' && desired !== 'tuned-out';
3505
4086
  const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
3506
4087
  entry = await this.waitForOpenChannel(entry);
@@ -3511,12 +4092,23 @@ export class ChannelRegistry {
3511
4092
  channelId: entry.descriptor.id,
3512
4093
  });
3513
4094
  }
3514
- return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
4095
+ return {
4096
+ status: opened ? 'opened' : 'already-open',
4097
+ channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label,
4098
+ };
3515
4099
  }
3516
4100
  catch (err) {
4101
+ // A channel the send now leaves as it stands, such as one the agent
4102
+ // closed or declined while queued work or the open was out, answers as
4103
+ // one it found so: nothing failed that the send would have done.
4104
+ // Convergence that never established its target is still reported
4105
+ // (refusalGivesWay).
4106
+ const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
4107
+ if (this.refusalGivesWay(err, now))
4108
+ return this.sendLeavesChannel(now);
3517
4109
  // Where a decision made during the open's round-trip replaced it, such
3518
- // as the agent's own close or channel_open, the channel is as that
3519
- // decision left it, and the agent isn't told the open failed.
4110
+ // as the agent's channel_open, the channel is as that decision left it,
4111
+ // and the agent isn't told the open failed.
3520
4112
  if (err instanceof ChannelLifecycleError && err.kind === 'overtaken')
3521
4113
  opening = false;
3522
4114
  this.emitTraceFn({
@@ -3525,8 +4117,8 @@ export class ChannelRegistry {
3525
4117
  channelId: entry.descriptor.id,
3526
4118
  error: err.message,
3527
4119
  });
3528
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3529
- return { status: 'open-failed', channelId: entry.descriptor.id, opening,
4120
+ const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
4121
+ return { status: 'open-failed', channelId: entry.descriptor.id, serverId: entry.serverId, opening,
3530
4122
  // Where the channel is selected for opening after the failure, the
3531
4123
  // agent is told so and why, as speech's failure says.
3532
4124
  ...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' } : {}),
@@ -3534,8 +4126,16 @@ export class ChannelRegistry {
3534
4126
  ...(current ? { label: current.descriptor.label } : {}) };
3535
4127
  }
3536
4128
  }
3537
- async handleToolOpen(input) {
3538
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
4129
+ /** What a send answers for a channel it leaves as it is
4130
+ * (speechLeavesAsItStands):
4131
+ * `already-open` where its transport is open with no close standing,
4132
+ * `stays-closed` otherwise. */
4133
+ sendLeavesChannel(entry) {
4134
+ const status = entry.open && !this.closeStands(entry) ? 'already-open' : 'stays-closed';
4135
+ return { status, channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
4136
+ }
4137
+ async handleToolOpen(input, own) {
4138
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
3539
4139
  const entry = resolved.entry;
3540
4140
  if (!entry) {
3541
4141
  return {
@@ -3544,12 +4144,17 @@ export class ChannelRegistry {
3544
4144
  isError: true,
3545
4145
  };
3546
4146
  }
4147
+ // From here, the channel's own id, whichever name the call gave for it
4148
+ // (a server-qualified one included).
4149
+ input = { ...input, channelId: entry.descriptor.id };
4150
+ // The channel as the agent is shown it, and names it back (nameShown).
4151
+ const shown = this.nameShown(refOf(entry));
3547
4152
  // A tuned-out channel is transport-open but attention-diverted; a plain
3548
4153
  // open would silently discard the epoch (and its pending backlog dump).
3549
4154
  // Cancelling is the tune-out coordinator's flow, reached via its own
3550
4155
  // tool — refuse with the pointer rather than eat the state.
3551
4156
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3552
- return tunedOutRefusal('channel_open', input.channelId);
4157
+ return this.tunedOutRefusal('channel_open', entry);
3553
4158
  }
3554
4159
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
3555
4160
  if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
@@ -3563,13 +4168,13 @@ export class ChannelRegistry {
3563
4168
  catch (err) {
3564
4169
  return {
3565
4170
  success: false,
3566
- error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${err.message}`,
4171
+ error: `Channel ${shown} is already open, but recording your decision to open it failed: ${err.message}`,
3567
4172
  isError: true,
3568
4173
  };
3569
4174
  }
3570
4175
  return {
3571
4176
  success: true,
3572
- data: { channelId: input.channelId, status: 'already open' },
4177
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already open' },
3573
4178
  };
3574
4179
  }
3575
4180
  try {
@@ -3584,6 +4189,7 @@ export class ChannelRegistry {
3584
4189
  success: true,
3585
4190
  data: {
3586
4191
  channelId: input.channelId,
4192
+ serverId: entry.serverId,
3587
4193
  status: alreadyDesiredOpen ? 'reconciled' : 'opened',
3588
4194
  ...(result.history ? { history: result.history } : {}),
3589
4195
  ...(result.historyTruncated ? { historyTruncated: true } : {}),
@@ -3603,7 +4209,7 @@ export class ChannelRegistry {
3603
4209
  }
3604
4210
  }
3605
4211
  async handleToolClose(input, origin) {
3606
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
4212
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
3607
4213
  const entry = resolved.entry;
3608
4214
  if (!entry) {
3609
4215
  return {
@@ -3612,13 +4218,19 @@ export class ChannelRegistry {
3612
4218
  isError: true,
3613
4219
  };
3614
4220
  }
4221
+ // From here, the channel's own id, whichever name the call gave for it
4222
+ // (a server-qualified one included).
4223
+ input = { ...input, channelId: entry.descriptor.id };
4224
+ // The channel as the agent is shown it, and names it back (nameShown).
4225
+ const shown = this.nameShown(refOf(entry));
3615
4226
  // Provenance comes from trusted dispatch, not from self-description:
3616
4227
  // only module-origin calls may record a machine source, and only from
3617
- // the closed vocabulary. Agent-origin (or origin-less — the safe
4228
+ // the closed vocabulary; without one, a module's close is its own
4229
+ // (MODULE_CLOSE_SOURCE). Agent-origin (or origin-less — the safe
3618
4230
  // default for any legacy caller) records 'agent-tool' and has its
3619
4231
  // machine fields ignored entirely.
3620
4232
  const isModuleOrigin = origin?.kind === 'module';
3621
- let closeSource = 'agent-tool';
4233
+ let closeSource = isModuleOrigin ? MODULE_CLOSE_SOURCE : 'agent-tool';
3622
4234
  if (isModuleOrigin && input.source !== undefined) {
3623
4235
  if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
3624
4236
  return {
@@ -3642,13 +4254,13 @@ export class ChannelRegistry {
3642
4254
  catch (err) {
3643
4255
  return {
3644
4256
  success: false,
3645
- error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${err.message}`,
4257
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}`,
3646
4258
  isError: true,
3647
4259
  };
3648
4260
  }
3649
4261
  return {
3650
4262
  success: true,
3651
- data: { channelId: input.channelId, status: 'already closed' },
4263
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
3652
4264
  };
3653
4265
  }
3654
4266
  // Housekeeping must not override stated intent (issue #5: GC closes wore
@@ -3656,7 +4268,7 @@ export class ChannelRegistry {
3656
4268
  // close of a channel whose current desired state is an agent/operator
3657
4269
  // 'open' is refused — structurally, so the caller can stand down rather
3658
4270
  // than retry — unless it certifies an explicit idle lease.
3659
- if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
4271
+ if (isModuleOrigin && MACHINE_CLOSE_SOURCES.has(closeSource) && !input.overrideExplicitOpen) {
3660
4272
  const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
3661
4273
  // Tuned-out is stated intent too: a GC/housekeeping close would
3662
4274
  // silently end the epoch and orphan its backlog.
@@ -3664,10 +4276,10 @@ export class ChannelRegistry {
3664
4276
  return {
3665
4277
  success: false,
3666
4278
  isError: false,
3667
- error: `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
4279
+ error: `Channel ${shown} was explicitly opened (source: agent-tool); ` +
3668
4280
  `a '${closeSource}' close does not override stated intent. Machine closes ` +
3669
4281
  `of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
3670
- data: { refusal: 'explicit-open', channelId: input.channelId },
4282
+ data: { refusal: 'explicit-open', channelId: input.channelId, serverId: entry.serverId },
3671
4283
  };
3672
4284
  }
3673
4285
  }
@@ -3679,7 +4291,7 @@ export class ChannelRegistry {
3679
4291
  // that certified an idle lease, which the guard above lets through, is
3680
4292
  // refused too, as a refusal it stands down on rather than an error.
3681
4293
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3682
- const refusal = tunedOutRefusal('channel_close', input.channelId);
4294
+ const refusal = this.tunedOutRefusal('channel_close', entry);
3683
4295
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
3684
4296
  }
3685
4297
  // A decision the log refused changed nothing, and the answer says so.
@@ -3689,7 +4301,9 @@ export class ChannelRegistry {
3689
4301
  catch (err) {
3690
4302
  return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
3691
4303
  }
3692
- this.stopTyping(input.channelId);
4304
+ // This channel's typing, not every channel with its id (stopTyping's
4305
+ // fan-out of a bare name).
4306
+ this.stopTyping(refOf(entry));
3693
4307
  // Refused now, the close stays recorded for the server's next
3694
4308
  // registration, and the text says so.
3695
4309
  if (!this.directsLifecycle(entry.serverId)) {
@@ -3702,27 +4316,37 @@ export class ChannelRegistry {
3702
4316
  throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
3703
4317
  return {
3704
4318
  success: true,
3705
- data: { channelId: input.channelId, status: 'closed' },
4319
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
3706
4320
  };
3707
4321
  }
3708
4322
  catch (err) {
3709
4323
  return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
3710
4324
  }
3711
4325
  }
3712
- async handleToolDecline(input) {
3713
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
4326
+ async handleToolDecline(input, own) {
4327
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
3714
4328
  const entry = resolved.entry;
3715
4329
  if (!entry) {
3716
4330
  return { success: false, error: resolved.error, isError: true };
3717
4331
  }
4332
+ // From here, the channel's own id, whichever name the call gave for it
4333
+ // (a server-qualified one included).
4334
+ input = { ...input, channelId: entry.descriptor.id };
4335
+ // The channel as the agent is shown it, and names it back (quotedShown).
4336
+ const quoted = this.quotedShown(refOf(entry));
3718
4337
  // A tuned-out channel's decision is the tune-out, whatever its transport,
3719
4338
  // and a decline recorded over it would end it without its cancel, so the
3720
4339
  // backlog it diverted would never reach the agent. Its cancel is what ends
3721
4340
  // it, as channel_open's refusal of a tuned-out channel says.
3722
4341
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
3723
- return tunedOutRefusal('channel_decline', input.channelId);
3724
- }
3725
- if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
4342
+ return this.tunedOutRefusal('channel_decline', entry);
4343
+ }
4344
+ // Refused only for a channel open at the transport. One decided open
4345
+ // whose open failed or is still on its way isn't open there, and the
4346
+ // invitation a message from it brings offers the decline as the way to
4347
+ // reply without opening it. The decline closes it below, through the
4348
+ // lifecycle queue, wherever its transport may be open.
4349
+ if (entry.open) {
3726
4350
  // channel_close is the call that closes it: its own answer says when,
3727
4351
  // as for a server that isn't up. A server that's up without that grant
3728
4352
  // keeps its channels as it chooses, so the text says so, and whom to
@@ -3731,7 +4355,7 @@ export class ChannelRegistry {
3731
4355
  ? this.lifecycleRefusal(entry.serverId) : undefined;
3732
4356
  return {
3733
4357
  success: false,
3734
- error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` +
4358
+ error: `Channel ${quoted} is open, so there's nothing to decline` +
3735
4359
  (refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
3736
4360
  : '. If you need it closed, call channel_close.'),
3737
4361
  isError: true,
@@ -3746,7 +4370,7 @@ export class ChannelRegistry {
3746
4370
  catch (err) {
3747
4371
  return {
3748
4372
  success: false,
3749
- error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` +
4373
+ error: `Nothing changed for channel ${quoted}: ${unrecordedDecision(err).message}, so no acknowledgment ` +
3750
4374
  'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
3751
4375
  'way, ask whoever runs this host.',
3752
4376
  isError: true,
@@ -3827,7 +4451,7 @@ export class ChannelRegistry {
3827
4451
  // below; the decision stands.
3828
4452
  let closeFailure;
3829
4453
  let closeFailed = false;
3830
- const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
4454
+ const current = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
3831
4455
  if (current && this.directsLifecycle(entry.serverId) &&
3832
4456
  this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
3833
4457
  (current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
@@ -3852,7 +4476,7 @@ export class ChannelRegistry {
3852
4476
  // asks it to open or close none of them, so a channel there is confirmed
3853
4477
  // closed only by the receipt of a request sent while the server had the
3854
4478
  // grant, whether the channel was registered or known only from a push.
3855
- const after = this.channels.get(`${entry.serverId}:${input.channelId}`);
4479
+ const after = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
3856
4480
  const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
3857
4481
  !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
3858
4482
  // `closeError` is the result's sentence for the agent: what became of
@@ -3965,6 +4589,7 @@ export class ChannelRegistry {
3965
4589
  success: true,
3966
4590
  data: {
3967
4591
  channelId: input.channelId,
4592
+ serverId: entry.serverId,
3968
4593
  status,
3969
4594
  acknowledged,
3970
4595
  ...(representation ? { representation } : {}),
@@ -4067,101 +4692,60 @@ export class ChannelRegistry {
4067
4692
  * inbound-only (#350's rule), not even for a publish delivered there just
4068
4693
  * before the declaration. One that started there before it goes on, since
4069
4694
  * the declaration doesn't unregister the channel: a later chunk is for a
4070
- * publish delivered before it. A bare channel id is resolved as before, to
4071
- * the one server that registers it, at each call (streamingServerFor), and
4072
- * keeps no record: a stream sent by destination ends by destination, since
4073
- * only a destination's `complete` removes its record.
4695
+ * publish delivered before it.
4074
4696
  */
4075
4697
  sendOutgoingChunk(to, conversationId, inferenceId, index, delta) {
4076
- let server;
4077
- if (typeof to === 'string') {
4078
- server = this.streamingServerFor(to);
4079
- }
4080
- else {
4081
- const key = destinationKey(to);
4082
- let started = this.takenStreams.get(inferenceId);
4083
- const continuing = started?.has(key) ?? false;
4084
- server = this.streamingServerAt(to, !continuing);
4085
- if (server && !continuing) {
4086
- if (!started)
4087
- this.takenStreams.set(inferenceId, started = new Set());
4088
- started.add(key);
4089
- }
4090
- }
4698
+ const key = destinationKey(to);
4699
+ let started = this.takenStreams.get(inferenceId);
4700
+ const continuing = started?.has(key) ?? false;
4701
+ const server = this.streamingServerAt(to, !continuing);
4091
4702
  if (!server)
4092
4703
  return;
4093
- const channelId = typeof to === 'string' ? to : to.channelId;
4704
+ if (!continuing) {
4705
+ if (!started)
4706
+ this.takenStreams.set(inferenceId, started = new Set());
4707
+ started.add(key);
4708
+ }
4094
4709
  try {
4095
- server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
4710
+ server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: to.channelId, index, delta });
4096
4711
  }
4097
4712
  catch { /* observer surface — never disturb the turn */ }
4098
4713
  }
4099
4714
  /**
4100
4715
  * Spec 14.3 companion: final moderated content per channel at stream end.
4101
- * Given a destination, it goes only where the stream started
4102
- * (sendOutgoingChunk), while that server still registers that channel,
4103
- * inbound-only or not, and its grant has `channels.streaming`: once per
4104
- * destination, whose consumer, such as a TTS utterance, finalizes on it
4105
- * and reconciles any chunk it missed. A bare channel id is resolved as
4106
- * before (streamingServerFor).
4716
+ * It goes only where the stream started (sendOutgoingChunk), while that
4717
+ * server still registers that channel, inbound-only or not, and its grant
4718
+ * has `channels.streaming`: once per destination, whose consumer, such as
4719
+ * a TTS utterance, finalizes on it and reconciles any chunk it missed.
4107
4720
  */
4108
4721
  sendOutgoingComplete(to, conversationId, inferenceId, text) {
4109
- let server;
4110
- if (typeof to === 'string') {
4111
- server = this.streamingServerFor(to);
4112
- }
4113
- else {
4114
- const started = this.takenStreams.get(inferenceId);
4115
- if (!started?.delete(destinationKey(to)))
4116
- return;
4117
- if (started.size === 0)
4118
- this.takenStreams.delete(inferenceId);
4119
- server = this.streamingServerAt(to, false);
4120
- }
4722
+ const started = this.takenStreams.get(inferenceId);
4723
+ if (!started?.delete(destinationKey(to)))
4724
+ return;
4725
+ if (started.size === 0)
4726
+ this.takenStreams.delete(inferenceId);
4727
+ const server = this.streamingServerAt(to, false);
4121
4728
  if (!server)
4122
4729
  return;
4123
4730
  try {
4124
4731
  server.sendChannelsOutgoingComplete({
4125
4732
  inferenceId,
4126
4733
  conversationId,
4127
- channelId: typeof to === 'string' ? to : to.channelId,
4734
+ channelId: to.channelId,
4128
4735
  content: [{ type: 'text', text }],
4129
4736
  });
4130
4737
  }
4131
4738
  catch { /* observer surface — never disturb the turn */ }
4132
4739
  }
4133
- streamingServerFor(channelId) {
4134
- // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
4135
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
4136
- // resolver does, and fail closed on cross-server ambiguity — the same
4137
- // never-guess rule that governs delivery.
4138
- const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
4139
- if (matches.length !== 1)
4140
- return null;
4141
- // Nothing is published into an inbound-only channel, so nothing is
4142
- // streamed there either.
4143
- if (isInboundOnly(matches[0]))
4144
- return null;
4145
- const server = this.serverRegistry.getServer(matches[0].serverId);
4146
- // §5.4: the GRANT gates streaming, not the raw advertisement. The old
4147
- // `capabilities?.channels?.streaming` check was doubly wrong: undefined
4148
- // for the boolean `channels: true` shape (masking discord-mcpl's latent
4149
- // double-post, AUDIT-001), and pre-policy it would have sent before the
4150
- // server was told anything was granted.
4151
- if (!CapabilityGrant.of(server).has('channels.streaming'))
4152
- return null;
4153
- return server;
4154
- }
4155
4740
  /**
4156
4741
  * A destination's server, for a stream message: while that server
4157
- * registers that channel, and its grant has `channels.streaming` (as in
4158
- * streamingServerFor). A stream doesn't start in an inbound-only channel.
4742
+ * registers that channel, and its grant has `channels.streaming` (§5.4:
4743
+ * the grant gates streaming, not the raw advertisement). A stream doesn't
4744
+ * start in an inbound-only channel.
4159
4745
  */
4160
4746
  streamingServerAt(to, starting) {
4161
- // The map's key joins the two ids with a colon, so another server's
4162
- // entry can sit under a destination's key: it has to be this server's.
4163
- const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
4164
- if (!entry || entry.serverId !== to.serverId)
4747
+ const entry = this.entryAt(to);
4748
+ if (!entry)
4165
4749
  return null;
4166
4750
  if (starting && isInboundOnly(entry))
4167
4751
  return null;
@@ -4169,11 +4753,16 @@ export class ChannelRegistry {
4169
4753
  return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
4170
4754
  }
4171
4755
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
4172
- * process-global default). Public so a multi-segment caller can snapshot it
4173
- * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
4756
+ * process-global default), with its server. Public so a multi-segment
4757
+ * caller can snapshot it ONCE and pin every segment to it via
4758
+ * routeSpeech's locus. */
4174
4759
  resolveLocus(conversationId) {
4175
4760
  const home = this.homeChannelResolver?.(conversationId);
4176
- return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
4761
+ if (home)
4762
+ return home;
4763
+ // A turn that deliberately has no channel (null) takes no fallback.
4764
+ const active = this.activeChannelResolver?.(conversationId);
4765
+ return active !== undefined ? active : this.globalFallbackFor(conversationId);
4177
4766
  }
4178
4767
  /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
4179
4768
  globalFallbackFor(conversationId) {
@@ -4181,9 +4770,10 @@ export class ChannelRegistry {
4181
4770
  }
4182
4771
  /**
4183
4772
  * Resolve a publish target to exactly one registered channel: `serverId`
4184
- * plus `channelId` when the caller has the server, otherwise a channel id
4185
- * registered by exactly one server. A shared id is refused, never resolved
4186
- * to whichever server registered it first.
4773
+ * plus `channelId` when the caller has the server, otherwise the one
4774
+ * channel the id, or its server-qualified name, means (entriesNamed). A
4775
+ * shared id is refused, never resolved to whichever server registered it
4776
+ * first.
4187
4777
  */
4188
4778
  resolveDestination(target) {
4189
4779
  const entry = this.findExactEntry(target);
@@ -4206,7 +4796,11 @@ export class ChannelRegistry {
4206
4796
  if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) {
4207
4797
  return { error: 'serverId must name a server (omit it to resolve the channel id alone)' };
4208
4798
  }
4209
- const matches = [...this.channels.values()].filter((e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId));
4799
+ // Without a server, the channel id, or its server-qualified name, means
4800
+ // what it means to every channel tool (entriesNamed).
4801
+ const matches = target.serverId
4802
+ ? [this.entryAt({ serverId: target.serverId, channelId: target.channelId })].filter((e) => e !== undefined)
4803
+ : this.entriesNamed(target.channelId);
4210
4804
  if (matches.length === 0) {
4211
4805
  return {
4212
4806
  error: target.serverId
@@ -4215,12 +4809,16 @@ export class ChannelRegistry {
4215
4809
  };
4216
4810
  }
4217
4811
  if (matches.length > 1) {
4812
+ // The framework's own speech always names its server, so only a
4813
+ // caller that named none meets this, and it can name one.
4814
+ // Each named by what picks it alone, or by its id and server where
4815
+ // nothing does; "more than one server" only where the servers differ.
4816
+ const names = matches.map((e) => this.quotedShown(refOf(e)));
4817
+ const servers = new Set(matches.map((e) => e.serverId));
4218
4818
  return {
4219
- // Speech names no server, and neither its >> prefix nor
4220
- // channel_publish can: whoever runs this host can. A caller that
4221
- // can name one reads that none was.
4222
- error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
4223
- 'if you need them there, ask whoever runs this host',
4819
+ error: servers.size > 1
4820
+ ? `channel id "${target.channelId}" is on more than one server (${names.join(', ')}), and the destination named no server`
4821
+ : `channel id "${target.channelId}" names more than one channel on server "${matches[0].serverId}" (${names.join(', ')})`,
4224
4822
  };
4225
4823
  }
4226
4824
  return { entry: matches[0] };
@@ -4234,16 +4832,19 @@ export class ChannelRegistry {
4234
4832
  * publishReceiptOutcome and publishErrorOutcome.
4235
4833
  *
4236
4834
  * Resolution is exact (resolveDestination): `serverId` plus `channelId`
4237
- * when the caller has the server, otherwise the channel id must be
4238
- * registered by exactly one server (a shared id is refused, never routed
4239
- * through whichever server came first). An omitted `serverId` means "not
4835
+ * when the caller has the server, otherwise the channel id, or its
4836
+ * server-qualified name, must mean exactly one channel (a shared id is
4837
+ * refused, never routed through whichever server came first). An omitted `serverId` means "not
4240
4838
  * in use"; a supplied one must be a non-empty server id, and an empty or
4241
4839
  * non-string selector is refused rather than read as omission. It is not
4242
- * possible to send into a closed channel: a closed but registered
4243
- * destination is opened first (`openSource` names why), once its server
4244
- * and that server's channels.publish grant are known to take the send, so
4245
- * a send refused before the open opens nothing; and a failed open sends
4246
- * nothing.
4840
+ * possible to send into a closed channel that speech opens (speechOpens):
4841
+ * it's opened first (`openSource` names why), once its server and that
4842
+ * server's channels.publish grant are known to take the send, so a send
4843
+ * refused before the open opens nothing; and a failed open sends nothing.
4844
+ * A channel speech leaves as it stands (speechLeavesAsItStands), one the
4845
+ * agent closed or declined itself or a module closed, or a closed one on
4846
+ * a server that's up but whose lifecycle this host doesn't direct, takes
4847
+ * the words as it is, once its queued lifecycle work has settled.
4247
4848
  *
4248
4849
  * A channel its server declared inbound-only (isInboundOnly) takes nothing
4249
4850
  * the host would post, whichever path the words took, a drafts resend
@@ -4306,30 +4907,41 @@ export class ChannelRegistry {
4306
4907
  const prevented = (err) => ({
4307
4908
  status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
4308
4909
  });
4910
+ // Words go into a channel that's open with no close standing, or into
4911
+ // one speech leaves as it stands (speechLeavesAsItStands): a channel the
4912
+ // agent closed or declined itself, or a module closed, and a closed
4913
+ // channel on a server that's up but whose lifecycle this host doesn't
4914
+ // direct, where nothing opens. Every other channel speech opens first
4915
+ // (speechOpens), and if it doesn't open, nothing goes out. A wait or an
4916
+ // open that fails is read against the channel as it is then, so a close
4917
+ // the agent decides while the words wait lets them go into the channel
4918
+ // as that close leaves it, unless the failure is convergence that never
4919
+ // established its target (refusalGivesWay). A channel the server last
4920
+ // answered for another one takes nothing as it stands (answeredElsewhere).
4309
4921
  // Lifecycle work already queued for the channel settles first: words go
4310
4922
  // out only into the channel it leaves, and fail visibly if it leaves
4311
- // none open.
4923
+ // none open and speech would open it.
4312
4924
  if (this.hasPendingLifecycle(entry)) {
4313
4925
  try {
4314
4926
  entry = await this.waitForOpenChannel(entry);
4315
4927
  }
4316
4928
  catch (err) {
4317
- return prevented(err);
4929
+ const now = this.entryAt(refOf(entry));
4930
+ if (!this.refusalGivesWay(err, now))
4931
+ return prevented(err);
4932
+ entry = now;
4318
4933
  }
4319
4934
  }
4320
- // INVARIANT: it is not possible to send into a closed channel. A send
4321
- // into a closed-but-registered channel (the canonical case: a DM that
4322
- // woke the agent, since DMs register closed) OPENS it first, so typing
4323
- // indicators, reaction machinery and inbound forwarding come alive with
4324
- // the words. If the open fails, nothing goes out: a half-alive delivery
4325
- // is worse than a loud marker. A channel decided closed is engaged even
4326
- // while its transport is still open (a close that failed), as a reply
4327
- // would engage it, wherever an open can succeed. Without the lifecycle
4328
- // grant it can't, and a send opens before it publishes, so there a
4329
- // channel whose transport is open keeps publishing.
4330
- const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
4331
- CapabilityGrant.of(server).has('channels.lifecycle');
4332
- if (!entry.open || engagesDecidedClosed) {
4935
+ // A send into a closed-but-registered channel (the canonical case: a DM
4936
+ // that woke the agent, since DMs register closed) OPENS it first where
4937
+ // speech opens it (speechOpens), so typing indicators, reaction
4938
+ // machinery and inbound forwarding come alive with the words. If the
4939
+ // open fails, nothing goes out: a half-alive delivery is worse than a
4940
+ // loud marker. A channel decided closed is engaged even while its
4941
+ // transport is still open (a close that failed), as a reply would engage
4942
+ // it, wherever an open can succeed, unless that close is the agent's or
4943
+ // a module's own.
4944
+ if (this.speechOpens(refOf(entry))) {
4333
4945
  try {
4334
4946
  const { opened } = await this.openChannelNow(entry, openSource);
4335
4947
  entry = await this.waitForOpenChannel(entry);
@@ -4358,7 +4970,10 @@ export class ChannelRegistry {
4358
4970
  }
4359
4971
  }
4360
4972
  catch (err) {
4361
- return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
4973
+ const now = this.entryAt(refOf(entry));
4974
+ if (!this.refusalGivesWay(err, now))
4975
+ return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
4976
+ entry = now;
4362
4977
  }
4363
4978
  }
4364
4979
  // Checked again here, after every await above, the lifecycle work's, the
@@ -4374,15 +4989,18 @@ export class ChannelRegistry {
4374
4989
  // its close standing. The loop opens nothing itself. The check then runs
4375
4990
  // again on the registration that stands. waitForOpenChannel returns
4376
4991
  // only a registration it found open and whose close doesn't stand, or
4377
- // throws, so the loop ends unless the channel keeps changing.
4378
- const key = `${entry.serverId}:${entry.descriptor.id}`;
4992
+ // throws, so the loop ends unless the channel keeps changing. A channel
4993
+ // speech leaves as it stands (speechLeavesAsItStands) needs none of
4994
+ // that, and its words go into it as it is.
4995
+ const key = destinationKey({ serverId: entry.serverId, channelId: entry.descriptor.id });
4379
4996
  let current = this.channels.get(key);
4380
- while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
4997
+ while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) {
4381
4998
  try {
4382
4999
  entry = await this.waitForOpenChannel(current);
4383
5000
  }
4384
5001
  catch (err) {
4385
- return prevented(err);
5002
+ if (!this.refusalGivesWay(err, this.channels.get(key)))
5003
+ return prevented(err);
4386
5004
  }
4387
5005
  current = this.channels.get(key);
4388
5006
  }
@@ -4391,6 +5009,17 @@ export class ChannelRegistry {
4391
5009
  if (isInboundOnly(current)) {
4392
5010
  return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
4393
5011
  }
5012
+ // A channel the words go into as it stands, with no open to check what
5013
+ // the server reaches by its id, takes nothing where the server answered
5014
+ // its last open for another channel, whenever that was (answeredElsewhere):
5015
+ // the words could reach that other channel.
5016
+ if ((!current.open || this.closeStands(current)) && this.answeredElsewhere.has(current)) {
5017
+ return {
5018
+ status: 'failed', destination, at: at(),
5019
+ reason: `the "${current.serverId}" server answered this channel's last open for a different channel, so words ` +
5020
+ "sent by its id may not reach it; once an open of it is answered for this channel, they go into it again",
5021
+ };
5022
+ }
4394
5023
  // A grant reduced during any of the waits holds at once (§6.7): the
4395
5024
  // words already on their way take it too. Its refusal is worded for the
4396
5025
  // connection that refused (sendRefusal's `checked`).
@@ -4423,7 +5052,7 @@ export class ChannelRegistry {
4423
5052
  * that used to live in discord-mcpl.
4424
5053
  */
4425
5054
  async deliverSpeech(conversationId, text,
4426
- /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
5055
+ /** The turn's FROZEN locus, with its server, snapshotted once by the caller (resolveLocus
4427
5056
  * at turn start) and passed to every segment of the turn. Mandatory:
4428
5057
  * there is deliberately no live-resolution fallback here — a late
4429
5058
  * dispatch resolving live can read the NEXT turn's trigger state or a
@@ -4432,21 +5061,23 @@ export class ChannelRegistry {
4432
5061
  * "this turn is pinned to no locus": fail loudly rather than guess,
4433
5062
  * except for an ephemeral run, whose speech is then its caller's
4434
5063
  * result. */
4435
- locusChannelId,
5064
+ locus,
4436
5065
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
4437
5066
  * draft with its own notice, so no failure marker is written for it.
4438
5067
  * An `unknown` outcome still gets its marker. */
4439
5068
  opts) {
4440
5069
  // `logged` is what the log line and the trace say, where it isn't the
4441
5070
  // agent's reason (the two no-locus reasons below).
4442
- const fail = (channelId, reason, outcome, logged = reason) => {
5071
+ const fail = (channel, reason, outcome, logged = reason) => {
4443
5072
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
5073
+ const channelId = channel?.channelId ?? null;
4444
5074
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
4445
5075
  const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
4446
5076
  console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
4447
5077
  this.emitTraceFn({
4448
5078
  type: 'mcpl:speech-route-failed',
4449
5079
  conversationId,
5080
+ ...(channel ? { serverId: channel.serverId } : {}),
4450
5081
  channelId: channelId ?? '',
4451
5082
  reason: logged,
4452
5083
  textLen: text.length,
@@ -4454,7 +5085,10 @@ export class ChannelRegistry {
4454
5085
  ...(detail !== undefined ? { detail } : {}),
4455
5086
  });
4456
5087
  if (status === 'unknown' || !opts?.heldOnFailure) {
4457
- this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) });
5088
+ this.onRouteFailure?.({
5089
+ conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
5090
+ reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
5091
+ });
4458
5092
  }
4459
5093
  return outcome;
4460
5094
  };
@@ -4467,11 +5101,11 @@ export class ChannelRegistry {
4467
5101
  // Runtime backstop for JS callers the compiler can't see: an omitted
4468
5102
  // locus is a routing bug at the call site, never something to paper over
4469
5103
  // with a live re-resolution.
4470
- if (locusChannelId === undefined) {
5104
+ if (locus === undefined) {
4471
5105
  const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
4472
5106
  return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
4473
5107
  }
4474
- if (!locusChannelId) {
5108
+ if (!locus) {
4475
5109
  // An ephemeral run with no channel of its own: its plain speech is its
4476
5110
  // caller's result (takesGlobalFallback). It returns as not delivered,
4477
5111
  // with its own reason, and writes no line, trace or notice.
@@ -4486,10 +5120,10 @@ export class ChannelRegistry {
4486
5120
  'had one, ask whoever runs this host';
4487
5121
  return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
4488
5122
  }
4489
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
4490
- const channelId = outcome.destination?.channelId ?? locusChannelId;
5123
+ const outcome = await this.publish(conversationId, text, { serverId: locus.serverId, channelId: locus.channelId, routed: true });
5124
+ const channelId = outcome.destination?.channelId ?? locus.channelId;
4491
5125
  if (outcome.status !== 'delivered') {
4492
- return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
5126
+ return fail(outcome.destination ?? locus, outcome.reason ?? 'delivery not confirmed', outcome);
4493
5127
  }
4494
5128
  console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination.serverId}, delivered=true)`);
4495
5129
  this.emitTraceFn({
@@ -4508,8 +5142,8 @@ export class ChannelRegistry {
4508
5142
  return outcome;
4509
5143
  }
4510
5144
  /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
4511
- async routeSpeech(conversationId, text, locusChannelId) {
4512
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
5145
+ async routeSpeech(conversationId, text, locus) {
5146
+ const outcome = await this.deliverSpeech(conversationId, text, locus);
4513
5147
  if (outcome.status !== 'delivered')
4514
5148
  return null;
4515
5149
  return {
@@ -4525,10 +5159,14 @@ export class ChannelRegistry {
4525
5159
  * channel id differs: the subconscious isn't offered channel_list, and is
4526
5160
  * given speak_in_channel for the tuned-out channels whose messages it reads.
4527
5161
  */
4528
- async handleToolPublish(input, origin,
4529
- /** Filled for publishForAgent: the publish's outcome once a request is
4530
- * attempted, for its trace, or which check of the destination refused
4531
- * it before anything went out, for its answer. */
5162
+ async handleToolPublish(
5163
+ /** `serverId` isn't in channel_publish's schema: the framework gives it
5164
+ * with a conversation fork's home, and publishForAgent with a ref. */
5165
+ input, origin,
5166
+ /** Filled for publishForAgent: the channel the call names once it
5167
+ * resolves, and the publish's outcome once a request is attempted, for
5168
+ * its trace, or which check of the destination refused it before
5169
+ * anything went out, for its answer. */
4532
5170
  seen, tool = 'channel_publish') {
4533
5171
  const findIds = tool === 'channel_publish'
4534
5172
  ? 'channel_list shows the channel ids.'
@@ -4551,9 +5189,13 @@ export class ChannelRegistry {
4551
5189
  // marker, apart from an edit the coalescer delivers on acceptance as the
4552
5190
  // agent's first view. An ephemeral run never takes that default
4553
5191
  // (takesGlobalFallback), so it names one.
5192
+ // A channel named by a string means what it means to every channel tool
5193
+ // (entriesNamed): one channel, by its id or its server-qualified name,
5194
+ // and a name two servers' channels answer to is refused with them as
5195
+ // candidates, never sent to the first.
4554
5196
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
4555
- const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
4556
- if (!channelId) {
5197
+ const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
5198
+ if (!(input.channelId ?? fallback)) {
4557
5199
  return {
4558
5200
  success: false,
4559
5201
  error: tool === 'speak_in_channel'
@@ -4565,18 +5207,90 @@ export class ChannelRegistry {
4565
5207
  isError: true,
4566
5208
  };
4567
5209
  }
4568
- const entry = this.findChannelEntry(channelId);
5210
+ // A host's ref (publishForAgent, the one caller that gives
5211
+ // speak_in_channel a serverId) names its exact pair, an empty serverId
5212
+ // included, as publish's destination does. A tool's empty serverId is a
5213
+ // parameter filled with nothing, read as none given, as the channel tools
5214
+ // read one.
5215
+ const serverGiven = tool === 'speak_in_channel' ? input.serverId !== undefined : !!input.serverId;
5216
+ // Inside the calling turn, a name one of the turn's own channels answers
5217
+ // to by its id means that channel, as a `>>` target does (ownChannelNamed).
5218
+ const own = input.channelId !== undefined && !serverGiven && origin?.kind === 'agent'
5219
+ ? this.ownChannelNamed(input.channelId, origin.own)
5220
+ : undefined;
5221
+ let entry;
5222
+ if (input.channelId === undefined) {
5223
+ // A serverId given with no channelId names the default's id on that
5224
+ // server, held to as one given with a channelId is.
5225
+ entry = this.entryAt(serverGiven ? { serverId: input.serverId, channelId: fallback.channelId } : fallback);
5226
+ }
5227
+ else if (own && 'neither' in own) {
5228
+ if (seen)
5229
+ seen.refusal = 'shared-id';
5230
+ return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
5231
+ }
5232
+ else if (own) {
5233
+ entry = this.entryAt(own.channel);
5234
+ if (!entry) {
5235
+ if (seen)
5236
+ seen.refusal = 'channel-not-registered';
5237
+ return { success: false, error: this.ownChannelGoneRefusal(input.channelId, own.channel, 'publish'), isError: true };
5238
+ }
5239
+ }
5240
+ else if (serverGiven) {
5241
+ entry = this.entryAt({ serverId: input.serverId, channelId: input.channelId });
5242
+ }
5243
+ else {
5244
+ const matches = this.entriesNamed(input.channelId);
5245
+ if (matches.length > 1) {
5246
+ if (seen)
5247
+ seen.refusal = 'shared-id';
5248
+ return {
5249
+ success: false,
5250
+ error: this.sharedNameRefusal(input.channelId, matches, { takesServerId: false, nothing: 'nothing was posted' }),
5251
+ isError: true,
5252
+ };
5253
+ }
5254
+ entry = matches[0];
5255
+ }
4569
5256
  if (!entry) {
4570
5257
  if (seen)
4571
5258
  seen.refusal = 'channel-not-registered';
4572
- return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true };
5259
+ return {
5260
+ success: false,
5261
+ // The default channel is gone (its server no longer registers it),
5262
+ // though another server's channel may have its id.
5263
+ // No advice: the default's channel is gone, and another server's
5264
+ // channel with its id isn't where its message came from.
5265
+ error: input.channelId === undefined
5266
+ ? serverGiven
5267
+ ? `Server "${input.serverId}" has no channel "${fallback.channelId}", the id of the channel this call would default to, so nothing was posted.`
5268
+ : `The channel this would default to, ${this.quotedShown(fallback)}, isn't registered now, so nothing was posted.`
5269
+ // This tool's schema has no serverId, so one given here is a host's
5270
+ // ref (publishForAgent), or one an agent added unasked, and the
5271
+ // refusal offers names its channelId takes instead. (A fork's guard
5272
+ // refuses a home its server no longer registers before this runs.)
5273
+ : serverGiven
5274
+ ? `No channel "${input.channelId}" is on server "${input.serverId}", so nothing was posted. ${this.elsewhereText(input.channelId, false)}`
5275
+ : `No channel has the id "${input.channelId}", so nothing was posted. ${findIds}`,
5276
+ isError: true,
5277
+ };
4573
5278
  }
5279
+ // From here, the channel's own id, whichever name the call gave for it,
5280
+ // and the name the agent is shown it by (nameShown).
5281
+ const channelId = entry.descriptor.id;
5282
+ // The channel the call names, for a caller that reports a refusal before
5283
+ // the send (publishForAgent's trace).
5284
+ if (seen)
5285
+ seen.resolved = refOf(entry);
5286
+ const shown = this.nameShown(refOf(entry));
5287
+ const quoted = this.quotedShown(refOf(entry));
4574
5288
  if (isInboundOnly(entry)) {
4575
5289
  if (seen)
4576
5290
  seen.refusal = 'inbound-only';
4577
5291
  return {
4578
5292
  success: false,
4579
- error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`,
5293
+ error: `Channel ${shown} is ${inboundOnlyReason(entry)}`,
4580
5294
  isError: true,
4581
5295
  };
4582
5296
  }
@@ -4588,7 +5302,7 @@ export class ChannelRegistry {
4588
5302
  const reach = this.serverReach(entry.serverId);
4589
5303
  seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
4590
5304
  }
4591
- return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true };
5305
+ return { success: false, error: `Nothing was posted to channel ${quoted}: ${this.sendRefusal(entry.serverId)}.`, isError: true };
4592
5306
  }
4593
5307
  const publishParams = {
4594
5308
  conversationId: '', // Framework will fill this when wired
@@ -4621,11 +5335,15 @@ export class ChannelRegistry {
4621
5335
  return { success: true, data: result };
4622
5336
  // The connector's data may say what a multi-part send already posted.
4623
5337
  const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
5338
+ // Named as it's shown now, after the send's await: registrations may have
5339
+ // changed meanwhile, and the name shown before it may now pick another
5340
+ // channel.
5341
+ const sentTo = this.quotedShown(refOf(entry));
4624
5342
  return {
4625
5343
  success: false,
4626
5344
  error: (outcome.status === 'failed'
4627
- ? `Nothing was posted to channel "${channelId}": ${outcome.reason}.`
4628
- : `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` +
5345
+ ? `Nothing was posted to channel ${sentTo}: ${outcome.reason}.`
5346
+ : `Posting to channel ${sentTo} wasn't confirmed: ${outcome.reason}. ` +
4629
5347
  'It may or may not have been posted, so sending it again could post it twice.') + detail,
4630
5348
  isError: true,
4631
5349
  };