@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
@@ -12,7 +12,9 @@
12
12
  * - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
13
13
  * - Build channel context for beforeInference params
14
14
  */
15
- import { convertBlock } from './convert-block.js';
15
+ import { NOTICE_TAG } from '../notice-tags.js';
16
+ import { convertBlocks } from './convert-block.js';
17
+ import { cutToFit, markedText, markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from '../supplied-text.js';
16
18
  import { isDeepStrictEqual } from 'node:util';
17
19
  import { ensureStateRegistered } from '../module-registry.js';
18
20
  import { expandCoreTags, isMarkerTagged } from './tags.js';
@@ -22,7 +24,7 @@ import { logDetail, logValue } from './log-value.js';
22
24
  import { RefusalLines } from './refusal-lines.js';
23
25
  import { CapabilityGrant } from './capability-grant.js';
24
26
  import { McplRequestError } from './server-connection.js';
25
- import { readSkipReplyWake } from './skip-reply-wake.js';
27
+ import { readSkipReplyWake, skipReplyNote } from './skip-reply-wake.js';
26
28
  import { isProseTargetToken } from './prose-grammar.js';
27
29
  // ============================================================================
28
30
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
@@ -337,11 +339,11 @@ const CHANNEL_TOOL_DEFINITIONS = [
337
339
  },
338
340
  {
339
341
  name: 'channel_publish',
340
- 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.",
342
+ 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.",
341
343
  inputSchema: {
342
344
  type: 'object',
343
345
  properties: {
344
- 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)" },
346
+ 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)" },
345
347
  content: { type: 'string', description: 'Text content to publish' },
346
348
  text: { type: 'string', description: 'Alias for content' },
347
349
  },
@@ -410,20 +412,22 @@ const CHANNEL_TOOL_DEFINITIONS = [
410
412
  // ============================================================================
411
413
  // Constructor Options
412
414
  // ============================================================================
413
- /** Bound connector values for notices, logs and an outcome's reason. A
414
- * PublishOutcome keeps the connector's structured data (`detail`) as the
415
- * connection gives it, its strings redacted (redactedData). */
415
+ /** Bound connector values for notices, logs and an outcome's reason, on one
416
+ * line with their notice openings marked (suppliedValue). A PublishOutcome
417
+ * keeps the connector's structured data (`detail`) as the connection gives
418
+ * it, its strings redacted (redactedData). */
416
419
  export function connectorText(value) {
417
420
  let text;
418
421
  try {
419
422
  text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
420
423
  }
421
424
  catch {
422
- return '[unserializable connector value]';
425
+ return `[${NOTICE_TAG.unserializableConnectorValue}]`;
423
426
  }
424
427
  const limit = 2000;
425
- const suffix = '… [truncated]';
426
- return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
428
+ const suffix = `… [${NOTICE_TAG.truncated}]`;
429
+ const shown = suppliedValue(text);
430
+ return shown.length > limit ? cutToFit(shown, limit - suffix.length) + suffix : shown;
427
431
  }
428
432
  /** One key per channel, its server and id: unlike joining the two with a
429
433
  * separator, no two channels share one, whatever their ids hold. */
@@ -437,6 +441,8 @@ export function sameChannel(a, b) {
437
441
  return !a && !b;
438
442
  return a.serverId === b.serverId && a.channelId === b.channelId;
439
443
  }
444
+ export const NO_CHANNEL_EXAMPLE = '#<channel>';
445
+ export const NO_PERSON_EXAMPLE = '@<person>';
440
446
  /**
441
447
  * A channel's server-qualified name, `serverId:channelId`. A name given as a
442
448
  * string (a tool's channelId, a `>>` target, a host's call) may take this
@@ -447,11 +453,32 @@ export function sameChannel(a, b) {
447
453
  export function qualifiedChannelName(ref) {
448
454
  return `${ref.serverId}:${ref.channelId}`;
449
455
  }
456
+ function markedHistory(history) {
457
+ return history.map((item) => {
458
+ if (item === null || typeof item !== 'object' || Array.isArray(item) || !('content' in item))
459
+ return item;
460
+ const { content } = item;
461
+ if (Array.isArray(content))
462
+ return { ...item, content: markNoticeOpenings(content.map(withEmbeddedTextMarked)) };
463
+ return typeof content === 'string' ? { ...item, content: markedText(content) } : item;
464
+ });
465
+ }
466
+ function withEmbeddedTextMarked(block) {
467
+ if (block === null || typeof block !== 'object' || block.type !== 'resource')
468
+ return block;
469
+ const { resource } = block;
470
+ if (resource === null || typeof resource !== 'object' || typeof resource.text !== 'string')
471
+ return block;
472
+ return { ...block, resource: { ...resource, text: markedText(resource.text) } };
473
+ }
450
474
  /** A channel told apart by its id and its server, for one no string picks
451
- * alone: `"b:room" on server "a"`. No input takes it for a name. */
475
+ * alone or whose name wouldn't show as itself: `"b:room" on server "a"`,
476
+ * each a JSON literal a channel tool's channelId and serverId take back
477
+ * (quotedValue). No input takes the whole form for a name. */
452
478
  function channelOnServer(ref) {
453
- return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
479
+ return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
454
480
  }
481
+ const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
455
482
  /**
456
483
  * A failed request's words, bounded, for a reason that a caller composes.
457
484
  * This host's own words, for a request that wasn't sent or wasn't answered
@@ -650,6 +677,9 @@ export class ChannelRegistry {
650
677
  emitTraceFn;
651
678
  sendTypingFn;
652
679
  shouldTriggerInference;
680
+ latestInboundLocus;
681
+ onToolClose;
682
+ focusChannelResolver;
653
683
  onRouteFailure;
654
684
  onChannelAutoOpened;
655
685
  homeChannelResolver;
@@ -1020,6 +1050,9 @@ export class ChannelRegistry {
1020
1050
  this.emitTraceFn = emitTraceFn;
1021
1051
  this.sendTypingFn = options?.sendTypingFn;
1022
1052
  this.shouldTriggerInference = options?.shouldTriggerInference;
1053
+ this.latestInboundLocus = options?.latestInboundLocus === true;
1054
+ this.onToolClose = options?.onToolClose;
1055
+ this.focusChannelResolver = options?.focusChannelResolver;
1023
1056
  this.onRouteFailure = options?.onRouteFailure;
1024
1057
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1025
1058
  this.homeChannelResolver = options?.homeChannelResolver;
@@ -1228,9 +1261,6 @@ export class ChannelRegistry {
1228
1261
  // The batch came on the transport open now: a close after this point
1229
1262
  // (transportClosed) leaves its traffic nothing to confirm.
1230
1263
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1231
- // And came from a server registered now: one removed since (removeServer)
1232
- // gives no message the agent replies to (markAccepted).
1233
- const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1234
1264
  for (const message of params.messages) {
1235
1265
  if (!message || typeof message.channelId !== 'string' || !message.channelId
1236
1266
  || typeof message.messageId !== 'string' || !message.messageId) {
@@ -1328,33 +1358,24 @@ export class ChannelRegistry {
1328
1358
  }
1329
1359
  if (message.tags)
1330
1360
  message.tags = expandCoreTags(message.tags);
1331
- const convertedContent = message.content.map((block) => convertBlock(block, 'attachment on channel message', serverId));
1361
+ const convertedContent = convertBlocks(message.content, 'attachment on channel message', serverId);
1332
1362
  if (!emptyAllowed && isVisiblyEmptyConversion(message.content, convertedContent)) {
1333
1363
  rejectEmpty();
1334
1364
  continue;
1335
1365
  }
1336
- // Track default publish channel (most recent ACCEPTED incoming) —
1337
- // deliberately after §14.5 validation: a rejected message from an
1338
- // unregistered channel must not retarget outbound speech (the locus is
1339
- // exactly the authority a self-attested channel would be stealing).
1340
- // A coalesced item is "accepted" only once the coalescer admits it, so
1341
- // for those this runs after the hook (below).
1366
+ // A server sending channels/incoming is evidence that the transport
1367
+ // is open. This repairs transient status only; durable desired state
1368
+ // still changes exclusively through lifecycle operations. Nothing else
1369
+ // is decided here: whether this message is delivered, wakes anyone or
1370
+ // counts as the latest inbound is the framework's admission decision,
1371
+ // taken once the hold is known (see `noteDelivered`; issue #232 root
1372
+ // cause A). A coalesced item is "accepted" only once the coalescer
1373
+ // admits it, so for those this runs after the hook (below).
1342
1374
  const incomingEntry = this.channels.get(incomingKey);
1343
- const markAccepted = (firstView = false) => {
1344
- // Non-conversational markers (a reaction added or removed, an edit, a
1345
- // deletion, and a chat:system notice such as a join or the weather)
1346
- // are ABOUT a message or the place, not one the agent replies to: they
1347
- // must not retarget the default publish locus or become
1348
- // `context.incoming`'s reply anchor. An edit the coalescer delivered
1349
- // as the agent's first view counts as the message it replaced.
1350
- // A message the coalescer admits after its server was removed sets no
1351
- // default: the removal cleared that server's message, and a server
1352
- // that's gone mustn't displace a live one's default.
1353
- if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1354
- this.defaultPublishChannel = { serverId, channelId: message.channelId };
1355
- this.defaultPublishMessageId = message.messageId;
1356
- this.defaultPublishThreadId = message.threadId;
1357
- }
1375
+ // `firstView` is the coalescer's mark that an edit was the agent's first
1376
+ // view of its message; the registry no longer records the publish locus
1377
+ // here (admission does, through noteDelivered, with the same marker rule).
1378
+ const markAccepted = (_firstView = false) => {
1358
1379
  // Where the host directs this server's lifecycle, traffic shows the
1359
1380
  // transport open only for a channel still decided open or tuned out,
1360
1381
  // and, but for one exception, only for a confirmed target: after a
@@ -1392,7 +1413,7 @@ export class ChannelRegistry {
1392
1413
  };
1393
1414
  if (!coalesced)
1394
1415
  markAccepted();
1395
- // Determine whether to trigger inference
1416
+ // The server-level veto only; the wake gate runs at admission.
1396
1417
  let triggerInference = true;
1397
1418
  if (this.shouldTriggerInference) {
1398
1419
  // The text the agent's context gets, as the push lane's filter reads
@@ -1674,15 +1695,58 @@ export class ChannelRegistry {
1674
1695
  // ==========================================================================
1675
1696
  // Accessors
1676
1697
  // ==========================================================================
1698
+ /**
1699
+ * The most recent DELIVERED inbound message (channel, message, thread).
1700
+ * Recorded by the framework after admission, so a held message never
1701
+ * becomes "the latest inbound" (issue #232 gap 2), and never for a marker
1702
+ * (a reaction, reaction removal, edit or deletion, apart from an edit the
1703
+ * coalescer delivered as the agent's first view: isMarkerTagged). Read as
1704
+ * the `incoming` reference in the channel context and, under
1705
+ * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The
1706
+ * channel is on the server the message came from. A message whose server
1707
+ * no longer registers its channel sets nothing, as one the coalescer admits
1708
+ * after its server was removed: the removal cleared that server's message,
1709
+ * and a server that's gone mustn't displace a live one's anchor. That's
1710
+ * read at admission, where main's guard compared the server's removal
1711
+ * count at arrival and at acceptance (markAccepted): a message that
1712
+ * arrived before its server's removal and is admitted once the server
1713
+ * registers the channel again sets the anchor here, as a message delivered
1714
+ * on a live channel, where main's would set nothing.
1715
+ */
1716
+ noteDelivered(channel, messageId, threadId, tags, firstView = false) {
1717
+ // A marker is ABOUT a message, not one the agent replies to: it leaves
1718
+ // the anchor where it was (#157), and so does a chat:system notice (a
1719
+ // join, the weather), which is about the place (isMarkerTagged). An edit
1720
+ // the coalescer delivered as the agent's first view of its message counts
1721
+ // as that message.
1722
+ if (isMarkerTagged(tags, firstView))
1723
+ return;
1724
+ if (!this.entryAt(channel))
1725
+ return;
1726
+ this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId };
1727
+ this.defaultPublishMessageId = messageId;
1728
+ this.defaultPublishThreadId = threadId;
1729
+ }
1677
1730
  /**
1678
1731
  * Get the default publish channel ID: the channel of the most recent
1679
- * incoming message that isn't a reaction, reaction removal, edit or
1680
- * deletion marker, apart from an edit the coalescer delivers on acceptance
1681
- * as the agent's first view.
1732
+ * delivered incoming message that isn't a reaction, reaction removal, edit
1733
+ * or deletion marker, apart from an edit the coalescer delivers on
1734
+ * acceptance as the agent's first view.
1682
1735
  */
1683
1736
  getDefaultPublishChannel() {
1684
1737
  return this.defaultPublishChannel;
1685
1738
  }
1739
+ /**
1740
+ * Public form of the tool-input channel resolution (resolveToolChannelEntry):
1741
+ * optional server, the calling turn's own channels (`own`), ambiguity is an
1742
+ * error.
1743
+ */
1744
+ resolveChannel(channelId, serverId, own) {
1745
+ const resolved = this.resolveToolChannelEntry(channelId, serverId, own);
1746
+ if (!resolved.entry)
1747
+ return { error: resolved.error ?? `Channel not found: ${channelId}` };
1748
+ return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id };
1749
+ }
1686
1750
  /**
1687
1751
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
1688
1752
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -1764,7 +1828,7 @@ export class ChannelRegistry {
1764
1828
  case 'journal':
1765
1829
  return this.handleToolJournal(input);
1766
1830
  case 'skip_reply':
1767
- return this.handleToolSkipReply(input);
1831
+ return this.handleToolSkipReply(input, origin?.kind === 'agent' ? origin.earlierWake : undefined);
1768
1832
  default:
1769
1833
  // Not every agent is given every channel tool, so the refusal points
1770
1834
  // to the ones this agent has rather than listing them all.
@@ -1780,7 +1844,7 @@ export class ChannelRegistry {
1780
1844
  // ==========================================================================
1781
1845
  /**
1782
1846
  * The message the agent is replying to, with its channel: the most recent
1783
- * inbound message that isn't a reaction, reaction removal, edit or deletion
1847
+ * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion
1784
1848
  * marker, apart from an edit the coalescer delivers on acceptance as the
1785
1849
  * agent's first view. Left process-global: per-channel inbound tracking
1786
1850
  * (the right messageId for a fork's own channel) is a separate concern from
@@ -1797,10 +1861,14 @@ export class ChannelRegistry {
1797
1861
  if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
1798
1862
  return undefined;
1799
1863
  }
1800
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1801
- if (!fallback || !this.defaultPublishMessageId)
1864
+ // The latest delivered inbound itself, not the speech fallback
1865
+ // (globalFallbackFor), which under `speech.locus: 'delivery'` is the
1866
+ // all-except hold's excepted channel while one is open, and none
1867
+ // otherwise.
1868
+ const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel;
1869
+ if (!anchor || !this.defaultPublishMessageId)
1802
1870
  return undefined;
1803
- return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
1871
+ return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
1804
1872
  }
1805
1873
  /**
1806
1874
  * The channel context one server is told for an agent's inference
@@ -1817,21 +1885,25 @@ export class ChannelRegistry {
1817
1885
  // declared it inbound-only, since speech isn't posted there): a conversation
1818
1886
  // fork's home channel, else this turn's channel (single trunk agent — item-3
1819
1887
  // redux: its locus, else its triggering channel), else the global default
1820
- // (heartbeats). Without this, a fork
1888
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
1889
+ // Without this, a fork
1821
1890
  // was advertised the global locus but published somewhere else; and a trunk
1822
1891
  // agent was told the wrong channel under concurrency.
1823
1892
  // Each lookup is keyed on whether a name was given, not its truthiness:
1824
1893
  // '' is a name an ephemeral run can have, and resolveLocus reads it too.
1825
1894
  const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
1826
1895
  const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
1827
- // An ephemeral run takes no global fallback (takesGlobalFallback): neither
1828
- // the latest inbound message's channel as its outgoing one, nor that
1829
- // message as the one it is replying to.
1830
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1896
+ // An ephemeral run takes no global fallback (takesGlobalFallback): not the
1897
+ // all-except hold's channel, not the latest inbound message's channel as
1898
+ // its outgoing one, nor that message as the one it is replying to.
1899
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
1831
1900
  // A turn that deliberately has no channel (null) takes no fallback.
1832
1901
  const outgoing = home ?? (active !== undefined ? active : fallback);
1833
1902
  const context = {};
1834
- // Incoming: the message the agent is replying to (incomingFor).
1903
+ // Incoming: the message the agent is replying to (incomingFor). The
1904
+ // anchor is independent of the speech rule: under `speech.locus:
1905
+ // 'delivery'` the latest inbound is no fallback for where prose lands,
1906
+ // but it is still the message the agent is replying to.
1835
1907
  const incoming = this.incomingFor(agentName);
1836
1908
  if (incoming && incoming.channel.serverId === serverId) {
1837
1909
  context.incoming = {
@@ -3215,13 +3287,20 @@ export class ChannelRegistry {
3215
3287
  * name, only where no registered channel takes that name, as a channel
3216
3288
  * tool reads it or a `>>` target does (by label or raw id too), so the
3217
3289
  * name can't send anything to another channel; else by its id and server.
3290
+ * A name that wouldn't show as itself (showsAsItself: one holding a line
3291
+ * break, a notice opening, a quote, a backslash or a lone surrogate, or
3292
+ * whitespace at its edges) is shown by its id and its server too, which
3293
+ * the channel tools take exactly and the prose resolver doesn't, so no name
3294
+ * shown can be another channel's.
3218
3295
  */
