@animalabs/agent-framework 0.28.0 → 0.30.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 (289) hide show
  1. package/README.md +88 -25
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +21 -0
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +47 -7
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +3 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +7 -3
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/author-presentation.d.ts +50 -0
  17. package/dist/src/author-presentation.d.ts.map +1 -0
  18. package/dist/src/author-presentation.js +58 -0
  19. package/dist/src/author-presentation.js.map +1 -0
  20. package/dist/src/auto-continue.d.ts +2 -0
  21. package/dist/src/auto-continue.d.ts.map +1 -1
  22. package/dist/src/auto-continue.js +5 -2
  23. package/dist/src/auto-continue.js.map +1 -1
  24. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  25. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/isolate/runner.js +7 -5
  27. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  28. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  29. package/dist/src/code-execution/isolate/scripts.js +3 -2
  30. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  31. package/dist/src/dendrite/index.d.ts +6 -0
  32. package/dist/src/dendrite/index.d.ts.map +1 -0
  33. package/dist/src/dendrite/index.js +3 -0
  34. package/dist/src/dendrite/index.js.map +1 -0
  35. package/dist/src/dendrite/presets.d.ts +60 -0
  36. package/dist/src/dendrite/presets.d.ts.map +1 -0
  37. package/dist/src/dendrite/presets.js +117 -0
  38. package/dist/src/dendrite/presets.js.map +1 -0
  39. package/dist/src/dendrite/registry.d.ts +260 -0
  40. package/dist/src/dendrite/registry.d.ts.map +1 -0
  41. package/dist/src/dendrite/registry.js +742 -0
  42. package/dist/src/dendrite/registry.js.map +1 -0
  43. package/dist/src/dendrite/types.d.ts +392 -0
  44. package/dist/src/dendrite/types.d.ts.map +1 -0
  45. package/dist/src/dendrite/types.js +24 -0
  46. package/dist/src/dendrite/types.js.map +1 -0
  47. package/dist/src/framework.d.ts +846 -67
  48. package/dist/src/framework.d.ts.map +1 -1
  49. package/dist/src/framework.js +4213 -924
  50. package/dist/src/framework.js.map +1 -1
  51. package/dist/src/gate/event-gate.d.ts +46 -14
  52. package/dist/src/gate/event-gate.d.ts.map +1 -1
  53. package/dist/src/gate/event-gate.js +143 -53
  54. package/dist/src/gate/event-gate.js.map +1 -1
  55. package/dist/src/gate/index.d.ts +1 -1
  56. package/dist/src/gate/index.d.ts.map +1 -1
  57. package/dist/src/gate/index.js +1 -1
  58. package/dist/src/gate/index.js.map +1 -1
  59. package/dist/src/gate/types.d.ts +12 -2
  60. package/dist/src/gate/types.d.ts.map +1 -1
  61. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  62. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  63. package/dist/src/heartbeat/heartbeat.js +34 -8
  64. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  65. package/dist/src/held-writes.d.ts +3 -3
  66. package/dist/src/held-writes.js +3 -3
  67. package/dist/src/host-presentation.d.ts +174 -0
  68. package/dist/src/host-presentation.d.ts.map +1 -0
  69. package/dist/src/host-presentation.js +565 -0
  70. package/dist/src/host-presentation.js.map +1 -0
  71. package/dist/src/index.d.ts +7 -4
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +7 -2
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/line-reader.d.ts +4 -0
  76. package/dist/src/line-reader.d.ts.map +1 -0
  77. package/dist/src/line-reader.js +59 -0
  78. package/dist/src/line-reader.js.map +1 -0
  79. package/dist/src/mcpl/channel-registry.d.ts +118 -15
  80. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  81. package/dist/src/mcpl/channel-registry.js +266 -110
  82. package/dist/src/mcpl/channel-registry.js.map +1 -1
  83. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  84. package/dist/src/mcpl/conversation-router.js +2 -1
  85. package/dist/src/mcpl/conversation-router.js.map +1 -1
  86. package/dist/src/mcpl/convert-block.d.ts +1 -0
  87. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  88. package/dist/src/mcpl/convert-block.js +16 -4
  89. package/dist/src/mcpl/convert-block.js.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  91. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  92. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  93. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  94. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  95. package/dist/src/mcpl/inbound-source.js +3 -1
  96. package/dist/src/mcpl/inbound-source.js.map +1 -1
  97. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/modern-connection.js +11 -7
  99. package/dist/src/mcpl/modern-connection.js.map +1 -1
  100. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  101. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  102. package/dist/src/mcpl/protocol-family.js +25 -3
  103. package/dist/src/mcpl/protocol-family.js.map +1 -1
  104. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  105. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  106. package/dist/src/mcpl/push-coalescer.js +21 -0
  107. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  108. package/dist/src/mcpl/push-handler.js +2 -2
  109. package/dist/src/mcpl/push-handler.js.map +1 -1
  110. package/dist/src/mcpl/references.d.ts +12 -40
  111. package/dist/src/mcpl/references.d.ts.map +1 -1
  112. package/dist/src/mcpl/references.js +29 -23
  113. package/dist/src/mcpl/references.js.map +1 -1
  114. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  115. package/dist/src/mcpl/server-connection.js +20 -20
  116. package/dist/src/mcpl/server-connection.js.map +1 -1
  117. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  118. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  119. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  120. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  121. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-presence.js +18 -17
  123. package/dist/src/mcpl/tool-presence.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  125. package/dist/src/mcpl/tool-signature.js +17 -16
  126. package/dist/src/mcpl/tool-signature.js.map +1 -1
  127. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  128. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  129. package/dist/src/mcpl/toolset-host.js +16 -6
  130. package/dist/src/mcpl/toolset-host.js.map +1 -1
  131. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  132. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  133. package/dist/src/mcpl/toolsets-module.js +187 -50
  134. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  135. package/dist/src/mcpl/transport.d.ts +1 -1
  136. package/dist/src/mcpl/transport.d.ts.map +1 -1
  137. package/dist/src/mcpl/transport.js +16 -16
  138. package/dist/src/mcpl/transport.js.map +1 -1
  139. package/dist/src/mcpl/types.d.ts +28 -12
  140. package/dist/src/mcpl/types.d.ts.map +1 -1
  141. package/dist/src/mcpl/types.js.map +1 -1
  142. package/dist/src/module-registry.d.ts +6 -7
  143. package/dist/src/module-registry.d.ts.map +1 -1
  144. package/dist/src/module-registry.js +6 -7
  145. package/dist/src/module-registry.js.map +1 -1
  146. package/dist/src/modules/api/index.d.ts.map +1 -1
  147. package/dist/src/modules/api/index.js +4 -2
  148. package/dist/src/modules/api/index.js.map +1 -1
  149. package/dist/src/modules/discord/index.d.ts.map +1 -1
  150. package/dist/src/modules/discord/index.js +33 -25
  151. package/dist/src/modules/discord/index.js.map +1 -1
  152. package/dist/src/modules/history/index.js +8 -6
  153. package/dist/src/modules/history/index.js.map +1 -1
  154. package/dist/src/modules/sender-name.d.ts +7 -0
  155. package/dist/src/modules/sender-name.d.ts.map +1 -0
  156. package/dist/src/modules/sender-name.js +10 -0
  157. package/dist/src/modules/sender-name.js.map +1 -0
  158. package/dist/src/modules/workspace/answers.d.ts +5 -1
  159. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/answers.js +16 -1
  161. package/dist/src/modules/workspace/answers.js.map +1 -1
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +6 -4
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/effects.d.ts +4 -2
  166. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/effects.js +50 -40
  168. package/dist/src/modules/workspace/effects.js.map +1 -1
  169. package/dist/src/modules/workspace/index.d.ts +1 -0
  170. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/index.js +66 -36
  172. package/dist/src/modules/workspace/index.js.map +1 -1
  173. package/dist/src/modules/workspace/observe.d.ts +51 -20
  174. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/observe.js +120 -82
  176. package/dist/src/modules/workspace/observe.js.map +1 -1
  177. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  178. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/reconcile.js +133 -37
  180. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  181. package/dist/src/notice-tags.d.ts +81 -0
  182. package/dist/src/notice-tags.d.ts.map +1 -0
  183. package/dist/src/notice-tags.js +85 -0
  184. package/dist/src/notice-tags.js.map +1 -0
  185. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  186. package/dist/src/reasoning-loss-notice.js +5 -3
  187. package/dist/src/reasoning-loss-notice.js.map +1 -1
  188. package/dist/src/silent-heartbeat.d.ts +5 -55
  189. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  190. package/dist/src/silent-heartbeat.js +63 -7
  191. package/dist/src/silent-heartbeat.js.map +1 -1
  192. package/dist/src/stored-injections.d.ts +4 -0
  193. package/dist/src/stored-injections.d.ts.map +1 -1
  194. package/dist/src/stored-injections.js +6 -0
  195. package/dist/src/stored-injections.js.map +1 -1
  196. package/dist/src/supplied-text.d.ts +35 -0
  197. package/dist/src/supplied-text.d.ts.map +1 -0
  198. package/dist/src/supplied-text.js +141 -0
  199. package/dist/src/supplied-text.js.map +1 -0
  200. package/dist/src/tool-args.d.ts.map +1 -1
  201. package/dist/src/tool-args.js +9 -8
  202. package/dist/src/tool-args.js.map +1 -1
  203. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  204. package/dist/src/tool-image-ledger.js +2 -1
  205. package/dist/src/tool-image-ledger.js.map +1 -1
  206. package/dist/src/tool-presentation.d.ts.map +1 -1
  207. package/dist/src/tool-presentation.js +5 -4
  208. package/dist/src/tool-presentation.js.map +1 -1
  209. package/dist/src/tune-out/coordinator.d.ts +382 -11
  210. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  211. package/dist/src/tune-out/coordinator.js +1100 -119
  212. package/dist/src/tune-out/coordinator.js.map +1 -1
  213. package/dist/src/tune-out/tools.d.ts +101 -5
  214. package/dist/src/tune-out/tools.d.ts.map +1 -1
  215. package/dist/src/tune-out/tools.js +55 -18
  216. package/dist/src/tune-out/tools.js.map +1 -1
  217. package/dist/src/types/agent.d.ts +48 -9
  218. package/dist/src/types/agent.d.ts.map +1 -1
  219. package/dist/src/types/framework.d.ts +142 -0
  220. package/dist/src/types/framework.d.ts.map +1 -1
  221. package/dist/src/types/module.d.ts +22 -11
  222. package/dist/src/types/module.d.ts.map +1 -1
  223. package/dist/src/types/trace.d.ts +136 -0
  224. package/dist/src/types/trace.d.ts.map +1 -1
  225. package/package.json +3 -3
  226. package/src/admission/admit.ts +113 -0
  227. package/src/agent.ts +52 -7
  228. package/src/api/mcp-server-core.ts +3 -14
  229. package/src/api/server.ts +7 -3
  230. package/src/author-presentation.ts +81 -0
  231. package/src/auto-continue.ts +8 -2
  232. package/src/code-execution/isolate/runner.ts +7 -5
  233. package/src/code-execution/isolate/scripts.ts +3 -2
  234. package/src/dendrite/index.ts +32 -0
  235. package/src/dendrite/presets.ts +149 -0
  236. package/src/dendrite/registry.ts +808 -0
  237. package/src/dendrite/types.ts +397 -0
  238. package/src/framework.ts +4362 -852
  239. package/src/gate/event-gate.ts +159 -52
  240. package/src/gate/index.ts +1 -1
  241. package/src/gate/types.ts +12 -2
  242. package/src/heartbeat/heartbeat.ts +48 -9
  243. package/src/held-writes.ts +3 -3
  244. package/src/host-presentation.ts +612 -0
  245. package/src/index.ts +17 -4
  246. package/src/line-reader.ts +58 -0
  247. package/src/mcpl/channel-registry.ts +306 -111
  248. package/src/mcpl/conversation-router.ts +2 -1
  249. package/src/mcpl/convert-block.ts +17 -4
  250. package/src/mcpl/hook-orchestrator.ts +4 -3
  251. package/src/mcpl/inbound-source.ts +3 -1
  252. package/src/mcpl/modern-connection.ts +11 -7
  253. package/src/mcpl/protocol-family.ts +24 -3
  254. package/src/mcpl/push-coalescer.ts +19 -0
  255. package/src/mcpl/push-handler.ts +2 -2
  256. package/src/mcpl/references.ts +31 -23
  257. package/src/mcpl/server-connection.ts +20 -18
  258. package/src/mcpl/skip-reply-wake.ts +35 -0
  259. package/src/mcpl/tool-presence.ts +18 -17
  260. package/src/mcpl/tool-signature.ts +17 -16
  261. package/src/mcpl/toolset-host.ts +18 -6
  262. package/src/mcpl/toolsets-module.ts +149 -50
  263. package/src/mcpl/transport.ts +18 -14
  264. package/src/mcpl/types.ts +29 -12
  265. package/src/module-registry.ts +6 -7
  266. package/src/modules/api/index.ts +4 -2
  267. package/src/modules/discord/index.ts +37 -25
  268. package/src/modules/history/index.ts +8 -6
  269. package/src/modules/sender-name.ts +9 -0
  270. package/src/modules/workspace/answers.ts +20 -2
  271. package/src/modules/workspace/direct-write.ts +4 -3
  272. package/src/modules/workspace/effects.ts +50 -40
  273. package/src/modules/workspace/index.ts +58 -34
  274. package/src/modules/workspace/observe.ts +139 -88
  275. package/src/modules/workspace/reconcile.ts +159 -36
  276. package/src/notice-tags.ts +86 -0
  277. package/src/reasoning-loss-notice.ts +5 -3
  278. package/src/silent-heartbeat.ts +11 -7
  279. package/src/stored-injections.ts +7 -0
  280. package/src/supplied-text.ts +154 -0
  281. package/src/tool-args.ts +9 -8
  282. package/src/tool-image-ledger.ts +2 -1
  283. package/src/tool-presentation.ts +5 -4
  284. package/src/tune-out/coordinator.ts +1252 -123
  285. package/src/tune-out/tools.ts +152 -11
  286. package/src/types/agent.ts +51 -9
  287. package/src/types/framework.ts +146 -0
  288. package/src/types/module.ts +23 -11
  289. package/src/types/trace.ts +150 -0
