@animalabs/agent-framework 0.22.4 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -16,6 +16,7 @@
16
16
  import type { ContentBlock } from '@animalabs/membrane';
17
17
  import { convertBlock } from './convert-block.js';
18
18
  import type { JsStore } from '@animalabs/chronicle';
19
+ import { isDeepStrictEqual } from 'node:util';
19
20
  import { ensureStateRegistered } from '../module-registry.js';
20
21
 
21
22
  import type {
@@ -51,8 +52,42 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
51
52
  // ============================================================================
52
53
 
53
54
  const TYPING_INTERVAL_MS = 7_000;
55
+ const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
56
+
57
+ /** A successful RPC can still fail to establish the latest channel target. */
58
+ class ChannelLifecycleConvergenceError extends Error {}
59
+ /** A channels/open answered for a channel other than the one it named. */
60
+ class ChannelOpenSubstitutionError extends Error {}
61
+ /** An operation queued for a server's registrations that were removed. */
62
+ class ChannelLifecycleCancelledError extends Error {}
54
63
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
55
64
 
65
+ /**
66
+ * What a channels/open answer says about which channel the server opened:
67
+ * its `channel.id`, or else the `channelId` of a history item. Each is
68
+ * evidence. A string other than the requested id names another channel (ids
69
+ * compare exactly, as the server's traffic is matched). Null or absence names
70
+ * no channel, and gives undefined, as does an answer that names none at all.
71
+ * Any other value is malformed: it names nothing the host could register.
72
+ */
73
+ function substitutedChannel(
74
+ result: ChannelsOpenResult | undefined,
75
+ requested: string,
76
+ ): { kind: 'other'; id: string } | { kind: 'malformed'; value: unknown } | undefined {
77
+ const named = (value: unknown) => {
78
+ if (value === undefined || value === null || value === requested) return undefined;
79
+ return typeof value === 'string' ? { kind: 'other' as const, id: value } : { kind: 'malformed' as const, value };
80
+ };
81
+ const answered = named(result?.channel?.id);
82
+ if (answered) return answered;
83
+ const history: unknown[] = Array.isArray(result?.history) ? result.history : [];
84
+ for (const item of history) {
85
+ const from = named((item as { channelId?: unknown } | null)?.channelId);
86
+ if (from) return from;
87
+ }
88
+ return undefined;
89
+ }
90
+
56
91
  /**
57
92
  * Durable "which label did this channelId have, each time we saw it" log.
58
93
  * `resolveProseTarget()` resolves a label to an id only for channels
@@ -213,9 +248,14 @@ function shallowEqualRecord(
213
248
  interface ChannelEntry {
214
249
  serverId: string;
215
250
  descriptor: ChannelDescriptor;
251
+ /** Whether the current target is confirmed open; false can be unconfirmed. */
216
252
  open: boolean;
217
253
  }
218
254
 
255
+ function sameChannelTarget(a: ChannelDescriptor, b: ChannelDescriptor): boolean {
256
+ return a.type === b.type && isDeepStrictEqual(a.address, b.address);
257
+ }
258
+
219
259
  /** Minimal responder interface for sending JSON-RPC results back. */