3219
3296
  nameShown(ref) {
3220
3297
  const entry = this.entryAt(ref);
3221
- if (entry)
3222
- return this.nameFor(entry) ?? channelOnServer(ref);
3298
+ if (entry) {
3299
+ const name = this.nameFor(entry);
3300
+ return name !== undefined && showsAsItself(name) ? name : channelOnServer(ref);
3301
+ }
3223
3302
  for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
3224
- if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name)))
3303
+ if (showsAsItself(name) && this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name)))
3225
3304
  return name;
3226
3305
  }
3227
3306
  return channelOnServer(ref);
@@ -3244,8 +3323,8 @@ export class ChannelRegistry {
3244
3323
  /**
3245
3324
  * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3246
3325
  * channel_open doesn't change it: a tune_out cancel, by the name that
3247
- * picks the channel (nameFor), or, where no name picks it alone, its own
3248
- * end, since tune_out takes a name.
3326
+ * picks the channel (nameFor), or, where no name picks it alone, by its id
3327
+ * and server, since tune_out takes a serverId.
3249
3328
  * For channel_close, which refuses a tuned-out channel too, it adds that
3250
3329
  * the close comes after that.
3251
3330
  */
@@ -3257,12 +3336,15 @@ export class ChannelRegistry {
3257
3336
  }
3258
3337
  tuneOutEndText(entry, tool) {
3259
3338
  const close = tool === 'channel_close';
3260
- return this.nameFor(entry) !== undefined
3261
- ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
3262
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3263
- : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3264
- 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
3265
- (close ? ' Once it has ended, channel_close can close it.' : '');
3339
+ // A name is written as a literal tune_out's channelId takes back
3340
+ // (quotedValue, main's #852); one no name picks alone, by its id and
3341
+ // server, since this branch's tune_out takes a serverId.
3342
+ const name = this.nameFor(entry);
3343
+ const names = name !== undefined
3344
+ ? `channelId ${quotedValue(name)}`
3345
+ : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
3346
+ return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
3347
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
3266
3348
  }
3267
3349
  /**
3268
3350
  * A channel's name as nameShown gives it, quoted for a sentence: the name
@@ -3270,7 +3352,7 @@ export class ChannelRegistry {
3270
3352
  */
3271
3353
  quotedShown(ref) {
3272
3354
  const shown = this.nameShown(ref);
3273
- return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
3355
+ return shown === channelOnServer(ref) ? shown : quotedValue(shown);
3274
3356
  }
3275
3357
  /**
3276
3358
  * The string that names exactly this entry by entriesNamed: its id when no
@@ -3303,7 +3385,7 @@ export class ChannelRegistry {
3303
3385
  const nothing = opts.nothing ?? 'nothing ran';
3304
3386
  const names = entries.map((e) => this.nameFor(e));
3305
3387
  if (names.every((n) => n !== undefined)) {
3306
- const listed = names.map((n) => JSON.stringify(n)).join(', ');
3388
+ const listed = names.map((n) => quotedValue(n)).join(', ');
3307
3389
  const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
3308
3390
  // One server's two channels can answer to one name too, as `s`'s
3309
3391
  // `s:room` and `room` both do to `s:room`; its server picks neither.
@@ -3325,8 +3407,8 @@ export class ChannelRegistry {
3325
3407
  if (own === undefined && !opts.takesServerId)
3326
3408
  return `- ${where}: no channelId reaches it alone.`;
3327
3409
  return own !== undefined
3328
- ? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
3329
- : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
3410
+ ? `- ${where}: call again with channelId ${quotedValue(own)}.`
3411
+ : `- ${where}: call again with channelId ${quotedValue(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
3330
3412
  });
3331
3413
  const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
3332
3414
  return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
@@ -3364,7 +3446,7 @@ export class ChannelRegistry {
3364
3446
  if (!alone)
3365
3447
  return 'channel_list shows the channels and their servers.';
3366
3448
  const one = named[0];
3367
- const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
3449
+ const names = `${JSON.stringify(channelId)} alone names channel ${quotedValue(one.descriptor.id)} on server `
3368
3450
  + JSON.stringify(one.serverId);
3369
3451
  return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
3370
3452
  }
@@ -3376,7 +3458,7 @@ export class ChannelRegistry {
3376
3458
  const places = elsewhere.map((e) => {
3377
3459
  const at = `on server ${JSON.stringify(e.serverId)}`;
3378
3460
  const name = this.nameFor(e);
3379
- return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
3461
+ return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${quotedValue(name)}`;
3380
3462
  });
3381
3463
  return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
3382
3464
  }
@@ -3639,19 +3721,22 @@ export class ChannelRegistry {
3639
3721
  * before any other spelling (ownChannelNamed).
3640
3722
  */
3641
3723
  resolveProseTarget(spec, opts) {
3642
- const own = this.ownChannelNamed(spec.trim(), opts?.own);
3724
+ const name = spec.trim();
3725
+ if (name === NO_CHANNEL_EXAMPLE || name === NO_PERSON_EXAMPLE)
3726
+ return { error: `${name} is the example's placeholder: write the name it stands for in its place` };
3727
+ const own = this.ownChannelNamed(name, opts?.own);
3643
3728
  let resolved;
3644
3729
  if (own === undefined) {
3645
- resolved = this.resolveTargetByName(spec);
3730
+ resolved = this.resolveTargetByName(name);
3646
3731
  }
3647
3732
  else if ('neither' in own) {
3648
- 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) };
3733
+ 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) };
3649
3734
  }