@@ -13,8 +13,10 @@
13
13
  * - Build channel context for beforeInference params
14
14
  */
15
15
 
16
+ import { NOTICE_TAG } from '../notice-tags.js';
16
17
  import type { ContentBlock } from '@animalabs/membrane';
17
- import { convertBlock } from './convert-block.js';
18
+ import { convertBlocks } from './convert-block.js';
19
+ import { cutToFit, markedText, markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from '../supplied-text.js';
18
20
  import type { JsStore } from '@animalabs/chronicle';
19
21
  import { isDeepStrictEqual } from 'node:util';
20
22
  import { ensureStateRegistered } from '../module-registry.js';
@@ -45,7 +47,7 @@ import { RefusalLines } from './refusal-lines.js';
45
47
  import { CapabilityGrant } from './capability-grant.js';
46
48
  import type { InboundSource } from './inbound-source.js';
47
49
  import { McplRequestError, type McplServerConnection } from './server-connection.js';
48
- import { readSkipReplyWake } from './skip-reply-wake.js';
50
+ import { readSkipReplyWake, skipReplyNote, type SkipReplyEarlierWake } from './skip-reply-wake.js';
49
51
  import { isProseTargetToken } from './prose-grammar.js';
50
52
 
51
53
  // ============================================================================
@@ -564,11 +566,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
564
566
  },
