@animalabs/agent-framework 0.22.3 → 0.23.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 (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 -1
  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
@@ -13,6 +13,7 @@
13
13
  * - Build channel context for beforeInference params
14
14
  */
15
15
  import { convertBlock } from './convert-block.js';
16
+ import { isDeepStrictEqual } from 'node:util';
16
17
  import { ensureStateRegistered } from '../module-registry.js';
17
18
  import { expandCoreTags } from './tags.js';
18
19
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
@@ -26,7 +27,42 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
26
27
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
27
28
  // ============================================================================
28
29
  const TYPING_INTERVAL_MS = 7_000;
30
+ const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
31
+ /** A successful RPC can still fail to establish the latest channel target. */
32
+ class ChannelLifecycleConvergenceError extends Error {
33
+ }
34
+ /** A channels/open answered for a channel other than the one it named. */
35
+ class ChannelOpenSubstitutionError extends Error {
36
+ }
37
+ /** An operation queued for a server's registrations that were removed. */
38
+ class ChannelLifecycleCancelledError extends Error {
39
+ }
29
40
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
41
+ /**
42
+ * What a channels/open answer says about which channel the server opened:
43
+ * its `channel.id`, or else the `channelId` of a history item. Each is
44
+ * evidence. A string other than the requested id names another channel (ids
45
+ * compare exactly, as the server's traffic is matched). Null or absence names
46
+ * no channel, and gives undefined, as does an answer that names none at all.
47
+ * Any other value is malformed: it names nothing the host could register.
48
+ */
49
+ function substitutedChannel(result, requested) {
50
+ const named = (value) => {
51
+ if (value === undefined || value === null || value === requested)
52
+ return undefined;
53
+ return typeof value === 'string' ? { kind: 'other', id: value } : { kind: 'malformed', value };
54
+ };
55
+ const answered = named(result?.channel?.id);
56
+ if (answered)
57
+ return answered;
58
+ const history = Array.isArray(result?.history) ? result.history : [];
59
+ for (const item of history) {
60
+ const from = named(item?.channelId);
61
+ if (from)
62
+ return from;
63
+ }
64
+ return undefined;
65
+ }
30
66
  /**
31
67
  * Durable "which label did this channelId have, each time we saw it" log.
32
68
  * `resolveProseTarget()` resolves a label to an id only for channels
@@ -85,9 +121,25 @@ function shallowEqualRecord(a, b) {
85
121
  }
86
122
  return true;
87
123
  }
124
+ function sameChannelTarget(a, b) {
125
+ return a.type === b.type && isDeepStrictEqual(a.address, b.address);
126
+ }
88
127
  // ============================================================================
89
128
  // Channel Tool Definitions
90
129
  // ============================================================================
130
+ /**
131
+ * `journal`'s description. It tells the model to call `skip_reply` to end the
132
+ * turn only where the agent may call it: the framework builds each agent's
133
+ * surface with that agent's permission, and a refused `skip_reply` leaves the
134
+ * turn going on.
135
+ */
136
+ export function journalToolDescription(canSkipReply) {
137
+ return 'Write an entry in your private journal. The entry stays in your own context and ' +
138
+ 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
139
+ 'than a line that you want to keep for yourself — reflections, what you decided and ' +
140
+ 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
141
+ 'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
142
+ }
91
143
  const CHANNEL_TOOL_DEFINITIONS = [
92
144
  {
93
145
  name: 'channel_list',
@@ -149,11 +201,11 @@ const CHANNEL_TOOL_DEFINITIONS = [
149
201
  },
150
202
  {
151
203
  name: 'channel_publish',
152
- description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
204
+ 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.',
153
205
  inputSchema: {
154
206
  type: 'object',
155
207
  properties: {
156
- channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
208
+ 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)' },
157
209
  content: { type: 'string', description: 'Text content to publish' },
158
210
  text: { type: 'string', description: 'Alias for content' },
159
211
  },
@@ -162,6 +214,9 @@ const CHANNEL_TOOL_DEFINITIONS = [
162
214
  },
163
215
  {
164
216
  name: 'think',
217
+ // No agent's surface shows this description: getToolsForAgent gives every
218
+ // agent its own think (buildThinkTool), which names skip_reply only where
219
+ // that agent may call it.
165
220
  description: 'Reason privately. The content stays in your own context and is NOT sent to any ' +
166
221
  'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
167
222
  'your current same_round_think_text_policy; inspect or change it with agent_settings. ' +
@@ -180,11 +235,7 @@ const CHANNEL_TOOL_DEFINITIONS = [
180
235
  },
181
236
  {
182
237
  name: 'journal',
183
- description: 'Write an entry in your private journal. The entry stays in your own context and ' +
184
- 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
185
- 'than a line that you want to keep for yourself — reflections, what you decided and ' +
186
- 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
187
- 'text is routed; to end the turn without replying, call skip_reply afterwards.',
238
+ description: journalToolDescription(true),
188
239
  inputSchema: {
189
240
  type: 'object',
190
241
  properties: {
@@ -246,7 +297,12 @@ export function connectorText(value) {
246
297
  * a failed request this way.
247
298
  */
248
299
  function publishErrorOutcome(err, destination, at) {
249
- const message = connectorText(err instanceof Error ? err.message : String(err));
300
+ // This host's own words for a request that wasn't sent or wasn't answered
301
+ // are sentences, and every composition of the reason adds its own ending,
302
+ // so one final period comes off them. A server's error answer keeps its
303
+ // words as they came.
304
+ const text = connectorText(err instanceof Error ? err.message : String(err));
305
+ const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
250
306
  const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
251
307
  const detail = err instanceof McplRequestError ? err.data : undefined;
252
308
  return {
@@ -310,6 +366,7 @@ export class ChannelRegistry {
310
366
  onChannelAutoOpened;
311
367
  homeChannelResolver;
312
368
  activeChannelResolver;
369
+ takesGlobalFallback;
313
370
  store;
314
371
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
315
372
  channels = new Map();
@@ -378,6 +435,41 @@ export class ChannelRegistry {
378
435
  /** One-time migration inputs from the retired recipe auto-open policy. */
379
436
  legacyPolicies = new Map();
380
437
  migratedLegacyPolicies = new Set();
438
+ // Keep one transport operation in flight per logical channel, including
439
+ // across descriptor replacement. Settled tails release themselves; a
440
+ // failed operation must not poison later decisions.
441
+ channelLifecycleTails = new Map();
442
+ // Bumped when a server's registrations are removed: an operation queued
443
+ // before that is cancelled when it would run, rather than acting on a
444
+ // closing connection or on a later re-registration's entries.
445
+ serverRemovals = new Map();
446
+ // Operations queued or running per channel that will still read its
447
+ // target before confirming anything. Unlike the tail, which settles a few
448
+ // microtasks after its operation's last check, this drops at that check.
449
+ lifecycleOutstanding = new Map();
450
+ // Losing an open receipt does not confirm closure. Keep that distinction
451
+ // private while preserving the public open:boolean availability projection.
452
+ unconfirmedChannelTargets = new WeakSet();
453
+ invalidateTransportConfirmation(entry) {
454
+ entry.open = false;
455
+ this.unconfirmedChannelTargets.add(entry);
456
+ }
457
+ /** Whether the host directs this server's channel lifecycle (§14.1: its
458
+ * `channels.lifecycle` grant). Where it doesn't, the server opens and
459
+ * closes its own channels, and sends no receipt the host could wait for. */
460
+ directsLifecycle(serverId) {
461
+ return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
462
+ }
463
+ /** A registration's entry: nothing about its transport is known until a
464
+ * receipt for its target confirms it open or closed. */
465
+ registerEntry(key, serverId, descriptor) {
466
+ const entry = { serverId, descriptor, open: false };
467
+ this.channels.set(key, entry);
468
+ this.unconfirmedChannelTargets.add(entry);
469
+ }
470
+ hasPendingLifecycle(entry) {
471
+ return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
472
+ }
381
473
  handleCoalescedIncoming;
382
474
  acceptInbound;
383
475
  channelTools;
@@ -405,6 +497,7 @@ export class ChannelRegistry {
405
497
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
406
498
  this.homeChannelResolver = options?.homeChannelResolver;
407
499
  this.activeChannelResolver = options?.activeChannelResolver;
500
+ this.takesGlobalFallback = options?.takesGlobalFallback;
408
501
  this.store = options?.store;
409
502
  this.initializeLifecycleStore();
410
503
  this.initializeLabelHistoryStore();
@@ -440,11 +533,7 @@ export class ChannelRegistry {
440
533
  continue;
441
534
  }
442
535
  const key = `${serverId}:${channel.id}`;
443
- this.channels.set(key, {
444
- serverId,
445
- descriptor: channel,
446
- open: false,
447
- });
536
+ this.registerEntry(key, serverId, channel);
448
537
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
449
538
  registeredIds.push(channel.id);
450
539
  results.push({ id: channel.id, accepted: true });
@@ -477,6 +566,7 @@ export class ChannelRegistry {
477
566
  // channels/register — a changed-set cannot smuggle in what register
478
567
  // would have rejected (PR #79 review blocker 5).
479
568
  const addedResults = [];
569
+ const retargeted = [];
480
570
  // Process removed channels — itemized truthfully: removal of a channel
481
571
  // we never had is reported, not silently accepted.
482
572
  if (params.removed) {
@@ -505,6 +595,21 @@ export class ChannelRegistry {
505
595
  this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
506
596
  continue;
507
597
  }
598
+ // A live subscription to the old target does not confirm the new
599
+ // type/address. Label-only updates preserve that confirmation. A
600
+ // channel decided open (or tuned out) is opened at its new target.
601
+ if (!sameChannelTarget(existing.descriptor, channel)) {
602
+ this.invalidateTransportConfirmation(existing);
603
+ // An operation queued or running for this channel reads its target
604
+ // again before it confirms anything, and after a failed RPC, so it
605
+ // opens the new one. With none, a channel decided open (or tuned
606
+ // out) is reconciled here.
607
+ const desired = this.getDesiredState(serverId, channel.id);
608
+ if ((desired === 'open' || desired === 'tuned-out') &&
609
+ !this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
610
+ retargeted.push(channel);
611
+ }
612
+ }
508
613
  existing.descriptor = channel;
509
614
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
510
615
  addedResults.push({ id: channel.id, accepted: true });
@@ -521,11 +626,7 @@ export class ChannelRegistry {
521
626
  continue;
522
627
  }
523
628
  const key = `${serverId}:${channel.id}`;
524
- this.channels.set(key, {
525
- serverId,
526
- descriptor: channel,
527
- open: false,
528
- });
629
+ this.registerEntry(key, serverId, channel);
529
630
  this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
530
631
  addedResults.push({ id: channel.id, accepted: true });
531
632
  accepted.push(channel);
@@ -538,8 +639,9 @@ export class ChannelRegistry {
538
639
  // deadlocks both sides until one times out (#160). The verdicts are
539
640
  // settled above, so nothing in the response depends on reconciling.
540
641
  responder?.respond({ results: addedResults });
541
- if (accepted.length > 0)
542
- await this.reconcileChannels(serverId, accepted);
642
+ const reconcile = [...accepted, ...retargeted];
643
+ if (reconcile.length > 0)
644
+ await this.reconcileChannels(serverId, reconcile);
543
645
  this.emitTraceFn({
544
646
  type: 'mcpl:channels-changed',
545
647
  serverId,
@@ -706,15 +808,34 @@ export class ChannelRegistry {
706
808
  // exactly the authority a self-attested channel would be stealing).
707
809
  // A coalesced item is "accepted" only once the coalescer admits it, so
708
810
  // for those this runs after the hook (below).
811
+ const incomingEntry = this.channels.get(incomingKey);
709
812
  const markAccepted = () => {
710
813
  this.defaultPublishChannel = message.channelId;
711
814
  this.defaultPublishMessageId = message.messageId;
712
815
  this.defaultPublishThreadId = message.threadId;
713
- // A server sending channels/incoming is authoritative evidence that
714
- // the transport is actually open. This repairs transient status only;
715
- // durable desired state still changes exclusively through lifecycle
716
- // operations.
717
- this.channels.get(incomingKey).open = true;
816
+ // Where the host directs this server's lifecycle, only a receipt for
817
+ // the current target confirms it: traffic shows the transport open
818
+ // only for a confirmed target, and only for a channel still decided
819
+ // open or tuned out. After a retarget, traffic for this id may still
820
+ // come from the old subscription, and a close receipt that landed
821
+ // first (or during the coalescer's await) stands. Where the host
822
+ // doesn't direct it, the server opens its own channels and its
823
+ // traffic is the only evidence the host gets, so it marks the
824
+ // channel open, as it always has. The entry is the one the message
825
+ // was accepted for: one a re-registration put in its place meanwhile
826
+ // waits for its own evidence. Durable desired state still changes
827
+ // exclusively through lifecycle operations.
828
+ if (this.directsLifecycle(serverId)) {
829
+ if (this.unconfirmedChannelTargets.has(incomingEntry))
830
+ return;
831
+ const desired = this.getDesiredState(serverId, message.channelId);
832
+ if (desired !== 'open' && desired !== 'tuned-out')
833
+ return;
834
+ }
835
+ else {
836
+ this.unconfirmedChannelTargets.delete(incomingEntry);
837
+ }
838
+ incomingEntry.open = true;
718
839
  };
719
840
  if (!coalesced)
720
841
  markAccepted();
@@ -1044,7 +1165,7 @@ export class ChannelRegistry {
1044
1165
  case 'channel_decline':
1045
1166
  return this.handleToolDecline(input);
1046
1167
  case 'channel_publish':
1047
- return this.handleToolPublish(input);
1168
+ return this.handleToolPublish(input, origin);
1048
1169
  case 'think':
1049
1170
  return this.handleToolThink(input);
1050
1171
  case 'journal':
@@ -1072,9 +1193,15 @@ export class ChannelRegistry {
1072
1193
  // (heartbeats). Without this, a fork
1073
1194
  // was advertised the global locus but published somewhere else; and a trunk
1074
1195
  // agent was told the wrong channel under concurrency.
1075
- const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
1076
- const active = agentName ? this.activeChannelResolver?.(agentName) : undefined;
1077
- const outgoing = home ?? active ?? this.defaultPublishChannel;
1196
+ // Each lookup is keyed on whether a name was given, not its truthiness:
1197
+ // '' is a name an ephemeral run can have, and resolveLocus reads it too.
1198
+ const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
1199
+ const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
1200
+ // An ephemeral run takes no global fallback (takesGlobalFallback): neither
1201
+ // the latest inbound message's channel as its outgoing one, nor that
1202
+ // message as the one it is replying to.
1203
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1204
+ const outgoing = home ?? active ?? fallback;
1078
1205
  if (openChannels.length === 0 && !outgoing) {
1079
1206
  return undefined;
1080
1207
  }
@@ -1082,9 +1209,9 @@ export class ChannelRegistry {
1082
1209
  // Incoming: the most-recent inbound message (what the agent is replying to).
1083
1210
  // Left process-global — per-channel inbound tracking (the right messageId for
1084
1211
  // a fork's own channel) is a separate concern from the outbound routing fix.
1085
- if (this.defaultPublishChannel && this.defaultPublishMessageId) {
1212
+ if (fallback && this.defaultPublishMessageId) {
1086
1213
  context.incoming = {
1087
- channelId: this.defaultPublishChannel,
1214
+ channelId: fallback,
1088
1215
  messageId: this.defaultPublishMessageId,
1089
1216
  threadId: this.defaultPublishThreadId,
1090
1217
  };
@@ -1110,6 +1237,10 @@ export class ChannelRegistry {
1110
1237
  * server's channels don't linger and route speech into the void.
1111
1238
  */
1112
1239
  removeServer(serverId) {
1240
+ // Every lifecycle operation queued for this server is cancelled when it
1241
+ // would run (ChannelLifecycleCancelledError), so none acts on a closing
1242
+ // connection or on a later re-registration's entries.
1243
+ this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
1113
1244
  for (const [key, entry] of this.channels) {
1114
1245
  if (entry.serverId !== serverId)
1115
1246
  continue;
@@ -1728,6 +1859,223 @@ export class ChannelRegistry {
1728
1859
  : 'default-closed');
1729
1860
  return channel.initiallyOpen !== true && byPolicy;
1730
1861
  }
1862
+ /**
1863
+ * Apply current durable intent, serializing the transport rather than the
1864
+ * server's registration ACK. Re-read after every await: registration,
1865
+ * descriptor updates, or a newer decision may have replaced our snapshot.
1866
+ */
1867
+ applyDesiredChannelState(serverId, channelId, options) {
1868
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
1869
+ const removals = this.serverRemovals.get(serverId) ?? 0;
1870
+ const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
1871
+ this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
1872
+ const operation = previous.catch(() => { }).then(() => this.runLifecycleOperation(serverId, channelId, removals, options));
1873
+ // Keep the rejection visible to existing joiners, while observing it
1874
+ // immediately and letting each queued successor start after either result.
1875
+ const tail = operation.then(() => { });
1876
+ this.channelLifecycleTails.set(lifecycleKey, tail);
1877
+ const cleanup = () => {
1878
+ if (this.channelLifecycleTails.get(lifecycleKey) === tail) {
1879
+ this.channelLifecycleTails.delete(lifecycleKey);
1880
+ }
1881
+ };
1882
+ void tail.then(cleanup, cleanup);
1883
+ return operation;
1884
+ }
1885
+ /** One queued lifecycle operation, run when its turn in the queue comes. */
1886
+ async runLifecycleOperation(serverId, channelId, removals, options) {
1887
+ const channelKey = `${serverId}:${channelId}`;
1888
+ const lifecycleKey = this.lifecycleKey(serverId, channelId);
1889
+ try {
1890
+ let attempts = 0;
1891
+ for (;;) {
1892
+ if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
1893
+ throw new ChannelLifecycleCancelledError(`Channel ${channelId}: its server's registrations were removed while this operation was queued`);
1894
+ }
1895
+ const entry = this.channels.get(channelKey);
1896
+ if (!entry)
1897
+ throw new Error(`Channel is no longer registered: ${channelId}`);
1898
+ const server = this.serverRegistry.getServer(serverId);
1899
+ if (!server)
1900
+ throw new Error(`Server not found: ${serverId}`);
1901
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1902
+ throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
1903
+ }
1904
+ const descriptor = entry.descriptor;
1905
+ const desired = this.getDesiredState(serverId, channelId);
1906
+ const open = desired === 'open' || desired === 'tuned-out';
1907
+ // Per-RPC timeouts do not bound a stream of promptly successful but
1908
+ // continually superseded receipts. Bound this whole operation, then
1909
+ // release its queue slot and surface the failure to delivery joiners.
1910
+ // Its open flag needs no change here: every retry follows a replaced
1911
+ // registration, a retarget (already invalidated, whether the old
1912
+ // target's RPC was answered or failed), or a receipt for this very
1913
+ // target.
1914
+ if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
1915
+ const error = new ChannelLifecycleConvergenceError(`Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`);
1916
+ this.emitTraceFn({
1917
+ type: 'mcpl:channel-reconcile-failed',
1918
+ serverId, channelId, desired, attempts, error: error.message,
1919
+ });
1920
+ throw error;
1921
+ }
1922
+ attempts++;
1923
+ const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
1924
+ sameChannelTarget(entry.descriptor, descriptor);
1925
+ let result;
1926
+ try {
1927
+ if (open) {
1928
+ result = await server.sendChannelsOpen({
1929
+ channelId, type: descriptor.type, address: descriptor.address,
1930
+ ...(options?.history ? { history: options.history } : {}),
1931
+ });
1932
+ }
1933
+ else {
1934
+ await server.sendChannelsClose({ channelId });
1935
+ }
1936
+ }
1937
+ catch (error) {
1938
+ // An RPC can fail after the server acted, so a failure for the
1939
+ // current target leaves its outcome unknown: it is marked
1940
+ // unconfirmed with open unchanged, which keeps a confirmed-open
1941
+ // channel usable after a failed backscroll (the mark is read only
1942
+ // while not open). A failure from an older connection or target
1943
+ // also withdraws the open flag.
1944
+ if (this.channels.get(channelKey) === entry) {
1945
+ if (sameTarget())
1946
+ this.unconfirmedChannelTargets.add(entry);
1947
+ else
1948
+ this.invalidateTransportConfirmation(entry);
1949
+ }
1950
+ // A retarget while this RPC was out queued nothing for the new
1951
+ // target, since handleChanged leaves that to this operation. So a
1952
+ // failure for the old target says nothing about the new one, and a
1953
+ // channel still decided open or tuned out tries its new target,
1954
+ // within the attempt limit.
1955
+ if (this.channels.get(channelKey) === entry && !sameChannelTarget(entry.descriptor, descriptor)) {
1956
+ const now = this.getDesiredState(serverId, channelId);
1957
+ if (now === 'open' || now === 'tuned-out')
1958
+ continue;
1959
+ }
1960
+ throw error;
1961
+ }
1962
+ // A server can answer for a channel other than the one named: one
1963
+ // that falls back to its first channel of the type for an id it no
1964
+ // longer knows opens that channel and returns its history. Whatever
1965
+ // becomes of this receipt, the server did that: say so, and undo the
1966
+ // open it made if the host wants that channel closed.
1967
+ const substitute = open ? substitutedChannel(result, channelId) : undefined;
1968
+ if (substitute !== undefined) {
1969
+ // Each id prints through logValue, as the rejection lines' ids do,
1970
+ // so an odd one can't add a line or a field.
1971
+ const answered = logValue(substitute.kind === 'other' ? substitute.id : substitute.value);
1972
+ console.error(`[channel-open-substituted] server=${serverId} requested=${logValue(channelId)} answered=${answered}`);
1973
+ this.emitTraceFn({ type: 'mcpl:channel-open-substituted', serverId, channelId, answeredFor: answered });
1974
+ // A malformed id names nothing registered here, so there is
1975
+ // nothing to close.
1976
+ if (substitute.kind === 'other')
1977
+ this.closeSubstitutedChannel(serverId, substitute.id);
1978
+ }
1979
+ // Never apply an old receipt to the current target, even transiently:
1980
+ // a corrective open might fail before that stale bit is replaced.
1981
+ if (this.channels.get(channelKey) !== entry)
1982
+ continue;
1983
+ if (!sameTarget()) {
1984
+ this.invalidateTransportConfirmation(entry);
1985
+ continue;
1986
+ }
1987
+ // A current receipt for another channel confirms nothing about this
1988
+ // one, and its history is another channel's. So the open fails,
1989
+ // nothing in the answer is used, and this channel's open state is
1990
+ // unconfirmed: a server that doesn't resolve this id may not resolve
1991
+ // its sends either.
1992
+ if (substitute !== undefined) {
1993
+ this.invalidateTransportConfirmation(entry);
1994
+ throw new ChannelOpenSubstitutionError(`Server "${serverId}" answered channels/open for ${channelId} with ` +
1995
+ (substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
1996
+ `; ${channelId} is not marked open, and nothing in the answer is used`);
1997
+ }
1998
+ entry.open = open;
1999
+ this.unconfirmedChannelTargets.delete(entry);
2000
+ const now = this.getDesiredState(serverId, channelId);
2001
+ if (open !== (now === 'open' || now === 'tuned-out'))
2002
+ continue;
2003
+ return { open, result };
2004
+ }
2005
+ }
2006
+ finally {
2007
+ // Synchronous with the operation's last look at its target: from here
2008
+ // on, nothing it does will open a new target.
2009
+ const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
2010
+ if (left > 0)
2011
+ this.lifecycleOutstanding.set(lifecycleKey, left);
2012
+ else
2013
+ this.lifecycleOutstanding.delete(lifecycleKey);
2014
+ }
2015
+ }
2016
+ /**
2017
+ * A server that answered an open with another channel really opened that
2018
+ * one. When it's registered here and the host wants it closed, close it
2019
+ * through its own lifecycle queue: otherwise its traffic would reach the
2020
+ * resident, marked open by its first incoming message. A channel the host
2021
+ * wants open is left alone, and a close can't be answered for another
2022
+ * channel, so this never starts another substitution. An id the host
2023
+ * doesn't know needs nothing: §14.5 refuses its traffic.
2024
+ */
2025
+ closeSubstitutedChannel(serverId, channelId) {
2026
+ if (!this.channels.has(`${serverId}:${channelId}`))
2027
+ return;
2028
+ if (this.getDesiredState(serverId, channelId) !== 'closed')
2029
+ return;
2030
+ this.applyDesiredChannelState(serverId, channelId).catch((err) => {
2031
+ // Removal while queued is cancellation, as in reconcileChannels, and
2032
+ // exhausted convergence emitted its own diagnostic.
2033
+ if (!this.channels.has(`${serverId}:${channelId}`) ||
2034
+ err instanceof ChannelLifecycleCancelledError ||
2035
+ err instanceof ChannelLifecycleConvergenceError)
2036
+ return;
2037
+ this.emitTraceFn({
2038
+ type: 'mcpl:channel-reconcile-failed',
2039
+ serverId, channelId, desired: 'closed', error: err.message,
2040
+ });
2041
+ });
2042
+ }
2043
+ /**
2044
+ * Join outstanding lifecycle work without recording new intent. Retargeting
2045
+ * may have invalidated open confirmation while the decision remains pending.
2046
+ * A close that settles during the wait remains authoritative; waiting for
2047
+ * it is not an instruction to open the channel again.
2048
+ */
2049
+ async waitForOpenChannel(entry) {
2050
+ const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
2051
+ for (;;) {
2052
+ const pending = this.channelLifecycleTails.get(lifecycleKey);
2053
+ if (!pending)
2054
+ break;
2055
+ try {
2056
+ await pending;
2057
+ }
2058
+ catch (error) {
2059
+ // A redundant backscroll failure can leave a known-open channel
2060
+ // usable. Exhausted convergence is different: it never established
2061
+ // the current target, so joined delivery must fail visibly.
2062
+ if (error instanceof ChannelLifecycleConvergenceError)
2063
+ throw error;
2064
+ }
2065
+ // A settled tail releases its own key. One still current after it
2066
+ // settled would be awaited again without ever yielding to the event
2067
+ // loop, so stop waiting rather than spin.
2068
+ if (this.channelLifecycleTails.get(lifecycleKey) === pending)
2069
+ break;
2070
+ }
2071
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2072
+ if (!current)
2073
+ throw new Error('Channel registration was removed while delivery waited');
2074
+ if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
2075
+ throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
2076
+ }
2077
+ return current;
2078
+ }
1731
2079
  async reconcileChannels(serverId, channels) {
1732
2080
  const server = this.serverRegistry.getServer(serverId);
1733
2081
  if (!server)
@@ -1748,57 +2096,17 @@ export class ChannelRegistry {
1748
2096
  // announced to the agent as one batched notice after the loop, so a
1749
2097
  // first boot on a new policy doesn't produce one notice per channel.
1750
2098
  const policyOpened = [];
2099
+ // Record all admissions before waiting on any channel. A slow first RPC
2100
+ // must not delay intent for the rest of this registration batch.
1751
2101
  for (const channel of channels) {
1752
- if (this.ensureInitialDesiredState(serverId, channel)) {
1753
- policyOpened.push({ channelId: channel.id, label: channel.label });
1754
- }
1755
- const key = `${serverId}:${channel.id}`;
1756
- const desired = this.getDesiredState(serverId, channel.id);
1757
- const entry = this.channels.get(key);
1758
- // Tuned-out sits on the OPEN side of reconcile: traffic must keep
1759
- // arriving (the subconscious reads it); only main's wake/visibility
1760
- // is diverted, downstream at ingestion.
1761
- if (desired !== 'open' && desired !== 'tuned-out') {
1762
- try {
1763
- await server.sendChannelsClose({ channelId: channel.id });
1764
- if (entry)
1765
- entry.open = false;
1766
- }
1767
- catch (err) {
1768
- this.emitTraceFn({
1769
- type: 'mcpl:channel-reconcile-failed',
1770
- serverId,
1771
- channelId: channel.id,
1772
- desired,
1773
- error: err.message,
1774
- });
1775
- }
1776
- continue;
1777
- }
1778
- try {
1779
- await server.sendChannelsOpen({
1780
- channelId: channel.id,
1781
- type: channel.type,
1782
- address: channel.address,
1783
- });
1784
- if (entry)
1785
- entry.open = true;
1786
- }
1787
- catch (err) {
1788
- if (entry)
1789
- entry.open = false;
1790
- this.emitTraceFn({
1791
- type: 'mcpl:channel-reconcile-failed',
1792
- serverId,
1793
- channelId: channel.id,
1794
- desired,
1795
- error: err.message,
1796
- });
2102
+ const entry = this.channels.get(`${serverId}:${channel.id}`);
2103
+ if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
2104
+ policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
1797
2105
  }
1798
2106
  }
1799
- // Announce policy admissions to the agent — no channel may open without
1800
- // them being told, and told how to opt out (channel_close; their
1801
- // decision outranks policy).
2107
+ // Announce the durable admission decision before awaiting transport.
2108
+ // This is intent, not confirmation that an open RPC succeeded. It stays
2109
+ // truthful if the first attempt fails, and a retry needs no new admission.
1802
2110
  if (policyOpened.length > 0) {
1803
2111
  try {
1804
2112
  this.onChannelAutoOpened?.({
@@ -1811,6 +2119,32 @@ export class ChannelRegistry {
1811
2119
  console.error('onChannelAutoOpened (policy) failed:', err);
1812
2120
  }
1813
2121
  }
2122
+ // Each channel has its own lifecycle queue, so a held RPC for one (up to
2123
+ // its timeout, times its attempts) doesn't delay its siblings.
2124
+ await Promise.allSettled(channels.map(async (channel) => {
2125
+ if (!this.channels.has(`${serverId}:${channel.id}`))
2126
+ return;
2127
+ try {
2128
+ await this.applyDesiredChannelState(serverId, channel.id);
2129
+ }
2130
+ catch (err) {
2131
+ // Removal while queued is cancellation, not a failed subscription.
2132
+ // Exhausted convergence and a substituted answer already emitted
2133
+ // their own diagnostics.
2134
+ if (!this.channels.has(`${serverId}:${channel.id}`) ||
2135
+ err instanceof ChannelLifecycleCancelledError ||
2136
+ err instanceof ChannelLifecycleConvergenceError ||
2137
+ err instanceof ChannelOpenSubstitutionError)
2138
+ return;
2139
+ this.emitTraceFn({
2140
+ type: 'mcpl:channel-reconcile-failed',
2141
+ serverId,
2142
+ channelId: channel.id,
2143
+ desired: this.getDesiredState(serverId, channel.id),
2144
+ error: err.message,
2145
+ });
2146
+ }
2147
+ }));
1814
2148
  }
1815
2149
  // ==========================================================================
1816
2150
  // Private: Typing notification
@@ -1907,33 +2241,35 @@ export class ChannelRegistry {
1907
2241
  };
1908
2242
  }
1909
2243
  /**
1910
- * The single open executor: record durable desired-open intent, tell the
1911
- * server to subscribe, mark the live entry open. Every path that opens a
1912
- * channel — the agent's channel_open tool, delivery into a closed locus,
1913
- * an explicit send into a closed channel — funnels here, so lifecycle
1914
- * events, desired state, and live state can never diverge by path.
2244
+ * Record durable desired-open intent, then queue the channel's lifecycle
2245
+ * work (applyDesiredChannelState), which tells the server to subscribe and
2246
+ * marks the live entry open on a receipt for its current target. Every
2247
+ * open the agent or its speech asks for — the channel_open tool, delivery
2248
+ * into a closed locus, an explicit send into a closed channel — comes
2249
+ * here. Reconciliation queues that work directly, as the closes do
2250
+ * (channel_close, a decline, and the close of a substituted channel).
1915
2251
  * Desired state is recorded BEFORE the server round-trip: intent sticks
1916
2252
  * even if the subscribe fails, and reconciliation retries later.
2253
+ * Automatic delivery needs open transport, not a change of attention:
2254
+ * preserve tune-out epochs both before and during the round-trip.
2255
+ *
2256
+ * `opened` says whether this call turned a closed channel open, which is
2257
+ * what the delivery and reply notices tell the resident. Reconfirming the
2258
+ * transport of a channel already decided open or tuned out isn't one.
1917
2259
  */
1918
2260
  async openChannelNow(entry, source, history) {
1919
- this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
1920
- const server = this.serverRegistry.getServer(entry.serverId);
1921
- if (!server) {
1922
- throw new Error(`Server not found: ${entry.serverId}`);
2261
+ const transportOnly = source !== 'agent-tool';
2262
+ const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
2263
+ const opened = before !== 'open' && before !== 'tuned-out';
2264
+ if (!transportOnly || before !== 'tuned-out') {
2265
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
1923
2266
  }
1924
- if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1925
- // Desired state is already recorded — intent sticks; reconciliation
1926
- // will retry if the grant later widens (§6.7 expansion-on-receipt).
1927
- throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
2267
+ const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
2268
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2269
+ if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
2270
+ throw new Error('Channel open was superseded by a newer lifecycle decision');
1928
2271
  }
1929
- const result = await server.sendChannelsOpen({
1930
- channelId: entry.descriptor.id,
1931
- type: entry.descriptor.type,
1932
- address: entry.descriptor.address,
1933
- ...(history ? { history } : {}),
1934
- });
1935
- entry.open = true;
1936
- return result;
2272
+ return { result: applied.result, opened };
1937
2273
  }
1938
2274
  /**
1939
2275
  * Resolve a `>>` prose-routing target (explicit prose routing —
@@ -2119,19 +2455,25 @@ export class ChannelRegistry {
2119
2455
  return { status: 'unknown-channel' };
2120
2456
  if (matches.length > 1)
2121
2457
  return { status: 'ambiguous' };
2122
- const entry = matches[0];
2123
- const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
2124
- if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
2125
- return { status: 'already-open', ...resolved };
2126
- }
2458
+ let entry = matches[0];
2127
2459
  try {
2128
- await this.openChannelNow(entry, 'opened-by-reply');
2129
- this.emitTraceFn({
2130
- type: 'mcpl:channel-opened-by-send',
2131
- serverId: entry.serverId,
2132
- channelId: entry.descriptor.id,
2133
- });
2134
- return { status: 'opened', ...resolved };
2460
+ if (this.hasPendingLifecycle(entry)) {
2461
+ entry = await this.waitForOpenChannel(entry);
2462
+ }
2463
+ const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
2464
+ if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
2465
+ return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2466
+ }
2467
+ const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
2468
+ entry = await this.waitForOpenChannel(entry);
2469
+ if (opened) {
2470
+ this.emitTraceFn({
2471
+ type: 'mcpl:channel-opened-by-send',
2472
+ serverId: entry.serverId,
2473
+ channelId: entry.descriptor.id,
2474
+ });
2475
+ }
2476
+ return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
2135
2477
  }
2136
2478
  catch (err) {
2137
2479
  this.emitTraceFn({
@@ -2140,7 +2482,9 @@ export class ChannelRegistry {
2140
2482
  channelId: entry.descriptor.id,
2141
2483
  error: err.message,
2142
2484
  });
2143
- return { status: 'open-failed', ...resolved };
2485
+ const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
2486
+ return { status: 'open-failed', channelId: entry.descriptor.id,
2487
+ ...(current ? { label: current.descriptor.label } : {}) };
2144
2488
  }
2145
2489
  }
2146
2490
  async handleToolOpen(input) {
@@ -2170,7 +2514,7 @@ export class ChannelRegistry {
2170
2514
  };
2171
2515
  }
2172
2516
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
2173
- if (entry.open && alreadyDesiredOpen && !input.backscroll) {
2517
+ if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
2174
2518
  return {
2175
2519
  success: true,
2176
2520
  data: { channelId: input.channelId, status: 'already open' },
@@ -2183,7 +2527,7 @@ export class ChannelRegistry {
2183
2527
  ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
2184
2528
  }
2185
2529
  : undefined;
2186
- const result = await this.openChannelNow(entry, 'agent-tool', history);
2530
+ const { result } = await this.openChannelNow(entry, 'agent-tool', history);
2187
2531
  return {
2188
2532
  success: true,
2189
2533
  data: {
@@ -2195,9 +2539,18 @@ export class ChannelRegistry {
2195
2539
  };
2196
2540
  }
2197
2541
  catch (err) {
2542
+ // The decision was recorded before the round-trip and outlives its
2543
+ // failure. Say so: otherwise a later delivery that opens the channel
2544
+ // would be the resident's first sign that it was still wanted open.
2545
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
2546
+ const stillSelected = current !== undefined && !current.open &&
2547
+ this.getDesiredState(entry.serverId, input.channelId) === 'open';
2198
2548
  return {
2199
2549
  success: false,
2200
- error: `Failed to open channel: ${err.message}`,
2550
+ error: `Failed to open channel: ${err.message}` +
2551
+ (stillSelected
2552
+ ? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
2553
+ : ''),
2201
2554
  isError: true,
2202
2555
  };
2203
2556
  }
@@ -2213,7 +2566,8 @@ export class ChannelRegistry {
2213
2566
  };
2214
2567
  }
2215
2568
  const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
2216
- if (!entry.open && alreadyDesiredClosed) {
2569
+ if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
2570
+ !this.unconfirmedChannelTargets.has(entry)) {
2217
2571
  return {
2218
2572
  success: true,
2219
2573
  data: { channelId: input.channelId, status: 'already closed' },
@@ -2275,8 +2629,10 @@ export class ChannelRegistry {
2275
2629
  isError: true,
2276
2630
  };
2277
2631
  }
2278
- await server.sendChannelsClose({ channelId: input.channelId });
2279
- entry.open = false;
2632
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
2633
+ if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
2634
+ throw new Error('Channel close was superseded by a newer lifecycle decision');
2635
+ }
2280
2636
  return {
2281
2637
  success: true,
2282
2638
  data: { channelId: input.channelId, status: 'closed' },
@@ -2337,6 +2693,24 @@ export class ChannelRegistry {
2337
2693
  }
2338
2694
  }
2339
2695
  }
2696
+ // Where the host directs the lifecycle, a decline goes through the
2697
+ // lifecycle queue like any closed decision, unless a receipt confirmed
2698
+ // the transport closed with nothing pending: an unconfirmed transport may
2699
+ // be open, and pending work must settle on this decision. A failed close
2700
+ // is reported; the decision stands.
2701
+ let closeError;
2702
+ const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
2703
+ if (current && this.directsLifecycle(entry.serverId) &&
2704
+ (this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
2705
+ try {
2706
+ const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
2707
+ if (applied.open)
2708
+ closeError = 'the close was superseded by a newer lifecycle decision';
2709
+ }
2710
+ catch (error) {
2711
+ closeError = error.message;
2712
+ }
2713
+ }
2340
2714
  this.appendLifecycleEvent({
2341
2715
  kind: 'invitation-declined',
2342
2716
  serverId: entry.serverId,
@@ -2353,6 +2727,7 @@ export class ChannelRegistry {
2353
2727
  acknowledged,
2354
2728
  ...(representation ? { representation } : {}),
2355
2729
  ...(acknowledgmentError ? { acknowledgmentError } : {}),
2730
+ ...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
2356
2731
  },
2357
2732
  };
2358
2733
  }
@@ -2360,7 +2735,8 @@ export class ChannelRegistry {
2360
2735
  * Handle the synthesized `think` tool — a private reasoning scratchpad. It
2361
2736
  * sends nothing, and (unlike before) does NOT silence the turn: trailing
2362
2737
  * plain text is still routed as the reply. The thought stays in the agent's
2363
- * own context/chronicle. To deliberately not reply, the agent uses skip_reply.
2738
+ * own context/chronicle. To deliberately not reply, an agent that may call
2739
+ * skip_reply uses it.
2364
2740
  */
2365
2741
  handleToolThink(input) {
2366
2742
  return {
@@ -2369,9 +2745,11 @@ export class ChannelRegistry {
2369
2745
  // tool_use block; don't duplicate it in the result.
2370
2746
  data: {
2371
2747
  noted: true,
2748
+ // No tool to end the turn with: this handler can't see whether the
2749
+ // caller may call skip_reply, and think's own description says so
2750
+ // where it may.
2372
2751
  note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
2373
- 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
2374
- 'call skip_reply to end the turn without replying.',
2752
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it.',
2375
2753
  },
2376
2754
  };
2377
2755
  }
@@ -2495,7 +2873,11 @@ export class ChannelRegistry {
2495
2873
  }
2496
2874
  resolveLocus(conversationId) {
2497
2875
  const home = this.homeChannelResolver?.(conversationId);
2498
- return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
2876
+ return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
2877
+ }
2878
+ /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
2879
+ globalFallbackFor(conversationId) {
2880
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
2499
2881
  }
2500
2882
  /**
2501
2883
  * Resolve a publish target to exactly one registered channel: `serverId`
@@ -2573,7 +2955,7 @@ export class ChannelRegistry {
2573
2955
  const found = this.findExactEntry(target);
2574
2956
  if ('error' in found)
2575
2957
  return { status: 'failed', reason: found.error, at: at() };
2576
- const entry = found.entry;
2958
+ let entry = found.entry;
2577
2959
  const destination = {
2578
2960
  serverId: entry.serverId,
2579
2961
  channelId: entry.descriptor.id,
@@ -2595,28 +2977,59 @@ export class ChannelRegistry {
2595
2977
  at: at(),
2596
2978
  };
2597
2979
  }
2598
- if (!entry.open) {
2980
+ // Lifecycle work already queued for the channel settles first: words go
2981
+ // out only into the channel it leaves, and fail visibly if it leaves
2982
+ // none open.
2983
+ if (this.hasPendingLifecycle(entry)) {
2599
2984
  try {
2600
- await this.openChannelNow(entry, openSource);
2601
- if (target.routed) {
2602
- console.error(`[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`);
2603
- }
2604
- this.emitTraceFn({
2605
- type: 'mcpl:channel-opened-by-send',
2606
- serverId: entry.serverId,
2607
- channelId: entry.descriptor.id,
2608
- });
2609
- if (openSource === 'opened-by-delivery') {
2610
- try {
2611
- this.onChannelAutoOpened?.({
2612
- conversationId,
2613
- serverId: entry.serverId,
2614
- source: 'opened-by-delivery',
2615
- channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
2616
- });
2985
+ entry = await this.waitForOpenChannel(entry);
2986
+ }
2987
+ catch (err) {
2988
+ return {
2989
+ status: 'failed',
2990
+ destination,
2991
+ reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
2992
+ at: at(),
2993
+ };
2994
+ }
2995
+ }
2996
+ // INVARIANT: it is not possible to send into a closed channel. A send
2997
+ // into a closed-but-registered channel (the canonical case: a DM that
2998
+ // woke the agent, since DMs register closed) OPENS it first, so typing
2999
+ // indicators, reaction machinery and inbound forwarding come alive with
3000
+ // the words. If the open fails, nothing goes out: a half-alive delivery
3001
+ // is worse than a loud marker. A channel decided closed is engaged even
3002
+ // while its transport is still open (a close that failed), as a reply
3003
+ // would engage it, wherever an open can succeed. Without the lifecycle
3004
+ // grant it can't, and a send opens before it publishes, so there a
3005
+ // channel whose transport is open keeps publishing.
3006
+ const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
3007
+ CapabilityGrant.of(server).has('channels.lifecycle');
3008
+ if (!entry.open || engagesDecidedClosed) {
3009
+ try {
3010
+ const { opened } = await this.openChannelNow(entry, openSource);
3011
+ entry = await this.waitForOpenChannel(entry);
3012
+ if (opened) {
3013
+ if (target.routed) {
3014
+ console.error(`[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`);
2617
3015
  }
2618
- catch (err) {
2619
- console.error('onChannelAutoOpened (delivery) failed:', err);
3016
+ this.emitTraceFn({
3017
+ type: 'mcpl:channel-opened-by-send',
3018
+ serverId: entry.serverId,
3019
+ channelId: entry.descriptor.id,
3020
+ });
3021
+ if (openSource === 'opened-by-delivery') {
3022
+ try {
3023
+ this.onChannelAutoOpened?.({
3024
+ conversationId,
3025
+ serverId: entry.serverId,
3026
+ source: 'opened-by-delivery',
3027
+ channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
3028
+ });
3029
+ }
3030
+ catch (err) {
3031
+ console.error('onChannelAutoOpened (delivery) failed:', err);
3032
+ }
2620
3033
  }
2621
3034
  }
2622
3035
  }
@@ -2646,7 +3059,9 @@ export class ChannelRegistry {
2646
3059
  * Plain speech for one turn segment: publish `text` to the turn's frozen
2647
3060
  * locus and report the PublishOutcome. A failure (or an uncertain outcome)
2648
3061
  * is surfaced as a trace and through onRouteFailure, so the agent learns
2649
- * its words did not (or may not have) reached anyone.
3062
+ * its words did not (or may not have) reached anyone. An ephemeral run's
3063
+ * speech with no locus is the exception: it is its caller's result, so it
3064
+ * returns as not delivered and surfaces nothing.
2650
3065
  */
2651
3066
  async deliverSpeech(conversationId, text,
2652
3067
  /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
@@ -2655,7 +3070,9 @@ export class ChannelRegistry {
2655
3070
  * dispatch resolving live can read the NEXT turn's trigger state or a
2656
3071
  * stale global default and land the reply in the wrong channel (item-3,
2657
3072
  * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
2658
- * "this turn is pinned to no locus": fail loudly rather than guess. */
3073
+ * "this turn is pinned to no locus": fail loudly rather than guess,
3074
+ * except for an ephemeral run, whose speech is then its caller's
3075
+ * result. */
2659
3076
  locusChannelId,
2660
3077
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
2661
3078
  * draft with its own notice, so no failure marker is written for it.
@@ -2688,6 +3105,12 @@ export class ChannelRegistry {
2688
3105
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
2689
3106
  }
2690
3107
  if (!locusChannelId) {
3108
+ // An ephemeral run with no channel of its own: its plain speech is its
3109
+ // caller's result (takesGlobalFallback). It returns as not delivered,
3110
+ // with its own reason, and writes no line, trace or notice.
3111
+ if (this.takesGlobalFallback?.(conversationId) === false) {
3112
+ return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
3113
+ }
2691
3114
  // The turn froze with no locus (no home, no triggering channel, no
2692
3115
  // global inbound ever seen) — the agent was told its prose stays in
2693
3116
  // the archive; honor that.
@@ -2726,7 +3149,7 @@ export class ChannelRegistry {
2726
3149
  ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
2727
3150
  };
2728
3151
  }
2729
- async handleToolPublish(input) {
3152
+ async handleToolPublish(input, origin) {
2730
3153
  // Resolve content: accept both `content` and `text` (backward compat)
2731
3154
  const messageText = input.content ?? input.text;
2732
3155
  if (!messageText) {
@@ -2736,12 +3159,17 @@ export class ChannelRegistry {
2736
3159
  isError: true,
2737
3160
  };
2738
3161
  }
2739
- // Resolve channelId: default to most recent incoming channel
2740
- const channelId = input.channelId ?? this.defaultPublishChannel;
3162
+ // Resolve channelId: default to the most recent incoming channel, which an
3163
+ // ephemeral run never takes (takesGlobalFallback), so it names one.
3164
+ const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
3165
+ const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
2741
3166
  if (!channelId) {
2742
3167
  return {
2743
3168
  success: false,
2744
- error: 'No channelId specified and no default channel available',
3169
+ error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
3170
+ ? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
3171
+ + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
3172
+ : 'No channelId specified and no default channel available',
2745
3173
  isError: true,
2746
3174
  };
2747
3175
  }