3650
3735
  else {
3651
3736
  const entry = this.entryAt(own.channel);
3652
3737
  resolved = entry
3653
3738
  ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
3654
- : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
3739
+ : { error: `${this.ownChannelGoneText(name, own.channel)}, and nothing can be sent there until that server registers it again` };
3655
3740
  }
3656
3741
  if (!opts?.forSpeech || 'error' in resolved)
3657
3742
  return resolved;
@@ -3704,7 +3789,7 @@ export class ChannelRegistry {
3704
3789
  return undefined;
3705
3790
  if ('neither' in mine) {
3706
3791
  const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
3707
- return { error: this.ownNeitherRefusal(name, mine.neither, done) };
3792
+ return { error: this.ownNeitherRefusal(name, mine.neither, done, false) };
3708
3793
  }
3709
3794
  return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
3710
3795
  }
@@ -3721,32 +3806,73 @@ export class ChannelRegistry {
3721
3806
  /**
3722
3807
  * The turn's channels a name means neither of (ownChannelNamed), each by a
3723
3808
  * string that reaches it outside the turn's rule: its server-qualified
3724
- * name, where that picks it alone, since its id is the name refused. One
3725
- * no string picks alone, and one its server no longer registers, is named
3726
- * by its id and server, saying so; `reaches` says which a name reaches.
3809
+ * name, where that picks it alone, since its id is the name refused. `text`
3810
+ * is that name, which a refusal writes as JSON; `prose`, which a `>>`
3811
+ * target reads, is that name where it shows as itself, else its id and
3812
+ * server. One no string picks alone, and one its server no longer
3813
+ * registers, are named by id and server in both, saying so. `reaches` says
3814
+ * which a name reaches.
3727
3815
  */
3728
3816
  ownCandidates(channels) {
3729
3817
  return channels.map((c) => {
3730
3818
  const entry = this.entryAt(c);
3731
- if (!entry)
3732
- return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
3819
+ if (!entry) {
3820
+ const text = `${channelOnServer(c)} (its server no longer registers it)`;
3821
+ return { text, prose: text, reaches: false, byServer: false };
3822
+ }
3733
3823
  const qualified = qualifiedChannelName(c);
3734
3824
  const picked = this.entriesNamed(qualified);
3735
- return picked.length === 1 && picked[0] === entry
3736
- ? { text: qualified, reaches: true }
3737
- : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
3825
+ if (picked.length !== 1 || picked[0] !== entry) {
3826
+ const text = `${channelOnServer(c)} (no channelId reaches it alone)`;
3827
+ return { text, prose: text, reaches: false, byServer: true };
3828
+ }
3829
+ return { text: qualified, prose: showsAsItself(qualified) ? qualified : channelOnServer(c), reaches: true, byServer: false };
3738
3830
  });
3739
3831
  }
3832
+ /**
3833
+ * What a refusal listing these candidates says of each one that is a
3834
+ * channel shown by its id and server: the channelId channel_publish reaches
3835
+ * it by, or that no channelId reaches it alone. That is its server-qualified
3836
+ * name where that picks it alone, since a bare id is what a turn's own
3837
+ * channels answer to first, whether or not their server still registers
3838
+ * them (ownChannelNamed), and else its id where that picks it alone and
3839
+ * isn't empty (channel_publish refuses an empty one). It leaves out a channel no
3840
+ * longer registered and a candidate that already says no channelId reaches
3841
+ * it alone, and is empty when nothing is left.
3842
+ */
3843
+ publishRouteFor(candidates) {
3844
+ const parts = new Set();
3845
+ for (const candidate of candidates) {
3846
+ const shown = CHANNEL_ON_SERVER_AT_END.exec(candidate);
3847
+ if (!shown)
3848
+ continue;
3849
+ const ref = { channelId: JSON.parse(shown[1]), serverId: JSON.parse(shown[2]) };
3850
+ const entry = this.entryAt(ref);
3851
+ if (!entry)
3852
+ continue;
3853
+ const name = [qualifiedChannelName(ref), ref.channelId].find((n) => {
3854
+ const named = n === '' ? [] : this.entriesNamed(n);
3855
+ return named.length === 1 && named[0] === entry;
3856
+ });
3857
+ parts.add(name !== undefined
3858
+ ? `channel_publish reaches ${channelOnServer(ref)} with channelId ${quotedValue(name)}`
3859
+ : `no channelId reaches ${channelOnServer(ref)} alone`);
3860
+ }
3861
+ return parts.size ? ` For a channel shown by its id and server: ${[...parts].join('; ')}.` : '';
3862
+ }
3740
3863
  /** A tool's refusal of a name the turn's channels on more than one server
3741
3864
  * answer to by their id (ownChannelNamed's neither). */
3742
- ownNeitherRefusal(name, channels, done) {
3865
+ ownNeitherRefusal(name, channels, done, takesServer = true) {
3743
3866
  const candidates = this.ownCandidates(channels);
3744
- const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
3867
+ const named = candidates.filter((c) => c.reaches).map((c) => quotedValue(c.text));
3745
3868
  const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
3746
3869
  : named.length === 1 ? ` Call again with channelId ${named[0]}.`
3747
3870
  : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
3748
- const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
3749
- return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
3871
+ const listed = candidates.map((c) => (c.reaches ? quotedValue(c.text) : c.text)).join(', ');
3872
+ const byServer = takesServer && candidates.some((c) => c.byServer)
3873
+ ? ' For a channel shown by its id and server, call again with that channelId and that serverId.'
3874
+ : '';
3875
+ return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}${byServer}`;
3750
3876
  }
3751
3877
  resolveTargetByName(spec) {
3752
3878
  const entries = [...this.channels.values()];
@@ -3757,7 +3883,7 @@ export class ChannelRegistry {
3757
3883
  // A candidate is named by the token that picks it alone (nameFor), so
3758
3884
  // two servers' channels with one id or one label can be told apart, or,
3759
3885
  // where no string picks it alone, by its id and its server.
3760
- const token = (e) => this.nameFor(e) ?? channelOnServer(refOf(e));
3886
+ const token = (e) => this.nameShown(refOf(e));
3761
3887
  // A DM is shown by its label, the person, where that label, given as a
3762
3888
  // target, picks that DM. It doesn't where another channel answers to that
3763
3889
  // label too, as two servers' DMs with one person do (a label is matched
@@ -3774,7 +3900,7 @@ export class ChannelRegistry {
3774
3900
  };
3775
3901
  const dmShown = (list) => list.map((e) => {
3776
3902
  const label = e.descriptor.label ?? e.descriptor.id;
3777
- return labelPicks(e, label) ? label : token(e);
3903
+ return showsAsItself(label) && labelPicks(e, label) ? label : token(e);
3778
3904
  });
3779
3905
  // A channel id, or its server-qualified name (entriesNamed).
3780
3906
  const exact = this.entriesNamed(trimmed);
@@ -3879,7 +4005,7 @@ export class ChannelRegistry {
3879
4005
  if (byName.length > 1) {
3880
4006
  return {
3881
4007
  error: `"${trimmed}" matches several channels`,
3882
- candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
4008
+ candidates: byName.map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`),
3883
4009
  };