565
567
  {
566
568
  name: 'channel_publish',
567
- description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.",
569
+ description: "Publish a message to a channel. If channelId is omitted, publishes where your plain speech goes this turn: your conversation's channel if you are bound to one; otherwise the channel of the message that woke this turn, or one you opened during it; otherwise, while a focus holds, the channel it keeps. Where none of those names a channel, nothing is posted, unless this host is set to use the channel of the latest incoming message instead. A turn with no channel of its own, such as a heartbeat's, has no default, so it names one. A subagent has no default, so it must name a channel.",
568
570
  inputSchema: {
569
571
  type: 'object' as const,
570
572
  properties: {
571
- channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" },
573
+ channelId: { type: 'string', description: "ID of the channel to publish to (defaults to where your plain speech goes this turn, as the tool's description says; required of a subagent)" },
572
574
  content: { type: 'string', description: 'Text content to publish' },
573
575
  text: { type: 'string', description: 'Alias for content' },
574
576
  },
@@ -643,19 +645,21 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
643
645
  // Constructor Options
644
646
  // ============================================================================
645
647
 
646
- /** Bound connector values for notices, logs and an outcome's reason. A
647
- * PublishOutcome keeps the connector's structured data (`detail`) as the
648
- * connection gives it, its strings redacted (redactedData). */
648
+ /** Bound connector values for notices, logs and an outcome's reason, on one
649
+ * line with their notice openings marked (suppliedValue). A PublishOutcome
650
+ * keeps the connector's structured data (`detail`) as the connection gives
651
+ * it, its strings redacted (redactedData). */
649
652
  export function connectorText(value: unknown): string {
650
653
  let text: string;
651
654
  try {
652
655
  text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
653
656
  } catch {
654
- return '[unserializable connector value]';
657
+ return `[${NOTICE_TAG.unserializableConnectorValue}]`;
655
658
  }
656
659
  const limit = 2000;
657
- const suffix = '… [truncated]';
658
- return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
660
+ const suffix = `… [${NOTICE_TAG.truncated}]`;
661
+ const shown = suppliedValue(text);
662
+ return shown.length > limit ? cutToFit(shown, limit - suffix.length) + suffix : shown;
659
663
  }
660
664
 
661
665
  /**
@@ -688,6 +692,9 @@ export function sameChannel(a: ChannelRef | null | undefined, b: ChannelRef | nu
688
692
  return a.serverId === b.serverId && a.channelId === b.channelId;
689
693
  }
690
694
 
695
+ export const NO_CHANNEL_EXAMPLE = '#<channel>';
696
+ export const NO_PERSON_EXAMPLE = '@<person>';
697
+
691
698
  /**
692
699
  * A channel's server-qualified name, `serverId:channelId`. A name given as a
693
700
  * string (a tool's channelId, a `>>` target, a host's call) may take this
@@ -699,12 +706,32 @@ export function qualifiedChannelName(ref: ChannelRef): string {
699
706
  return `${ref.serverId}:${ref.channelId}`;
700
707
  }
701
708
 
709
+ function markedHistory(history: readonly unknown[]): unknown[] {
710
+ return history.map((item) => {
711
+ if (item === null || typeof item !== 'object' || Array.isArray(item) || !('content' in item)) return item;
712
+ const { content } = item as { content: unknown };
713
+ if (Array.isArray(content)) return { ...item, content: markNoticeOpenings(content.map(withEmbeddedTextMarked) as object[]) };
714
+ return typeof content === 'string' ? { ...item, content: markedText(content) } : item;
715
+ });
716
+ }
717
+
718
+ function withEmbeddedTextMarked(block: unknown): unknown {
719
+ if (block === null || typeof block !== 'object' || (block as { type?: unknown }).type !== 'resource') return block;
720
+ const { resource } = block as { resource?: unknown };
721
+ if (resource === null || typeof resource !== 'object' || typeof (resource as { text?: unknown }).text !== 'string') return block;
722
+ return { ...block, resource: { ...resource, text: markedText((resource as { text: string }).text) } };
723
+ }
724
+
702
725
  /** A channel told apart by its id and its server, for one no string picks
703
- * alone: `"b:room" on server "a"`. No input takes it for a name. */
726
+ * alone or whose name wouldn't show as itself: `"b:room" on server "a"`,
727
+ * each a JSON literal a channel tool's channelId and serverId take back
728
+ * (quotedValue). No input takes the whole form for a name. */
704
729
  function channelOnServer(ref: ChannelRef): string {
705
- return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
730
+ return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
706
731
  }
707
732
 
733
+ const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
734
+
708
735
  /**
709
736
  * What one channels/publish attempt established (ChannelRegistry.publish).
710
737
  * - `delivered`: the connector returned `delivered: true`. A historical
@@ -965,8 +992,35 @@ interface ChannelRegistryOptions {
965
992
  ) => Promise<ChannelIncomingMessageResult & { firstView?: true }>;
966
993
  /** Chronicle store used for durable desired channel lifecycle state. */
967
994
  store?: JsStore;
968
- /** Callback to determine whether an incoming message should trigger inference. */
995
+ /**
996
+ * The server-level veto on waking for an incoming message (a per-server
997
+ * `McplServerConfig.shouldTriggerInference`). The wake gate is NOT wired
998
+ * here: it runs in the framework's admission stage, after the hold
999
+ * decision, so a held message never forms a gate batch (issue #232 gap 3).
1000
+ */
969
1001
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1002
+ /**
1003
+ * Whether the most recent delivered inbound message is a fallback speech
1004
+ * locus for turns that have none of their own (`speech.locus:
1005
+ * 'latest-inbound'`). Off by default: incoming traffic never moves the
1006
+ * locus (architecture channel, 2026-10-06).
1007
+ */
1008
+ latestInboundLocus?: boolean;
1009
+ /**
1010
+ * A channel is being closed by tool: an all-except hold whose exception
1011
+ * this is ends through the cancel flow (release the backlog, record the
1012
+ * end) before the lifecycle state flips to closed. A
1013
+ * tuned-out channel's close is refused before this (its cancel ends it).
1014
+ * Returns what it ended, in a sentence the close's answer adds when the
1015
+ * close itself isn't recorded.
1016
+ */
1017
+ onToolClose?: (serverId: string, channelId: string) => string | undefined;
1018
+ /**
1019
+ * The channel unprefixed speech lands in while an all-except hold is
1020
+ * active (its exception), else null. Consulted after a fork's home and
1021
+ * the turn's own delivery, before the legacy fallback.
1022
+ */
1023
+ focusChannelResolver?: () => ChannelRef | null;
970
1024
  /**
971
1025
  * Called when a text-only turn's speech was not delivered to its
972
1026
  * conversational locus (`failed`), or may not have been (`unknown`), for
@@ -1016,7 +1070,7 @@ interface ChannelRegistryOptions {
1016
1070
  /**
1017
1071
  * Resolve a conversation fork's HOME channel from its agent name. Conversation
1018
1072
  * forks are spawned bound to a single channel (the framework tracks this in
1019
- * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
1073
+ * the agent registry's `homeChannel` / `ConversationRouter.channelForAgent`); their
1020
1074
  * plain-text speech must route THERE. The process-global `defaultPublishChannel`
1021
1075
  * tracks only the most recent inbound message across ALL channels (reaction,
1022
1076
  * reaction-removal, edit and deletion markers aside, apart from an edit the
@@ -1089,6 +1143,14 @@ export type ChannelToolOrigin =
1089
1143
  * name one of them answers to by its id means that channel
1090
1144
  * (ChannelRegistry.ownChannelNamed). */
1091
1145
  own?: readonly ChannelRef[];
1146
+ /** For a skip_reply whose self-wake the framework armed: the heartbeat's
1147
+ * wake due before it, when there is one, which its confirmation names
1148
+ * (skipReplyNote). */
1149
+ earlierWake?: SkipReplyEarlierWake;
1150
+ /** Made inside one of the agent's turns (the framework's callingTurnOf):
1151
+ * a channel_publish naming no channel arrives with that turn's default
1152
+ * already applied, so it takes no other. */
1153
+ inTurn?: boolean;
1092
1154
  };
1093
1155
 
1094
1156
  /** The machine decision sources a module-origin channel_close may record.
@@ -1123,6 +1185,9 @@ export class ChannelRegistry {
1123
1185
  op?: 'start' | 'stop',
1124
1186
  ) => void;
1125
1187
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1188
+ private readonly latestInboundLocus: boolean;
1189
+ private onToolClose?: (serverId: string, channelId: string) => string | undefined;
1190
+ private focusChannelResolver?: () => ChannelRef | null;
1126
1191
  private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
1127
1192
  private onChannelAutoOpened?: (info: {
1128
1193
  conversationId?: string;
@@ -1551,6 +1616,9 @@ export class ChannelRegistry {
1551
1616
  this.emitTraceFn = emitTraceFn;
1552
1617
  this.sendTypingFn = options?.sendTypingFn;
1553
1618
  this.shouldTriggerInference = options?.shouldTriggerInference;
1619
+ this.latestInboundLocus = options?.latestInboundLocus === true;
1620
+ this.onToolClose = options?.onToolClose;
1621
+ this.focusChannelResolver = options?.focusChannelResolver;
1554
1622
  this.onRouteFailure = options?.onRouteFailure;
1555
1623
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1556
1624
  this.homeChannelResolver = options?.homeChannelResolver;
@@ -1792,9 +1860,6 @@ export class ChannelRegistry {
1792
1860
  // The batch came on the transport open now: a close after this point
1793
1861
  // (transportClosed) leaves its traffic nothing to confirm.
1794
1862
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1795
- // And came from a server registered now: one removed since (removeServer)
1796
- // gives no message the agent replies to (markAccepted).
1797
- const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1798
1863
 
1799
1864
  for (const message of params.messages) {
1800
1865
  if (!message || typeof message.channelId !== 'string' || !message.channelId
@@ -1894,34 +1959,25 @@ export class ChannelRegistry {
1894
1959
  }
1895
1960
  }
1896
1961
  if (message.tags) message.tags = expandCoreTags(message.tags);
1897
- const convertedContent: ContentBlock[] = message.content.map((block) => convertBlock(block, 'attachment on channel message', serverId));
1962
+ const convertedContent: ContentBlock[] = convertBlocks(message.content, 'attachment on channel message', serverId);
1898
1963
  if (!emptyAllowed && isVisiblyEmptyConversion(message.content, convertedContent)) {
1899
1964
  rejectEmpty();
1900
1965
  continue;
1901
1966
  }
1902
1967
 
1903
- // Track default publish channel (most recent ACCEPTED incoming) —
1904
- // deliberately after §14.5 validation: a rejected message from an
1905
- // unregistered channel must not retarget outbound speech (the locus is
1906
- // exactly the authority a self-attested channel would be stealing).
1907
- // A coalesced item is "accepted" only once the coalescer admits it, so
1908
- // for those this runs after the hook (below).
1968
+ // A server sending channels/incoming is evidence that the transport
1969
+ // is open. This repairs transient status only; durable desired state
1970
+ // still changes exclusively through lifecycle operations. Nothing else
1971
+ // is decided here: whether this message is delivered, wakes anyone or
1972
+ // counts as the latest inbound is the framework's admission decision,
1973
+ // taken once the hold is known (see `noteDelivered`; issue #232 root
1974
+ // cause A). A coalesced item is "accepted" only once the coalescer
1975
+ // admits it, so for those this runs after the hook (below).
1909
1976
  const incomingEntry = this.channels.get(incomingKey)!;
1910
- const markAccepted = (firstView = false) => {
1911
- // Non-conversational markers (a reaction added or removed, an edit, a
1912
- // deletion, and a chat:system notice such as a join or the weather)
1913
- // are ABOUT a message or the place, not one the agent replies to: they
1914
- // must not retarget the default publish locus or become
1915
- // `context.incoming`'s reply anchor. An edit the coalescer delivered
1916
- // as the agent's first view counts as the message it replaced.
1917
- // A message the coalescer admits after its server was removed sets no
1918
- // default: the removal cleared that server's message, and a server
1919
- // that's gone mustn't displace a live one's default.
1920
- if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1921
- this.defaultPublishChannel = { serverId, channelId: message.channelId };
1922
- this.defaultPublishMessageId = message.messageId;
1923
- this.defaultPublishThreadId = message.threadId;
1924
- }
1977
+ // `firstView` is the coalescer's mark that an edit was the agent's first
1978
+ // view of its message; the registry no longer records the publish locus
1979
+ // here (admission does, through noteDelivered, with the same marker rule).
1980
+ const markAccepted = (_firstView = false) => {
1925
1981
  // Where the host directs this server's lifecycle, traffic shows the
1926
1982
  // transport open only for a channel still decided open or tuned out,
1927
1983
  // and, but for one exception, only for a confirmed target: after a
@@ -1956,7 +2012,7 @@ export class ChannelRegistry {
1956
2012
  };
1957
2013
  if (!coalesced) markAccepted();
1958
2014
 
1959
- // Determine whether to trigger inference
2015
+ // The server-level veto only; the wake gate runs at admission.
1960
2016
  let triggerInference = true;
1961
2017
  if (this.shouldTriggerInference) {
1962
2018
  // The text the agent's context gets, as the push lane's filter reads
@@ -2258,16 +2314,58 @@ export class ChannelRegistry {
2258
2314
  // Accessors
2259
2315
  // ==========================================================================
2260
2316
 
2317
+ /**
2318
+ * The most recent DELIVERED inbound message (channel, message, thread).
2319
+ * Recorded by the framework after admission, so a held message never
2320
+ * becomes "the latest inbound" (issue #232 gap 2), and never for a marker
2321
+ * (a reaction, reaction removal, edit or deletion, apart from an edit the
2322
+ * coalescer delivered as the agent's first view: isMarkerTagged). Read as
2323
+ * the `incoming` reference in the channel context and, under
2324
+ * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The
2325
+ * channel is on the server the message came from. A message whose server
2326
+ * no longer registers its channel sets nothing, as one the coalescer admits
2327
+ * after its server was removed: the removal cleared that server's message,
2328
+ * and a server that's gone mustn't displace a live one's anchor. That's
2329
+ * read at admission, where main's guard compared the server's removal
2330
+ * count at arrival and at acceptance (markAccepted): a message that
2331
+ * arrived before its server's removal and is admitted once the server
2332
+ * registers the channel again sets the anchor here, as a message delivered
2333
+ * on a live channel, where main's would set nothing.
2334
+ */
2335
+ noteDelivered(channel: ChannelRef, messageId: string, threadId?: string, tags?: unknown, firstView = false): void {
2336
+ // A marker is ABOUT a message, not one the agent replies to: it leaves
2337
+ // the anchor where it was (#157), and so does a chat:system notice (a
2338
+ // join, the weather), which is about the place (isMarkerTagged). An edit
2339
+ // the coalescer delivered as the agent's first view of its message counts
2340
+ // as that message.
2341
+ if (isMarkerTagged(tags, firstView)) return;
2342
+ if (!this.entryAt(channel)) return;
2343
+ this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId };
2344
+ this.defaultPublishMessageId = messageId;
2345
+ this.defaultPublishThreadId = threadId;
2346
+ }
2347
+
2261
2348
  /**
2262
2349
  * Get the default publish channel ID: the channel of the most recent
2263
- * incoming message that isn't a reaction, reaction removal, edit or
2264
- * deletion marker, apart from an edit the coalescer delivers on acceptance
2265
- * as the agent's first view.
2350
+ * delivered incoming message that isn't a reaction, reaction removal, edit
2351
+ * or deletion marker, apart from an edit the coalescer delivers on
2352
+ * acceptance as the agent's first view.
2266
2353
  */