220
260
  interface Responder {
221
261
  respond(result: unknown): void;
@@ -252,6 +292,20 @@ interface McplChannelIncomingEvent {
252
292
  // Channel Tool Definitions
253
293
  // ============================================================================
254
294
 
295
+ /**
296
+ * `journal`'s description. It tells the model to call `skip_reply` to end the
297
+ * turn only where the agent may call it: the framework builds each agent's
298
+ * surface with that agent's permission, and a refused `skip_reply` leaves the
299
+ * turn going on.
300
+ */
301
+ export function journalToolDescription(canSkipReply: boolean): string {
302
+ return 'Write an entry in your private journal. The entry stays in your own context and ' +
303
+ 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
304
+ 'than a line that you want to keep for yourself — reflections, what you decided and ' +
305
+ 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
306
+ 'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
307
+ }
308
+
255
309
  const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
256
310
  {
257
311
  name: 'channel_list',
@@ -316,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
316
370
  },
317
371
  {
318
372
  name: 'channel_publish',
319
- description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
373
+ description: 'Publish a message to a channel. If channelId is omitted, publishes to the channel this conversation is bound to, if any, else to the most recent incoming channel. A subagent has neither, and must name one.',
320
374
  inputSchema: {
321
375
  type: 'object' as const,
322
376
  properties: {
323
- channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
377
+ channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the conversation\'s bound channel, else the most recent incoming channel; required of a subagent)' },
324
378
  content: { type: 'string', description: 'Text content to publish' },
325
379
  text: { type: 'string', description: 'Alias for content' },
326
380
  },
@@ -329,6 +383,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
329
383
  },
330
384
  {
331
385
  name: 'think',
386
+ // No agent's surface shows this description: getToolsForAgent gives every
387
+ // agent its own think (buildThinkTool), which names skip_reply only where
388
+ // that agent may call it.
332
389
  description:
333
390
  'Reason privately. The content stays in your own context and is NOT sent to any ' +
334
391
  'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
@@ -348,12 +405,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
348
405
  },
349
406
  {
350
407
  name: 'journal',
351
- description:
352
- 'Write an entry in your private journal. The entry stays in your own context and ' +
353
- 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
354
- 'than a line that you want to keep for yourself — reflections, what you decided and ' +
355
- 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
356
- 'text is routed; to end the turn without replying, call skip_reply afterwards.',
408
+ description: journalToolDescription(true),
357
409
  inputSchema: {
358
410
  type: 'object' as const,
359
411
  properties: {
@@ -457,7 +509,12 @@ export interface PublishOutcome {
457
509
  * a failed request this way.
458
510
  */
459
511
  function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome {
460
- const message = connectorText(err instanceof Error ? err.message : String(err));
512
+ // This host's own words for a request that wasn't sent or wasn't answered
513
+ // are sentences, and every composition of the reason adds its own ending,
514
+ // so one final period comes off them. A server's error answer keeps its
515
+ // words as they came.
516
+ const text = connectorText(err instanceof Error ? err.message : String(err));
517
+ const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
461
518
  const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
462
519
  const detail = err instanceof McplRequestError ? err.data : undefined;
463
520
  return {
@@ -534,8 +591,10 @@ interface ChannelRegistryOptions {
534
591
  /**
535
592
  * Called when a text-only turn's speech could NOT be delivered to its
536
593
  * conversational locus — no locus, an unregistered channel, a missing
537
- * server, or the server reporting `delivered: false`. The host wires this
538
- * to drop a `[discord-send-failed]` marker into chronicle so the failure is
594
+ * server, or the server reporting `delivered: false`. Not called for an
595
+ * ephemeral run's speech with no locus, which is its caller's result
596
+ * (`takesGlobalFallback`). The host wires this to drop a
597
+ * `[discord-send-failed]` marker into chronicle so the failure is
539
598
  * visible to the agent (and operator) rather than silently lost. Must not
540
599
  * itself trigger inference (avoid wake loops).
541
600
  */
@@ -551,14 +610,15 @@ interface ChannelRegistryOptions {
551
610
  detail?: string;
552
611
  }) => void;
553
612
  /**
554
- * Called when channels were opened WITHOUT the agent asking (subscription
555
- * policy admitting a newly discovered channel, or delivery into a closed
556
- * locus). The host wires this to drop a durable notice into the agent's
557
- * window — the agent must always learn that the channel opened, and that
558
- * `channel_close` opts out (their decision outranks policy). What an open
559
- * channel delivers is the server's to say (#406). Fires ONCE per channel
560
- * ever: the desired-state decision is durable, so reboots do not
561
- * re-announce. Must not trigger inference.
613
+ * Announces either a durable subscription-policy admission (before its
614
+ * transport RPC; not an open confirmation), or a delivery that turned a
615
+ * closed channel open, once its transport is confirmed. Reconfirming the
616
+ * transport of a channel already decided open or tuned out isn't one.
617
+ * The host records a durable, non-waking notice. What an open channel
618
+ * delivers is the server's to say (#406).
619
+ * Policy admissions fire once per channel: the persisted desired-state
620
+ * decision prevents re-announcement on retry or reboot. channel_close
621
+ * supersedes policy; a fresh delivery into a closed locus engages it again.
562
622
  */
563
623
  onChannelAutoOpened?: (info: {
564
624
  /** Agent whose action caused the open (delivery); absent for policy opens. */
@@ -594,6 +654,18 @@ interface ChannelRegistryOptions {
594
654
  * `defaultPublishChannel`.
595
655
  */
596
656
  activeChannelResolver?: (agentName: string) => string | undefined;
657
+ /**
658
+ * Whether a conversation takes the process-global fallback, the channel of
659
+ * the most recent delivered inbound message: as where its plain speech lands
660
+ * when it has no home and no channel of its own this turn, as its channel
661
+ * context's `incoming` and default outgoing channel, and as
662
+ * `channel_publish`'s target when it names none. False for an ephemeral run
663
+ * (a subagent): its prose is its caller's result, so a channel that only the
664
+ * latest inbound message picked is never its destination, and its plain
665
+ * speech with no channel of its own is undelivered rather than a failed
666
+ * delivery. Absent, every conversation takes it.
667
+ */
668
+ takesGlobalFallback?: (conversationId: string) => boolean;
597
669
  /** Whether this framework has EventGate and can honor skip_reply self-wakes. */
598
670
  eventGatePresent?: boolean;
599
671
  }
@@ -638,6 +710,7 @@ export class ChannelRegistry {
638
710
  }) => void;
639
711
  private homeChannelResolver?: (agentName: string) => string | undefined;
640
712
  private activeChannelResolver?: (agentName: string) => string | undefined;
713
+ private takesGlobalFallback?: (conversationId: string) => boolean;
641
714
  private store?: JsStore;
642
715
 
643
716
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
@@ -727,6 +800,46 @@ export class ChannelRegistry {
727
800
  /** One-time migration inputs from the retired recipe auto-open policy. */
728
801
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
729
802
  private migratedLegacyPolicies = new Set<string>();
803
+
804
+ // Keep one transport operation in flight per logical channel, including
805
+ // across descriptor replacement. Settled tails release themselves; a
806
+ // failed operation must not poison later decisions.
807
+ private channelLifecycleTails = new Map<string, Promise<void>>();
808
+ // Bumped when a server's registrations are removed: an operation queued
809
+ // before that is cancelled when it would run, rather than acting on a
810
+ // closing connection or on a later re-registration's entries.
811
+ private serverRemovals = new Map<string, number>();
812
+ // Operations queued or running per channel that will still read its
813
+ // target before confirming anything. Unlike the tail, which settles a few
814
+ // microtasks after its operation's last check, this drops at that check.
815
+ private lifecycleOutstanding = new Map<string, number>();
816
+ // Losing an open receipt does not confirm closure. Keep that distinction
817
+ // private while preserving the public open:boolean availability projection.
818
+ private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
819
+
820
+ private invalidateTransportConfirmation(entry: ChannelEntry): void {
821
+ entry.open = false;
822
+ this.unconfirmedChannelTargets.add(entry);
823
+ }
824
+
825
+ /** Whether the host directs this server's channel lifecycle (§14.1: its
826
+ * `channels.lifecycle` grant). Where it doesn't, the server opens and
827
+ * closes its own channels, and sends no receipt the host could wait for. */
828
+ private directsLifecycle(serverId: string): boolean {
829
+ return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
830
+ }
831
+
832
+ /** A registration's entry: nothing about its transport is known until a
833
+ * receipt for its target confirms it open or closed. */
834
+ private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): void {
835
+ const entry: ChannelEntry = { serverId, descriptor, open: false };
836
+ this.channels.set(key, entry);
837
+ this.unconfirmedChannelTargets.add(entry);
838
+ }
839
+
840
+ private hasPendingLifecycle(entry: ChannelEntry): boolean {
841
+ return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
842
+ }
730
843
  private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming'];
731
844
  private acceptInbound?: ChannelRegistryOptions['acceptInbound'];
732
845
  private channelTools: ToolDefinition[];
@@ -774,6 +887,7 @@ export class ChannelRegistry {
774
887
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
775
888
  this.homeChannelResolver = options?.homeChannelResolver;
776
889
  this.activeChannelResolver = options?.activeChannelResolver;
890
+ this.takesGlobalFallback = options?.takesGlobalFallback;
777
891
  this.store = options?.store;
778
892
  this.initializeLifecycleStore();
779
893
  this.initializeLabelHistoryStore();
@@ -817,11 +931,7 @@ export class ChannelRegistry {
817
931
  continue;
818
932
  }
819
933
  const key = `${serverId}:${channel.id}`;
820
- this.channels.set(key, {
821
- serverId,
822
- descriptor: channel,
823
- open: false,
824
- });
934
+ this.registerEntry(key, serverId, channel);
825
935
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
826
936
  registeredIds.push(channel.id);
827
937
  results.push({ id: channel.id, accepted: true });
@@ -862,6 +972,7 @@ export class ChannelRegistry {
862
972
  // channels/register — a changed-set cannot smuggle in what register
863
973
  // would have rejected (PR #79 review blocker 5).
864
974
  const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
975
+ const retargeted: ChannelDescriptor[] = [];
865
976
  // Process removed channels — itemized truthfully: removal of a channel
866
977
  // we never had is reported, not silently accepted.
867
978
  if (params.removed) {
@@ -891,6 +1002,21 @@ export class ChannelRegistry {
891
1002
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
892
1003
  continue;
893
1004
  }
1005
+ // A live subscription to the old target does not confirm the new
1006
+ // type/address. Label-only updates preserve that confirmation. A
1007
+ // channel decided open (or tuned out) is opened at its new target.
1008
+ if (!sameChannelTarget(existing.descriptor, channel)) {
1009
+ this.invalidateTransportConfirmation(existing);
1010
+ // An operation queued or running for this channel reads its target
1011
+ // again before it confirms anything, and after a failed RPC, so it
1012
+ // opens the new one. With none, a channel decided open (or tuned
1013
+ // out) is reconciled here.
1014
+ const desired = this.getDesiredState(serverId, channel.id);
1015
+ if ((desired === 'open' || desired === 'tuned-out') &&
1016
+ !this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
1017
+ retargeted.push(channel);
1018
+ }
1019
+ }
894
1020
  existing.descriptor = channel;
895
1021
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
896
1022
  addedResults.push({ id: channel.id, accepted: true });
@@ -908,11 +1034,7 @@ export class ChannelRegistry {
908
1034
  continue;
909
1035
  }
910
1036
  const key = `${serverId}:${channel.id}`;
911
- this.channels.set(key, {
912
- serverId,
913
- descriptor: channel,
914
- open: false,
915
- });
1037
+ this.registerEntry(key, serverId, channel);
916
1038
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
917
1039
  addedResults.push({ id: channel.id, accepted: true });
918
1040
  accepted.push(channel);
@@ -927,7 +1049,8 @@ export class ChannelRegistry {
927
1049
  // settled above, so nothing in the response depends on reconciling.
928
1050
  responder?.respond({ results: addedResults });
929
1051
 
930
- if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
1052
+ const reconcile = [...accepted, ...retargeted];
1053
+ if (reconcile.length > 0) await this.reconcileChannels(serverId, reconcile);
931
1054
 
932
1055
  this.emitTraceFn({
933
1056
  type: 'mcpl:channels-changed',
@@ -1111,15 +1234,31 @@ export class ChannelRegistry {
1111
1234
  // exactly the authority a self-attested channel would be stealing).
1112
1235
  // A coalesced item is "accepted" only once the coalescer admits it, so
1113
1236
  // for those this runs after the hook (below).
1237
+ const incomingEntry = this.channels.get(incomingKey)!;
1114
1238
  const markAccepted = () => {
1115
1239
  this.defaultPublishChannel = message.channelId;
1116
1240
  this.defaultPublishMessageId = message.messageId;
1117
1241
  this.defaultPublishThreadId = message.threadId;
1118
- // A server sending channels/incoming is authoritative evidence that
1119
- // the transport is actually open. This repairs transient status only;
1120
- // durable desired state still changes exclusively through lifecycle
1121
- // operations.
1122
- this.channels.get(incomingKey)!.open = true;
1242
+ // Where the host directs this server's lifecycle, only a receipt for
1243
+ // the current target confirms it: traffic shows the transport open
1244
+ // only for a confirmed target, and only for a channel still decided
1245
+ // open or tuned out. After a retarget, traffic for this id may still
1246
+ // come from the old subscription, and a close receipt that landed
1247
+ // first (or during the coalescer's await) stands. Where the host
1248
+ // doesn't direct it, the server opens its own channels and its
1249
+ // traffic is the only evidence the host gets, so it marks the
1250
+ // channel open, as it always has. The entry is the one the message
1251
+ // was accepted for: one a re-registration put in its place meanwhile
1252
+ // waits for its own evidence. Durable desired state still changes
1253
+ // exclusively through lifecycle operations.
1254
+ if (this.directsLifecycle(serverId)) {
1255
+ if (this.unconfirmedChannelTargets.has(incomingEntry)) return;
1256
+ const desired = this.getDesiredState(serverId, message.channelId);
1257
+ if (desired !== 'open' && desired !== 'tuned-out') return;
1258
+ } else {
1259
+ this.unconfirmedChannelTargets.delete(incomingEntry);
1260
+ }
1261
+ incomingEntry.open = true;
1123
1262
  };
1124
1263
  if (!coalesced) markAccepted();
1125
1264
 
@@ -1508,7 +1647,7 @@ export class ChannelRegistry {
1508
1647
  });
1509
1648
 
1510
1649
  case 'channel_publish':
1511
- return this.handleToolPublish(input as { channelId: string; content: string });
1650
+ return this.handleToolPublish(input as { channelId: string; content: string }, origin);
1512
1651
 
1513
1652
  case 'think':
1514
1653
  return this.handleToolThink(input as { content?: string });
@@ -1543,9 +1682,15 @@ export class ChannelRegistry {
1543
1682
  // (heartbeats). Without this, a fork
1544
1683
  // was advertised the global locus but published somewhere else; and a trunk
1545
1684
  // agent was told the wrong channel under concurrency.
1546
- const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
1547
- const active = agentName ? this.activeChannelResolver?.(agentName) : undefined;
1548
- const outgoing = home ?? active ?? this.defaultPublishChannel;
1685
+ // Each lookup is keyed on whether a name was given, not its truthiness:
1686
+ // '' is a name an ephemeral run can have, and resolveLocus reads it too.
1687
+ const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
1688
+ const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
1689
+ // An ephemeral run takes no global fallback (takesGlobalFallback): neither
1690
+ // the latest inbound message's channel as its outgoing one, nor that
1691
+ // message as the one it is replying to.
1692
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1693
+ const outgoing = home ?? active ?? fallback;
1549
1694
 
1550
1695
  if (openChannels.length === 0 && !outgoing) {
1551
1696
  return undefined;
@@ -1556,9 +1701,9 @@ export class ChannelRegistry {
1556
1701
  // Incoming: the most-recent inbound message (what the agent is replying to).
1557
1702
  // Left process-global — per-channel inbound tracking (the right messageId for
1558
1703
  // a fork's own channel) is a separate concern from the outbound routing fix.
1559
- if (this.defaultPublishChannel && this.defaultPublishMessageId) {
1704
+ if (fallback && this.defaultPublishMessageId) {
1560
1705
  context.incoming = {
1561
- channelId: this.defaultPublishChannel,
1706
+ channelId: fallback,
1562
1707
  messageId: this.defaultPublishMessageId,
1563
1708
  threadId: this.defaultPublishThreadId,
1564
1709
  };
@@ -1589,6 +1734,10 @@ export class ChannelRegistry {
1589
1734
  * server's channels don't linger and route speech into the void.
1590
1735
  */
1591
1736
  removeServer(serverId: string): void {
1737
+ // Every lifecycle operation queued for this server is cancelled when it
1738
+ // would run (ChannelLifecycleCancelledError), so none acts on a closing
1739
+ // connection or on a later re-registration's entries.
1740
+ this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
1592
1741
  for (const [key, entry] of this.channels) {
1593
1742
  if (entry.serverId !== serverId) continue;
1594
1743
  this.channels.delete(key);
@@ -2260,6 +2409,225 @@ export class ChannelRegistry {
2260
2409
  return channel.initiallyOpen !== true && byPolicy;
2261
2410
  }
2262
2411
 
2412
+ /**
2413
+ * Apply current durable intent, serializing the transport rather than the
2414
+ * server's registration ACK. Re-read after every await: registration,
2415
+ * descriptor updates, or a newer decision may have replaced our snapshot.
2416
+ */
2417
+ private applyDesiredChannelState(
2418
+ serverId: string,
2419
+ channelId: string,
2420
+ options?: { history?: ChannelHistoryRequest },
2421
+ ): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
2422
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2423
+ const removals = this.serverRemovals.get(serverId) ?? 0;
2424
+ const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
2425
+ this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
2426
+ const operation = previous.catch(() => {}).then(
2427
+ () => this.runLifecycleOperation(serverId, channelId, removals, options),
2428
+ );
2429
+ // Keep the rejection visible to existing joiners, while observing it
2430
+ // immediately and letting each queued successor start after either result.
2431
+ const tail = operation.then(() => {});
2432
+ this.channelLifecycleTails.set(lifecycleKey, tail);
2433
+ const cleanup = () => {
2434
+ if (this.channelLifecycleTails.get(lifecycleKey) === tail) {
2435
+ this.channelLifecycleTails.delete(lifecycleKey);
2436
+ }
2437
+ };
2438
+ void tail.then(cleanup, cleanup);
2439
+ return operation;
2440
+ }
2441
+
2442
+ /** One queued lifecycle operation, run when its turn in the queue comes. */
2443
+ private async runLifecycleOperation(
2444
+ serverId: string,
2445
+ channelId: string,
2446
+ removals: number,
2447
+ options?: { history?: ChannelHistoryRequest },
2448
+ ): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
2449
+ const channelKey = `${serverId}:${channelId}`;
2450
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
2451
+ try {
2452
+ let attempts = 0;
2453
+ for (;;) {
2454
+ if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
2455
+ throw new ChannelLifecycleCancelledError(
2456
+ `Channel ${channelId}: its server's registrations were removed while this operation was queued`,
2457
+ );
2458
+ }
2459
+ const entry = this.channels.get(channelKey);
2460
+ if (!entry) throw new Error(`Channel is no longer registered: ${channelId}`);
2461
+ const server = this.serverRegistry.getServer(serverId);
2462
+ if (!server) throw new Error(`Server not found: ${serverId}`);
2463
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2464
+ throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
2465
+ }
2466
+ const descriptor = entry.descriptor;
2467
+ const desired = this.getDesiredState(serverId, channelId);
2468
+ const open = desired === 'open' || desired === 'tuned-out';
2469
+ // Per-RPC timeouts do not bound a stream of promptly successful but
2470
+ // continually superseded receipts. Bound this whole operation, then
2471
+ // release its queue slot and surface the failure to delivery joiners.
2472
+ // Its open flag needs no change here: every retry follows a replaced
2473
+ // registration, a retarget (already invalidated, whether the old
2474
+ // target's RPC was answered or failed), or a receipt for this very
2475
+ // target.
2476
+ if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
2477
+ const error = new ChannelLifecycleConvergenceError(
2478
+ `Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`,
2479
+ );
2480
+ this.emitTraceFn({
2481
+ type: 'mcpl:channel-reconcile-failed',
2482
+ serverId, channelId, desired, attempts, error: error.message,
2483
+ });
2484
+ throw error;
2485
+ }
2486
+ attempts++;
2487
+ const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
2488
+ sameChannelTarget(entry.descriptor, descriptor);
2489
+ let result: ChannelsOpenResult | undefined;
2490
+ try {
2491
+ if (open) {
2492
+ result = await server.sendChannelsOpen({
2493
+ channelId, type: descriptor.type, address: descriptor.address,
2494
+ ...(options?.history ? { history: options.history } : {}),
2495
+ });
2496
+ } else {
2497
+ await server.sendChannelsClose({ channelId });
2498
+ }
2499
+ } catch (error) {
2500
+ // An RPC can fail after the server acted, so a failure for the
2501
+ // current target leaves its outcome unknown: it is marked
2502
+ // unconfirmed with open unchanged, which keeps a confirmed-open
2503
+ // channel usable after a failed backscroll (the mark is read only
2504
+ // while not open). A failure from an older connection or target
2505
+ // also withdraws the open flag.
2506
+ if (this.channels.get(channelKey) === entry) {
2507
+ if (sameTarget()) this.unconfirmedChannelTargets.add(entry);
2508
+ else this.invalidateTransportConfirmation(entry);
2509
+ }
2510
+ // A retarget while this RPC was out queued nothing for the new
2511
+ // target, since handleChanged leaves that to this operation. So a
2512
+ // failure for the old target says nothing about the new one, and a
2513
+ // channel still decided open or tuned out tries its new target,
2514
+ // within the attempt limit.
2515
+ if (this.channels.get(channelKey) === entry && !sameChannelTarget(entry.descriptor, descriptor)) {
2516
+ const now = this.getDesiredState(serverId, channelId);
2517
+ if (now === 'open' || now === 'tuned-out') continue;
2518
+ }
2519
+ throw error;
2520
+ }
2521
+ // A server can answer for a channel other than the one named: one
2522
+ // that falls back to its first channel of the type for an id it no
2523
+ // longer knows opens that channel and returns its history. Whatever
2524
+ // becomes of this receipt, the server did that: say so, and undo the
2525
+ // open it made if the host wants that channel closed.
2526
+ const substitute = open ? substitutedChannel(result, channelId) : undefined;
2527
+ if (substitute !== undefined) {
2528
+ // Each id prints through logValue, as the rejection lines' ids do,
2529
+ // so an odd one can't add a line or a field.
2530
+ const answered = logValue(substitute.kind === 'other' ? substitute.id : substitute.value);
2531
+ console.error(
2532
+ `[channel-open-substituted] server=${serverId} requested=${logValue(channelId)} answered=${answered}`,
2533
+ );
2534
+ this.emitTraceFn({ type: 'mcpl:channel-open-substituted', serverId, channelId, answeredFor: answered });
2535
+ // A malformed id names nothing registered here, so there is
2536
+ // nothing to close.
2537
+ if (substitute.kind === 'other') this.closeSubstitutedChannel(serverId, substitute.id);
2538
+ }
2539
+ // Never apply an old receipt to the current target, even transiently:
2540
+ // a corrective open might fail before that stale bit is replaced.
2541
+ if (this.channels.get(channelKey) !== entry) continue;
2542
+ if (!sameTarget()) {
2543
+ this.invalidateTransportConfirmation(entry);
2544
+ continue;
2545
+ }
2546
+ // A current receipt for another channel confirms nothing about this
2547
+ // one, and its history is another channel's. So the open fails,
2548
+ // nothing in the answer is used, and this channel's open state is
2549
+ // unconfirmed: a server that doesn't resolve this id may not resolve
2550
+ // its sends either.
2551
+ if (substitute !== undefined) {
2552
+ this.invalidateTransportConfirmation(entry);
2553
+ throw new ChannelOpenSubstitutionError(
2554
+ `Server "${serverId}" answered channels/open for ${channelId} with ` +
2555
+ (substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
2556
+ `; ${channelId} is not marked open, and nothing in the answer is used`,
2557
+ );
2558
+ }
2559
+ entry.open = open;
2560
+ this.unconfirmedChannelTargets.delete(entry);
2561
+ const now = this.getDesiredState(serverId, channelId);
2562
+ if (open !== (now === 'open' || now === 'tuned-out')) continue;
2563
+ return { open, result };
2564
+ }
2565
+ } finally {
2566
+ // Synchronous with the operation's last look at its target: from here
2567
+ // on, nothing it does will open a new target.
2568
+ const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
2569
+ if (left > 0) this.lifecycleOutstanding.set(lifecycleKey, left);
2570
+ else this.lifecycleOutstanding.delete(lifecycleKey);
2571
+ }
2572
+ }
2573
+
2574
+ /**
2575
+ * A server that answered an open with another channel really opened that
2576
+ * one. When it's registered here and the host wants it closed, close it
2577
+ * through its own lifecycle queue: otherwise its traffic would reach the
2578
+ * resident, marked open by its first incoming message. A channel the host
2579
+ * wants open is left alone, and a close can't be answered for another
2580
+ * channel, so this never starts another substitution. An id the host
2581
+ * doesn't know needs nothing: §14.5 refuses its traffic.
2582
+ */
2583
+ private closeSubstitutedChannel(serverId: string, channelId: string): void {
2584
+ if (!this.channels.has(`${serverId}:${channelId}`)) return;
2585
+ if (this.getDesiredState(serverId, channelId) !== 'closed') return;
2586
+ this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2587
+ // Removal while queued is cancellation, as in reconcileChannels, and
2588
+ // exhausted convergence emitted its own diagnostic.
2589
+ if (!this.channels.has(`${serverId}:${channelId}`) ||
2590
+ err instanceof ChannelLifecycleCancelledError ||
2591
+ err instanceof ChannelLifecycleConvergenceError) return;
2592
+ this.emitTraceFn({
2593
+ type: 'mcpl:channel-reconcile-failed',
2594
+ serverId, channelId, desired: 'closed', error: (err as Error).message,
2595
+ });
2596
+ });
2597
+ }
2598
+
2599
+ /**
2600
+ * Join outstanding lifecycle work without recording new intent. Retargeting
2601
+ * may have invalidated open confirmation while the decision remains pending.
2602
+ * A close that settles during the wait remains authoritative; waiting for
2603
+ * it is not an instruction to open the channel again.
2604
+ */
2605
+ private async waitForOpenChannel(entry: ChannelEntry): Promise<ChannelEntry> {
2606
+ const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
2607
+ for (;;) {
2608
+ const pending = this.channelLifecycleTails.get(lifecycleKey);
2609
+ if (!pending) break;
2610
+ try {
2611
+ await pending;
2612
+ } catch (error) {
2613
+ // A redundant backscroll failure can leave a known-open channel
2614
+ // usable. Exhausted convergence is different: it never established
2615
+ // the current target, so joined delivery must fail visibly.
2616
+ if (error instanceof ChannelLifecycleConvergenceError) throw error;
2617
+ }
2618
+ // A settled tail releases its own key. One still current after it
2619
+ // settled would be awaited again without ever yielding to the event
2620
+ // loop, so stop waiting rather than spin.
2621
+ if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
2622
+ }
2623
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2624
+ if (!current) throw new Error('Channel registration was removed while delivery waited');
2625
+ if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
2626
+ throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
2627
+ }
2628
+ return current;
2629
+ }
2630
+
2263
2631
  private async reconcileChannels(
2264
2632
  serverId: string,
2265
2633
  channels: ChannelDescriptor[],
@@ -2285,54 +2653,18 @@ export class ChannelRegistry {
2285
2653
  // first boot on a new policy doesn't produce one notice per channel.
2286
2654
  const policyOpened: Array<{ channelId: string; label?: string }> = [];
2287
2655
 
2656
+ // Record all admissions before waiting on any channel. A slow first RPC
2657
+ // must not delay intent for the rest of this registration batch.
2288
2658
  for (const channel of channels) {
2289
- if (this.ensureInitialDesiredState(serverId, channel)) {
2290
- policyOpened.push({ channelId: channel.id, label: channel.label });
2291
- }
2292
- const key = `${serverId}:${channel.id}`;
2293
- const desired = this.getDesiredState(serverId, channel.id);
2294
- const entry = this.channels.get(key);
2295
- // Tuned-out sits on the OPEN side of reconcile: traffic must keep
2296
- // arriving (the subconscious reads it); only main's wake/visibility
2297
- // is diverted, downstream at ingestion.
2298
- if (desired !== 'open' && desired !== 'tuned-out') {
2299
- try {
2300
- await server.sendChannelsClose({ channelId: channel.id });
2301
- if (entry) entry.open = false;
2302
- } catch (err) {
2303
- this.emitTraceFn({
2304
- type: 'mcpl:channel-reconcile-failed',
2305
- serverId,
2306
- channelId: channel.id,
2307
- desired,
2308
- error: (err as Error).message,
2309
- });
2310
- }
2311
- continue;
2312
- }
2313
-
2314
- try {
2315
- await server.sendChannelsOpen({
2316
- channelId: channel.id,
2317
- type: channel.type,
2318
- address: channel.address,
2319
- });
2320
- if (entry) entry.open = true;
2321
- } catch (err) {
2322
- if (entry) entry.open = false;
2323
- this.emitTraceFn({
2324
- type: 'mcpl:channel-reconcile-failed',
2325
- serverId,
2326
- channelId: channel.id,
2327
- desired,
2328
- error: (err as Error).message,
2329
- });
2659
+ const entry = this.channels.get(`${serverId}:${channel.id}`);
2660
+ if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
2661
+ policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
2330
2662
  }
2331
2663
  }
2332
2664
 
2333
- // Announce policy admissions to the agent — no channel may open without
2334
- // them being told, and told how to opt out (channel_close; their
2335
- // decision outranks policy).
2665
+ // Announce the durable admission decision before awaiting transport.
2666
+ // This is intent, not confirmation that an open RPC succeeded. It stays
2667
+ // truthful if the first attempt fails, and a retry needs no new admission.
2336
2668
  if (policyOpened.length > 0) {
2337
2669
  try {
2338
2670
  this.onChannelAutoOpened?.({
@@ -2344,6 +2676,30 @@ export class ChannelRegistry {
2344
2676
  console.error('onChannelAutoOpened (policy) failed:', err);
2345
2677
  }
2346
2678
  }
2679
+
2680
+ // Each channel has its own lifecycle queue, so a held RPC for one (up to
2681
+ // its timeout, times its attempts) doesn't delay its siblings.
2682
+ await Promise.allSettled(channels.map(async (channel) => {
2683
+ if (!this.channels.has(`${serverId}:${channel.id}`)) return;
2684
+ try {
2685
+ await this.applyDesiredChannelState(serverId, channel.id);
2686
+ } catch (err) {
2687
+ // Removal while queued is cancellation, not a failed subscription.
2688
+ // Exhausted convergence and a substituted answer already emitted
2689
+ // their own diagnostics.
2690
+ if (!this.channels.has(`${serverId}:${channel.id}`) ||
2691
+ err instanceof ChannelLifecycleCancelledError ||
2692
+ err instanceof ChannelLifecycleConvergenceError ||
2693
+ err instanceof ChannelOpenSubstitutionError) return;
2694
+ this.emitTraceFn({
2695
+ type: 'mcpl:channel-reconcile-failed',
2696
+ serverId,
2697
+ channelId: channel.id,
2698
+ desired: this.getDesiredState(serverId, channel.id),
2699
+ error: (err as Error).message,
2700
+ });
2701
+ }
2702
+ }));
2347
2703
  }
2348
2704
 
2349
2705
  // ==========================================================================
@@ -2465,37 +2821,39 @@ export class ChannelRegistry {
2465
2821
  }
2466
2822
 
2467
2823
  /**
2468
- * The single open executor: record durable desired-open intent, tell the
2469
- * server to subscribe, mark the live entry open. Every path that opens a
2470
- * channel — the agent's channel_open tool, delivery into a closed locus,
2471
- * an explicit send into a closed channel — funnels here, so lifecycle
2472
- * events, desired state, and live state can never diverge by path.
2824
+ * Record durable desired-open intent, then queue the channel's lifecycle
2825
+ * work (applyDesiredChannelState), which tells the server to subscribe and
2826
+ * marks the live entry open on a receipt for its current target. Every
2827
+ * open the agent or its speech asks for — the channel_open tool, delivery
2828
+ * into a closed locus, an explicit send into a closed channel — comes
2829
+ * here. Reconciliation queues that work directly, as the closes do
2830
+ * (channel_close, a decline, and the close of a substituted channel).
2473
2831
  * Desired state is recorded BEFORE the server round-trip: intent sticks
2474
2832
  * even if the subscribe fails, and reconciliation retries later.
2833
+ * Automatic delivery needs open transport, not a change of attention:
2834
+ * preserve tune-out epochs both before and during the round-trip.
2835
+ *
2836
+ * `opened` says whether this call turned a closed channel open, which is
2837
+ * what the delivery and reply notices tell the resident. Reconfirming the
2838
+ * transport of a channel already decided open or tuned out isn't one.
2475
2839
  */
2476
2840
  private async openChannelNow(
2477
2841
  entry: ChannelEntry,
2478
- source: string,
2842
+ source: 'agent-tool' | 'opened-by-reply' | 'opened-by-delivery',
2479
2843
  history?: ChannelHistoryRequest,
2480
- ): Promise<ChannelsOpenResult> {
2481
- this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
2482
- const server = this.serverRegistry.getServer(entry.serverId);
2483
- if (!server) {
2484
- throw new Error(`Server not found: ${entry.serverId}`);
2844
+ ): Promise<{ result: ChannelsOpenResult; opened: boolean }> {
2845
+ const transportOnly = source !== 'agent-tool';
2846
+ const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
2847
+ const opened = before !== 'open' && before !== 'tuned-out';
2848
+ if (!transportOnly || before !== 'tuned-out') {
2849
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
2485
2850
  }
2486
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
2487
- // Desired state is already recorded — intent sticks; reconciliation
2488
- // will retry if the grant later widens (§6.7 expansion-on-receipt).
2489
- throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
2851
+ const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
2852
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2853
+ if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
2854
+ throw new Error('Channel open was superseded by a newer lifecycle decision');
2490
2855
  }
2491
- const result: ChannelsOpenResult = await server.sendChannelsOpen({
2492
- channelId: entry.descriptor.id,
2493
- type: entry.descriptor.type,
2494
- address: entry.descriptor.address,
2495
- ...(history ? { history } : {}),
2496
- });
2497
- entry.open = true;
2498
- return result;
2856
+ return { result: applied.result!, opened };
2499
2857
  }
2500
2858
 
2501
2859
  /**
@@ -2706,19 +3064,25 @@ export class ChannelRegistry {
2706
3064
  }
2707
3065
  if (matches.length === 0) return { status: 'unknown-channel' };
2708
3066
  if (matches.length > 1) return { status: 'ambiguous' };
2709
- const entry = matches[0]!;
2710
- const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
2711
- if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
2712
- return { status: 'already-open', ...resolved };
2713
- }
3067
+ let entry = matches[0]!;
2714
3068
  try {
2715
- await this.openChannelNow(entry, 'opened-by-reply');
2716
- this.emitTraceFn({
2717
- type: 'mcpl:channel-opened-by-send',
2718
- serverId: entry.serverId,
2719
- channelId: entry.descriptor.id,
2720
- });
2721
- return { status: 'opened', ...resolved };
3069
+ if (this.hasPendingLifecycle(entry)) {
3070
+ entry = await this.waitForOpenChannel(entry);
3071
+ }
3072
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
3073
+ if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
3074
+ return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
3075
+ }
3076
+ const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
3077
+ entry = await this.waitForOpenChannel(entry);
3078
+ if (opened) {
3079
+ this.emitTraceFn({
3080
+ type: 'mcpl:channel-opened-by-send',
3081
+ serverId: entry.serverId,
3082
+ channelId: entry.descriptor.id,
3083
+ });
3084
+ }
3085
+ return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2722
3086
  } catch (err) {
2723
3087
  this.emitTraceFn({
2724
3088
  type: 'mcpl:channel-open-failed',
@@ -2726,7 +3090,9 @@ export class ChannelRegistry {
2726
3090
  channelId: entry.descriptor.id,
2727
3091
  error: (err as Error).message,
2728
3092
  });
2729
- return { status: 'open-failed', ...resolved };
3093
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
3094
+ return { status: 'open-failed', channelId: entry.descriptor.id,
3095
+ ...(current ? { label: current.descriptor.label } : {}) };
2730
3096
  }
2731
3097
  }
2732
3098
 
@@ -2765,7 +3131,7 @@ export class ChannelRegistry {
2765
3131
  }
2766
3132
 
2767
3133
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
2768
- if (entry.open && alreadyDesiredOpen && !input.backscroll) {
3134
+ if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
2769
3135
  return {
2770
3136
  success: true,
2771
3137
  data: { channelId: input.channelId, status: 'already open' },
@@ -2779,7 +3145,7 @@ export class ChannelRegistry {
2779
3145
  ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
2780
3146
  }
2781
3147
  : undefined;
2782
- const result = await this.openChannelNow(entry, 'agent-tool', history);
3148
+ const { result } = await this.openChannelNow(entry, 'agent-tool', history);
2783
3149
  return {
2784
3150
  success: true,
2785
3151
  data: {
@@ -2790,9 +3156,18 @@ export class ChannelRegistry {
2790
3156
  },
2791
3157
  };
2792
3158
  } catch (err) {
3159
+ // The decision was recorded before the round-trip and outlives its
3160
+ // failure. Say so: otherwise a later delivery that opens the channel
3161
+ // would be the resident's first sign that it was still wanted open.
3162
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3163
+ const stillSelected = current !== undefined && !current.open &&
3164
+ this.getDesiredState(entry.serverId, input.channelId) === 'open';
2793
3165
  return {
2794
3166
  success: false,
2795
- error: `Failed to open channel: ${(err as Error).message}`,
3167
+ error: `Failed to open channel: ${(err as Error).message}` +
3168
+ (stillSelected
3169
+ ? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
3170
+ : ''),
2796
3171
  isError: true,
2797
3172
  };
2798
3173
  }
@@ -2828,7 +3203,8 @@ export class ChannelRegistry {
2828
3203
  }
2829
3204
 
2830
3205
  const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
2831
- if (!entry.open && alreadyDesiredClosed) {
3206
+ if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
3207
+ !this.unconfirmedChannelTargets.has(entry)) {
2832
3208
  return {
2833
3209
  success: true,
2834
3210
  data: { channelId: input.channelId, status: 'already closed' },
@@ -2897,8 +3273,10 @@ export class ChannelRegistry {
2897
3273
  isError: true,
2898
3274
  };
2899
3275
  }
2900
- await server.sendChannelsClose({ channelId: input.channelId });
2901
- entry.open = false;
3276
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3277
+ if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
3278
+ throw new Error('Channel close was superseded by a newer lifecycle decision');
3279
+ }
2902
3280
  return {
2903
3281
  success: true,
2904
3282
  data: { channelId: input.channelId, status: 'closed' },
@@ -2964,6 +3342,23 @@ export class ChannelRegistry {
2964
3342
  }
2965
3343
  }
2966
3344
 
3345
+ // Where the host directs the lifecycle, a decline goes through the
3346
+ // lifecycle queue like any closed decision, unless a receipt confirmed
3347
+ // the transport closed with nothing pending: an unconfirmed transport may
3348
+ // be open, and pending work must settle on this decision. A failed close
3349
+ // is reported; the decision stands.
3350
+ let closeError: string | undefined;
3351
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
3352
+ if (current && this.directsLifecycle(entry.serverId) &&
3353
+ (this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
3354
+ try {
3355
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
3356
+ if (applied.open) closeError = 'the close was superseded by a newer lifecycle decision';
3357
+ } catch (error) {
3358
+ closeError = (error as Error).message;
3359
+ }
3360
+ }
3361
+
2967
3362
  this.appendLifecycleEvent({
2968
3363
  kind: 'invitation-declined',
2969
3364
  serverId: entry.serverId,
@@ -2980,6 +3375,7 @@ export class ChannelRegistry {
2980
3375
  acknowledged,
2981
3376
  ...(representation ? { representation } : {}),
2982
3377
  ...(acknowledgmentError ? { acknowledgmentError } : {}),
3378
+ ...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
2983
3379
  },
2984
3380
  };
2985
3381
  }
@@ -2988,7 +3384,8 @@ export class ChannelRegistry {
2988
3384
  * Handle the synthesized `think` tool — a private reasoning scratchpad. It
2989
3385
  * sends nothing, and (unlike before) does NOT silence the turn: trailing
2990
3386
  * plain text is still routed as the reply. The thought stays in the agent's
2991
- * own context/chronicle. To deliberately not reply, the agent uses skip_reply.
3387
+ * own context/chronicle. To deliberately not reply, an agent that may call
3388
+ * skip_reply uses it.
2992
3389
  */
2993
3390
  private handleToolThink(input: { content?: string }): ToolResult {
2994
3391
  return {
@@ -2997,10 +3394,12 @@ export class ChannelRegistry {
2997
3394
  // tool_use block; don't duplicate it in the result.
2998
3395
  data: {
2999
3396
  noted: true,
3397
+ // No tool to end the turn with: this handler can't see whether the
3398
+ // caller may call skip_reply, and think's own description says so
3399
+ // where it may.
3000
3400
  note:
3001
3401
  'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
3002
- 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
3003
- 'call skip_reply to end the turn without replying.',
3402
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it.',
3004
3403
  },
3005
3404
  };
3006
3405
  }
@@ -3135,7 +3534,12 @@ export class ChannelRegistry {
3135
3534
 
3136
3535
  resolveLocus(conversationId: string): string | null {
3137
3536
  const home = this.homeChannelResolver?.(conversationId);
3138
- return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
3537
+ return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
3538
+ }
3539
+
3540
+ /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
3541
+ private globalFallbackFor(conversationId: string): string | null {
3542
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
3139
3543
  }
3140
3544
 
3141
3545
  /**
@@ -3232,7 +3636,7 @@ export class ChannelRegistry {
3232
3636
  const at = (): number => Date.now();
3233
3637
  const found = this.findExactEntry(target);
3234
3638
  if ('error' in found) return { status: 'failed', reason: found.error, at: at() };
3235
- const entry = found.entry;
3639
+ let entry = found.entry;
3236
3640
  const destination: PublishDestination = {
3237
3641
  serverId: entry.serverId,
3238
3642
  channelId: entry.descriptor.id,
@@ -3256,29 +3660,60 @@ export class ChannelRegistry {
3256
3660
  };
3257
3661
  }
3258
3662
 
3259
- if (!entry.open) {
3663
+ // Lifecycle work already queued for the channel settles first: words go
3664
+ // out only into the channel it leaves, and fail visibly if it leaves
3665
+ // none open.
3666
+ if (this.hasPendingLifecycle(entry)) {
3260
3667
  try {
3261
- await this.openChannelNow(entry, openSource);
3262
- if (target.routed) {
3263
- console.error(
3264
- `[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`,
3265
- );
3266
- }
3267
- this.emitTraceFn({
3268
- type: 'mcpl:channel-opened-by-send',
3269
- serverId: entry.serverId,
3270
- channelId: entry.descriptor.id,
3271
- });
3272
- if (openSource === 'opened-by-delivery') {
3273
- try {
3274
- this.onChannelAutoOpened?.({
3275
- conversationId,
3276
- serverId: entry.serverId,
3277
- source: 'opened-by-delivery',
3278
- channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
3279
- });
3280
- } catch (err) {
3281
- console.error('onChannelAutoOpened (delivery) failed:', err);
3668
+ entry = await this.waitForOpenChannel(entry);
3669
+ } catch (err) {
3670
+ return {
3671
+ status: 'failed',
3672
+ destination,
3673
+ reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
3674
+ at: at(),
3675
+ };
3676
+ }
3677
+ }
3678
+
3679
+ // INVARIANT: it is not possible to send into a closed channel. A send
3680
+ // into a closed-but-registered channel (the canonical case: a DM that
3681
+ // woke the agent, since DMs register closed) OPENS it first, so typing
3682
+ // indicators, reaction machinery and inbound forwarding come alive with
3683
+ // the words. If the open fails, nothing goes out: a half-alive delivery
3684
+ // is worse than a loud marker. A channel decided closed is engaged even
3685
+ // while its transport is still open (a close that failed), as a reply
3686
+ // would engage it, wherever an open can succeed. Without the lifecycle
3687
+ // grant it can't, and a send opens before it publishes, so there a
3688
+ // channel whose transport is open keeps publishing.
3689
+ const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
3690
+ CapabilityGrant.of(server).has('channels.lifecycle');
3691
+ if (!entry.open || engagesDecidedClosed) {
3692
+ try {
3693
+ const { opened } = await this.openChannelNow(entry, openSource);
3694
+ entry = await this.waitForOpenChannel(entry);
3695
+ if (opened) {
3696
+ if (target.routed) {
3697
+ console.error(
3698
+ `[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`,
3699
+ );
3700
+ }
3701
+ this.emitTraceFn({
3702
+ type: 'mcpl:channel-opened-by-send',
3703
+ serverId: entry.serverId,
3704
+ channelId: entry.descriptor.id,
3705
+ });
3706
+ if (openSource === 'opened-by-delivery') {
3707
+ try {
3708
+ this.onChannelAutoOpened?.({
3709
+ conversationId,
3710
+ serverId: entry.serverId,
3711
+ source: 'opened-by-delivery',
3712
+ channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
3713
+ });
3714
+ } catch (err) {
3715
+ console.error('onChannelAutoOpened (delivery) failed:', err);
3716
+ }
3282
3717
  }
3283
3718
  }
3284
3719
  } catch (err) {
@@ -3308,7 +3743,9 @@ export class ChannelRegistry {
3308
3743
  * Plain speech for one turn segment: publish `text` to the turn's frozen
3309
3744
  * locus and report the PublishOutcome. A failure (or an uncertain outcome)
3310
3745
  * is surfaced as a trace and through onRouteFailure, so the agent learns
3311
- * its words did not (or may not have) reached anyone.
3746
+ * its words did not (or may not have) reached anyone. An ephemeral run's
3747
+ * speech with no locus is the exception: it is its caller's result, so it
3748
+ * returns as not delivered and surfaces nothing.
3312
3749
  */
3313
3750
  async deliverSpeech(
3314
3751
  conversationId: string,
@@ -3319,7 +3756,9 @@ export class ChannelRegistry {
3319
3756
  * dispatch resolving live can read the NEXT turn's trigger state or a
3320
3757
  * stale global default and land the reply in the wrong channel (item-3,
3321
3758
  * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
3322
- * "this turn is pinned to no locus": fail loudly rather than guess. */
3759
+ * "this turn is pinned to no locus": fail loudly rather than guess,
3760
+ * except for an ephemeral run, whose speech is then its caller's
3761
+ * result. */
3323
3762
  locusChannelId: string | null,
3324
3763
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3325
3764
  * draft with its own notice, so no failure marker is written for it.
@@ -3354,6 +3793,12 @@ export class ChannelRegistry {
3354
3793
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3355
3794
  }
3356
3795
  if (!locusChannelId) {
3796
+ // An ephemeral run with no channel of its own: its plain speech is its
3797
+ // caller's result (takesGlobalFallback). It returns as not delivered,
3798
+ // with its own reason, and writes no line, trace or notice.
3799
+ if (this.takesGlobalFallback?.(conversationId) === false) {
3800
+ return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
3801
+ }
3357
3802
  // The turn froze with no locus (no home, no triggering channel, no
3358
3803
  // global inbound ever seen) — the agent was told its prose stays in
3359
3804
  // the archive; honor that.
@@ -3399,7 +3844,10 @@ export class ChannelRegistry {
3399
3844
  };
3400
3845
  }
3401
3846
 
3402
- private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
3847
+ private async handleToolPublish(
3848
+ input: { channelId?: string; content?: string; text?: string },
3849
+ origin?: ChannelToolOrigin,
3850
+ ): Promise<ToolResult> {
3403
3851
  // Resolve content: accept both `content` and `text` (backward compat)
3404
3852
  const messageText = input.content ?? input.text;
3405
3853
  if (!messageText) {
@@ -3410,12 +3858,17 @@ export class ChannelRegistry {
3410
3858
  };
3411
3859
  }
3412
3860
 
3413
- // Resolve channelId: default to most recent incoming channel
3414
- const channelId = input.channelId ?? this.defaultPublishChannel;
3861
+ // Resolve channelId: default to the most recent incoming channel, which an
3862
+ // ephemeral run never takes (takesGlobalFallback), so it names one.
3863
+ const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
3864
+ const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
3415
3865
  if (!channelId) {
3416
3866
  return {
3417
3867
  success: false,
3418
- error: 'No channelId specified and no default channel available',
3868
+ error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
3869
+ ? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
3870
+ + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
3871
+ : 'No channelId specified and no default channel available',
3419
3872
  isError: true,
3420
3873
  };
3421
3874
  }