3884
4010
  }
3885
4011
  // No match: offer near-candidates (labels containing the name) so the
@@ -3887,7 +4013,7 @@ export class ChannelRegistry {
3887
4013
  const near = entries
3888
4014
  .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
3889
4015
  .slice(0, 5)
3890
- .map((e) => `${e.descriptor.label} = ${token(e)}`);
4016
+ .map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`);
3891
4017
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
3892
4018
  }
3893
4019
  /**
@@ -3924,7 +4050,7 @@ export class ChannelRegistry {
3924
4050
  qualifiedChannelName(ref),
3925
4051
  ];
3926
4052
  for (const c of candidates) {
3927
- if (!isProseTargetToken(c) || c === '#')
4053
+ if (!isProseTargetToken(c) || c === '#' || !showsAsItself(c))
3928
4054
  continue;
3929
4055
  const r = this.resolveProseTarget(c, { forSpeech: true });
3930
4056
  if ('channelId' in r && sameChannel(r, ref))
@@ -4192,7 +4318,7 @@ export class ChannelRegistry {
4192
4318
  channelId: input.channelId,
4193
4319
  serverId: entry.serverId,
4194
4320
  status: alreadyDesiredOpen ? 'reconciled' : 'opened',
4195
- ...(result.history ? { history: result.history } : {}),
4321
+ ...(result.history ? { history: markedHistory(result.history) } : {}),
4196
4322
  ...(result.historyTruncated ? { historyTruncated: true } : {}),
4197
4323
  },
4198
4324
  };
@@ -4249,13 +4375,14 @@ export class ChannelRegistry {
4249
4375
  // Closed already, perhaps only by default: the agent's close is still
4250
4376
  // its decision, and is recorded as one (setDesiredState), so a later
4251
4377
  // registration's lift of a default leaves the channel closed.
4378
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
4252
4379
  try {
4253
4380
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4254
4381
  }
4255
4382
  catch (err) {
4256
4383
  return {
4257
4384
  success: false,
4258
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}`,
4385
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}${ended ? ` ${ended}` : ''}`,
4259
4386
  isError: true,
4260
4387
  };
4261
4388
  }
@@ -4295,12 +4422,18 @@ export class ChannelRegistry {
4295
4422
  const refusal = this.tunedOutRefusal('channel_close', entry);
4296
4423
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
4297
4424
  }
4298
- // A decision the log refused changed nothing, and the answer says so.
4425
+ // A hold around this channel — the all-except scope it is the excepted
4426
+ // channel of — ends through the cancel flow: backlog released, end
4427
+ // recorded, never by overwriting state (#232 gap 9).
4428
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
4429
+ // A decision the log refused changed nothing for the channel, and the
4430
+ // answer says so, and what the hook ended all the same.
4299
4431
  try {
4300
4432
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4301
4433
  }
4302
4434
  catch (err) {
4303
- return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
4435
+ const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
4436
+ return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
4304
4437
  }
4305
4438
  // This channel's typing, not every channel with its id (stopTyping's
4306
4439
  // fan-out of a bare name).
@@ -4649,13 +4782,14 @@ export class ChannelRegistry {
4649
4782
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
4650
4783
  * overloaded use of `think` for staying silent.
4651
4784
  */
4652
- handleToolSkipReply(input) {
4785
+ handleToolSkipReply(input, earlierWake) {
4653
4786
  // The self-wake itself is armed by the framework (which owns the
4654
4787
  // EventGate) BEFORE its dispatch reaches this handler, from the same
4655
4788
  // reading, and the framework refuses a wake the host can't set before
4656
4789
  // dispatching. A caller of this registry's handleChannelToolCall
4657
4790
  // (AgentFramework.channels) goes past both, so its note names a wake
4658
- // nothing armed.
4791
+ // nothing armed. The framework, which also owns the heartbeat, says
4792
+ // which of its wakes is due before the self-wake (`earlierWake`).
4659
4793
  const wake = readSkipReplyWake(input);
4660
4794
  const selfWake = wake.kind === 'wake' ? wake.seconds : undefined;
4661
4795
  return {
@@ -4671,9 +4805,7 @@ export class ChannelRegistry {
4671
4805
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
4672
4806
  data: {
4673
4807
  skipped: true,
4674
- note: selfWake !== undefined
4675
- ? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
4676
- : 'Turn ended; nothing sent.',
4808
+ note: skipReplyNote(selfWake, selfWake !== undefined ? earlierWake : undefined),
4677
4809
  },
4678
4810
  };
4679
4811
  }
@@ -4765,9 +4897,27 @@ export class ChannelRegistry {
4765
4897
  const active = this.activeChannelResolver?.(conversationId);
4766
4898
  return active !== undefined ? active : this.globalFallbackFor(conversationId);
4767
4899
  }
4768
- /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
4900
+ /**
4901
+ * The process-global fallback for this conversation, or null when it takes
4902
+ * none (takesGlobalFallback): an ephemeral run's prose is its caller's
4903
+ * result, so neither the all-except hold's channel nor the latest inbound
4904
+ * message's is ever its destination.
4905
+ */
4769
4906
  globalFallbackFor(conversationId) {
4770
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
4907
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
4908
+ }
4909
+ /**
4910
+ * What every conversation that takes the global fallback falls back to when
4911
+ * neither a home channel nor its own delivery names one: the all-except
4912
+ * hold's channel while one is open, else the latest delivered inbound
4913
+ * channel under the legacy locus rule, else none — the prose stays private
4914
+ * (architecture channel §3 carve-out, 2026-10-06).
4915
+ */
4916
+ sharedFallback() {
4917
+ return this.focusChannelResolver?.() ?? this.fallbackLocus();
4918
+ }
4919
+ fallbackLocus() {
4920
+ return this.latestInboundLocus ? this.defaultPublishChannel : null;
4771
4921
  }
4772
4922
  /**
4773
4923
  * Resolve a publish target to exactly one registered channel: `serverId`
@@ -5185,17 +5335,20 @@ export class ChannelRegistry {
5185
5335
  if (input.channelId === '' && tool === 'channel_publish') {
5186
5336
  return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
5187
5337
  }
5188
- // Resolve channelId: default to the channel of the most recent incoming
5189
- // message that isn't a reaction, reaction removal, edit or deletion
5190
- // marker, apart from an edit the coalescer delivers on acceptance as the
5191
- // agent's first view. An ephemeral run never takes that default
5338
+ // Resolve channelId: a call made inside the agent's turn (inTurn) has
5339
+ // the turn's default applied already, where its plain speech goes, and
5340
+ // takes none here. Any other defaults to what the caller falls back to:
5341
+ // the all-except hold's channel, else the latest delivered inbound
5342
+ // channel under the legacy locus rule (a marker — reaction, edit,
5343
+ // deletion — is never that channel), which an ephemeral run never takes
5192
5344
  // (takesGlobalFallback), so it names one.
5193
5345
  // A channel named by a string means what it means to every channel tool
5194
5346
  // (entriesNamed): one channel, by its id or its server-qualified name,
5195
5347
  // and a name two servers' channels answer to is refused with them as
5196
5348
  // candidates, never sent to the first.
5197
5349
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
5198
- const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
5350
+ const inTurn = origin?.kind === 'agent' && origin.inTurn === true;
5351
+ const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller);
5199
5352
  if (!(input.channelId ?? fallback)) {
5200
5353
  return {
5201
5354
  success: false,
@@ -5204,7 +5357,10 @@ export class ChannelRegistry {
5204
5357
  : caller !== undefined && this.takesGlobalFallback?.(caller) === false
5205
5358
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
5206
5359
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
5207
- : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
5360
+ : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
5361
+ ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
5362
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
5363
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
5208
5364
  isError: true,
5209
5365
  };
5210
5366
  }
@@ -5228,7 +5384,7 @@ export class ChannelRegistry {
5228
5384
  else if (own && 'neither' in own) {
5229
5385
  if (seen)
5230
5386
  seen.refusal = 'shared-id';
5231
- return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
5387
+ return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted', false), isError: true };
5232
5388
  }
5233
5389
  else if (own) {
5234
5390
  entry = this.entryAt(own.channel);
@@ -5265,7 +5421,7 @@ export class ChannelRegistry {
5265
5421
  // channel with its id isn't where its message came from.
5266
5422
  error: input.channelId === undefined
5267
5423
  ? serverGiven
5268
- ? `Server "${input.serverId}" has no channel "${fallback.channelId}", the id of the channel this call would default to, so nothing was posted.`
5424
+ ? `Server "${input.serverId}" has no channel ${quotedValue(fallback.channelId)}, the id of the channel this call would default to, so nothing was posted.`
5269
5425
  : `The channel this would default to, ${this.quotedShown(fallback)}, isn't registered now, so nothing was posted.`
5270
5426
  // This tool's schema has no serverId, so one given here is a host's
5271
5427
  // ref (publishForAgent), or one an agent added unasked, and the