2267
2354
  getDefaultPublishChannel(): ChannelRef | null {
2268
2355
  return this.defaultPublishChannel;
2269
2356
  }
2270
2357
 
2358
+ /**
2359
+ * Public form of the tool-input channel resolution (resolveToolChannelEntry):
2360
+ * optional server, the calling turn's own channels (`own`), ambiguity is an
2361
+ * error.
2362
+ */
2363
+ resolveChannel(channelId: string, serverId?: string, own?: readonly ChannelRef[]): ChannelRef | { error: string } {
2364
+ const resolved = this.resolveToolChannelEntry(channelId, serverId, own);
2365
+ if (!resolved.entry) return { error: resolved.error ?? `Channel not found: ${channelId}` };
2366
+ return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id };
2367
+ }
2368
+
2271
2369
  /**
2272
2370
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
2273
2371
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -2386,7 +2484,10 @@ export class ChannelRegistry {
2386
2484
  return this.handleToolJournal(input as { content?: string });
2387
2485
 
2388
2486
  case 'skip_reply':
2389
- return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });
2487
+ return this.handleToolSkipReply(
2488
+ input as { reason?: string; wake_in_seconds?: unknown },
2489
+ origin?.kind === 'agent' ? origin.earlierWake : undefined,
2490
+ );
2390
2491
 
2391
2492
  default:
2392
2493
  // Not every agent is given every channel tool, so the refusal points
@@ -2405,7 +2506,7 @@ export class ChannelRegistry {
2405
2506
 
2406
2507
  /**
2407
2508
  * The message the agent is replying to, with its channel: the most recent
2408
- * inbound message that isn't a reaction, reaction removal, edit or deletion
2509
+ * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion
2409
2510
  * marker, apart from an edit the coalescer delivers on acceptance as the
2410
2511
  * agent's first view. Left process-global: per-channel inbound tracking
2411
2512
  * (the right messageId for a fork's own channel) is a separate concern from
@@ -2422,9 +2523,13 @@ export class ChannelRegistry {
2422
2523
  if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2423
2524
  return undefined;
2424
2525
  }
2425
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2426
- if (!fallback || !this.defaultPublishMessageId) return undefined;
2427
- return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2526
+ // The latest delivered inbound itself, not the speech fallback
2527
+ // (globalFallbackFor), which under `speech.locus: 'delivery'` is the
2528
+ // all-except hold's excepted channel while one is open, and none
2529
+ // otherwise.
2530
+ const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel;
2531
+ if (!anchor || !this.defaultPublishMessageId) return undefined;
2532
+ return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2428
2533
  }
2429
2534
 
2430
2535
  /**
@@ -2442,23 +2547,27 @@ export class ChannelRegistry {
2442
2547
  // declared it inbound-only, since speech isn't posted there): a conversation
2443
2548
  // fork's home channel, else this turn's channel (single trunk agent — item-3
2444
2549
  // redux: its locus, else its triggering channel), else the global default
2445
- // (heartbeats). Without this, a fork
2550
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
2551
+ // Without this, a fork
2446
2552
  // was advertised the global locus but published somewhere else; and a trunk
2447
2553
  // agent was told the wrong channel under concurrency.
2448
2554
  // Each lookup is keyed on whether a name was given, not its truthiness:
2449
2555
  // '' is a name an ephemeral run can have, and resolveLocus reads it too.
2450
2556
  const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
2451
2557
  const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
2452
- // An ephemeral run takes no global fallback (takesGlobalFallback): neither
2453
- // the latest inbound message's channel as its outgoing one, nor that
2454
- // message as the one it is replying to.
2455
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2558
+ // An ephemeral run takes no global fallback (takesGlobalFallback): not the
2559
+ // all-except hold's channel, not the latest inbound message's channel as
2560
+ // its outgoing one, nor that message as the one it is replying to.
2561
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
2456
2562
  // A turn that deliberately has no channel (null) takes no fallback.
2457
2563
  const outgoing = home ?? (active !== undefined ? active : fallback);
2458
2564
 
2459
2565
  const context: ChannelContext = {};
2460
2566
 
2461
- // Incoming: the message the agent is replying to (incomingFor).
2567
+ // Incoming: the message the agent is replying to (incomingFor). The
2568
+ // anchor is independent of the speech rule: under `speech.locus:
2569
+ // 'delivery'` the latest inbound is no fallback for where prose lands,
2570
+ // but it is still the message the agent is replying to.
2462
2571
  const incoming = this.incomingFor(agentName);
2463
2572
  if (incoming && incoming.channel.serverId === serverId) {
2464
2573
  context.incoming = {
@@ -3921,12 +4030,20 @@ export class ChannelRegistry {
3921
4030
  * name, only where no registered channel takes that name, as a channel
3922
4031
  * tool reads it or a `>>` target does (by label or raw id too), so the
3923
4032
  * name can't send anything to another channel; else by its id and server.
4033
+ * A name that wouldn't show as itself (showsAsItself: one holding a line
4034
+ * break, a notice opening, a quote, a backslash or a lone surrogate, or
4035
+ * whitespace at its edges) is shown by its id and its server too, which
4036
+ * the channel tools take exactly and the prose resolver doesn't, so no name
4037
+ * shown can be another channel's.
3924
4038
  */
