@animalabs/agent-framework 0.26.2 → 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 +56 -6
  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 +759 -129
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3482 -928
  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 +736 -77
  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 +176 -24
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +411 -91
  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 +15 -1
  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 +3601 -967
  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 +851 -77
  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 +471 -90
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +15 -1
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -46,6 +46,7 @@ import { CapabilityGrant } from './capability-grant.js';
46
46
  import type { InboundSource } from './inbound-source.js';
47
47
  import { McplRequestError, type McplServerConnection } from './server-connection.js';
48
48
  import { readSkipReplyWake } from './skip-reply-wake.js';
49
+ import { isProseTargetToken } from './prose-grammar.js';
49
50
 
50
51
  // ============================================================================
51
52
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
@@ -134,6 +135,20 @@ class ChannelLifecycleError extends Error {
134
135
  }
135
136
  }
136
137
 
138
+ /** Whether a lifecycle failure, or one it wraps (an overtaken open's
139
+ * `cause`), is convergence that never established the channel's target.
140
+ * Words don't go into such a channel on the strength of a close the agent
141
+ * decided meanwhile (refusalGivesWay): the address they'd go to is the one
142
+ * that kept changing. A later operation that settles on the current target
143
+ * establishes it, so this is the failure's, not the channel's (compare
144
+ * answeredElsewhere). */
145
+ function convergeFailure(err: unknown): boolean {
146
+ for (let e: unknown = err; e instanceof Error; e = e.cause) {
147
+ if (e instanceof ChannelLifecycleError && e.kind === 'converge') return true;
148
+ }
149
+ return false;
150
+ }
151
+
137
152
  /** Why a channel is selected for opening after a failed open by speech or a
138
153
  * reply: its put-back couldn't be recorded, or the server's latest
139
154
  * announcement or the subscription policy lifted the default it restored. */