3925
4039
  nameShown(ref: ChannelRef): string {
3926
4040
  const entry = this.entryAt(ref);
3927
- if (entry) return this.nameFor(entry) ?? channelOnServer(ref);
4041
+ if (entry) {
4042
+ const name = this.nameFor(entry);
4043
+ return name !== undefined && showsAsItself(name) ? name : channelOnServer(ref);
4044
+ }
3928
4045
  for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
3929
- if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
4046
+ if (showsAsItself(name) && this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
3930
4047
  }
3931
4048
  return channelOnServer(ref);
3932
4049
  }
@@ -3951,8 +4068,8 @@ export class ChannelRegistry {
3951
4068
  /**
3952
4069
  * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3953
4070
  * channel_open doesn't change it: a tune_out cancel, by the name that
3954
- * picks the channel (nameFor), or, where no name picks it alone, its own
3955
- * end, since tune_out takes a name.
4071
+ * picks the channel (nameFor), or, where no name picks it alone, by its id
4072
+ * and server, since tune_out takes a serverId.
3956
4073
  * For channel_close, which refuses a tuned-out channel too, it adds that
3957
4074
  * the close comes after that.
3958
4075
  */
@@ -3965,12 +4082,15 @@ export class ChannelRegistry {
3965
4082
 
3966
4083
  private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
3967
4084
  const close = tool === 'channel_close';
3968
- return this.nameFor(entry) !== undefined
3969
- ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
3970
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3971
- : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3972
- 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
3973
- (close ? ' Once it has ended, channel_close can close it.' : '');
4085
+ // A name is written as a literal tune_out's channelId takes back
4086
+ // (quotedValue, main's #852); one no name picks alone, by its id and
4087
+ // server, since this branch's tune_out takes a serverId.
4088
+ const name = this.nameFor(entry);
4089
+ const names = name !== undefined
4090
+ ? `channelId ${quotedValue(name)}`
4091
+ : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
4092
+ return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
4093
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
3974
4094
  }
3975
4095
 
3976
4096
  /**
@@ -3979,7 +4099,7 @@ export class ChannelRegistry {
3979
4099
  */
3980
4100
  quotedShown(ref: ChannelRef): string {
3981
4101
  const shown = this.nameShown(ref);
3982
- return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
4102
+ return shown === channelOnServer(ref) ? shown : quotedValue(shown);
3983
4103
  }
3984
4104
 
3985
4105
  /**
@@ -4017,7 +4137,7 @@ export class ChannelRegistry {
4017
4137
  const nothing = opts.nothing ?? 'nothing ran';
4018
4138
  const names = entries.map((e) => this.nameFor(e));
4019
4139
  if (names.every((n) => n !== undefined)) {
4020
- const listed = names.map((n) => JSON.stringify(n)).join(', ');
4140
+ const listed = names.map((n) => quotedValue(n!)).join(', ');
4021
4141
  const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
4022
4142
  // One server's two channels can answer to one name too, as `s`'s
4023
4143
  // `s:room` and `room` both do to `s:room`; its server picks neither.
@@ -4038,8 +4158,8 @@ export class ChannelRegistry {
4038
4158
  const own = names[i];
4039
4159
  if (own === undefined && !opts.takesServerId) return `- ${where}: no channelId reaches it alone.`;
4040
4160
  return own !== undefined
4041
- ? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
4042
- : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
4161
+ ? `- ${where}: call again with channelId ${quotedValue(own)}.`
4162
+ : `- ${where}: call again with channelId ${quotedValue(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
4043
4163
  });
4044
4164
  const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
4045
4165
  return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
@@ -4078,7 +4198,7 @@ export class ChannelRegistry {
4078
4198
  // so the text says whose.
4079
4199
  if (!alone) return 'channel_list shows the channels and their servers.';
4080
4200
  const one = named[0]!;
4081
- const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
4201
+ const names = `${JSON.stringify(channelId)} alone names channel ${quotedValue(one.descriptor.id)} on server `
4082
4202
  + JSON.stringify(one.serverId);
4083
4203
  return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
4084
4204
  }
@@ -4090,7 +4210,7 @@ export class ChannelRegistry {
4090
4210
  const places = elsewhere.map((e) => {
4091
4211
  const at = `on server ${JSON.stringify(e.serverId)}`;
4092
4212
  const name = this.nameFor(e);
4093
- return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
4213
+ return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${quotedValue(name)}`;
4094
4214
  });
4095
4215
  return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
4096
4216
  }
@@ -4394,17 +4514,19 @@ export class ChannelRegistry {
4394
4514
  spec: string,
4395
4515
  opts?: { forSpeech?: boolean; own?: readonly ChannelRef[] },
4396
4516
  ): PublishDestination | { error: string; candidates?: string[]; refusedChannelId?: string } {
4397
- const own = this.ownChannelNamed(spec.trim(), opts?.own);
4517
+ const name = spec.trim();
4518
+ if (name === NO_CHANNEL_EXAMPLE || name === NO_PERSON_EXAMPLE) return { error: `${name} is the example's placeholder: write the name it stands for in its place` };
4519
+ const own = this.ownChannelNamed(name, opts?.own);
4398
4520
  let resolved: PublishDestination | { error: string; candidates?: string[] };
4399
4521
  if (own === undefined) {
4400
- resolved = this.resolveTargetByName(spec);
4522
+ resolved = this.resolveTargetByName(name);
4401
4523
  } else if ('neither' in own) {
4402
- resolved = { error: `"${spec.trim()}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.text) };
4524
+ resolved = { error: `"${name}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.prose) };
4403
4525
  } else {
4404
4526
  const entry = this.entryAt(own.channel);
4405
4527
  resolved = entry
4406
4528
  ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
4407
- : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
4529
+ : { error: `${this.ownChannelGoneText(name, own.channel)}, and nothing can be sent there until that server registers it again` };
4408
4530
  }
4409
4531
  if (!opts?.forSpeech || 'error' in resolved) return resolved;
4410
4532
  const entry = this.entryAt(resolved);
@@ -4463,7 +4585,7 @@ export class ChannelRegistry {
4463
4585
  if (!mine) return undefined;
4464
4586
  if ('neither' in mine) {
4465
4587
  const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
4466
- return { error: this.ownNeitherRefusal(name, mine.neither, done) };
4588
+ return { error: this.ownNeitherRefusal(name, mine.neither, done, false) };
4467
4589
  }
4468
4590
  return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
4469
4591
  }
@@ -4482,32 +4604,73 @@ export class ChannelRegistry {
4482
4604
  /**
4483
4605
  * The turn's channels a name means neither of (ownChannelNamed), each by a
4484
4606
  * string that reaches it outside the turn's rule: its server-qualified
4485
- * name, where that picks it alone, since its id is the name refused. One
4486
- * no string picks alone, and one its server no longer registers, is named
4487
- * by its id and server, saying so; `reaches` says which a name reaches.
4607
+ * name, where that picks it alone, since its id is the name refused. `text`
4608
+ * is that name, which a refusal writes as JSON; `prose`, which a `>>`
4609
+ * target reads, is that name where it shows as itself, else its id and
4610
+ * server. One no string picks alone, and one its server no longer
4611
+ * registers, are named by id and server in both, saying so. `reaches` says
4612
+ * which a name reaches.
4488
4613
  */
4489
- private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; reaches: boolean }> {
4614
+ private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; prose: string; reaches: boolean; byServer: boolean }> {
4490
4615
  return channels.map((c) => {
4491
4616
  const entry = this.entryAt(c);
4492
- if (!entry) return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
4617
+ if (!entry) {
4618
+ const text = `${channelOnServer(c)} (its server no longer registers it)`;
4619
+ return { text, prose: text, reaches: false, byServer: false };
4620
+ }
4493
4621
  const qualified = qualifiedChannelName(c);
4494
4622
  const picked = this.entriesNamed(qualified);
4495
- return picked.length === 1 && picked[0] === entry
4496
- ? { text: qualified, reaches: true }
4497
- : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
4623
+ if (picked.length !== 1 || picked[0] !== entry) {
4624
+ const text = `${channelOnServer(c)} (no channelId reaches it alone)`;
4625
+ return { text, prose: text, reaches: false, byServer: true };
4626
+ }
4627
+ return { text: qualified, prose: showsAsItself(qualified) ? qualified : channelOnServer(c), reaches: true, byServer: false };
4498
4628
  });
4499
4629
  }
4500
4630
 
4631
+ /**
4632
+ * What a refusal listing these candidates says of each one that is a
4633
+ * channel shown by its id and server: the channelId channel_publish reaches
4634
+ * it by, or that no channelId reaches it alone. That is its server-qualified
4635
+ * name where that picks it alone, since a bare id is what a turn's own
4636
+ * channels answer to first, whether or not their server still registers
4637
+ * them (ownChannelNamed), and else its id where that picks it alone and
4638
+ * isn't empty (channel_publish refuses an empty one). It leaves out a channel no
4639
+ * longer registered and a candidate that already says no channelId reaches
4640
+ * it alone, and is empty when nothing is left.
4641
+ */
4642
+ publishRouteFor(candidates: readonly string[]): string {
4643
+ const parts = new Set<string>();
4644
+ for (const candidate of candidates) {
4645
+ const shown = CHANNEL_ON_SERVER_AT_END.exec(candidate);
4646
+ if (!shown) continue;
4647
+ const ref: ChannelRef = { channelId: JSON.parse(shown[1]!) as string, serverId: JSON.parse(shown[2]!) as string };
4648
+ const entry = this.entryAt(ref);
4649
+ if (!entry) continue;
4650
+ const name = [qualifiedChannelName(ref), ref.channelId].find((n) => {
4651
+ const named = n === '' ? [] : this.entriesNamed(n);
4652
+ return named.length === 1 && named[0] === entry;
4653
+ });
4654
+ parts.add(name !== undefined
4655
+ ? `channel_publish reaches ${channelOnServer(ref)} with channelId ${quotedValue(name)}`
4656
+ : `no channelId reaches ${channelOnServer(ref)} alone`);
4657
+ }
4658
+ return parts.size ? ` For a channel shown by its id and server: ${[...parts].join('; ')}.` : '';
4659
+ }
4660
+
4501
4661
  /** A tool's refusal of a name the turn's channels on more than one server
4502
4662
  * answer to by their id (ownChannelNamed's neither). */