@@ -367,7 +382,7 @@ function shallowEqualRecord(
367
382
  // Internal Types
368
383
  // ============================================================================
369
384
 
370
- /** A registered channel entry, keyed by `{serverId}:{channelId}`. */
385
+ /** A registered channel entry, keyed by its server and id (destinationKey). */
371
386
  interface ChannelEntry {
372
387
  serverId: string;
373
388
  descriptor: ChannelDescriptor;
@@ -375,6 +390,11 @@ interface ChannelEntry {
375
390
  open: boolean;
376
391
  }
377
392
 
393
+ /** The ref naming a registered channel's entry. */
394
+ function refOf(entry: ChannelEntry): ChannelRef {
395
+ return { serverId: entry.serverId, channelId: entry.descriptor.id };
396
+ }
397
+
378
398
  /**
379
399
  * A channel its server declared inbound-only (SPEC §14.2 `direction`): its
380
400
  * messages come in, and the host publishes nothing into it. No publish, from
@@ -531,7 +551,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
531
551
  name: 'channel_close',
532
552
  description:
533
553
  'Close a channel. Messages that address you still reach you; what else stops reaching you ' +
534
- 'depends on its server.',
554
+ 'depends on its server. You can still speak there. It stays closed until you open it with ' +
555
+ 'channel_open: neither speaking there nor subscription policy reopens it.',
535
556
  inputSchema: {
536
557
  type: 'object' as const,
537
558
  properties: {
@@ -637,21 +658,53 @@ export function connectorText(value: unknown): string {
637
658
  return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
638
659
  }
639
660
 
640
- /** Where a publish was resolved to go — named by the registry, never by
641
- * the caller's spelling. */
642
- export interface PublishDestination {
661
+ /**
662
+ * A registered channel, by its server and its id. MCPL requires a channel id
663
+ * to be unique only within one server's connection (§14.2), so two servers
664
+ * can each register `general`, and only the pair names one of them.
665
+ */
666
+ export interface ChannelRef {
643
667
  serverId: string;
644
668
  channelId: string;
669
+ }
670
+
671
+ /** Where a publish was resolved to go — named by the registry, never by
672
+ * the caller's spelling. */
673
+ export interface PublishDestination extends ChannelRef {
645
674
  /** The channel's registered label when the attempt was made. */
646
675
  label?: string;
647
676
  }
648
677
 
649
- /** One key per destination, its server and channel: unlike joining the two
650
- * with a separator, no two destinations share one, whatever their ids hold. */
651
- export function destinationKey(to: PublishDestination): string {
678
+ /** One key per channel, its server and id: unlike joining the two with a
679
+ * separator, no two channels share one, whatever their ids hold. */
680
+ export function destinationKey(to: ChannelRef): string {
652
681
  return JSON.stringify([to.serverId, to.channelId]);
653
682
  }
654
683
 
684
+ /** Whether two refs name the same channel. Two absent refs are the same:
685
+ * neither names a channel. */
686
+ export function sameChannel(a: ChannelRef | null | undefined, b: ChannelRef | null | undefined): boolean {
687
+ if (!a || !b) return !a && !b;
688
+ return a.serverId === b.serverId && a.channelId === b.channelId;
689
+ }
690
+
691
+ /**
692
+ * A channel's server-qualified name, `serverId:channelId`. A name given as a
693
+ * string (a tool's channelId, a `>>` target, a host's call) may take this
694
+ * form to pick one server's channel where several servers register the id.
695
+ * It is looked up among the registered pairs, never split at a colon, since
696
+ * ids hold colons (ChannelRegistry.entriesNamed).
697
+ */
698
+ export function qualifiedChannelName(ref: ChannelRef): string {
699
+ return `${ref.serverId}:${ref.channelId}`;
700
+ }
701
+
702
+ /** A channel told apart by its id and its server, for one no string picks
703
+ * alone: `"b:room" on server "a"`. No input takes it for a name. */
704
+ function channelOnServer(ref: ChannelRef): string {
705
+ return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
706
+ }
707
+
655
708
  /**
656
709
  * What one channels/publish attempt established (ChannelRegistry.publish).
657
710
  * - `delivered`: the connector returned `delivered: true`. A historical
@@ -684,7 +737,9 @@ export interface PublishOutcome {
684
737
  /**
685
738
  * Why the destination refused a publishForAgent before anything went out.
686
739
  * Its words say the same, since both come from one check: the channel isn't
687
- * registered here, it takes no posts, or its server is gone, this host is
740
+ * registered here, the name given means more than one registered channel
741
+ * (`shared-id`: a ref picks one, as a server-qualified name does where it names
742
+ * one channel alone), it takes no posts, or its server is gone, this host is
688
743
  * connecting to it, it's reconnecting, or it's up without channels.publish
689
744
  * (serverReach); or the request wasn't sent, as its connection refused it
690
745
  * before anything was written (McplRequestError's `not-sent`). A refusal of
@@ -694,6 +749,7 @@ export interface PublishOutcome {
694
749
  */
695
750
  export type PublishRefusalReason =
696
751
  | 'channel-not-registered'
752
+ | 'shared-id'
697
753
  | 'inbound-only'
698
754
  | 'server-gone'
699
755
  | 'server-connecting'
@@ -813,30 +869,6 @@ function supersededClause(action: 'open' | 'close', now: DesiredChannelState | u
813
869
  return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
814
870
  }
815
871
 
816
- /** What ends a tune-out, for a channel tool's answer about a tuned-out
817
- * channel (`channel` as the answer shows it, quoted): its cancel, with the
818
- * backlog it delivers, and, for a close, that channel_close closes the
819
- * channel once the cancel has ended the tune-out. */
820
- function tunedOutPointer(tool: string, channel: string): string {
821
- return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
822
- 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
823
- }
824
-
825
- /** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
826
- * the tune-out, with the backlog it delivers. As an error result the agent
827
- * reads the pointer itself; `data` names the refusal for an in-process
828
- * caller. */
829
- function tunedOutRefusal(tool: string, channelId: string): ToolResult {
830
- return {
831
- success: false,
832
- isError: true,
833
- error:
834
- `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
835
- tunedOutPointer(tool, `"${channelId}"`),
836
- data: { refusal: 'tuned-out', channelId },
837
- };
838
- }
839
-
840
872
  /** A clause as the start of a sentence. */
841
873
  function sentenceStart(clause: string): string {
842
874
  return clause.charAt(0).toUpperCase() + clause.slice(1);
@@ -953,6 +985,8 @@ interface ChannelRegistryOptions {
953
985
  onRouteFailure?: (info: {
954
986
  conversationId: string;
955
987
  channelId: string | null;
988
+ /** The channel's server, beside `channelId` whenever the failure names a channel. */
989
+ serverId?: string;
956
990
  reason: string;
957
991
  textLen: number;
958
992
  /** `failed`: nothing was posted; `unknown`: the request was dispatched
@@ -991,7 +1025,7 @@ interface ChannelRegistryOptions {
991
1025
  * whichever channel last spoke (item 3). Returns undefined for the trunk/primary agent, which has
992
1026
  * no home and correctly falls back to the global locus (heartbeats, etc.).
993
1027
  */
994
- homeChannelResolver?: (agentName: string) => string | undefined;
1028
+ homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
995
1029
  /**
996
1030
  * Resolve an agent's channel for its CURRENT inference turn, by agent name:
997
1031
  * the turn's locus once it has one, else the channel that triggered the
@@ -1006,9 +1040,11 @@ interface ChannelRegistryOptions {
1006
1040
  * turn's locus is resolved, so that
1007
1041
  * resolution takes the global fallback, which the turn then keeps.
1008
1042
  * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
1009
- * `defaultPublishChannel`.
1043
+ * `defaultPublishChannel`. Null where the turn deliberately has no channel,
1044
+ * as one woken for a channel id two servers register, given without its
1045
+ * server: that takes no fallback either.
1010
1046
  */
1011
- activeChannelResolver?: (agentName: string) => string | undefined;
1047
+ activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1012
1048
  /**
1013
1049
  * Whether a conversation takes the process-global fallback, the channel of
1014
1050
  * the most recent delivered inbound message: as where its plain speech lands
@@ -1045,13 +1081,36 @@ interface ChannelRegistryOptions {
1045
1081
  */
1046
1082
  export type ChannelToolOrigin =
1047
1083
  | { kind: 'module'; moduleName?: string }
1048
- | { kind: 'agent'; agentName: string };
1084
+ | {
1085
+ kind: 'agent';
1086
+ agentName: string;
1087
+ /** For a call made inside one of the agent's turns, that turn's own
1088
+ * channels, every channel that has been its trigger or its locus: a
1089
+ * name one of them answers to by its id means that channel
1090
+ * (ChannelRegistry.ownChannelNamed). */
1091
+ own?: readonly ChannelRef[];
1092
+ };
1049
1093
 
1050
1094
  /** The machine decision sources a module-origin channel_close may record.
1051
1095
  * Closed on purpose: honest provenance means a small, auditable vocabulary,
1052
1096
  * not arbitrary self-description. */
1053
1097
  export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
1054
1098
 
1099
+ /** A close a module made through its own tool call (ctx.callTool) without a
1100
+ * machine source, such as connectome-host's mentions mode: a decision the
1101
+ * module owns, with its own way back. Speech leaves it closed, as it does a
1102
+ * channel the agent closed (speechOpens), but the agent isn't told how to
1103
+ * open it, since channel_open alone would undo only part of what the
1104
+ * module did. */
1105
+ export const MODULE_CLOSE_SOURCE = 'module-tool';
1106
+
1107
+ /** Whether a closed record is a close the agent or a module decided
1108
+ * (channel_close, channel_decline, or a module's own close), which speech
1109
+ * never undoes. */
1110
+ function isCloseDecision(source: string): boolean {
1111
+ return AGENT_DECISION_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE;
1112
+ }
1113
+
1055
1114
  export class ChannelRegistry {
1056
1115
  private serverRegistry: McplServerRegistry;
1057
1116
  private featureSetManager: FeatureSetManager;
@@ -1071,13 +1130,17 @@ export class ChannelRegistry {
1071
1130
  source: 'subscription-policy' | 'opened-by-delivery';
1072
1131
  channels: Array<{ channelId: string; label?: string }>;
1073
1132
  }) => void;
1074
- private homeChannelResolver?: (agentName: string) => string | undefined;
1075
- private activeChannelResolver?: (agentName: string) => string | undefined;
1133
+ private homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
1134
+ private activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1076
1135
  private takesGlobalFallback?: (conversationId: string) => boolean;
1077
1136
  private serverConnecting?: (serverId: string) => boolean;
1078
1137
  private store?: JsStore;
1079
1138
 
1080
- /** Registered channels, keyed by `{serverId}:{channelId}`. */
1139
+ /**
1140
+ * Registered channels, keyed by their server and id (destinationKey): a
1141
+ * key that joined the two with a separator would let server `a`'s channel
1142
+ * `b:room` and server `a:b`'s channel `room` share one entry.
1143
+ */
1081
1144
  private channels = new Map<string, ChannelEntry>();
1082
1145
 
1083
1146
  /**
@@ -1146,17 +1209,19 @@ export class ChannelRegistry {
1146
1209
  * reaction removal, edit or deletion marker, apart from an edit the
1147
1210
  * coalescer delivers on acceptance as the agent's first view: used for
1148
1211
  * speech routing and the default publish. */
1149
- private defaultPublishChannel: string | null = null;
1212
+ private defaultPublishChannel: ChannelRef | null = null;
1150
1213
 
1151
1214
  /** The same message's id and thread, used for buildChannelContext. */
1152
1215
  private defaultPublishMessageId: string | null = null;
1153
1216
  private defaultPublishThreadId: string | undefined = undefined;
1154
1217
 
1155
- /** Per-channel typing indicator timers. */
1156
- private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
1218
+ /** Per-channel typing indicator timers, by destinationKey, with the
1219
+ * channel each one types in. */
1220
+ private typingIntervals = new Map<string, { ref: ChannelRef; interval: ReturnType<typeof setInterval> }>();
1157
1221
 
1158
- /** Per-channel typing metadata — carried on the 7s refresh so the target
1159
- * server keeps getting the same routing hints (e.g. Zulip topic). */
1222
+ /** Per-channel typing metadata, by destinationKey — carried on the 7s
1223
+ * refresh so the target server keeps getting the same routing hints (e.g.
1224
+ * Zulip topic). */
1160
1225
  private typingMetadata = new Map<string, Record<string, unknown>>();
1161
1226
 
1162
1227
  /** Chronicle-projected desired lifecycle state, keyed by server + channel.
@@ -1195,6 +1260,16 @@ export class ChannelRegistry {
1195
1260
  // Losing an open receipt does not confirm closure. Keep that distinction
1196
1261
  // private while preserving the public open:boolean availability projection.
1197
1262
  private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
1263
+ // Registrations whose last open the server answered for another channel,
1264
+ // until an open is answered for this one, or the server registers the
1265
+ // channel again, which makes a new registration: a server that doesn't
1266
+ // resolve this id may not resolve a publish by it either. A close's answer
1267
+ // names no channel, so it doesn't clear the mark, and a retarget keeps the
1268
+ // registration and its mark. Read where speech would put words into such a
1269
+ // channel without an open (publish). Where the grant loses
1270
+ // channels.lifecycle while the connection stays (§6.7), no open can clear
1271
+ // the mark until the server reconnects and registers the channel again.
1272
+ private answeredElsewhere = new WeakSet<ChannelEntry>();
1198
1273
  // Marked entries a close failed for since a receipt or traffic last
1199
1274
  // confirmed them, cleared where the mark is: a failed request's outcome is
1200
1275
  // unknown, so such a close may have closed the channel, where an open,
@@ -1229,7 +1304,7 @@ export class ChannelRegistry {
1229
1304
  /** Whether a decided close holds words back from a channel: only where
1230
1305
  * the host directs the server's lifecycle. Without that grant the host
1231
1306
  * can't close the channel, and words go into a transport that is open,
1232
- * as the open step's engagesDecidedClosed reads it too. */
1307
+ * as the open step reads it too (speechOpens). */
1233
1308
  private closeStands(entry: ChannelEntry): boolean {
1234
1309
  return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
1235
1310
  this.directsLifecycle(entry.serverId);
@@ -1307,7 +1382,8 @@ export class ChannelRegistry {
1307
1382
  err: unknown,
1308
1383
  opts?: { history?: boolean },
1309
1384
  ): string {
1310
- const channel = `"${entry.descriptor.id}"`;
1385
+ // As the agent names it back (nameShown), where another server shares its id.
1386
+ const channel = this.quotedShown(refOf(entry));
1311
1387
  const tool = action === 'open' ? 'channel_open' : 'channel_close';
1312
1388
  const done = action === 'open' ? 'opened' : 'closed';
1313
1389
  const later = laterClause(entry);
@@ -1319,7 +1395,7 @@ export class ChannelRegistry {
1319
1395
  }
1320
1396
  const failure = err instanceof ChannelLifecycleError ? err : undefined;
1321
1397
  const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
1322
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
1398
+ const current = this.entryAt(refOf(entry));
1323
1399
  if (!current) {
1324
1400
  // Its registration may have gone at the server's word or at this
1325
1401
  // host's (removeServer). Only a failure that says which (cancelled,
@@ -1342,13 +1418,13 @@ export class ChannelRegistry {
1342
1418
  if (action === 'open' ? now !== 'open' : now !== 'closed') {
1343
1419
  if (action === 'open' && now === 'tuned-out') {
1344
1420
  return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
1345
- `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
1421
+ `change that. ${this.tuneOutEndText(current)}`;
1346
1422
  }
1347
1423
  // channel_close refuses a tuned-out channel too, so calling it again
1348
1424
  // isn't the way: the tune-out's cancel is, and it delivers the backlog.
1349
1425
  if (action === 'close' && now === 'tuned-out') {
1350
1426
  return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
1351
- `change that. ${tunedOutPointer('channel_close', channel)}`;
1427
+ `change that. ${this.tuneOutEndText(current, 'channel_close')}`;
1352
1428
  }
1353
1429
  // The later decision's own work settles the transport, and its call
1354
1430
  // says what became of it, so this says only that this call's change
@@ -1523,7 +1599,7 @@ export class ChannelRegistry {
1523
1599
  results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
1524
1600
  continue;
1525
1601
  }
1526
- const key = `${serverId}:${channel.id}`;
1602
+ const key = destinationKey({ serverId, channelId: channel.id });
1527
1603
  this.registerEntry(key, serverId, channel);
1528
1604
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1529
1605
  registeredIds.push(channel.id);
@@ -1570,9 +1646,11 @@ export class ChannelRegistry {
1570
1646
  // we never had is reported, not silently accepted.
1571
1647
  if (params.removed) {
1572
1648
  for (const channelId of params.removed) {
1573
- const key = `${serverId}:${channelId}`;
1574
- const existed = this.channels.delete(key);
1575
- this.stopTyping(channelId);
1649
+ // A server may name the channel by a number: it means the channel
1650
+ // whose id is that number as a string, as it always has.
1651
+ const ref = { serverId, channelId: String(channelId) };
1652
+ const existed = this.channels.delete(destinationKey(ref));
1653
+ this.stopTyping(ref);
1576
1654
  addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
1577
1655
  }
1578
1656
  }
@@ -1588,7 +1666,7 @@ export class ChannelRegistry {
1588
1666
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
1589
1667
  continue;
1590
1668
  }
1591
- const key = `${serverId}:${channel.id}`;
1669
+ const key = destinationKey({ serverId, channelId: channel.id });
1592
1670
  const existing = this.channels.get(key);
1593
1671
  if (!existing) {
1594
1672
  addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
@@ -1629,7 +1707,7 @@ export class ChannelRegistry {
1629
1707
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
1630
1708
  continue;
1631
1709
  }
1632
- const key = `${serverId}:${channel.id}`;
1710
+ const key = destinationKey({ serverId, channelId: channel.id });
1633
1711
  this.registerEntry(key, serverId, channel);
1634
1712
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1635
1713
  addedResults.push({ id: channel.id, accepted: true });
@@ -1714,6 +1792,9 @@ export class ChannelRegistry {
1714
1792
  // The batch came on the transport open now: a close after this point
1715
1793
  // (transportClosed) leaves its traffic nothing to confirm.
1716
1794
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1795
+ // And came from a server registered now: one removed since (removeServer)
1796
+ // gives no message the agent replies to (markAccepted).
1797
+ const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1717
1798
 
1718
1799
  for (const message of params.messages) {
1719
1800
  if (!message || typeof message.channelId !== 'string' || !message.channelId
@@ -1750,7 +1831,7 @@ export class ChannelRegistry {
1750
1831
  // exists registers it first; discord's DM case goes through
1751
1832
  // ensureChannelRegistered on push/event, which creates a routable
1752
1833
  // entry that isn't open, rather than an open self-attested one.
1753
- const incomingKey = `${serverId}:${message.channelId}`;
1834
+ const incomingKey = destinationKey({ serverId, channelId: message.channelId });
1754
1835
  if (!this.channels.has(incomingKey)) {
1755
1836
  // Loud, coalesced or not, like every other refusal of a message, but
1756
1837
  // at most once a minute per channel (RefusalLines): a server that
@@ -1832,8 +1913,11 @@ export class ChannelRegistry {
1832
1913
  // must not retarget the default publish locus or become
1833
1914
  // `context.incoming`'s reply anchor. An edit the coalescer delivered
1834
1915
  // as the agent's first view counts as the message it replaced.
1835
- if (!isMarkerTagged(message.tags, firstView)) {
1836
- this.defaultPublishChannel = message.channelId;
1916
+ // A message the coalescer admits after its server was removed sets no
1917
+ // default: the removal cleared that server's message, and a server
1918
+ // that's gone mustn't displace a live one's default.
1919
+ if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1920
+ this.defaultPublishChannel = { serverId, channelId: message.channelId };
1837
1921
  this.defaultPublishMessageId = message.messageId;
1838
1922
  this.defaultPublishThreadId = message.threadId;
1839
1923
  }
@@ -2003,7 +2087,9 @@ export class ChannelRegistry {
2003
2087
  label?: string,
2004
2088
  extraMetadata?: Record<string, unknown>,
2005
2089
  ): void {
2006
- const existing = this.findChannelEntry(channelId);
2090
+ // This server's own registration: another server's channel with the same
2091
+ // id is another channel (ChannelRef), which this one doesn't make.
2092
+ const existing = this.entryAt({ serverId, channelId });
2007
2093
  if (existing) {
2008
2094
  // Backfill identity onto a bare lazy registration: a DM that first
2009
2095
  // arrived with no label/recipient info gets a usable name the next
@@ -2045,7 +2131,7 @@ export class ChannelRegistry {
2045
2131
  // the server may still hold a channel an earlier process opened. Counting
2046
2132
  // it confirmed closed would answer a close or a decline as already done
2047
2133
  // and send nothing, even where the server still delivers.
2048
- const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, {
2134
+ const entry = this.registerEntry(destinationKey({ serverId, channelId }), serverId, {
2049
2135
  id: channelId,
2050
2136
  type: serverId,
2051
2137
  label: label ?? channelId,
@@ -2066,93 +2152,104 @@ export class ChannelRegistry {
2066
2152
  // ==========================================================================
2067
2153
 
2068
2154
  /**
2069
- * Start sending typing indicators for a channel.
2155
+ * Start sending typing indicators for a channel: the channel a ref names,
2156
+ * or each channel a name given as a string means (entriesNamed). A host
2157
+ * names channels by the ids it keeps (connectome-host's activity module),
2158
+ * and typing posts nothing, so a name two servers' channels answer to
2159
+ * shows typing in both, rather than in neither or in whichever registered
2160
+ * first. Where two servers reach one platform channel, two bots can then
2161
+ * show typing there.
2070
2162
  *
2071
2163
  * Sends a typing notification immediately and every 7 seconds thereafter.
2072
2164
  * Discord typing indicators last ~10s, so 7s keeps them alive.
2073
2165
  *
2074
- * No-op if already typing on this channel.
2166
+ * No-op for a channel already typing, or not registered.
2075
2167
  */
2076
- startTyping(channelId: string, metadata?: Record<string, unknown>): void {
2168
+ startTyping(channel: string | ChannelRef, metadata?: Record<string, unknown>): void {
2169
+ const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
2170
+ for (const ref of refs) this.startTypingAt(ref, metadata);
2171
+ }
2172
+
2173
+ private startTypingAt(ref: ChannelRef, metadata?: Record<string, unknown>): void {
2174
+ // A channel types only while it's registered, and keeps no metadata
2175
+ // otherwise: each removal of a channel or a server stops its typing
2176
+ // (stopTyping), which drops the metadata too, and stopAll clears both.
2177
+ if (!this.entryAt(ref)) return;
2178
+ const key = destinationKey(ref);
2077
2179
  const metadataChanged =
2078
2180
  metadata !== undefined &&
2079
- !shallowEqualRecord(this.typingMetadata.get(channelId), metadata);
2181
+ !shallowEqualRecord(this.typingMetadata.get(key), metadata);
2080
2182
  if (metadata) {
2081
- this.typingMetadata.set(channelId, metadata);
2183
+ this.typingMetadata.set(key, metadata);
2082
2184
  }
2083
2185
 
2084
- if (this.typingIntervals.has(channelId)) {
2186
+ if (this.typingIntervals.has(key)) {
2085
2187
  // Already typing. If the caller supplied new routing metadata (e.g. the
2086
2188
  // relevant Zulip topic just moved because a newer message arrived),
2087
2189
  // dispatch an immediate refresh so the server sees the new routing
2088
2190
  // within this request instead of waiting up to TYPING_INTERVAL_MS for
2089
2191
  // the next tick.
2090
- if (metadataChanged) {
2091
- const entry = this.findChannelEntry(channelId);
2092
- if (entry) {
2093
- this.sendTypingNotification(entry.serverId, channelId);
2094
- }
2095
- }
2096
- return;
2097
- }
2098
-
2099
- // Find the channel entry and its server
2100
- const entry = this.findChannelEntry(channelId);
2101
- if (!entry) {
2192
+ if (metadataChanged) this.sendTypingNotification(ref);
2102
2193
  return;
2103
2194
  }
2104
2195
 
2105
2196
  // Send typing immediately
2106
- this.sendTypingNotification(entry.serverId, channelId);
2197
+ this.sendTypingNotification(ref);
2107
2198
 
2108
2199
  // Set up interval — pulls the latest metadata on each tick so mid-stream
2109
2200
  // updates (e.g. a newer incoming message switching the relevant topic)
2110
2201
  // take effect on the next refresh.
2111
2202
  const interval = setInterval(() => {
2112
- this.sendTypingNotification(entry.serverId, channelId);
2203
+ this.sendTypingNotification(ref);
2113
2204
  }, TYPING_INTERVAL_MS);
2114
2205
 
2115
- this.typingIntervals.set(channelId, interval);
2206
+ this.typingIntervals.set(key, { ref, interval });
2116
2207
  }
2117
2208
 
2118
2209
  /**
2119
2210
  * Stop sending typing indicators.
2120
2211
  *
2121
- * If channelId is specified, stops typing on that channel only.
2122
- * If no channelId, stops all typing indicators.
2212
+ * Given a ref, stops typing in that channel. Given a name as a string,
2213
+ * stops typing in each channel the name means, by its id or its
2214
+ * server-qualified name (entriesNamed), as startTyping started it: a
2215
+ * channel types only while it's registered, since startTypingAt starts
2216
+ * none that isn't and each removal stops its channel's typing. Given
2217
+ * nothing, stops all typing indicators.
2123
2218
  */
2124
- stopTyping(channelId?: string): void {
2125
- if (channelId !== undefined) {
2126
- const interval = this.typingIntervals.get(channelId);
2127
- if (interval) {
2128
- clearInterval(interval);
2129
- this.typingIntervals.delete(channelId);
2219
+ stopTyping(channel?: string | ChannelRef): void {
2220
+ if (channel === undefined) {
2221
+ // Clear all typing intervals and dispatch stop for each known channel
2222
+ const running = [...this.typingIntervals.values()];
2223
+ for (const { interval } of running) clearInterval(interval);
2224
+ this.typingIntervals.clear();
2225
+ for (const { ref } of running) this.sendTypingStop(ref);
2226
+ this.typingMetadata.clear();
2227
+ return;
2228
+ }
2229
+ const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
2230
+ for (const ref of refs) {
2231
+ const key = destinationKey(ref);
2232
+ const typing = this.typingIntervals.get(key);
2233
+ if (typing) {
2234
+ clearInterval(typing.interval);
2235
+ this.typingIntervals.delete(key);
2130
2236
  // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
2131
2237
  // clear the indicator immediately rather than waiting for auto-expire.
2132
2238
  // Metadata still carries the routing hint so the stop hits the same
2133
- // topic/thread as the start. Guarded by `interval`: matches the
2239
+ // topic/thread as the start. Guarded by the interval: matches the
2134
2240
  // global-clear branch's semantics, and keeps defensive stopTyping(ch)
2135
2241
  // calls from spamming stops at a server that never saw a start.
2136
- const entry = this.findChannelEntry(channelId);
2137
- if (entry && this.sendTypingFn) {
2138
- this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
2139
- }
2140
- }
2141
- this.typingMetadata.delete(channelId);
2142
- } else {
2143
- // Clear all typing intervals and dispatch stop for each known channel
2144
- const channels = Array.from(this.typingIntervals.keys());
2145
- for (const interval of this.typingIntervals.values()) {
2146
- clearInterval(interval);
2242
+ this.sendTypingStop(ref);
2147
2243
  }
2148
- this.typingIntervals.clear();
2149
- if (this.sendTypingFn) {
2150
- for (const id of channels) {
2151
- const entry = this.findChannelEntry(id);
2152
- if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
2153
- }
2154
- }
2155
- this.typingMetadata.clear();
2244
+ this.typingMetadata.delete(key);
2245
+ }
2246
+ }
2247
+
2248
+ /** The explicit 'stop' for a channel whose typing ended, while its
2249
+ * server still registers it. */
2250
+ private sendTypingStop(ref: ChannelRef): void {
2251
+ if (this.sendTypingFn && this.entryAt(ref)) {
2252
+ this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
2156
2253
  }
2157
2254
  }
2158
2255
 
@@ -2166,14 +2263,10 @@ export class ChannelRegistry {
2166
2263
  * deletion marker, apart from an edit the coalescer delivers on acceptance
2167
2264
  * as the agent's first view.
2168
2265
  */
2169
- getDefaultPublishChannel(): string | null {
2266
+ getDefaultPublishChannel(): ChannelRef | null {
2170
2267
  return this.defaultPublishChannel;
2171
2268
  }
2172
2269
 
2173
- /**
2174
- * Get the descriptor for a channel by its channelId (first match across
2175
- * servers). Used by the conversation router for DM classification.
2176
- */
2177
2270
  /**
2178
2271
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
2179
2272
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -2182,30 +2275,25 @@ export class ChannelRegistry {
2182
2275
  * registration.
2183
2276
  */
2184
2277
  isDeclaredChannel(serverId: string, channelId: string): boolean {
2185
- const entry = this.channels.get(`${serverId}:${channelId}`);
2278
+ const entry = this.entryAt({ serverId, channelId });
2186
2279
  return !!entry && !pushPlaceholder(entry);
2187
2280
  }
2188
2281
 
2189
- getDescriptor(channelId: string): ChannelDescriptor | undefined {
2190
- return this.findChannelEntry(channelId)?.descriptor;
2282
+ /** A registered channel's descriptor. Used by the conversation router for
2283
+ * DM classification. */
2284
+ getDescriptor(ref: ChannelRef): ChannelDescriptor | undefined {
2285
+ return this.entryAt(ref)?.descriptor;
2191
2286
  }
2192
2287
 
2193
2288
  /** The registered label of `channelId` on exactly `serverId` (no
2194
2289
  * cross-server first match), or undefined when it has none. */
2195
2290
  getChannelLabel(serverId: string, channelId: string): string | undefined {
2196
- const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label;
2291
+ const label = this.entryAt({ serverId, channelId })?.descriptor.label;
2197
2292
  return typeof label === 'string' && label.length > 0 ? label : undefined;
2198
2293
  }
2199
2294
 
2200
- /** Whether `channelId`'s transport is open: on `serverId` where the caller
2201
- * knows the channel's server, and otherwise on the first server found to
2202
- * register that id (findChannelEntry). The map's key joins the two ids
2203
- * with a colon, so another server's entry can sit under a key: it has to
2204
- * be this server's (as in streamingServerAt). */
2205
- isChannelOpen(channelId: string, serverId?: string): boolean {
2206
- if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true;
2207
- const entry = this.channels.get(`${serverId}:${channelId}`);
2208
- return entry?.serverId === serverId && entry.open === true;
2295
+ isChannelOpen(ref: ChannelRef): boolean {
2296
+ return this.entryAt(ref)?.open === true;
2209
2297
  }
2210
2298
 
2211
2299
  getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
@@ -2252,6 +2340,9 @@ export class ChannelRegistry {
2252
2340
  toolName: string,
2253
2341
  input: unknown,
2254
2342
  origin?: ChannelToolOrigin,
2343
+ /** Filled with a channel_publish's outcome once its request is
2344
+ * attempted, so the caller learns where it was delivered. */
2345
+ seen?: { outcome?: PublishOutcome },
2255
2346
  ): Promise<ToolResult> {
2256
2347
  switch (toolName) {
2257
2348
  case 'channel_list':
@@ -2263,7 +2354,7 @@ export class ChannelRegistry {
2263
2354
  serverId?: string;
2264
2355
  backscroll?: number;
2265
2356
  beforeMessageId?: string;
2266
- });
2357
+ }, origin?.kind === 'agent' ? origin.own : undefined);
2267
2358
 
2268
2359
  case 'channel_close':
2269
2360
  return this.handleToolClose(
@@ -2282,10 +2373,10 @@ export class ChannelRegistry {
2282
2373
  serverId?: string;
2283
2374
  messageId: string;
2284
2375
  acknowledge?: string;
2285
- });
2376
+ }, origin?.kind === 'agent' ? origin.own : undefined);
2286
2377
 
2287
2378
  case 'channel_publish':
2288
- return this.handleToolPublish(input as { channelId: string; content: string }, origin);
2379
+ return this.handleToolPublish(input as { channelId?: string; serverId?: string; content?: string; text?: string }, origin, seen);
2289
2380
 
2290
2381
  case 'think':
2291
2382
  return this.handleToolThink(input as { content?: string });
@@ -2312,13 +2403,39 @@ export class ChannelRegistry {
2312
2403
  // ==========================================================================
2313
2404
 
2314
2405
  /**
2315
- * Build channel context for inclusion in beforeInference params.
2316
- *
2317
- * Returns undefined if no channels are active.
2406
+ * The message the agent is replying to, with its channel: the most recent
2407
+ * inbound message that isn't a reaction, reaction removal, edit or deletion
2408
+ * marker, apart from an edit the coalescer delivers on acceptance as the
2409
+ * agent's first view. Left process-global: per-channel inbound tracking
2410
+ * (the right messageId for a fork's own channel) is a separate concern from
2411
+ * the outbound routing fix. An ephemeral run has none (takesGlobalFallback),
2412
+ * so a subagent's refusal marks no one else's channel. Nor has a turn that
2413
+ * deliberately has no channel (activeChannelResolver's null): woken for a
2414
+ * channel id two servers register, it isn't replying to the latest inbound
2415
+ * message, which may be anywhere. A fork's turn always has a channel, its
2416
+ * home, so it's taken to be replying to the latest inbound message as every
2417
+ * fork's turn is, in explicit mode as in locus mode, where its locus is
2418
+ * its home.
2318
2419
  */
2319
- buildChannelContext(agentName?: string): ChannelContext | undefined {
2320
- const openChannels = this.getOpenChannels();
2420
+ incomingFor(agentName?: string): { channel: ChannelRef; messageId: string; threadId?: string } | undefined {
2421
+ if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2422
+ return undefined;
2423
+ }
2424
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2425
+ if (!fallback || !this.defaultPublishMessageId) return undefined;
2426
+ return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2427
+ }
2321
2428
 
2429
+ /**
2430
+ * The channel context one server is told for an agent's inference
2431
+ * (context/beforeInference, §14.4). Each server is told only of its own
2432
+ * channels: ids are unique only within one server's connection (§14.2), so
2433
+ * another server's channel named by its id alone could read as this
2434
+ * server's own, and §14.4 leaves the field to the host. So `incoming` and
2435
+ * `defaultOutgoing` appear only where that channel is `serverId`'s, and
2436
+ * `candidates` lists only its open channels. Undefined when none applies.
2437
+ */
2438
+ buildChannelContext(agentName: string | undefined, serverId: string): ChannelContext | undefined {
2322
2439
  // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
2323
2440
  // told the channel its speech will actually land in (none, when its server
2324
2441
  // declared it inbound-only, since speech isn't posted there): a conversation
@@ -2335,43 +2452,37 @@ export class ChannelRegistry {
2335
2452
  // the latest inbound message's channel as its outgoing one, nor that
2336
2453
  // message as the one it is replying to.
2337
2454
  const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2338
- const outgoing = home ?? active ?? fallback;
2339
-
2340
- if (openChannels.length === 0 && !outgoing) {
2341
- return undefined;
2342
- }
2455
+ // A turn that deliberately has no channel (null) takes no fallback.
2456
+ const outgoing = home ?? (active !== undefined ? active : fallback);
2343
2457
 
2344
2458
  const context: ChannelContext = {};
2345
2459
 
2346
- // Incoming: the most recent inbound message that isn't a reaction,
2347
- // reaction removal, edit or deletion marker, apart from an edit the
2348
- // coalescer delivers on acceptance as the agent's first view (what the
2349
- // agent is replying to).
2350
- // Left process-global — per-channel inbound tracking (the right messageId for
2351
- // a fork's own channel) is a separate concern from the outbound routing fix.
2352
- if (fallback && this.defaultPublishMessageId) {
2460
+ // Incoming: the message the agent is replying to (incomingFor).
2461
+ const incoming = this.incomingFor(agentName);
2462
+ if (incoming && incoming.channel.serverId === serverId) {
2353
2463
  context.incoming = {
2354
- channelId: fallback,
2355
- messageId: this.defaultPublishMessageId,
2356
- threadId: this.defaultPublishThreadId,
2464
+ channelId: incoming.channel.channelId,
2465
+ messageId: incoming.messageId,
2466
+ threadId: incoming.threadId,
2357
2467
  };
2358
2468
  }
2359
2469
 
2360
2470
  // Default outgoing: the resolved outbound locus (home channel for forks),
2361
2471
  // unless its server declared it inbound-only: routeSpeech publishes nothing
2362
2472
  // there, so it isn't where the agent's speech goes.
2363
- if (outgoing && !this.inboundOnlyServer(outgoing)) {
2473
+ if (outgoing && outgoing.serverId === serverId && !this.inboundOnlyServer(outgoing)) {
2364
2474
  context.defaultOutgoing = {
2365
- channelId: outgoing,
2475
+ channelId: outgoing.channelId,
2366
2476
  };
2367
2477
  }
2368
2478
 
2369
- // Candidates: all open channel IDs
2370
- if (openChannels.length > 0) {
2371
- context.candidates = openChannels.map((e) => e.descriptor.id);
2479
+ // Candidates: this server's open channels.
2480
+ const candidates = this.getOpenChannels().filter((e) => e.serverId === serverId).map((e) => e.descriptor.id);
2481
+ if (candidates.length > 0) {
2482
+ context.candidates = candidates;
2372
2483
  }
2373
2484
 
2374
- return context;
2485
+ return Object.keys(context).length > 0 ? context : undefined;
2375
2486
  }
2376
2487
 
2377
2488
  // ==========================================================================
@@ -2391,12 +2502,18 @@ export class ChannelRegistry {
2391
2502
  for (const [key, entry] of this.channels) {
2392
2503
  if (entry.serverId !== serverId) continue;
2393
2504
  this.channels.delete(key);
2394
- this.stopTyping(entry.descriptor.id);
2395
- if (this.defaultPublishChannel === entry.descriptor.id) {
2396
- this.defaultPublishChannel = null;
2397
- this.defaultPublishMessageId = null;
2398
- this.defaultPublishThreadId = undefined;
2399
- }
2505
+ this.stopTyping(refOf(entry));
2506
+ }
2507
+ // The default channel's ref stays, as a channels/changed removal leaves
2508
+ // it: a call that would default to it is refused as the default gone,
2509
+ // naming it, rather than told there's no default yet, and another
2510
+ // server's channel with its id isn't taken for it. Its message and
2511
+ // thread go, as on main, so no message on a server that's gone is given
2512
+ // as the one an agent replies to (incomingFor), to that server added
2513
+ // back or to a refusal's reaction.
2514
+ if (this.defaultPublishChannel?.serverId === serverId) {
2515
+ this.defaultPublishMessageId = null;
2516
+ this.defaultPublishThreadId = undefined;
2400
2517
  }
2401
2518
  // Desired state and migration markers deliberately survive disconnects.
2402
2519
  }
@@ -2441,11 +2558,13 @@ export class ChannelRegistry {
2441
2558
  * Stop all typing intervals and clear all channel registrations.
2442
2559
  */
2443
2560
  stopAll(): void {
2444
- // Clear all typing intervals
2445
- for (const interval of this.typingIntervals.values()) {
2561
+ // Clear all typing intervals, and their metadata, which a channel
2562
+ // registered again later would otherwise send
2563
+ for (const { interval } of this.typingIntervals.values()) {
2446
2564
  clearInterval(interval);
2447
2565
  }
2448
2566
  this.typingIntervals.clear();
2567
+ this.typingMetadata.clear();
2449
2568
 
2450
2569
  // Clear channels map
2451
2570
  this.channels.clear();
@@ -2855,17 +2974,30 @@ export class ChannelRegistry {
2855
2974
  ): void {
2856
2975
  const key = this.lifecycleKey(serverId, channelId);
2857
2976
  const current = this.desiredStates.get(key);
2977
+ // A machine's close isn't a decision: of a channel with no record (one
2978
+ // registered lazily, or on a server whose grant lacks channels.lifecycle),
2979
+ // it records the default that channel already had, so policy and
2980
+ // bootstrap can still lift it (#417).
2981
+ const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source;
2858
2982
  // A decision that records the state already on record changes nothing,
2859
- // so it writes nothing, unless it's the agent's own (channel_open,
2860
- // channel_close or channel_decline) and the record isn't. The record
2861
- // then says the agent decided, which is all handleToolClose's
2862
- // explicit-open guard and ensureInitialDesiredState's lift of a default
2863
- // ask of it. openChannelNow depends on it too: a failed open by speech
2864
- // or a reply tells a decision made during its round-trip by the record
2865
- // no longer being the object it wrote, and the agent's channel_open,
2866
- // the only decision of the same state that can be made then, gets a new
2867
- // record here.
2868
- if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) {
2983
+ // so it writes nothing, with two exceptions. The agent's own decision
2984
+ // (channel_open, channel_close or channel_decline) records itself over a
2985
+ // record that isn't the agent's. The record then says the agent decided,
2986
+ // which is all handleToolClose's explicit-open guard and
2987
+ // ensureInitialDesiredState's lift of a default ask of it. openChannelNow
2988
+ // depends on it too: a failed open by speech or a reply tells a decision
2989
+ // made during its round-trip by the record no longer being the object it
2990
+ // wrote, and the agent's channel_open, the only decision of the same
2991
+ // state that can be made then, gets a new record here. And the latest
2992
+ // deliberate close wins: the agent's close or decline, or a module's
2993
+ // close, records itself over any closed record from another source,
2994
+ // another deliberate close included, so a decline after a close is the
2995
+ // decision on record. Speech opens no channel whose close was decided
2996
+ // (speechOpens). A machine's close never replaces a closed record, so
2997
+ // what nobody decided stays liftable.
2998
+ const agentOverOther = AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current?.source ?? '');
2999
+ const latestClose = desired === 'closed' && isCloseDecision(source) && current?.source !== source;
3000
+ if (current?.state === desired && !agentOverOther && !latestClose) {
2869
3001
  return;
2870
3002
  }
2871
3003
  this.appendLifecycleEvent({
@@ -2873,10 +3005,10 @@ export class ChannelRegistry {
2873
3005
  serverId,
2874
3006
  channelId,
2875
3007
  desired,
2876
- source,
3008
+ source: recorded,
2877
3009
  timestamp: new Date().toISOString(),
2878
3010
  });
2879
- this.desiredStates.set(key, { state: desired, source });
3011
+ this.desiredStates.set(key, { state: desired, source: recorded });
2880
3012
  }
2881
3013
 
2882
3014
  // ==========================================================================
@@ -2967,7 +3099,7 @@ export class ChannelRegistry {
2967
3099
  return this.getTuneOutState(serverId, channelId) !== null &&
2968
3100
  !this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
2969
3101
  this.serverReach(serverId) === 'up' &&
2970
- this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`));
3102
+ this.tuneOutReadsOpen(this.channels.get(destinationKey({ serverId, channelId })));
2971
3103
  }
2972
3104
 
2973
3105
  /**
@@ -2995,7 +3127,7 @@ export class ChannelRegistry {
2995
3127
  channelId: string,
2996
3128
  epochId: string | undefined,
2997
3129
  ): Promise<string | undefined> {
2998
- const channelKey = `${serverId}:${channelId}`;
3130
+ const channelKey = destinationKey({ serverId, channelId });
2999
3131
  const lifecycleKey = this.lifecycleKey(serverId, channelId);
3000
3132
  const inEffect = () =>
3001
3133
  epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
@@ -3207,32 +3339,48 @@ export class ChannelRegistry {
3207
3339
  * the resident matches none. A refusal of the destination before anything
3208
3340
  * went out carries its `reason` (PublishRefusalReason), so a caller can act
3209
3341
  * on why nothing was posted without reading the words.
3342
+ *
3343
+ * The channel is a ref, or a name given as a string, which resolves as
3344
+ * channel_publish's channelId does: one channel the name means, which may
3345
+ * be server-qualified, and a name two servers' channels answer to is
3346
+ * refused with them as candidates (`shared-id`). A host keeps channels by
3347
+ * their ids (connectome-host's notices module). Where several servers
3348
+ * register an id, a ref picks one server's channel, as a qualified name does
3349
+ * where it names one channel alone.
3210
3350
  */
3211
3351
  async publishForAgent(
3212
- channelId: string,
3352
+ channel: string | ChannelRef,
3213
3353
  text: string,
3214
3354
  agentName: string,
3215
3355
  ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean; reason?: PublishRefusalReason }> {
3216
- // The server the publish goes to, as handleToolPublish finds it before
3217
- // it awaits: the channel may be removed while the reply is pending.
3218
- const serverId = this.findChannelEntry(channelId)?.serverId ?? '';
3219
- const seen: { outcome?: PublishOutcome; refusal?: PublishRefusalReason } = {};
3220
- const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel');
3221
- if (seen.outcome?.status === 'delivered') {
3356
+ const input = typeof channel === 'string'
3357
+ ? { channelId: channel, content: text }
3358
+ : { channelId: channel.channelId, serverId: channel.serverId, content: text };
3359
+ const seen: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason } = {};
3360
+ const result = await this.handleToolPublish(input, undefined, seen, 'speak_in_channel');
3361
+ // The destination the publish resolved before it awaited, which the
3362
+ // outcome carries: the channel may be removed while the reply is pending.
3363
+ const destination = seen.outcome?.destination;
3364
+ if (seen.outcome?.status === 'delivered' && destination) {
3222
3365
  this.emitTraceFn({
3223
3366
  type: 'mcpl:speech-routed',
3224
3367
  conversationId: agentName,
3225
- serverId,
3226
- channelId,
3368
+ serverId: destination.serverId,
3369
+ channelId: destination.channelId,
3227
3370
  delivered: true,
3228
3371
  textLen: text.length,
3229
3372
  text,
3230
3373
  });
3231
3374
  } else {
3375
+ // The channel the publish went to, or that the call named before a
3376
+ // refusal, or, where nothing resolved, the server the caller gave, with
3377
+ // the name it gave.
3378
+ const named = destination ?? seen.resolved;
3232
3379
  this.emitTraceFn({
3233
3380
  type: 'mcpl:speech-route-failed',
3234
3381
  conversationId: agentName,
3235
- channelId,
3382
+ ...(named ? { serverId: named.serverId } : 'serverId' in input ? { serverId: input.serverId } : {}),
3383
+ channelId: named?.channelId ?? input.channelId,
3236
3384
  // The publish's own reason and the connector's detail, as
3237
3385
  // deliverSpeech's trace gives them, or the refusal before any publish.
3238
3386
  reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
@@ -3366,7 +3514,7 @@ export class ChannelRegistry {
3366
3514
  removals: number,
3367
3515
  options?: { history?: ChannelHistoryRequest },
3368
3516
  ): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
3369
- const channelKey = `${serverId}:${channelId}`;
3517
+ const channelKey = destinationKey({ serverId, channelId });
3370
3518
  const lifecycleKey = this.lifecycleKey(serverId, channelId);
3371
3519
  try {
3372
3520
  let attempts = 0;
@@ -3479,6 +3627,7 @@ export class ChannelRegistry {
3479
3627
  // its sends either.
3480
3628
  if (substitute !== undefined) {
3481
3629
  this.invalidateTransportConfirmation(entry);
3630
+ this.answeredElsewhere.add(entry);
3482
3631
  throw new ChannelLifecycleError(
3483
3632
  `the "${serverId}" server answered for ` +
3484
3633
  (substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
@@ -3488,6 +3637,7 @@ export class ChannelRegistry {
3488
3637
  }
3489
3638
  if (open) this.applyOpenDeclaration(entry, result);
3490
3639
  entry.open = open;
3640
+ if (open) this.answeredElsewhere.delete(entry);
3491
3641
  this.unconfirmedChannelTargets.delete(entry);
3492
3642
  this.failedCloseTargets.delete(entry);
3493
3643
  const now = this.getDesiredState(serverId, channelId);
@@ -3539,7 +3689,7 @@ export class ChannelRegistry {
3539
3689
  * doesn't know needs nothing: §14.5 refuses its traffic.
3540
3690
  */
3541
3691
  private closeSubstitutedChannel(serverId: string, channelId: string): void {
3542
- if (!this.channels.has(`${serverId}:${channelId}`)) return;
3692
+ if (!this.channels.has(destinationKey({ serverId, channelId }))) return;
3543
3693
  if (this.getDesiredState(serverId, channelId) !== 'closed') return;
3544
3694
  this.applyThroughQueue(serverId, channelId);
3545
3695
  }
@@ -3557,7 +3707,7 @@ export class ChannelRegistry {
3557
3707
  this.applyDesiredChannelState(serverId, channelId).catch((err) => {
3558
3708
  // Removal while queued is cancellation, as in reconcileChannels, and
3559
3709
  // exhausted convergence emitted its own diagnostic.
3560
- if (!this.channels.has(`${serverId}:${channelId}`) ||
3710
+ if (!this.channels.has(destinationKey({ serverId, channelId })) ||
3561
3711
  (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge'))) return;
3562
3712
  this.emitTraceFn({
3563
3713
  type: 'mcpl:channel-reconcile-failed',
@@ -3596,7 +3746,7 @@ export class ChannelRegistry {
3596
3746
  // loop, so stop waiting rather than spin.
3597
3747
  if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
3598
3748
  }
3599
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3749
+ const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
3600
3750
  if (!current) throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
3601
3751
  // A close that stands is what the words met, whatever became of an open,
3602
3752
  // and whether or not the close itself took: one that failed may have
@@ -3641,7 +3791,7 @@ export class ChannelRegistry {
3641
3791
  // Record all admissions before waiting on any channel. A slow first RPC
3642
3792
  // must not delay intent for the rest of this registration batch.
3643
3793
  for (const channel of channels) {
3644
- const entry = this.channels.get(`${serverId}:${channel.id}`);
3794
+ const entry = this.entryAt({ serverId, channelId: channel.id });
3645
3795
  if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
3646
3796
  policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
3647
3797
  }
@@ -3665,14 +3815,14 @@ export class ChannelRegistry {
3665
3815
  // Each channel has its own lifecycle queue, so a held RPC for one (up to
3666
3816
  // its timeout, times its attempts) doesn't delay its siblings.
3667
3817
  await Promise.allSettled(channels.map(async (channel) => {
3668
- if (!this.channels.has(`${serverId}:${channel.id}`)) return;
3818
+ if (!this.channels.has(destinationKey({ serverId, channelId: channel.id }))) return;
3669
3819
  try {
3670
3820
  await this.applyDesiredChannelState(serverId, channel.id);
3671
3821
  } catch (err) {
3672
3822
  // Removal while queued is cancellation, not a failed subscription.
3673
3823
  // Exhausted convergence and a substituted answer already emitted
3674
3824
  // their own diagnostics.
3675
- if (!this.channels.has(`${serverId}:${channel.id}`) ||
3825
+ if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })) ||
3676
3826
  (err instanceof ChannelLifecycleError &&
3677
3827
  (err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted'))) return;
3678
3828
  this.emitTraceFn({
@@ -3696,13 +3846,13 @@ export class ChannelRegistry {
3696
3846
  * Uses the sendTypingFn callback if provided. If not, this is a no-op
3697
3847
  * (typing timer lifecycle is still managed for when the callback is wired).
3698
3848
  */
3699
- private sendTypingNotification(serverId: string, channelId: string): void {
3849
+ private sendTypingNotification(ref: ChannelRef): void {
3700
3850
  // §14.1: channels/typing requires channels.typing in the grant. Silent
3701
3851
  // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
3702
3852
  // tick would be noise.
3703
- if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
3853
+ if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing')) return;
3704
3854
  if (this.sendTypingFn) {
3705
- this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
3855
+ this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
3706
3856
  }
3707
3857
  // TODO: When server-connection exposes a public sendNotification or
3708
3858
  // sendTyping method, wire it here directly instead of using a callback.
@@ -3719,76 +3869,274 @@ export class ChannelRegistry {
3719
3869
  .map((e) => e.descriptor);
3720
3870
  }
3721
3871
 
3722
- /** Server id owning a registered channel (by descriptor id), or null. */
3723
- getChannelServerId(channelId: string): string | null {
3724
- return this.findChannelEntry(channelId)?.serverId ?? null;
3725
- }
3726
-
3727
3872
  /**
3728
3873
  * The server that declared this channel inbound-only (isInboundOnly), or
3729
- * undefined when it isn't one. The notices name that server. A caller that
3730
- * knows the channel's server passes it; otherwise the channel is found by
3731
- * its id alone (findChannelEntry).
3874
+ * undefined when it isn't one, or isn't registered. The notices name that
3875
+ * server.
3732
3876
  */
3733
- inboundOnlyServer(channelId: string, serverId?: string): string | undefined {
3734
- const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId);
3877
+ inboundOnlyServer(ref: ChannelRef): string | undefined {
3878
+ const entry = this.entryAt(ref);
3735
3879
  return entry && isInboundOnly(entry) ? entry.serverId : undefined;
3736
3880
  }
3737
3881
 
3882
+ /** A channel's entry, by its server and id: the map is keyed by destinationKey. */
3883
+ private entryAt(ref: ChannelRef): ChannelEntry | undefined {
3884
+ return this.channels.get(destinationKey(ref));
3885
+ }
3886
+
3738
3887
  /**
3739
- * Find a channel entry by channelId (searches across all servers).
3740
- * Returns the first match.
3888
+ * The registered channels a name given as a string can mean (a tool's
3889
+ * channelId, a `>>` target, a host's call): those whose id is the name and
3890
+ * those whose server-qualified name (qualifiedChannelName) is the name,
3891
+ * taken together. The qualified form is matched against the registered
3892
+ * pairs, never split at a colon, since ids hold colons (discord-mcpl's are
3893
+ * `discord:<guild>:<channel>`). So a name that two entries answer to, in
3894
+ * either form, means both of them, and a caller that has to pick one
3895
+ * refuses it with both as candidates rather than taking the first.
3741
3896
  */
3742
- private findChannelEntry(channelId: string): ChannelEntry | undefined {
3743
- for (const [key, entry] of this.channels) {
3744
- if (entry.descriptor.id === channelId) {
3745
- return entry;
3746
- }
3897
+ private entriesNamed(name: string): ChannelEntry[] {
3898
+ return [...this.channels.values()].filter(
3899
+ (e) => e.descriptor.id === name || qualifiedChannelName(refOf(e)) === name,
3900
+ );
3901
+ }
3902
+
3903
+ /** The channels a name given as a string means (entriesNamed), by ref:
3904
+ * for a caller that has to settle a host's or a tool's name itself. */
3905
+ channelsNamed(name: string): ChannelRef[] {
3906
+ return this.entriesNamed(name).map(refOf);
3907
+ }
3908
+
3909
+ /**
3910
+ * The name to show an agent for a channel: one that picks it alone, which
3911
+ * a channel tool's channelId takes back, and a `>>` target too where the
3912
+ * name is one (isProseTargetToken: a single token other than `skip_reply`,
3913
+ * which a `>>` line reads as private speech); proseTargetFor gives the
3914
+ * target to write. A registered
3915
+ * channel's is nameFor's: its id, or, where another server registers the
3916
+ * same id, its server-qualified name. A channel no string picks alone, as
3917
+ * where qualified names collide too, is shown by its id and its server, as
3918
+ * the refusals show it (channelOnServer), which no input takes for a name.
3919
+ * A channel no longer registered is shown by its id, or by its qualified
3920
+ * name, only where no registered channel takes that name, as a channel
3921
+ * tool reads it or a `>>` target does (by label or raw id too), so the
3922
+ * name can't send anything to another channel; else by its id and server.
3923
+ */
3924
+ nameShown(ref: ChannelRef): string {
3925
+ const entry = this.entryAt(ref);
3926
+ if (entry) return this.nameFor(entry) ?? channelOnServer(ref);
3927
+ for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
3928
+ if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
3929
+ }
3930
+ return channelOnServer(ref);
3931
+ }
3932
+
3933
+ /**
3934
+ * A tool's refusal of a tuned-out channel, whose decision is the tune-out:
3935
+ * its cancel is what ends it (tuneOutEndText), and a decision recorded over
3936
+ * it would end it without that cancel, so the backlog it diverted would
3937
+ * never reach the agent.
3938
+ */
3939
+ private tunedOutRefusal(tool: string, entry: ChannelEntry): ToolResult {
3940
+ return {
3941
+ success: false,
3942
+ isError: true,
3943
+ error:
3944
+ `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
3945
+ `doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
3946
+ data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
3947
+ };
3948
+ }
3949
+
3950
+ /**
3951
+ * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3952
+ * channel_open doesn't change it: a tune_out cancel, by the name that
3953
+ * picks the channel (nameFor), or, where no name picks it alone, its own
3954
+ * end, since tune_out takes a name.
3955
+ * For channel_close, which refuses a tuned-out channel too, it adds that
3956
+ * the close comes after that.
3957
+ */
3958
+ /** How this channel's tune-out ends, as tune_out's own refusals say it
3959
+ * (tuneOutEndText), or undefined for a channel no longer registered. */
3960
+ tuneOutEnd(ref: ChannelRef): string | undefined {
3961
+ const entry = this.entryAt(ref);
3962
+ return entry ? this.tuneOutEndText(entry) : undefined;
3963
+ }
3964
+
3965
+ private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
3966
+ const close = tool === 'channel_close';
3967
+ return this.nameFor(entry) !== undefined
3968
+ ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
3969
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3970
+ : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3971
+ 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
3972
+ (close ? ' Once it has ended, channel_close can close it.' : '');
3973
+ }
3974
+
3975
+ /**
3976
+ * A channel's name as nameShown gives it, quoted for a sentence: the name
3977
+ * in quotes, or the id-and-server form, whose parts are quoted already.
3978
+ */
3979
+ quotedShown(ref: ChannelRef): string {
3980
+ const shown = this.nameShown(ref);
3981
+ return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
3982
+ }
3983
+
3984
+ /**
3985
+ * The string that names exactly this entry by entriesNamed: its id when no
3986
+ * other entry answers to that (the id is unique), else its server-qualified
3987
+ * name when no other entry answers to that, else undefined. The last can
3988
+ * happen only where qualified names collide, as server `a`'s channel
3989
+ * `b:room` and server `a:b`'s channel `room` do; such a channel is named
3990
+ * only by its server and id apart.
3991
+ */
3992
+ private nameFor(entry: ChannelEntry): string | undefined {
3993
+ for (const name of [entry.descriptor.id, qualifiedChannelName(refOf(entry))]) {
3994
+ const named = this.entriesNamed(name);
3995
+ if (named.length === 1 && named[0] === entry) return name;
3747
3996
  }
3748
3997
  return undefined;
3749
3998
  }
3750
3999
 
3751
- private resolveToolChannelEntry(
3752
- channelId: string,
3753
- serverId?: string,
3754
- ): { entry?: ChannelEntry; error?: string } {
3755
- const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId);
3756
- const matches = withId.filter((entry) => !serverId || entry.serverId === serverId);
3757
- if (matches.length === 0) {
3758
- if (serverId && withId.length > 0) {
3759
- // Leaving serverId out works only when one server has the id.
3760
- const servers = withId.map((entry) => `"${entry.serverId}"`);
3761
- return {
3762
- error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1
3763
- ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.`
3764
- : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`),
3765
- };
4000
+ /**
4001
+ * The refusal for a name that more than one registered channel answers
4002
+ * to (entriesNamed). Where each one has a name that picks it alone
4003
+ * (nameFor), as two servers' channels with one id do, it lists those.
4004
+ * Where one has none, the name reads two ways, as server `a`'s `b:room`
4005
+ * and server `a:b`'s `room` both answer to `a:b:room`, and the refusal
4006
+ * gives a line per channel, each with its own route, or, where the tool
4007
+ * has none to it, saying that no channelId reaches it alone.
4008
+ * `takesServerId` says whether the tool also takes a serverId to pick
4009
+ * with; `nothing` says what didn't happen, "nothing ran" unless given.
4010
+ */
4011
+ private sharedNameRefusal(
4012
+ name: string,
4013
+ entries: ChannelEntry[],
4014
+ opts: { takesServerId: boolean; nothing?: string },
4015
+ ): string {
4016
+ const nothing = opts.nothing ?? 'nothing ran';
4017
+ const names = entries.map((e) => this.nameFor(e));
4018
+ if (names.every((n) => n !== undefined)) {
4019
+ const listed = names.map((n) => JSON.stringify(n)).join(', ');
4020
+ const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
4021
+ // One server's two channels can answer to one name too, as `s`'s
4022
+ // `s:room` and `room` both do to `s:room`; its server picks neither.
4023
+ if (servers.length === 1) {
4024
+ return `Channel ${JSON.stringify(name)} could mean more than one channel on server ${servers[0]} ` +
4025
+ `(${listed}), so ${nothing}. Call again with channelId set to one of them.`;
3766
4026
  }
3767
- return {
3768
- error: serverId
3769
- ? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.`
3770
- : `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`,
3771
- };
4027
+ // A serverId goes with the channel's own id, so it's offered only
4028
+ // where the name is every candidate's own id.
4029
+ const byServer = opts.takesServerId && entries.every((e) => e.descriptor.id === name)
4030
+ ? `, or with serverId set to ${servers.join(' or ')}`
4031
+ : '';
4032
+ return `Channel ${JSON.stringify(name)} is on more than one server (${listed}), so ${nothing}.` +
4033
+ ` Call again with channelId set to one of them${byServer}.`;
3772
4034
  }
3773
- if (matches.length > 1) {
3774
- return {
3775
- error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` +
3776
- 'so nothing ran. Call again with serverId set to one of them.',
3777
- };
4035
+ const lines = entries.map((e, i) => {
4036
+ const where = channelOnServer(refOf(e));
4037
+ const own = names[i];
4038
+ if (own === undefined && !opts.takesServerId) return `- ${where}: no channelId reaches it alone.`;
4039
+ return own !== undefined
4040
+ ? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
4041
+ : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
4042
+ });
4043
+ const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
4044
+ return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
4045
+ }
4046
+
4047
+ /**
4048
+ * The refusal for a name more than one registered channel answers to, for
4049
+ * a tool outside the registry whose schema has no serverId (tune_out, the
4050
+ * subconscious's cancel_tuneout and deliver_summary), or undefined when the
4051
+ * name means one channel or none. `nothing` says what didn't happen,
4052
+ * "nothing ran" unless given.
4053
+ */
4054
+ sharedNameRefusalFor(name: string, nothing?: string): string | undefined {
4055
+ const entries = this.entriesNamed(name);
4056
+ return entries.length > 1 ? this.sharedNameRefusal(name, entries, { takesServerId: false, nothing }) : undefined;
4057
+ }
4058
+
4059
+ /**
4060
+ * Where a channel id given with a server that doesn't have it is, for that
4061
+ * refusal: the servers that register the id, each a serverId to call again
4062
+ * with, and leaving serverId out only where the id alone then names one
4063
+ * channel (entriesNamed), since another channel's qualified name can be
4064
+ * the same string. For a publish, whose schema has no serverId
4065
+ * (`takesServerId` false: a channel_publish given one the agent added, or
4066
+ * a host's publishForAgent given a ref), where the id is, by the names its
4067
+ * channelId takes (nameFor), and no advice to post there, since each is
4068
+ * another destination, perhaps another community's.
4069
+ */
4070
+ private elsewhereText(channelId: string, takesServerId = true): string {
4071
+ const elsewhere = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
4072
+ const named = this.entriesNamed(channelId);
4073
+ const alone = named.length === 1;
4074
+ if (elsewhere.length === 0) {
4075
+ // Such as a server-qualified name given with serverId too: alone, it
4076
+ // names its channel, which may be another server's than the one given,
4077
+ // so the text says whose.
4078
+ if (!alone) return 'channel_list shows the channels and their servers.';
4079
+ const one = named[0]!;
4080
+ const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
4081
+ + JSON.stringify(one.serverId);
4082
+ return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
3778
4083
  }
3779
- return { entry: matches[0] };
4084
+ if (!takesServerId) {
4085
+ // Each is another destination, perhaps another community's, so the
4086
+ // text names where the id is, each place by the name its channelId
4087
+ // takes, and leaves the choice to the caller. A place no channelId
4088
+ // reaches alone says so.
4089
+ const places = elsewhere.map((e) => {
4090
+ const at = `on server ${JSON.stringify(e.serverId)}`;
4091
+ const name = this.nameFor(e);
4092
+ return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
4093
+ });
4094
+ return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
4095
+ }
4096
+ const servers = elsewhere.map((e) => JSON.stringify(e.serverId));
4097
+ const leaveOut = alone ? ', or leave serverId out' : '';
4098
+ return servers.length === 1
4099
+ ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}${leaveOut}.`
4100
+ : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`;
3780
4101
  }
3781
4102
 
3782
4103
  /**
3783
- * Find the composite key for a channel by its channelId.
4104
+ * The channel a channel tool names: the channel with id `channelId` on
4105
+ * `serverId` when the call gives a serverId, the exact pair, as every
4106
+ * selector that gives a server reads it; otherwise the one channel its
4107
+ * name means (entriesNamed), which may be server-qualified. A name more
4108
+ * than one channel answers to is refused with them as candidates, never
4109
+ * resolved to the first. Its callers are the tools that take a serverId
4110
+ * (channel_open, channel_close, channel_decline), so the refusal offers
4111
+ * one. `own` is the calling turn's own channels, for an agent's call made
4112
+ * inside it (ChannelToolOrigin.own).
3784
4113
  */
3785
- private findChannelKey(channelId: string): string | undefined {
3786
- for (const [key, entry] of this.channels) {
3787
- if (entry.descriptor.id === channelId) {
3788
- return key;
3789
- }
4114
+ private resolveToolChannelEntry(
4115
+ channelId: string,
4116
+ serverId?: string,
4117
+ own?: readonly ChannelRef[],
4118
+ ): { entry?: ChannelEntry; error?: string } {
4119
+ // Inside the calling turn, a name one of the turn's own channels answers
4120
+ // to by its id means that channel, as a `>>` target does (ownChannelNamed):
4121
+ // once its server no longer registers it, the call is refused, with no
4122
+ // route to another server's channel with the id.
4123
+ const mine = serverId ? undefined : this.ownChannelNamed(channelId, own);
4124
+ if (mine && 'neither' in mine) return { error: this.ownNeitherRefusal(channelId, mine.neither, 'ran') };
4125
+ if (mine) {
4126
+ const entry = this.entryAt(mine.channel);
4127
+ return entry ? { entry } : { error: this.ownChannelGoneRefusal(channelId, mine.channel, 'other') };
3790
4128
  }
3791
- return undefined;
4129
+ if (serverId) {
4130
+ const entry = this.entryAt({ serverId, channelId });
4131
+ if (entry) return { entry };
4132
+ return { error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ${this.elsewhereText(channelId)}` };
4133
+ }
4134
+ const matches = this.entriesNamed(channelId);
4135
+ if (matches.length === 0) {
4136
+ return { error: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.` };
4137
+ }
4138
+ if (matches.length > 1) return { error: this.sharedNameRefusal(channelId, matches, { takesServerId: true }) };
4139
+ return { entry: matches[0] };
3792
4140
  }
3793
4141
 
3794
4142
  // ==========================================================================
@@ -3798,6 +4146,11 @@ export class ChannelRegistry {
3798
4146
  private handleToolList(): ToolResult {
3799
4147
  const allChannels: Array<{
3800
4148
  id: string;
4149
+ /** The name that picks the channel alone (nameFor), its server-qualified
4150
+ * id, where its id names another channel too, as where another server
4151
+ * registers the same id. A channel no string picks alone has none; a
4152
+ * channel tool that takes serverId reaches it by its id and server. */
4153
+ name?: string;
3801
4154
  type: string;
3802
4155
  label: string;
3803
4156
  direction: string;
@@ -3806,9 +4159,18 @@ export class ChannelRegistry {
3806
4159
  serverId: string;
3807
4160
  }> = [];
3808
4161
 
4162
+ // How many registered channels answer to each id and server-qualified
4163
+ // id, in one pass, so each entry's name is nameFor's (the one of those
4164
+ // that picks it alone) without a scan of every channel per entry.
4165
+ const answering = new Map<string, number>();
4166
+ for (const e of this.channels.values()) {
4167
+ for (const n of [e.descriptor.id, qualifiedChannelName(refOf(e))]) answering.set(n, (answering.get(n) ?? 0) + 1);
4168
+ }
3809
4169
  for (const entry of this.channels.values()) {
4170
+ const name = [entry.descriptor.id, qualifiedChannelName(refOf(entry))].find((n) => answering.get(n) === 1);
3810
4171
  allChannels.push({
3811
4172
  id: entry.descriptor.id,
4173
+ ...(name !== undefined && name !== entry.descriptor.id ? { name } : {}),
3812
4174
  type: entry.descriptor.type,
3813
4175
  label: entry.descriptor.label,
3814
4176
  direction: entry.descriptor.direction,
@@ -3930,7 +4292,7 @@ export class ChannelRegistry {
3930
4292
  // and lifted nothing. The lift is ensureInitialDesiredState's own,
3931
4293
  // which leaves every real decision alone. One that can't be recorded
3932
4294
  // leaves the default, closed.
3933
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
4295
+ const current = this.entryAt(refOf(entry));
3934
4296
  let selectedAgain: Exclude<SelectedForOpening, 'unrecorded'> | undefined;
3935
4297
  if (current) {
3936
4298
  try {
@@ -4004,7 +4366,10 @@ export class ChannelRegistry {
4004
4366
  * docs/explicit-prose-routing.md) to a registered channel.
4005
4367
  *
4006
4368
  * Accepted spellings, tried in order:
4007
- * 1. exact descriptor id (`discord:guild:123`, always unambiguous)
4369
+ * 1. exact descriptor id (`discord:guild:123`), or its server-qualified
4370
+ * name (`a:discord:guild:123`), as entriesNamed matches them; a name
4371
+ * two channels answer to, such as an id two servers register, is
4372
+ * refused with both as candidates
4008
4373
  * 2. exact raw server-internal id (`address.channelId`)
4009
4374
  * 3. `#label` / bare label — case-insensitive match on descriptor label
4010
4375
  * with any leading '#' stripped from both sides
@@ -4019,34 +4384,172 @@ export class ChannelRegistry {
4019
4384
  * names the channel as `refusedChannelId`, so a caller can tell a refused
4020
4385
  * channel from a name that resolved to nothing. Without it, as for reading
4021
4386
  * a channel's history, any registered channel resolves.
4387
+ *
4388
+ * `own` is the turn's own channels, for a target its speech names: a name
4389
+ * one of them answers to by its id means that channel, on its server,
4390
+ * before any other spelling (ownChannelNamed).
4022
4391
  */
4023
4392
  resolveProseTarget(
4024
4393
  spec: string,
4025
- opts?: { forSpeech?: boolean },
4026
- ): { channelId: string; label?: string } | { error: string; candidates?: string[]; refusedChannelId?: string } {
4027
- const resolved = this.resolveTargetByName(spec);
4394
+ opts?: { forSpeech?: boolean; own?: readonly ChannelRef[] },
4395
+ ): PublishDestination | { error: string; candidates?: string[]; refusedChannelId?: string } {
4396
+ const own = this.ownChannelNamed(spec.trim(), opts?.own);
4397
+ let resolved: PublishDestination | { error: string; candidates?: string[] };
4398
+ if (own === undefined) {
4399
+ resolved = this.resolveTargetByName(spec);
4400
+ } else if ('neither' in own) {
4401
+ 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) };
4402
+ } else {
4403
+ const entry = this.entryAt(own.channel);
4404
+ resolved = entry
4405
+ ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
4406
+ : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
4407
+ }
4028
4408
  if (!opts?.forSpeech || 'error' in resolved) return resolved;
4029
- const entry = this.findChannelEntry(resolved.channelId);
4409
+ const entry = this.entryAt(resolved);
4030
4410
  return entry && isInboundOnly(entry)
4031
- ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
4411
+ ? { error: `${this.nameShown(resolved)} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
4032
4412
  : resolved;
4033
4413
  }
4034
4414
 
4415
+ /**
4416
+ * The turn's own channel a name means: where the name is the id of one of
4417
+ * the turn's own channels, a channel that has been its trigger or its
4418
+ * locus, that channel, on its server, whether or not that server still
4419
+ * registers it. A message's
4420
+ * reply goes where the message came from, so another server that
4421
+ * registers the id, or takes it once the message's server withdrew it,
4422
+ * isn't reached through the name. Where the turn's channels with that id
4423
+ * are on more than one server, the name means neither. Undefined where no
4424
+ * channel of the turn's has that id.
4425
+ */
4426
+ private ownChannelNamed(
4427
+ name: string,
4428
+ own: readonly ChannelRef[] | undefined,
4429
+ ): { channel: ChannelRef } | { neither: ChannelRef[] } | undefined {
4430
+ const named: ChannelRef[] = [];
4431
+ for (const c of own ?? []) {
4432
+ if (c.channelId === name && !named.some((n) => sameChannel(n, c))) named.push(c);
4433
+ }
4434
+ if (named.length === 0) return undefined;
4435
+ return named.length === 1 ? { channel: named[0]! } : { neither: named };
4436
+ }
4437
+
4438
+ /** Why a name the turn's own channel answers to (ownChannelNamed) reaches
4439
+ * nothing once that channel's server no longer registers it. It offers no
4440
+ * other channel with the id, since that's another server's. */
4441
+ private ownChannelGoneText(name: string, channel: ChannelRef): string {
4442
+ return `"${name}" is this turn's channel on server "${channel.serverId}", which no longer registers it`;
4443
+ }
4444
+
4445
+ /**
4446
+ * A name the calling turn's own channels answer to by their id, for a tool
4447
+ * outside the registry that takes a channel's name (tune_out, and the
4448
+ * subconscious's cancel_tuneout, speak_in_channel and deliver_summary),
4449
+ * read as the registry's channel tools read it: the turn's channel, or a
4450
+ * refusal where its server no longer registers it, or where the name is
4451
+ * the id of the turn's channels on more than one server. Undefined where
4452
+ * none of the turn's channels has that id. `tool` words the refusal for
4453
+ * what the tool would have done: posted (speak_in_channel), delivered
4454
+ * (deliver_summary) or run (the others).
4455
+ */
4456
+ ownChannelFor(
4457
+ name: string,
4458
+ own: readonly ChannelRef[] | undefined,
4459
+ tool: 'publish' | 'deliver' | 'other' = 'other',
4460
+ ): { channel: ChannelRef } | { error: string } | undefined {
4461
+ const mine = this.ownChannelNamed(name, own);
4462
+ if (!mine) return undefined;
4463
+ if ('neither' in mine) {
4464
+ const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
4465
+ return { error: this.ownNeitherRefusal(name, mine.neither, done) };
4466
+ }
4467
+ return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
4468
+ }
4469
+
4470
+ /** A refusal of a name the turn's own channel answers to, once its server
4471
+ * no longer registers it: nothing was posted (channel_publish,
4472
+ * speak_in_channel), delivered (deliver_summary) or ran (the other channel
4473
+ * tools, tune_out, cancel_tuneout), and nothing can be until it does
4474
+ * again. */
4475
+ private ownChannelGoneRefusal(name: string, channel: ChannelRef, tool: 'publish' | 'deliver' | 'other'): string {
4476
+ const outcome = tool === 'publish' ? 'nothing was posted, and nothing can be'
4477
+ : tool === 'deliver' ? 'nothing was delivered, and nothing can be' : 'nothing ran, and nothing can run on it';
4478
+ return `${this.ownChannelGoneText(name, channel)}, so ${outcome} until that server registers it again.`;
4479
+ }
4480
+
4481
+ /**
4482
+ * The turn's channels a name means neither of (ownChannelNamed), each by a
4483
+ * string that reaches it outside the turn's rule: its server-qualified
4484
+ * name, where that picks it alone, since its id is the name refused. One
4485
+ * no string picks alone, and one its server no longer registers, is named
4486
+ * by its id and server, saying so; `reaches` says which a name reaches.
4487
+ */
4488
+ private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; reaches: boolean }> {
4489
+ return channels.map((c) => {
4490
+ const entry = this.entryAt(c);
4491
+ if (!entry) return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
4492
+ const qualified = qualifiedChannelName(c);
4493
+ const picked = this.entriesNamed(qualified);
4494
+ return picked.length === 1 && picked[0] === entry
4495
+ ? { text: qualified, reaches: true }
4496
+ : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
4497
+ });
4498
+ }
4499
+
4500
+ /** A tool's refusal of a name the turn's channels on more than one server
4501
+ * answer to by their id (ownChannelNamed's neither). */
4502
+ private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string): string {
4503
+ const candidates = this.ownCandidates(channels);
4504
+ const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
4505
+ const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
4506
+ : named.length === 1 ? ` Call again with channelId ${named[0]}.`
4507
+ : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
4508
+ const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
4509
+ return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
4510
+ }
4511
+
4035
4512
  private resolveTargetByName(
4036
4513
  spec: string,
4037
- ): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
4514
+ ): PublishDestination | { error: string; candidates?: string[] } {
4038
4515
  const entries = [...this.channels.values()];
4039
4516
  const trimmed = spec.trim();
4040
4517
  if (!trimmed) return { error: 'empty target' };
4518
+ const hit = (e: ChannelEntry): PublishDestination =>
4519
+ ({ channelId: e.descriptor.id, serverId: e.serverId, label: e.descriptor.label });
4520
+ // A candidate is named by the token that picks it alone (nameFor), so
4521
+ // two servers' channels with one id or one label can be told apart, or,
4522
+ // where no string picks it alone, by its id and its server.
4523
+ const token = (e: ChannelEntry): string => this.nameFor(e) ?? channelOnServer(refOf(e));
4524
+ // A DM is shown by its label, the person, where that label, given as a
4525
+ // target, picks that DM. It doesn't where another channel answers to that
4526
+ // label too, as two servers' DMs with one person do (a label is matched
4527
+ // ignoring case, so "DM: Antra" and "DM: antra" both answer), nor where
4528
+ // an id, a raw id or another label outranks it, so a bounce's example or
4529
+ // a resend's destination never names another channel. Those are shown by
4530
+ // their tokens instead. A label read as a mention or an @name would come
4531
+ // back here, so it's shown by its token too.
4532
+ const labelPicks = (e: ChannelEntry, label: string): boolean => {
4533
+ if (/^@|^<@!?\d+>$/.test(label.trim())) return false;
4534
+ const r = this.resolveTargetByName(label);
4535
+ return 'channelId' in r && sameChannel(r, refOf(e));
4536
+ };
4537
+ const dmShown = (list: ChannelEntry[]): string[] => list.map((e) => {
4538
+ const label = e.descriptor.label ?? e.descriptor.id;
4539
+ return labelPicks(e, label) ? label : token(e);
4540
+ });
4041
4541
 
4042
- const exact = entries.filter((e) => e.descriptor.id === trimmed);
4542
+ // A channel id, or its server-qualified name (entriesNamed).
4543
+ const exact = this.entriesNamed(trimmed);
4043
4544
  if (exact.length === 1) {
4044
- return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label };
4545
+ return hit(exact[0]!);
4045
4546
  }
4046
4547
  if (exact.length > 1) {
4047
4548
  return {
4048
- error: `channel id "${trimmed}" is ambiguous across servers`,
4049
- candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
4549
+ error: new Set(exact.map((e) => e.serverId)).size > 1
4550
+ ? `channel id "${trimmed}" is ambiguous across servers`
4551
+ : `channel id "${trimmed}" names more than one channel on server "${exact[0]!.serverId}"`,
4552
+ candidates: exact.map(token),
4050
4553
  };
4051
4554
  }
4052
4555
 
@@ -4054,12 +4557,12 @@ export class ChannelRegistry {
4054
4557
  (e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed,
4055
4558
  );
4056
4559
  if (raw.length === 1) {
4057
- return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label };
4560
+ return hit(raw[0]!);
4058
4561
  }
4059
4562
  if (raw.length > 1) {
4060
4563
  return {
4061
4564
  error: `raw id "${trimmed}" is ambiguous`,
4062
- candidates: raw.map((e) => e.descriptor.id),
4565
+ candidates: raw.map(token),
4063
4566
  };
4064
4567
  }
4065
4568
 
@@ -4090,11 +4593,15 @@ export class ChannelRegistry {
4090
4593
  const id = mention[1]!;
4091
4594
  const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
4092
4595
  if (byId.length === 1) {
4093
- return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label };
4596
+ return hit(byId[0]!);
4597
+ }
4598
+ if (byId.length > 1) {
4599
+ // One person's DMs on more than one server.
4600
+ return { error: `"${trimmed}" matches several DMs`, candidates: dmShown(byId) };
4094
4601
  }
4095
4602
  return {
4096
4603
  error: `no registered DM matches the mention <@${id}>`,
4097
- candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
4604
+ candidates: dmShown(dms.slice(0, 6)),
4098
4605
  };
4099
4606
  }
4100
4607
 
@@ -4107,17 +4614,17 @@ export class ChannelRegistry {
4107
4614
  return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
4108
4615
  });
4109
4616
  if (pool.length === 1) {
4110
- return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label };
4617
+ return hit(pool[0]!);
4111
4618
  }
4112
4619
  if (pool.length > 1) {
4113
4620
  return {
4114
4621
  error: `"${trimmed}" matches several DMs`,
4115
- candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
4622
+ candidates: dmShown(pool),
4116
4623
  };
4117
4624
  }
4118
4625
  return {
4119
4626
  error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
4120
- ...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
4627
+ ...(dms.length ? { candidates: dmShown(dms.slice(0, 6)) } : {}),
4121
4628
  };
4122
4629
  }
4123
4630
 
@@ -4125,12 +4632,12 @@ export class ChannelRegistry {
4125
4632
  (e) => norm(e.descriptor.label ?? '') === norm(trimmed),
4126
4633
  );
4127
4634
  if (byLabel.length === 1) {
4128
- return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label };
4635
+ return hit(byLabel[0]!);
4129
4636
  }
4130
4637
  if (byLabel.length > 1) {
4131
4638
  return {
4132
4639
  error: `label "${trimmed}" matches several channels`,
4133
- candidates: byLabel.map((e) => e.descriptor.id),
4640
+ candidates: byLabel.map(token),
4134
4641
  };
4135
4642
  }
4136
4643
 
@@ -4142,12 +4649,12 @@ export class ChannelRegistry {
4142
4649
  const nameOf = normalizeChannelLabelName;
4143
4650
  const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
4144
4651
  if (byName.length === 1) {
4145
- return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label };
4652
+ return hit(byName[0]!);
4146
4653
  }
4147
4654
  if (byName.length > 1) {
4148
4655
  return {
4149
4656
  error: `"${trimmed}" matches several channels`,
4150
- candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
4657
+ candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
4151
4658
  };
4152
4659
  }
4153
4660
 
@@ -4156,7 +4663,7 @@ export class ChannelRegistry {
4156
4663
  const near = entries
4157
4664
  .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
4158
4665
  .slice(0, 5)
4159
- .map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
4666
+ .map((e) => `${e.descriptor.label} = ${token(e)}`);
4160
4667
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
4161
4668
  }
4162
4669
 
@@ -4167,18 +4674,19 @@ export class ChannelRegistry {
4167
4674
  * a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
4168
4675
  * plus body `alice …`, and `>>#fable (antra's server)` delivers
4169
4676
  * `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
4170
- * `#name` (label without its server suffix), the descriptor id.
4677
+ * `#name` (label without its server suffix), the name that picks the
4678
+ * channel alone (nameFor): its id, or, where another server registers the
4679
+ * same id, its server-qualified name; and last its server-qualified name,
4680
+ * for a channel whose id is no target (isProseTargetToken), as
4681
+ * `skip_reply` isn't.
4171
4682
  *
4172
- * Undefined when there is no safe token: the channel isn't registered (on
4173
- * `serverId`, when given), no candidate is whitespace-free and resolves back,
4174
- * or the id is registered by more than one server. A resolved target names a
4175
- * channel by id alone, and ids are unique only within a connection, so a
4176
- * shared id could route the reply through the wrong server.
4683
+ * Undefined when there is no safe token: the channel isn't registered, or
4684
+ * no candidate is whitespace-free and resolves back to this channel, on
4685
+ * this server.
4177
4686
  */
4178
- proseTargetFor(channelId: string, serverId?: string): string | undefined {
4179
- const sameId = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
4180
- const entry = serverId ? sameId.find((e) => e.serverId === serverId) : sameId[0];
4181
- if (!entry || sameId.length > 1) return undefined;
4687
+ proseTargetFor(ref: ChannelRef): string | undefined {
4688
+ const entry = this.entryAt(ref);
4689
+ if (!entry) return undefined;
4182
4690
  const d = entry.descriptor;
4183
4691
  const label = d.label ?? '';
4184
4692
  const meta = d.metadata as { channelType?: string; recipientName?: string } | undefined;
@@ -4188,23 +4696,143 @@ export class ChannelRegistry {
4188
4696
  const candidates = [
4189
4697
  ...(isDm && dmName ? [`@${dmName}`] : []),
4190
4698
  ...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
4191
- d.id,
4699
+ ...(this.nameFor(entry) !== undefined ? [this.nameFor(entry)!] : []),
4700
+ qualifiedChannelName(ref),
4192
4701
  ];
4193
4702
  for (const c of candidates) {
4194
- if (/\s/.test(c) || c === '#') continue;
4703
+ if (!isProseTargetToken(c) || c === '#') continue;
4195
4704
  const r = this.resolveProseTarget(c, { forSpeech: true });
4196
- if ('channelId' in r && r.channelId === d.id) return c;
4705
+ if ('channelId' in r && sameChannel(r, ref)) return c;
4197
4706
  }
4198
4707
  return undefined;
4199
4708
  }
4200
4709
 
4710
+ /** Whether the decision on record is a close the agent or a module made
4711
+ * (isCloseDecision). */
4712
+ private closeDecided(ref: ChannelRef): boolean {
4713
+ const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
4714
+ return current?.state === 'closed' && isCloseDecision(current.source);
4715
+ }
4716
+
4717
+ /**
4718
+ * Whether the agent's words delivered into this channel open it first, by
4719
+ * publish() or after an explicit send (openIfClosedForSend): one that isn't
4720
+ * open, or whose close stands (closeStands: a close that failed may have
4721
+ * left its transport open), unless speech leaves it as it stands
4722
+ * (speechLeavesAsItStands). Where nobody decided (no record, a pure
4723
+ * default) or a machine closed the channel (subscription-gc,
4724
+ * housekeeping), it opens, as it always did, and so does one decided open
4725
+ * whose transport isn't confirmed. A tuned-out channel whose transport
4726
+ * isn't open is opened for its traffic, and its tune-out stays
4727
+ * (openChannelNow). A server that isn't up isn't one this host can't
4728
+ * direct, though a drop empties its grant until its next exchange, so
4729
+ * speech there is refused as it always was: by the open's failure, which
4730
+ * says the server is connecting or reconnecting, or, where its grant is
4731
+ * empty, by publish()'s grant check before any open.
4732
+ */
4733
+ speechOpens(ref: ChannelRef): boolean {
4734
+ const entry = this.entryAt(ref);
4735
+ return !!entry && (!entry.open || this.closeStands(entry)) && !this.speechLeavesAsItStands(entry);
4736
+ }
4737
+
4738
+ /** Whether speech leaves this registration as it stands, never opening
4739
+ * it, and words go into it as it is: the decision on record is a close
4740
+ * the agent made itself (channel_close, channel_decline) or a module made
4741
+ * (MODULE_CLOSE_SOURCE), which stands against the agent's own speech
4742
+ * whatever the channel's transport is; or its server is up and this host
4743
+ * doesn't direct its lifecycle (lifecycleWithheld), so nothing opens
4744
+ * there. A wait or an open that failed gives way to such a registration,
4745
+ * as when the agent closed the channel while the words waited, since
4746
+ * there's nothing for the words to wait for (refusalGivesWay). */
4747
+ private speechLeavesAsItStands(entry: ChannelEntry | undefined): entry is ChannelEntry {
4748
+ if (!entry) return false;
4749
+ return this.closeDecided(refOf(entry)) || this.lifecycleWithheld(entry.serverId);
4750
+ }
4751
+
4752
+ /** Whether words a wait or an open refused go into the channel after all:
4753
+ * its registration `now` is one speech leaves as it stands
4754
+ * (speechLeavesAsItStands), such as one the agent closed or declined
4755
+ * while the words waited, and the failure isn't convergence that never
4756
+ * established its target (convergeFailure). A registration that went
4757
+ * still fails them, and publish() refuses one the server last answered
4758
+ * for another channel (answeredElsewhere), whenever that happened. */
4759
+ private refusalGivesWay(err: unknown, now: ChannelEntry | undefined): now is ChannelEntry {
4760
+ return this.speechLeavesAsItStands(now) && !convergeFailure(err);
4761
+ }
4762
+
4763
+ /** Whether this host doesn't direct the server's channel lifecycle: the
4764
+ * server is up, and its grant lacks channels.lifecycle, so nothing opens
4765
+ * or closes its channels. A server that isn't up has an empty grant from
4766
+ * a drop until its next exchange, which says nothing yet of what it
4767
+ * grants, so it isn't counted. */
4768
+ lifecycleWithheld(serverId: string): boolean {
4769
+ return !this.directsLifecycle(serverId) && this.serverReach(serverId) === 'up';
4770
+ }
4771
+
4772
+ /** Whether the agent's words go into this channel and leave it closed: it
4773
+ * isn't open at the transport, and speech leaves it as it stands
4774
+ * (speechLeavesAsItStands: the agent's or a module's close, or a server
4775
+ * that's up without channels.lifecycle). False for a channel open at the
4776
+ * transport, whose traffic still flows, and for one speech opens. */
4777
+ speechLeavesClosed(ref: ChannelRef): boolean {
4778
+ const entry = this.entryAt(ref);
4779
+ return !!entry && !entry.open && this.speechLeavesAsItStands(entry);
4780
+ }
4781
+
4782
+ /**
4783
+ * How the agent kept this registered channel closed itself, if it did:
4784
+ * 'closed' for its channel_close, 'declined' for its channel_decline. Its
4785
+ * speech there leaves such a channel as it stands (speechOpens). A
4786
+ * channel no longer registered has nothing to keep closed, whatever its
4787
+ * record says, so it answers undefined.
4788
+ */
4789
+ closedByAgent(ref: ChannelRef): 'closed' | 'declined' | undefined {
4790
+ if (!this.entryAt(ref)) return undefined;
4791
+ const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
4792
+ if (current?.state !== 'closed') return undefined;
4793
+ if (current.source === 'agent-tool') return 'closed';
4794
+ if (current.source === 'invitation-declined') return 'declined';
4795
+ return undefined;
4796
+ }
4797
+
4798
+ /** The registered channels an explicit send tool may have delivered into,
4799
+ * from the id the tool reported or was given: a composite MCPL id, or the
4800
+ * raw server-internal id (`address.channelId`), since send tools receive
4801
+ * whatever the agent typed. Matches on `serverId` when it is given,
4802
+ * across every server otherwise. */
4803
+ private sendTargets(rawChannelId: string, serverId?: string): ChannelEntry[] {
4804
+ const matches = [...this.channels.values()].filter(
4805
+ (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
4806
+ );
4807
+ if (matches.length > 0) return matches;
4808
+ return [...this.channels.values()].filter(
4809
+ (e) =>
4810
+ (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
4811
+ (!serverId || e.serverId === serverId),
4812
+ );
4813
+ }
4814
+
4815
+ /** The one registered channel an explicit send tool delivered into
4816
+ * (sendTargets), or undefined when none or more than one matches. A
4817
+ * lookup only, for what's said beside the send's answer before it's
4818
+ * queued; openIfClosedForSend decides whether the send opens it. */
4819
+ resolveSendTarget(rawChannelId: string, serverId?: string): ChannelRef | undefined {
4820
+ const matches = this.sendTargets(rawChannelId, serverId);
4821
+ return matches.length === 1 ? refOf(matches[0]!) : undefined;
4822
+ }
4823
+
4201
4824
  /**
4202
- * Open a channel because something was DELIVERED into it (explicit send
4203
- * tool or routed speech). Sending into a closed channel is not a thing:
4204
- * engaging a channel opens it, so typing indicators, reaction machinery,
4205
- * and inbound forwarding all come alive with the first outbound message.
4206
- * Accepts composite MCPL ids or raw server-internal ids (explicit send
4207
- * tools receive whatever the agent typed). An `open-failed` answer says
4825
+ * Open a channel because something was DELIVERED into it by an explicit
4826
+ * send tool, so typing indicators, reaction machinery and inbound
4827
+ * forwarding all come alive with the first outbound message, unless
4828
+ * speech leaves it as it stands (speechLeavesAsItStands: the agent closed
4829
+ * or declined it itself, a module closed it, or its server is up and this
4830
+ * host doesn't direct its lifecycle). Such a channel opens nothing, and
4831
+ * answers `stays-closed`, or `already-open` where its transport is open
4832
+ * with no close standing, whether the send found it so or it became so
4833
+ * while the send's wait or open was out.
4834
+ * Accepts composite MCPL ids or raw server-internal ids (sendTargets). An
4835
+ * `open-failed` answer says
4208
4836
  * whether the open that failed was one that would have opened a channel
4209
4837
  * not selected open (`opening`), as opposed to one reconfirming the
4210
4838
  * transport of a channel already selected open or tuned out. It isn't one
@@ -4220,22 +4848,15 @@ export class ChannelRegistry {
4220
4848
  rawChannelId: string,
4221
4849
  serverId?: string,
4222
4850
  ): Promise<{
4223
- status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
4851
+ status: 'opened' | 'already-open' | 'stays-closed' | 'unknown-channel' | 'ambiguous' | 'open-failed';
4224
4852
  channelId?: string;
4853
+ /** The channel's server, beside `channelId` whenever it names one. */
4854
+ serverId?: string;
4225
4855
  label?: string;
4226
4856
  opening?: boolean;
4227
4857
  selectedForOpening?: SelectedForOpening;
4228
4858
  }> {
4229
- let matches = [...this.channels.values()].filter(
4230
- (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
4231
- );
4232
- if (matches.length === 0) {
4233
- matches = [...this.channels.values()].filter(
4234
- (e) =>
4235
- (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
4236
- (!serverId || e.serverId === serverId),
4237
- );
4238
- }
4859
+ const matches = this.sendTargets(rawChannelId, serverId);
4239
4860
  if (matches.length === 0) return { status: 'unknown-channel' };
4240
4861
  if (matches.length > 1) return { status: 'ambiguous' };
4241
4862
  let entry = matches[0]!;
@@ -4246,8 +4867,13 @@ export class ChannelRegistry {
4246
4867
  }
4247
4868
  const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
4248
4869
  if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
4249
- return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
4870
+ return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
4250
4871
  }
4872
+ // A channel speech leaves as it stands opens nothing. Every other one
4873
+ // is opened, as it always was: one open at the transport with no
4874
+ // decision on record too, so the send's engagement is recorded as an
4875
+ // open decision, which a retarget or a restart carries out.
4876
+ if (this.speechLeavesAsItStands(entry)) return this.sendLeavesChannel(entry);
4251
4877
  opening = desired !== 'open' && desired !== 'tuned-out';
4252
4878
  const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
4253
4879
  entry = await this.waitForOpenChannel(entry);
@@ -4258,11 +4884,21 @@ export class ChannelRegistry {
4258
4884
  channelId: entry.descriptor.id,
4259
4885
  });
4260
4886
  }
4261
- return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
4887
+ return {
4888
+ status: opened ? 'opened' : 'already-open',
4889
+ channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label,
4890
+ };
4262
4891
  } catch (err) {
4892
+ // A channel the send now leaves as it stands, such as one the agent
4893
+ // closed or declined while queued work or the open was out, answers as
4894
+ // one it found so: nothing failed that the send would have done.
4895
+ // Convergence that never established its target is still reported
4896
+ // (refusalGivesWay).
4897
+ const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
4898
+ if (this.refusalGivesWay(err, now)) return this.sendLeavesChannel(now);
4263
4899
  // Where a decision made during the open's round-trip replaced it, such
4264
- // as the agent's own close or channel_open, the channel is as that
4265
- // decision left it, and the agent isn't told the open failed.
4900
+ // as the agent's channel_open, the channel is as that decision left it,
4901
+ // and the agent isn't told the open failed.
4266
4902
  if (err instanceof ChannelLifecycleError && err.kind === 'overtaken') opening = false;
4267
4903
  this.emitTraceFn({
4268
4904
  type: 'mcpl:channel-open-failed',
@@ -4270,8 +4906,8 @@ export class ChannelRegistry {
4270
4906
  channelId: entry.descriptor.id,
4271
4907
  error: (err as Error).message,
4272
4908
  });
4273
- const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
4274
- return { status: 'open-failed', channelId: entry.descriptor.id, opening,
4909
+ const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
4910
+ return { status: 'open-failed', channelId: entry.descriptor.id, serverId: entry.serverId, opening,
4275
4911
  // Where the channel is selected for opening after the failure, the
4276
4912
  // agent is told so and why, as speech's failure says.
4277
4913
  ...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' as const } : {}),
@@ -4280,13 +4916,22 @@ export class ChannelRegistry {
4280
4916
  }
4281
4917
  }
4282
4918
 
4919
+ /** What a send answers for a channel it leaves as it is
4920
+ * (speechLeavesAsItStands):
4921
+ * `already-open` where its transport is open with no close standing,
4922
+ * `stays-closed` otherwise. */
4923
+ private sendLeavesChannel(entry: ChannelEntry): { status: 'already-open' | 'stays-closed'; channelId: string; serverId: string; label?: string } {
4924
+ const status = entry.open && !this.closeStands(entry) ? 'already-open' : 'stays-closed';
4925
+ return { status, channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
4926
+ }
4927
+
4283
4928
  private async handleToolOpen(input: {
4284
4929
  channelId: string;
4285
4930
  serverId?: string;
4286
4931
  backscroll?: number;
4287
4932
  beforeMessageId?: string;
4288
- }): Promise<ToolResult> {
4289
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
4933
+ }, own?: readonly ChannelRef[]): Promise<ToolResult> {
4934
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
4290
4935
  const entry = resolved.entry;
4291
4936
  if (!entry) {
4292
4937
  return {
@@ -4295,13 +4940,18 @@ export class ChannelRegistry {
4295
4940
  isError: true,
4296
4941
  };
4297
4942
  }
4943
+ // From here, the channel's own id, whichever name the call gave for it
4944
+ // (a server-qualified one included).
4945
+ input = { ...input, channelId: entry.descriptor.id };
4946
+ // The channel as the agent is shown it, and names it back (nameShown).
4947
+ const shown = this.nameShown(refOf(entry));
4298
4948
 
4299
4949
  // A tuned-out channel is transport-open but attention-diverted; a plain
4300
4950
  // open would silently discard the epoch (and its pending backlog dump).
4301
4951
  // Cancelling is the tune-out coordinator's flow, reached via its own
4302
4952
  // tool — refuse with the pointer rather than eat the state.
4303
4953
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4304
- return tunedOutRefusal('channel_open', input.channelId);
4954
+ return this.tunedOutRefusal('channel_open', entry);
4305
4955
  }
4306
4956
 
4307
4957
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
@@ -4315,13 +4965,13 @@ export class ChannelRegistry {
4315
4965
  } catch (err) {
4316
4966
  return {
4317
4967
  success: false,
4318
- error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${(err as Error).message}`,
4968
+ error: `Channel ${shown} is already open, but recording your decision to open it failed: ${(err as Error).message}`,
4319
4969
  isError: true,
4320
4970
  };
4321
4971
  }
4322
4972
  return {
4323
4973
  success: true,
4324
- data: { channelId: input.channelId, status: 'already open' },
4974
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already open' },
4325
4975
  };
4326
4976
  }
4327
4977
 
@@ -4337,6 +4987,7 @@ export class ChannelRegistry {
4337
4987
  success: true,
4338
4988
  data: {
4339
4989
  channelId: input.channelId,
4990
+ serverId: entry.serverId,
4340
4991
  status: alreadyDesiredOpen ? 'reconciled' : 'opened',
4341
4992
  ...(result.history ? { history: result.history } : {}),
4342
4993
  ...(result.historyTruncated ? { historyTruncated: true } : {}),
@@ -4364,7 +5015,8 @@ export class ChannelRegistry {
4364
5015
  * Honored ONLY for module-origin dispatch — tool input is untrusted,
4365
5016
  * so a model-origin call carrying this field is still recorded as
4366
5017
  * 'agent-tool' (the resident may close their own channel; they may
4367
- * not attribute the act to housekeeping). */
5018
+ * not attribute the act to housekeeping). A module-origin call
5019
+ * without it is the module's own close, MODULE_CLOSE_SOURCE. */
4368
5020
  source?: string;
4369
5021
  /** A machine close aimed at an explicitly-opened channel is refused
4370
5022
  * unless the caller certifies an explicit idle lease (a configured
@@ -4375,7 +5027,7 @@ export class ChannelRegistry {
4375
5027
  },
4376
5028
  origin?: ChannelToolOrigin,
4377
5029
  ): Promise<ToolResult> {
4378
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
5030
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
4379
5031
  const entry = resolved.entry;
4380
5032
  if (!entry) {
4381
5033
  return {
@@ -4384,14 +5036,20 @@ export class ChannelRegistry {
4384
5036
  isError: true,
4385
5037
  };
4386
5038
  }
5039
+ // From here, the channel's own id, whichever name the call gave for it
5040
+ // (a server-qualified one included).
5041
+ input = { ...input, channelId: entry.descriptor.id };
5042
+ // The channel as the agent is shown it, and names it back (nameShown).
5043
+ const shown = this.nameShown(refOf(entry));
4387
5044
 
4388
5045
  // Provenance comes from trusted dispatch, not from self-description:
4389
5046
  // only module-origin calls may record a machine source, and only from
4390
- // the closed vocabulary. Agent-origin (or origin-less — the safe
5047
+ // the closed vocabulary; without one, a module's close is its own
5048
+ // (MODULE_CLOSE_SOURCE). Agent-origin (or origin-less — the safe
4391
5049
  // default for any legacy caller) records 'agent-tool' and has its
4392
5050
  // machine fields ignored entirely.
4393
5051
  const isModuleOrigin = origin?.kind === 'module';
4394
- let closeSource = 'agent-tool';
5052
+ let closeSource = isModuleOrigin ? MODULE_CLOSE_SOURCE : 'agent-tool';
4395
5053
  if (isModuleOrigin && input.source !== undefined) {
4396
5054
  if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
4397
5055
  return {
@@ -4416,13 +5074,13 @@ export class ChannelRegistry {
4416
5074
  } catch (err) {
4417
5075
  return {
4418
5076
  success: false,
4419
- error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
5077
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
4420
5078
  isError: true,
4421
5079
  };
4422
5080
  }
4423
5081
  return {
4424
5082
  success: true,
4425
- data: { channelId: input.channelId, status: 'already closed' },
5083
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
4426
5084
  };
4427
5085
  }
4428
5086
 
@@ -4431,7 +5089,7 @@ export class ChannelRegistry {
4431
5089
  // close of a channel whose current desired state is an agent/operator
4432
5090
  // 'open' is refused — structurally, so the caller can stand down rather
4433
5091
  // than retry — unless it certifies an explicit idle lease.
4434
- if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
5092
+ if (isModuleOrigin && MACHINE_CLOSE_SOURCES.has(closeSource) && !input.overrideExplicitOpen) {
4435
5093
  const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
4436
5094
  // Tuned-out is stated intent too: a GC/housekeeping close would
4437
5095
  // silently end the epoch and orphan its backlog.
@@ -4440,10 +5098,10 @@ export class ChannelRegistry {
4440
5098
  success: false,
4441
5099
  isError: false,
4442
5100
  error:
4443
- `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
5101
+ `Channel ${shown} was explicitly opened (source: agent-tool); ` +
4444
5102
  `a '${closeSource}' close does not override stated intent. Machine closes ` +
4445
5103
  `of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
4446
- data: { refusal: 'explicit-open', channelId: input.channelId },
5104
+ data: { refusal: 'explicit-open', channelId: input.channelId, serverId: entry.serverId },
4447
5105
  };
4448
5106
  }
4449
5107
  }
@@ -4456,7 +5114,7 @@ export class ChannelRegistry {
4456
5114
  // that certified an idle lease, which the guard above lets through, is
4457
5115
  // refused too, as a refusal it stands down on rather than an error.
4458
5116
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4459
- const refusal = tunedOutRefusal('channel_close', input.channelId);
5117
+ const refusal = this.tunedOutRefusal('channel_close', entry);
4460
5118
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
4461
5119
  }
4462
5120
 
@@ -4466,7 +5124,9 @@ export class ChannelRegistry {
4466
5124
  } catch (err) {
4467
5125
  return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
4468
5126
  }
4469
- this.stopTyping(input.channelId);
5127
+ // This channel's typing, not every channel with its id (stopTyping's
5128
+ // fan-out of a bare name).
5129
+ this.stopTyping(refOf(entry));
4470
5130
 
4471
5131
  // Refused now, the close stays recorded for the server's next
4472
5132
  // registration, and the text says so.
@@ -4480,7 +5140,7 @@ export class ChannelRegistry {
4480
5140
  if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
4481
5141
  return {
4482
5142
  success: true,
4483
- data: { channelId: input.channelId, status: 'closed' },
5143
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
4484
5144
  };
4485
5145
  } catch (err) {
4486
5146
  return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
@@ -4492,20 +5152,30 @@ export class ChannelRegistry {
4492
5152
  serverId?: string;
4493
5153
  messageId: string;
4494
5154
  acknowledge?: string;
4495
- }): Promise<ToolResult> {
4496
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
5155
+ }, own?: readonly ChannelRef[]): Promise<ToolResult> {
5156
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
4497
5157
  const entry = resolved.entry;
4498
5158
  if (!entry) {
4499
5159
  return { success: false, error: resolved.error, isError: true };
4500
5160
  }
5161
+ // From here, the channel's own id, whichever name the call gave for it
5162
+ // (a server-qualified one included).
5163
+ input = { ...input, channelId: entry.descriptor.id };
5164
+ // The channel as the agent is shown it, and names it back (quotedShown).
5165
+ const quoted = this.quotedShown(refOf(entry));
4501
5166
  // A tuned-out channel's decision is the tune-out, whatever its transport,
4502
5167
  // and a decline recorded over it would end it without its cancel, so the
4503
5168
  // backlog it diverted would never reach the agent. Its cancel is what ends
4504
5169
  // it, as channel_open's refusal of a tuned-out channel says.
4505
5170
  if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4506
- return tunedOutRefusal('channel_decline', input.channelId);
5171
+ return this.tunedOutRefusal('channel_decline', entry);
4507
5172
  }
4508
- if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
5173
+ // Refused only for a channel open at the transport. One decided open
5174
+ // whose open failed or is still on its way isn't open there, and the
5175
+ // invitation a message from it brings offers the decline as the way to
5176
+ // reply without opening it. The decline closes it below, through the
5177
+ // lifecycle queue, wherever its transport may be open.
5178
+ if (entry.open) {
4509
5179
  // channel_close is the call that closes it: its own answer says when,
4510
5180
  // as for a server that isn't up. A server that's up without that grant
4511
5181
  // keeps its channels as it chooses, so the text says so, and whom to
@@ -4514,7 +5184,7 @@ export class ChannelRegistry {
4514
5184
  ? this.lifecycleRefusal(entry.serverId) : undefined;
4515
5185
  return {
4516
5186
  success: false,
4517
- error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` +
5187
+ error: `Channel ${quoted} is open, so there's nothing to decline` +
4518
5188
  (refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
4519
5189
  : '. If you need it closed, call channel_close.'),
4520
5190
  isError: true,
@@ -4529,7 +5199,7 @@ export class ChannelRegistry {
4529
5199
  } catch (err) {
4530
5200
  return {
4531
5201
  success: false,
4532
- error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` +
5202
+ error: `Nothing changed for channel ${quoted}: ${unrecordedDecision(err).message}, so no acknowledgment ` +
4533
5203
  'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
4534
5204
  'way, ask whoever runs this host.',
4535
5205
  isError: true,
@@ -4607,7 +5277,7 @@ export class ChannelRegistry {
4607
5277
  // below; the decision stands.
4608
5278
  let closeFailure: unknown;
4609
5279
  let closeFailed = false;
4610
- const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
5280
+ const current = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
4611
5281
  if (current && this.directsLifecycle(entry.serverId) &&
4612
5282
  this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
4613
5283
  (current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
@@ -4631,7 +5301,7 @@ export class ChannelRegistry {
4631
5301
  // asks it to open or close none of them, so a channel there is confirmed
4632
5302
  // closed only by the receipt of a request sent while the server had the
4633
5303
  // grant, whether the channel was registered or known only from a push.
4634
- const after = this.channels.get(`${entry.serverId}:${input.channelId}`);
5304
+ const after = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
4635
5305
  const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
4636
5306
  !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
4637
5307
  // `closeError` is the result's sentence for the agent: what became of
@@ -4745,6 +5415,7 @@ export class ChannelRegistry {
4745
5415
  success: true,
4746
5416
  data: {
4747
5417
  channelId: input.channelId,
5418
+ serverId: entry.serverId,
4748
5419
  status,
4749
5420
  acknowledged,
4750
5421
  ...(representation ? { representation } : {}),
@@ -4852,126 +5523,94 @@ export class ChannelRegistry {
4852
5523
  * inbound-only (#350's rule), not even for a publish delivered there just
4853
5524
  * before the declaration. One that started there before it goes on, since
4854
5525
  * the declaration doesn't unregister the channel: a later chunk is for a
4855
- * publish delivered before it. A bare channel id is resolved as before, to
4856
- * the one server that registers it, at each call (streamingServerFor), and
4857
- * keeps no record: a stream sent by destination ends by destination, since
4858
- * only a destination's `complete` removes its record.
5526
+ * publish delivered before it.
4859
5527
  */
4860
5528
  sendOutgoingChunk(
4861
- to: string | PublishDestination,
5529
+ to: PublishDestination,
4862
5530
  conversationId: string,
4863
5531
  inferenceId: string,
4864
5532
  index: number,
4865
5533
  delta: string,
4866
5534
  ): void {
4867
- let server: McplServerConnection | null;
4868
- if (typeof to === 'string') {
4869
- server = this.streamingServerFor(to);
4870
- } else {
4871
- const key = destinationKey(to);
4872
- let started = this.takenStreams.get(inferenceId);
4873
- const continuing = started?.has(key) ?? false;
4874
- server = this.streamingServerAt(to, !continuing);
4875
- if (server && !continuing) {
4876
- if (!started) this.takenStreams.set(inferenceId, started = new Set());
4877
- started.add(key);
4878
- }
4879
- }
5535
+ const key = destinationKey(to);
5536
+ let started = this.takenStreams.get(inferenceId);
5537
+ const continuing = started?.has(key) ?? false;
5538
+ const server = this.streamingServerAt(to, !continuing);
4880
5539
  if (!server) return;
4881
- const channelId = typeof to === 'string' ? to : to.channelId;
5540
+ if (!continuing) {
5541
+ if (!started) this.takenStreams.set(inferenceId, started = new Set());
5542
+ started.add(key);
5543
+ }
4882
5544
  try {
4883
- server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
5545
+ server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: to.channelId, index, delta });
4884
5546
  } catch { /* observer surface — never disturb the turn */ }
4885
5547
  }
4886
5548
 
4887
5549
  /**
4888
5550
  * Spec 14.3 companion: final moderated content per channel at stream end.
4889
- * Given a destination, it goes only where the stream started
4890
- * (sendOutgoingChunk), while that server still registers that channel,
4891
- * inbound-only or not, and its grant has `channels.streaming`: once per
4892
- * destination, whose consumer, such as a TTS utterance, finalizes on it
4893
- * and reconciles any chunk it missed. A bare channel id is resolved as
4894
- * before (streamingServerFor).
5551
+ * It goes only where the stream started (sendOutgoingChunk), while that
5552
+ * server still registers that channel, inbound-only or not, and its grant
5553
+ * has `channels.streaming`: once per destination, whose consumer, such as
5554
+ * a TTS utterance, finalizes on it and reconciles any chunk it missed.
4895
5555
  */
4896
5556
  sendOutgoingComplete(
4897
- to: string | PublishDestination,
5557
+ to: PublishDestination,
4898
5558
  conversationId: string,
4899
5559
  inferenceId: string,
4900
5560
  text: string,
4901
5561
  ): void {
4902
- let server: McplServerConnection | null;
4903
- if (typeof to === 'string') {
4904
- server = this.streamingServerFor(to);
4905
- } else {
4906
- const started = this.takenStreams.get(inferenceId);
4907
- if (!started?.delete(destinationKey(to))) return;
4908
- if (started.size === 0) this.takenStreams.delete(inferenceId);
4909
- server = this.streamingServerAt(to, false);
4910
- }
5562
+ const started = this.takenStreams.get(inferenceId);
5563
+ if (!started?.delete(destinationKey(to))) return;
5564
+ if (started.size === 0) this.takenStreams.delete(inferenceId);
5565
+ const server = this.streamingServerAt(to, false);
4911
5566
  if (!server) return;
4912
5567
  try {
4913
5568
  server.sendChannelsOutgoingComplete({
4914
5569
  inferenceId,
4915
5570
  conversationId,
4916
- channelId: typeof to === 'string' ? to : to.channelId,
5571
+ channelId: to.channelId,
4917
5572
  content: [{ type: 'text', text }],
4918
5573
  });
4919
5574
  } catch { /* observer surface — never disturb the turn */ }
4920
5575
  }
4921
5576
 
4922
- private streamingServerFor(channelId: string) {
4923
- // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
4924
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
4925
- // resolver does, and fail closed on cross-server ambiguity — the same
4926
- // never-guess rule that governs delivery.
4927
- const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
4928
- if (matches.length !== 1) return null;
4929
- // Nothing is published into an inbound-only channel, so nothing is
4930
- // streamed there either.
4931
- if (isInboundOnly(matches[0]!)) return null;
4932
- const server = this.serverRegistry.getServer(matches[0]!.serverId);
4933
- // §5.4: the GRANT gates streaming, not the raw advertisement. The old
4934
- // `capabilities?.channels?.streaming` check was doubly wrong: undefined
4935
- // for the boolean `channels: true` shape (masking discord-mcpl's latent
4936
- // double-post, AUDIT-001), and pre-policy it would have sent before the
4937
- // server was told anything was granted.
4938
- if (!CapabilityGrant.of(server).has('channels.streaming')) return null;
4939
- return server;
4940
- }
4941
-
4942
5577
  /**
4943
5578
  * A destination's server, for a stream message: while that server
4944
- * registers that channel, and its grant has `channels.streaming` (as in
4945
- * streamingServerFor). A stream doesn't start in an inbound-only channel.
5579
+ * registers that channel, and its grant has `channels.streaming` (§5.4:
5580
+ * the grant gates streaming, not the raw advertisement). A stream doesn't
5581
+ * start in an inbound-only channel.
4946
5582
  */
4947
- private streamingServerAt(to: PublishDestination, starting: boolean): McplServerConnection | null {
4948
- // The map's key joins the two ids with a colon, so another server's
4949
- // entry can sit under a destination's key: it has to be this server's.
4950
- const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
4951
- if (!entry || entry.serverId !== to.serverId) return null;
5583
+ private streamingServerAt(to: ChannelRef, starting: boolean): McplServerConnection | null {
5584
+ const entry = this.entryAt(to);
5585
+ if (!entry) return null;
4952
5586
  if (starting && isInboundOnly(entry)) return null;
4953
5587
  const server = this.serverRegistry.getServer(to.serverId);
4954
5588
  return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
4955
5589
  }
4956
5590
 
4957
5591
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
4958
- * process-global default). Public so a multi-segment caller can snapshot it
4959
- * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
4960
- resolveLocus(conversationId: string): string | null {
5592
+ * process-global default), with its server. Public so a multi-segment
5593
+ * caller can snapshot it ONCE and pin every segment to it via
5594
+ * routeSpeech's locus. */
5595
+ resolveLocus(conversationId: string): ChannelRef | null {
4961
5596
  const home = this.homeChannelResolver?.(conversationId);
4962
- return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
5597
+ if (home) return home;
5598
+ // A turn that deliberately has no channel (null) takes no fallback.
5599
+ const active = this.activeChannelResolver?.(conversationId);
5600
+ return active !== undefined ? active : this.globalFallbackFor(conversationId);
4963
5601
  }
4964
5602
 
4965
5603
  /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
4966
- private globalFallbackFor(conversationId: string): string | null {
5604
+ private globalFallbackFor(conversationId: string): ChannelRef | null {
4967
5605
  return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
4968
5606
  }
4969
5607
 
4970
5608
  /**
4971
5609
  * Resolve a publish target to exactly one registered channel: `serverId`
4972
- * plus `channelId` when the caller has the server, otherwise a channel id
4973
- * registered by exactly one server. A shared id is refused, never resolved
4974
- * to whichever server registered it first.
5610
+ * plus `channelId` when the caller has the server, otherwise the one
5611
+ * channel the id, or its server-qualified name, means (entriesNamed). A
5612
+ * shared id is refused, never resolved to whichever server registered it
5613
+ * first.
4975
5614
  */
4976
5615
  resolveDestination(
4977
5616
  target: { serverId?: string; channelId: string },
@@ -4998,9 +5637,11 @@ export class ChannelRegistry {
4998
5637
  if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) {
4999
5638
  return { error: 'serverId must name a server (omit it to resolve the channel id alone)' };
5000
5639
  }
5001
- const matches = [...this.channels.values()].filter(
5002
- (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId),
5003
- );
5640
+ // Without a server, the channel id, or its server-qualified name, means
5641
+ // what it means to every channel tool (entriesNamed).
5642
+ const matches = target.serverId
5643
+ ? [this.entryAt({ serverId: target.serverId, channelId: target.channelId })].filter((e): e is ChannelEntry => e !== undefined)
5644
+ : this.entriesNamed(target.channelId);
5004
5645
  if (matches.length === 0) {
5005
5646
  return {
5006
5647
  error: target.serverId
@@ -5009,12 +5650,16 @@ export class ChannelRegistry {
5009
5650
  };
5010
5651
  }
5011
5652
  if (matches.length > 1) {
5653
+ // The framework's own speech always names its server, so only a
5654
+ // caller that named none meets this, and it can name one.
5655
+ // Each named by what picks it alone, or by its id and server where
5656
+ // nothing does; "more than one server" only where the servers differ.
5657
+ const names = matches.map((e) => this.quotedShown(refOf(e)));
5658
+ const servers = new Set(matches.map((e) => e.serverId));
5012
5659
  return {
5013
- // Speech names no server, and neither its >> prefix nor
5014
- // channel_publish can: whoever runs this host can. A caller that
5015
- // can name one reads that none was.
5016
- error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
5017
- 'if you need them there, ask whoever runs this host',
5660
+ error: servers.size > 1
5661
+ ? `channel id "${target.channelId}" is on more than one server (${names.join(', ')}), and the destination named no server`
5662
+ : `channel id "${target.channelId}" names more than one channel on server "${matches[0]!.serverId}" (${names.join(', ')})`,
5018
5663
  };
5019
5664
  }
5020
5665
  return { entry: matches[0]! };
@@ -5029,16 +5674,19 @@ export class ChannelRegistry {
5029
5674
  * publishReceiptOutcome and publishErrorOutcome.
5030
5675
  *
5031
5676
  * Resolution is exact (resolveDestination): `serverId` plus `channelId`
5032
- * when the caller has the server, otherwise the channel id must be
5033
- * registered by exactly one server (a shared id is refused, never routed
5034
- * through whichever server came first). An omitted `serverId` means "not
5677
+ * when the caller has the server, otherwise the channel id, or its
5678
+ * server-qualified name, must mean exactly one channel (a shared id is
5679
+ * refused, never routed through whichever server came first). An omitted `serverId` means "not
5035
5680
  * in use"; a supplied one must be a non-empty server id, and an empty or
5036
5681
  * non-string selector is refused rather than read as omission. It is not
5037
- * possible to send into a closed channel: a closed but registered
5038
- * destination is opened first (`openSource` names why), once its server
5039
- * and that server's channels.publish grant are known to take the send, so
5040
- * a send refused before the open opens nothing; and a failed open sends
5041
- * nothing.
5682
+ * possible to send into a closed channel that speech opens (speechOpens):
5683
+ * it's opened first (`openSource` names why), once its server and that
5684
+ * server's channels.publish grant are known to take the send, so a send
5685
+ * refused before the open opens nothing; and a failed open sends nothing.
5686
+ * A channel speech leaves as it stands (speechLeavesAsItStands), one the
5687
+ * agent closed or declined itself or a module closed, or a closed one on
5688
+ * a server that's up but whose lifecycle this host doesn't direct, takes
5689
+ * the words as it is, once its queued lifecycle work has settled.
5042
5690
  *
5043
5691
  * A channel its server declared inbound-only (isInboundOnly) takes nothing
5044
5692
  * the host would post, whichever path the words took, a drafts resend
@@ -5114,30 +5762,41 @@ export class ChannelRegistry {
5114
5762
  status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
5115
5763
  });
5116
5764
 
5765
+ // Words go into a channel that's open with no close standing, or into
5766
+ // one speech leaves as it stands (speechLeavesAsItStands): a channel the
5767
+ // agent closed or declined itself, or a module closed, and a closed
5768
+ // channel on a server that's up but whose lifecycle this host doesn't
5769
+ // direct, where nothing opens. Every other channel speech opens first
5770
+ // (speechOpens), and if it doesn't open, nothing goes out. A wait or an
5771
+ // open that fails is read against the channel as it is then, so a close
5772
+ // the agent decides while the words wait lets them go into the channel
5773
+ // as that close leaves it, unless the failure is convergence that never
5774
+ // established its target (refusalGivesWay). A channel the server last
5775
+ // answered for another one takes nothing as it stands (answeredElsewhere).
5776
+
5117
5777
  // Lifecycle work already queued for the channel settles first: words go
5118
5778
  // out only into the channel it leaves, and fail visibly if it leaves
5119
- // none open.
5779
+ // none open and speech would open it.
5120
5780
  if (this.hasPendingLifecycle(entry)) {
5121
5781
  try {
5122
5782
  entry = await this.waitForOpenChannel(entry);
5123
5783
  } catch (err) {
5124
- return prevented(err);
5784
+ const now = this.entryAt(refOf(entry));
5785
+ if (!this.refusalGivesWay(err, now)) return prevented(err);
5786
+ entry = now;
5125
5787
  }
5126
5788
  }
5127
5789
 
5128
- // INVARIANT: it is not possible to send into a closed channel. A send
5129
- // into a closed-but-registered channel (the canonical case: a DM that
5130
- // woke the agent, since DMs register closed) OPENS it first, so typing
5131
- // indicators, reaction machinery and inbound forwarding come alive with
5132
- // the words. If the open fails, nothing goes out: a half-alive delivery
5133
- // is worse than a loud marker. A channel decided closed is engaged even
5134
- // while its transport is still open (a close that failed), as a reply
5135
- // would engage it, wherever an open can succeed. Without the lifecycle
5136
- // grant it can't, and a send opens before it publishes, so there a
5137
- // channel whose transport is open keeps publishing.
5138
- const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
5139
- CapabilityGrant.of(server).has('channels.lifecycle');
5140
- if (!entry.open || engagesDecidedClosed) {
5790
+ // A send into a closed-but-registered channel (the canonical case: a DM
5791
+ // that woke the agent, since DMs register closed) OPENS it first where
5792
+ // speech opens it (speechOpens), so typing indicators, reaction
5793
+ // machinery and inbound forwarding come alive with the words. If the
5794
+ // open fails, nothing goes out: a half-alive delivery is worse than a
5795
+ // loud marker. A channel decided closed is engaged even while its
5796
+ // transport is still open (a close that failed), as a reply would engage
5797
+ // it, wherever an open can succeed, unless that close is the agent's or
5798
+ // a module's own.
5799
+ if (this.speechOpens(refOf(entry))) {
5141
5800
  try {
5142
5801
  const { opened } = await this.openChannelNow(entry, openSource);
5143
5802
  entry = await this.waitForOpenChannel(entry);
@@ -5166,7 +5825,9 @@ export class ChannelRegistry {
5166
5825
  }
5167
5826
  }
5168
5827
  } catch (err) {
5169
- return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
5828
+ const now = this.entryAt(refOf(entry));
5829
+ if (!this.refusalGivesWay(err, now)) return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
5830
+ entry = now;
5170
5831
  }
5171
5832
  }
5172
5833
 
@@ -5183,14 +5844,16 @@ export class ChannelRegistry {
5183
5844
  // its close standing. The loop opens nothing itself. The check then runs
5184
5845
  // again on the registration that stands. waitForOpenChannel returns
5185
5846
  // only a registration it found open and whose close doesn't stand, or
5186
- // throws, so the loop ends unless the channel keeps changing.
5187
- const key = `${entry.serverId}:${entry.descriptor.id}`;
5847
+ // throws, so the loop ends unless the channel keeps changing. A channel
5848
+ // speech leaves as it stands (speechLeavesAsItStands) needs none of
5849
+ // that, and its words go into it as it is.
5850
+ const key = destinationKey({ serverId: entry.serverId, channelId: entry.descriptor.id });
5188
5851
  let current = this.channels.get(key);
5189
- while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
5852
+ while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) {
5190
5853
  try {
5191
5854
  entry = await this.waitForOpenChannel(current);
5192
5855
  } catch (err) {
5193
- return prevented(err);
5856
+ if (!this.refusalGivesWay(err, this.channels.get(key))) return prevented(err);
5194
5857
  }
5195
5858
  current = this.channels.get(key);
5196
5859
  }
@@ -5198,6 +5861,17 @@ export class ChannelRegistry {
5198
5861
  if (isInboundOnly(current)) {
5199
5862
  return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
5200
5863
  }
5864
+ // A channel the words go into as it stands, with no open to check what
5865
+ // the server reaches by its id, takes nothing where the server answered
5866
+ // its last open for another channel, whenever that was (answeredElsewhere):
5867
+ // the words could reach that other channel.
5868
+ if ((!current.open || this.closeStands(current)) && this.answeredElsewhere.has(current)) {
5869
+ return {
5870
+ status: 'failed', destination, at: at(),
5871
+ reason: `the "${current.serverId}" server answered this channel's last open for a different channel, so words ` +
5872
+ "sent by its id may not reach it; once an open of it is answered for this channel, they go into it again",
5873
+ };
5874
+ }
5201
5875
  // A grant reduced during any of the waits holds at once (§6.7): the
5202
5876
  // words already on their way take it too. Its refusal is worded for the
5203
5877
  // connection that refused (sendRefusal's `checked`).
@@ -5233,7 +5907,7 @@ export class ChannelRegistry {
5233
5907
  async deliverSpeech(
5234
5908
  conversationId: string,
5235
5909
  text: string,
5236
- /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
5910
+ /** The turn's FROZEN locus, with its server, snapshotted once by the caller (resolveLocus
5237
5911
  * at turn start) and passed to every segment of the turn. Mandatory:
5238
5912
  * there is deliberately no live-resolution fallback here — a late
5239
5913
  * dispatch resolving live can read the NEXT turn's trigger state or a
@@ -5242,7 +5916,7 @@ export class ChannelRegistry {
5242
5916
  * "this turn is pinned to no locus": fail loudly rather than guess,
5243
5917
  * except for an ephemeral run, whose speech is then its caller's
5244
5918
  * result. */
5245
- locusChannelId: string | null,
5919
+ locus: ChannelRef | null,
5246
5920
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
5247
5921
  * draft with its own notice, so no failure marker is written for it.
5248
5922
  * An `unknown` outcome still gets its marker. */
@@ -5250,14 +5924,16 @@ export class ChannelRegistry {
5250
5924
  ): Promise<PublishOutcome> {
5251
5925
  // `logged` is what the log line and the trace say, where it isn't the
5252
5926
  // agent's reason (the two no-locus reasons below).
5253
- const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
5927
+ const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
5254
5928
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
5929
+ const channelId = channel?.channelId ?? null;
5255
5930
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
5256
5931
  const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
5257
5932
  console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
5258
5933
  this.emitTraceFn({
5259
5934
  type: 'mcpl:speech-route-failed',
5260
5935
  conversationId,
5936
+ ...(channel ? { serverId: channel.serverId } : {}),
5261
5937
  channelId: channelId ?? '',
5262
5938
  reason: logged,
5263
5939
  textLen: text.length,
@@ -5265,7 +5941,10 @@ export class ChannelRegistry {
5265
5941
  ...(detail !== undefined ? { detail } : {}),
5266
5942
  });
5267
5943
  if (status === 'unknown' || !opts?.heldOnFailure) {
5268
- this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) });
5944
+ this.onRouteFailure?.({
5945
+ conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
5946
+ reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
5947
+ });
5269
5948
  }
5270
5949
  return outcome;
5271
5950
  };
@@ -5279,12 +5958,12 @@ export class ChannelRegistry {
5279
5958
  // Runtime backstop for JS callers the compiler can't see: an omitted
5280
5959
  // locus is a routing bug at the call site, never something to paper over
5281
5960
  // with a live re-resolution.
5282
- if (locusChannelId === undefined) {
5961
+ if (locus === undefined) {
5283
5962
  const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
5284
5963
  return fail(null, reason, { status: 'failed', reason, at: Date.now() },
5285
5964
  'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
5286
5965
  }
5287
- if (!locusChannelId) {
5966
+ if (!locus) {
5288
5967
  // An ephemeral run with no channel of its own: its plain speech is its
5289
5968
  // caller's result (takesGlobalFallback). It returns as not delivered,
5290
5969
  // with its own reason, and writes no line, trace or notice.
@@ -5301,10 +5980,10 @@ export class ChannelRegistry {
5301
5980
  'turn has no locus (no home/trigger channel; nothing to deliver into)');
5302
5981
  }
5303
5982
 
5304
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
5305
- const channelId = outcome.destination?.channelId ?? locusChannelId;
5983
+ const outcome = await this.publish(conversationId, text, { serverId: locus.serverId, channelId: locus.channelId, routed: true });
5984
+ const channelId = outcome.destination?.channelId ?? locus.channelId;
5306
5985
  if (outcome.status !== 'delivered') {
5307
- return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
5986
+ return fail(outcome.destination ?? locus, outcome.reason ?? 'delivery not confirmed', outcome);
5308
5987
  }
5309
5988
 
5310
5989
  console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination!.serverId}, delivered=true)`);
@@ -5328,9 +6007,9 @@ export class ChannelRegistry {
5328
6007
  async routeSpeech(
5329
6008
  conversationId: string,
5330
6009
  text: string,
5331
- locusChannelId: string | null,
6010
+ locus: ChannelRef | null,
5332
6011
  ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
5333
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
6012
+ const outcome = await this.deliverSpeech(conversationId, text, locus);
5334
6013
  if (outcome.status !== 'delivered') return null;
5335
6014
  return {
5336
6015
  delivered: true,
@@ -5347,12 +6026,15 @@ export class ChannelRegistry {
5347
6026
  * given speak_in_channel for the tuned-out channels whose messages it reads.
5348
6027
  */
5349
6028
  private async handleToolPublish(
5350
- input: { channelId?: string; content?: string; text?: string },
6029
+ /** `serverId` isn't in channel_publish's schema: the framework gives it
6030
+ * with a conversation fork's home, and publishForAgent with a ref. */
6031
+ input: { channelId?: string; serverId?: string; content?: string; text?: string },
5351
6032
  origin?: ChannelToolOrigin,
5352
- /** Filled for publishForAgent: the publish's outcome once a request is
5353
- * attempted, for its trace, or which check of the destination refused
5354
- * it before anything went out, for its answer. */
5355
- seen?: { outcome?: PublishOutcome; refusal?: PublishRefusalReason },
6033
+ /** Filled for publishForAgent: the channel the call names once it
6034
+ * resolves, and the publish's outcome once a request is attempted, for
6035
+ * its trace, or which check of the destination refused it before
6036
+ * anything went out, for its answer. */
6037
+ seen?: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason },
5356
6038
  tool: 'channel_publish' | 'speak_in_channel' = 'channel_publish',
5357
6039
  ): Promise<ToolResult> {
5358
6040
  const findIds = tool === 'channel_publish'
@@ -5377,9 +6059,13 @@ export class ChannelRegistry {
5377
6059
  // marker, apart from an edit the coalescer delivers on acceptance as the
5378
6060
  // agent's first view. An ephemeral run never takes that default
5379
6061
  // (takesGlobalFallback), so it names one.
6062
+ // A channel named by a string means what it means to every channel tool
6063
+ // (entriesNamed): one channel, by its id or its server-qualified name,
6064
+ // and a name two servers' channels answer to is refused with them as
6065
+ // candidates, never sent to the first.
5380
6066
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
5381
- const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
5382
- if (!channelId) {
6067
+ const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
6068
+ if (!(input.channelId ?? fallback)) {
5383
6069
  return {
5384
6070
  success: false,
5385
6071
  error: tool === 'speak_in_channel'
@@ -5391,17 +6077,80 @@ export class ChannelRegistry {
5391
6077
  isError: true,
5392
6078
  };
5393
6079
  }
5394
-
5395
- const entry = this.findChannelEntry(channelId);
6080
+ // A host's ref (publishForAgent, the one caller that gives
6081
+ // speak_in_channel a serverId) names its exact pair, an empty serverId
6082
+ // included, as publish's destination does. A tool's empty serverId is a
6083
+ // parameter filled with nothing, read as none given, as the channel tools
6084
+ // read one.
6085
+ const serverGiven = tool === 'speak_in_channel' ? input.serverId !== undefined : !!input.serverId;
6086
+ // Inside the calling turn, a name one of the turn's own channels answers
6087
+ // to by its id means that channel, as a `>>` target does (ownChannelNamed).
6088
+ const own = input.channelId !== undefined && !serverGiven && origin?.kind === 'agent'
6089
+ ? this.ownChannelNamed(input.channelId, origin.own)
6090
+ : undefined;
6091
+ let entry: ChannelEntry | undefined;
6092
+ if (input.channelId === undefined) {
6093
+ // A serverId given with no channelId names the default's id on that
6094
+ // server, held to as one given with a channelId is.
6095
+ entry = this.entryAt(serverGiven ? { serverId: input.serverId!, channelId: fallback!.channelId } : fallback!);
6096
+ } else if (own && 'neither' in own) {
6097
+ if (seen) seen.refusal = 'shared-id';
6098
+ return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
6099
+ } else if (own) {
6100
+ entry = this.entryAt(own.channel);
6101
+ if (!entry) {
6102
+ if (seen) seen.refusal = 'channel-not-registered';
6103
+ return { success: false, error: this.ownChannelGoneRefusal(input.channelId, own.channel, 'publish'), isError: true };
6104
+ }
6105
+ } else if (serverGiven) {
6106
+ entry = this.entryAt({ serverId: input.serverId!, channelId: input.channelId });
6107
+ } else {
6108
+ const matches = this.entriesNamed(input.channelId);
6109
+ if (matches.length > 1) {
6110
+ if (seen) seen.refusal = 'shared-id';
6111
+ return {
6112
+ success: false,
6113
+ error: this.sharedNameRefusal(input.channelId, matches, { takesServerId: false, nothing: 'nothing was posted' }),
6114
+ isError: true,
6115
+ };
6116
+ }
6117
+ entry = matches[0];
6118
+ }
5396
6119
  if (!entry) {
5397
6120
  if (seen) seen.refusal = 'channel-not-registered';
5398
- return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true };
6121
+ return {
6122
+ success: false,
6123
+ // The default channel is gone (its server no longer registers it),
6124
+ // though another server's channel may have its id.
6125
+ // No advice: the default's channel is gone, and another server's
6126
+ // channel with its id isn't where its message came from.
6127
+ error: input.channelId === undefined
6128
+ ? serverGiven
6129
+ ? `Server "${input.serverId}" has no channel "${fallback!.channelId}", the id of the channel this call would default to, so nothing was posted.`
6130
+ : `The channel this would default to, ${this.quotedShown(fallback!)}, isn't registered now, so nothing was posted.`
6131
+ // This tool's schema has no serverId, so one given here is a host's
6132
+ // ref (publishForAgent), or one an agent added unasked, and the
6133
+ // refusal offers names its channelId takes instead. (A fork's guard
6134
+ // refuses a home its server no longer registers before this runs.)
6135
+ : serverGiven
6136
+ ? `No channel "${input.channelId}" is on server "${input.serverId}", so nothing was posted. ${this.elsewhereText(input.channelId, false)}`
6137
+ : `No channel has the id "${input.channelId}", so nothing was posted. ${findIds}`,
6138
+ isError: true,
6139
+ };
5399
6140
  }
6141
+ // From here, the channel's own id, whichever name the call gave for it,
6142
+ // and the name the agent is shown it by (nameShown).
6143
+ const channelId = entry.descriptor.id;
6144
+ // The channel the call names, for a caller that reports a refusal before
6145
+ // the send (publishForAgent's trace).
6146
+ if (seen) seen.resolved = refOf(entry);
6147
+ const shown = this.nameShown(refOf(entry));
6148
+ const quoted = this.quotedShown(refOf(entry));
5400
6149
  if (isInboundOnly(entry)) {
5401
6150
  if (seen) seen.refusal = 'inbound-only';
5402
6151
  return {
5403
6152
  success: false,
5404
- error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`,
6153
+ error: `Channel ${shown} is ${inboundOnlyReason(entry)}`,
5405
6154
  isError: true,
5406
6155
  };
5407
6156
  }
@@ -5414,7 +6163,7 @@ export class ChannelRegistry {
5414
6163
  const reach = this.serverReach(entry.serverId);
5415
6164
  seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
5416
6165
  }
5417
- return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true };
6166
+ return { success: false, error: `Nothing was posted to channel ${quoted}: ${this.sendRefusal(entry.serverId)}.`, isError: true };
5418
6167
  }
5419
6168
  const publishParams: ChannelsPublishParams = {
5420
6169
  conversationId: '', // Framework will fill this when wired
@@ -5444,11 +6193,15 @@ export class ChannelRegistry {
5444
6193
  if (outcome.status === 'delivered') return { success: true, data: result };
5445
6194
  // The connector's data may say what a multi-part send already posted.
5446
6195
  const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
6196
+ // Named as it's shown now, after the send's await: registrations may have
6197
+ // changed meanwhile, and the name shown before it may now pick another
6198
+ // channel.
6199
+ const sentTo = this.quotedShown(refOf(entry));
5447
6200
  return {
5448
6201
  success: false,
5449
6202
  error: (outcome.status === 'failed'
5450
- ? `Nothing was posted to channel "${channelId}": ${outcome.reason}.`
5451
- : `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` +
6203
+ ? `Nothing was posted to channel ${sentTo}: ${outcome.reason}.`
6204
+ : `Posting to channel ${sentTo} wasn't confirmed: ${outcome.reason}. ` +
5452
6205
  'It may or may not have been posted, so sending it again could post it twice.') + detail,
5453
6206
  isError: true,
5454
6207
  };