4503
- private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string): string {
4663
+ private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string, takesServer = true): string {
4504
4664
  const candidates = this.ownCandidates(channels);
4505
- const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
4665
+ const named = candidates.filter((c) => c.reaches).map((c) => quotedValue(c.text));
4506
4666
  const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
4507
4667
  : named.length === 1 ? ` Call again with channelId ${named[0]}.`
4508
4668
  : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
4509
- const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
4510
- return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
4669
+ const listed = candidates.map((c) => (c.reaches ? quotedValue(c.text) : c.text)).join(', ');
4670
+ const byServer = takesServer && candidates.some((c) => c.byServer)
4671
+ ? ' For a channel shown by its id and server, call again with that channelId and that serverId.'
4672
+ : '';
4673
+ return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}${byServer}`;
4511
4674
  }
4512
4675
 
4513
4676
  private resolveTargetByName(
@@ -4521,7 +4684,7 @@ export class ChannelRegistry {
4521
4684
  // A candidate is named by the token that picks it alone (nameFor), so
4522
4685
  // two servers' channels with one id or one label can be told apart, or,
4523
4686
  // where no string picks it alone, by its id and its server.
4524
- const token = (e: ChannelEntry): string => this.nameFor(e) ?? channelOnServer(refOf(e));
4687
+ const token = (e: ChannelEntry): string => this.nameShown(refOf(e));
4525
4688
  // A DM is shown by its label, the person, where that label, given as a
4526
4689
  // target, picks that DM. It doesn't where another channel answers to that
4527
4690
  // label too, as two servers' DMs with one person do (a label is matched
@@ -4537,7 +4700,7 @@ export class ChannelRegistry {
4537
4700
  };
4538
4701
  const dmShown = (list: ChannelEntry[]): string[] => list.map((e) => {
4539
4702
  const label = e.descriptor.label ?? e.descriptor.id;
4540
- return labelPicks(e, label) ? label : token(e);
4703
+ return showsAsItself(label) && labelPicks(e, label) ? label : token(e);
4541
4704
  });
4542
4705
 
4543
4706
  // A channel id, or its server-qualified name (entriesNamed).
@@ -4655,7 +4818,7 @@ export class ChannelRegistry {
4655
4818
  if (byName.length > 1) {
4656
4819
  return {
4657
4820
  error: `"${trimmed}" matches several channels`,
4658
- candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
4821
+ candidates: byName.map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`),
4659
4822
  };
4660
4823
  }
4661
4824
 
@@ -4664,7 +4827,7 @@ export class ChannelRegistry {
4664
4827
  const near = entries
4665
4828
  .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
4666
4829
  .slice(0, 5)
4667
- .map((e) => `${e.descriptor.label} = ${token(e)}`);
4830
+ .map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`);
4668
4831
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
4669
4832
  }
4670
4833
 
@@ -4701,7 +4864,7 @@ export class ChannelRegistry {
4701
4864
  qualifiedChannelName(ref),
4702
4865
  ];
4703
4866
  for (const c of candidates) {
4704
- if (!isProseTargetToken(c) || c === '#') continue;
4867
+ if (!isProseTargetToken(c) || c === '#' || !showsAsItself(c)) continue;
4705
4868
  const r = this.resolveProseTarget(c, { forSpeech: true });
4706
4869
  if ('channelId' in r && sameChannel(r, ref)) return c;
4707
4870
  }
@@ -4990,7 +5153,7 @@ export class ChannelRegistry {
4990
5153
  channelId: input.channelId,
4991
5154
  serverId: entry.serverId,
4992
5155
  status: alreadyDesiredOpen ? 'reconciled' : 'opened',
4993
- ...(result.history ? { history: result.history } : {}),
5156
+ ...(result.history ? { history: markedHistory(result.history) } : {}),
4994
5157
  ...(result.historyTruncated ? { historyTruncated: true } : {}),
4995
5158
  },
4996
5159
  };
@@ -5070,12 +5233,13 @@ export class ChannelRegistry {
5070
5233
  // Closed already, perhaps only by default: the agent's close is still
5071
5234
  // its decision, and is recorded as one (setDesiredState), so a later
5072
5235
  // registration's lift of a default leaves the channel closed.
5236
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5073
5237
  try {
5074
5238
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5075
5239
  } catch (err) {
5076
5240
  return {
5077
5241
  success: false,
5078
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
5242
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`,
5079
5243
  isError: true,
5080
5244
  };
5081
5245
  }
@@ -5118,12 +5282,18 @@ export class ChannelRegistry {
5118
5282
  const refusal = this.tunedOutRefusal('channel_close', entry);
5119
5283
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
5120
5284
  }
5285
+ // A hold around this channel — the all-except scope it is the excepted
5286
+ // channel of — ends through the cancel flow: backlog released, end
5287
+ // recorded, never by overwriting state (#232 gap 9).
5288
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5121
5289
 
5122
- // A decision the log refused changed nothing, and the answer says so.
5290
+ // A decision the log refused changed nothing for the channel, and the
5291
+ // answer says so, and what the hook ended all the same.
5123
5292
  try {
5124
5293
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5125
5294
  } catch (err) {
5126
- return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
5295
+ const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
5296
+ return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
5127
5297
  }
5128
5298
  // This channel's typing, not every channel with its id (stopTyping's
5129
5299
  // fan-out of a bare name).
@@ -5479,13 +5649,14 @@ export class ChannelRegistry {
5479
5649
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
5480
5650
  * overloaded use of `think` for staying silent.
5481
5651
  */
5482
- private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }): ToolResult {
5652
+ private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }, earlierWake?: SkipReplyEarlierWake): ToolResult {
5483
5653
  // The self-wake itself is armed by the framework (which owns the
5484
5654
  // EventGate) BEFORE its dispatch reaches this handler, from the same
5485
5655
  // reading, and the framework refuses a wake the host can't set before
5486
5656
  // dispatching. A caller of this registry's handleChannelToolCall
5487
5657
  // (AgentFramework.channels) goes past both, so its note names a wake
5488
- // nothing armed.
5658
+ // nothing armed. The framework, which also owns the heartbeat, says
5659
+ // which of its wakes is due before the self-wake (`earlierWake`).
5489
5660
  const wake = readSkipReplyWake(input);
5490
5661
  const selfWake = wake.kind === 'wake' ? wake.seconds : undefined;
5491
5662
  return {
@@ -5501,9 +5672,7 @@ export class ChannelRegistry {
5501
5672
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
5502
5673
  data: {
5503
5674
  skipped: true,
5504
- note: selfWake !== undefined
5505
- ? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
5506
- : 'Turn ended; nothing sent.',
5675
+ note: skipReplyNote(selfWake, selfWake !== undefined ? earlierWake : undefined),
5507
5676
  },
5508
5677
  };
5509
5678
  }
@@ -5601,9 +5770,29 @@ export class ChannelRegistry {
5601
5770
  return active !== undefined ? active : this.globalFallbackFor(conversationId);
5602
5771
  }
5603
5772
 
5604
- /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
5773
+ /**
5774
+ * The process-global fallback for this conversation, or null when it takes
5775
+ * none (takesGlobalFallback): an ephemeral run's prose is its caller's
5776
+ * result, so neither the all-except hold's channel nor the latest inbound
5777
+ * message's is ever its destination.
5778
+ */
5605
5779
  private globalFallbackFor(conversationId: string): ChannelRef | null {
5606
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
5780
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
5781
+ }
5782
+
5783
+ /**
5784
+ * What every conversation that takes the global fallback falls back to when
5785
+ * neither a home channel nor its own delivery names one: the all-except
5786
+ * hold's channel while one is open, else the latest delivered inbound
5787
+ * channel under the legacy locus rule, else none — the prose stays private
5788
+ * (architecture channel §3 carve-out, 2026-10-06).
5789
+ */
5790
+ private sharedFallback(): ChannelRef | null {
5791
+ return this.focusChannelResolver?.() ?? this.fallbackLocus();
5792
+ }
5793
+
5794
+ private fallbackLocus(): ChannelRef | null {
5795
+ return this.latestInboundLocus ? this.defaultPublishChannel : null;
5607
5796
  }
5608
5797
 
5609
5798
  /**
@@ -6055,17 +6244,20 @@ export class ChannelRegistry {
6055
6244
  if (input.channelId === '' && tool === 'channel_publish') {
6056
6245
  return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
6057
6246
  }
6058
- // Resolve channelId: default to the channel of the most recent incoming
6059
- // message that isn't a reaction, reaction removal, edit or deletion
6060
- // marker, apart from an edit the coalescer delivers on acceptance as the
6061
- // agent's first view. An ephemeral run never takes that default
6247
+ // Resolve channelId: a call made inside the agent's turn (inTurn) has
6248
+ // the turn's default applied already, where its plain speech goes, and
6249
+ // takes none here. Any other defaults to what the caller falls back to:
6250
+ // the all-except hold's channel, else the latest delivered inbound
6251
+ // channel under the legacy locus rule (a marker — reaction, edit,
6252
+ // deletion — is never that channel), which an ephemeral run never takes
6062
6253
  // (takesGlobalFallback), so it names one.
6063
6254
  // A channel named by a string means what it means to every channel tool
6064
6255
  // (entriesNamed): one channel, by its id or its server-qualified name,
6065
6256
  // and a name two servers' channels answer to is refused with them as
6066
6257
  // candidates, never sent to the first.
6067
6258
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
6068
- const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
6259
+ const inTurn = origin?.kind === 'agent' && origin.inTurn === true;
6260
+ const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller);
6069
6261
  if (!(input.channelId ?? fallback)) {
6070
6262
  return {
6071
6263
  success: false,
@@ -6074,7 +6266,10 @@ export class ChannelRegistry {
6074
6266
  : caller !== undefined && this.takesGlobalFallback?.(caller) === false
6075
6267
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
6076
6268
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
6077
- : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6269
+ : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
6270
+ ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6271
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
6272
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6078
6273
  isError: true,
6079
6274
  };
6080
6275
  }
@@ -6096,7 +6291,7 @@ export class ChannelRegistry {
6096
6291
  entry = this.entryAt(serverGiven ? { serverId: input.serverId!, channelId: fallback!.channelId } : fallback!);
6097
6292
  } else if (own && 'neither' in own) {
6098
6293
  if (seen) seen.refusal = 'shared-id';
6099
- return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
6294
+ return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted', false), isError: true };
6100
6295
  } else if (own) {
6101
6296
  entry = this.entryAt(own.channel);
6102
6297
  if (!entry) {
@@ -6127,7 +6322,7 @@ export class ChannelRegistry {
6127
6322
  // channel with its id isn't where its message came from.
6128
6323
  error: input.channelId === undefined
6129
6324
  ? serverGiven
6130
- ? `Server "${input.serverId}" has no channel "${fallback!.channelId}", the id of the channel this call would default to, so nothing was posted.`
6325
+ ? `Server "${input.serverId}" has no channel ${quotedValue(fallback!.channelId)}, the id of the channel this call would default to, so nothing was posted.`
6131
6326
  : `The channel this would default to, ${this.quotedShown(fallback!)}, isn't registered now, so nothing was posted.`
6132
6327
  // This tool's schema has no serverId, so one given here is a host's
6133
6328
  // ref (publishForAgent), or one an agent added unasked, and the