@animalabs/agent-framework 0.32.0 → 0.33.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 (288) hide show
  1. package/README.md +204 -14
  2. package/dist/src/admission/admit.d.ts +18 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -1
  4. package/dist/src/admission/admit.js +16 -3
  5. package/dist/src/admission/admit.js.map +1 -1
  6. package/dist/src/agent.d.ts +185 -78
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +279 -102
  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 +50 -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 +4 -20
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
  17. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/scripts.js +24 -0
  19. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  20. package/dist/src/config-checks.d.ts +7 -0
  21. package/dist/src/config-checks.d.ts.map +1 -1
  22. package/dist/src/config-checks.js +14 -0
  23. package/dist/src/config-checks.js.map +1 -1
  24. package/dist/src/context-manager-errors.d.ts +25 -0
  25. package/dist/src/context-manager-errors.d.ts.map +1 -0
  26. package/dist/src/context-manager-errors.js +31 -0
  27. package/dist/src/context-manager-errors.js.map +1 -0
  28. package/dist/src/context-options.d.ts +67 -0
  29. package/dist/src/context-options.d.ts.map +1 -0
  30. package/dist/src/context-options.js +85 -0
  31. package/dist/src/context-options.js.map +1 -0
  32. package/dist/src/deferred-import.d.ts +93 -0
  33. package/dist/src/deferred-import.d.ts.map +1 -0
  34. package/dist/src/deferred-import.js +177 -0
  35. package/dist/src/deferred-import.js.map +1 -0
  36. package/dist/src/dendrite/audience.d.ts +223 -0
  37. package/dist/src/dendrite/audience.d.ts.map +1 -0
  38. package/dist/src/dendrite/audience.js +202 -0
  39. package/dist/src/dendrite/audience.js.map +1 -0
  40. package/dist/src/dendrite/index.d.ts +5 -3
  41. package/dist/src/dendrite/index.d.ts.map +1 -1
  42. package/dist/src/dendrite/index.js +2 -1
  43. package/dist/src/dendrite/index.js.map +1 -1
  44. package/dist/src/dendrite/presets.d.ts +10 -5
  45. package/dist/src/dendrite/presets.d.ts.map +1 -1
  46. package/dist/src/dendrite/presets.js +17 -5
  47. package/dist/src/dendrite/presets.js.map +1 -1
  48. package/dist/src/dendrite/registry.d.ts +62 -7
  49. package/dist/src/dendrite/registry.d.ts.map +1 -1
  50. package/dist/src/dendrite/registry.js +179 -10
  51. package/dist/src/dendrite/registry.js.map +1 -1
  52. package/dist/src/dendrite/types.d.ts +91 -8
  53. package/dist/src/dendrite/types.d.ts.map +1 -1
  54. package/dist/src/foreign-rounds.d.ts +69 -0
  55. package/dist/src/foreign-rounds.d.ts.map +1 -0
  56. package/dist/src/foreign-rounds.js +152 -0
  57. package/dist/src/foreign-rounds.js.map +1 -0
  58. package/dist/src/framework.d.ts +1976 -818
  59. package/dist/src/framework.d.ts.map +1 -1
  60. package/dist/src/framework.js +7566 -3422
  61. package/dist/src/framework.js.map +1 -1
  62. package/dist/src/gate/event-gate.d.ts +85 -15
  63. package/dist/src/gate/event-gate.d.ts.map +1 -1
  64. package/dist/src/gate/event-gate.js +421 -47
  65. package/dist/src/gate/event-gate.js.map +1 -1
  66. package/dist/src/gate/index.d.ts +1 -1
  67. package/dist/src/gate/index.d.ts.map +1 -1
  68. package/dist/src/gate/index.js.map +1 -1
  69. package/dist/src/gate/rule-scope.d.ts +30 -0
  70. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  71. package/dist/src/gate/rule-scope.js +41 -0
  72. package/dist/src/gate/rule-scope.js.map +1 -0
  73. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  74. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  75. package/dist/src/gate/suppress-script-worker.js +103 -0
  76. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  77. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  78. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  79. package/dist/src/gate/suppress-scripts.js +403 -0
  80. package/dist/src/gate/suppress-scripts.js.map +1 -0
  81. package/dist/src/gate/suppression-review.d.ts +143 -0
  82. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  83. package/dist/src/gate/suppression-review.js +463 -0
  84. package/dist/src/gate/suppression-review.js.map +1 -0
  85. package/dist/src/gate/suppression-stats.d.ts +125 -0
  86. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  87. package/dist/src/gate/suppression-stats.js +214 -0
  88. package/dist/src/gate/suppression-stats.js.map +1 -0
  89. package/dist/src/gate/types.d.ts +135 -7
  90. package/dist/src/gate/types.d.ts.map +1 -1
  91. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  92. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  93. package/dist/src/gate/wake-onboarding.js +277 -0
  94. package/dist/src/gate/wake-onboarding.js.map +1 -0
  95. package/dist/src/held-writes.d.ts +31 -23
  96. package/dist/src/held-writes.d.ts.map +1 -1
  97. package/dist/src/held-writes.js +44 -59
  98. package/dist/src/held-writes.js.map +1 -1
  99. package/dist/src/index.d.ts +5 -2
  100. package/dist/src/index.d.ts.map +1 -1
  101. package/dist/src/index.js +3 -0
  102. package/dist/src/index.js.map +1 -1
  103. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  104. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  105. package/dist/src/jsonrpc-envelope.js +66 -0
  106. package/dist/src/jsonrpc-envelope.js.map +1 -0
  107. package/dist/src/mcpl/channel-registry.d.ts +131 -15
  108. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  109. package/dist/src/mcpl/channel-registry.js +290 -46
  110. package/dist/src/mcpl/channel-registry.js.map +1 -1
  111. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  112. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  113. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  114. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  115. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  116. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  117. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  118. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  119. package/dist/src/mcpl/inbound-source.js +2 -1
  120. package/dist/src/mcpl/inbound-source.js.map +1 -1
  121. package/dist/src/mcpl/inference-router.d.ts +6 -6
  122. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  123. package/dist/src/mcpl/inference-router.js.map +1 -1
  124. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  125. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  126. package/dist/src/mcpl/push-coalescer.js +105 -15
  127. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  128. package/dist/src/mcpl/push-handler.d.ts +9 -7
  129. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  130. package/dist/src/mcpl/push-handler.js.map +1 -1
  131. package/dist/src/mcpl/server-connection.d.ts +13 -10
  132. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  133. package/dist/src/mcpl/server-connection.js +134 -56
  134. package/dist/src/mcpl/server-connection.js.map +1 -1
  135. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  136. package/dist/src/mcpl/tool-classes.js +3 -0
  137. package/dist/src/mcpl/tool-classes.js.map +1 -1
  138. package/dist/src/mcpl/types.d.ts +8 -2
  139. package/dist/src/mcpl/types.d.ts.map +1 -1
  140. package/dist/src/mcpl/types.js.map +1 -1
  141. package/dist/src/module-registry.d.ts +24 -1
  142. package/dist/src/module-registry.d.ts.map +1 -1
  143. package/dist/src/module-registry.js +68 -18
  144. package/dist/src/module-registry.js.map +1 -1
  145. package/dist/src/modules/discord/index.d.ts +45 -8
  146. package/dist/src/modules/discord/index.d.ts.map +1 -1
  147. package/dist/src/modules/discord/index.js +237 -86
  148. package/dist/src/modules/discord/index.js.map +1 -1
  149. package/dist/src/modules/history/index.d.ts +59 -0
  150. package/dist/src/modules/history/index.d.ts.map +1 -1
  151. package/dist/src/modules/history/index.js +432 -67
  152. package/dist/src/modules/history/index.js.map +1 -1
  153. package/dist/src/modules/history/semantic.d.ts +6 -5
  154. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  155. package/dist/src/modules/history/semantic.js +15 -22
  156. package/dist/src/modules/history/semantic.js.map +1 -1
  157. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  158. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  159. package/dist/src/modules/workspace/index.d.ts +28 -7
  160. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/index.js +76 -22
  162. package/dist/src/modules/workspace/index.js.map +1 -1
  163. package/dist/src/notice-tags.d.ts +10 -1
  164. package/dist/src/notice-tags.d.ts.map +1 -1
  165. package/dist/src/notice-tags.js +10 -1
  166. package/dist/src/notice-tags.js.map +1 -1
  167. package/dist/src/operator-log.d.ts +36 -3
  168. package/dist/src/operator-log.d.ts.map +1 -1
  169. package/dist/src/operator-log.js +2 -2
  170. package/dist/src/operator-log.js.map +1 -1
  171. package/dist/src/presentation.d.ts +746 -0
  172. package/dist/src/presentation.d.ts.map +1 -0
  173. package/dist/src/presentation.js +1435 -0
  174. package/dist/src/presentation.js.map +1 -0
  175. package/dist/src/prose-drafts.d.ts +15 -11
  176. package/dist/src/prose-drafts.d.ts.map +1 -1
  177. package/dist/src/prose-drafts.js +16 -12
  178. package/dist/src/prose-drafts.js.map +1 -1
  179. package/dist/src/read-marks.d.ts +192 -0
  180. package/dist/src/read-marks.d.ts.map +1 -0
  181. package/dist/src/read-marks.js +242 -0
  182. package/dist/src/read-marks.js.map +1 -0
  183. package/dist/src/recovery/offline-branch.d.ts +91 -6
  184. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  185. package/dist/src/recovery/offline-branch.js +273 -29
  186. package/dist/src/recovery/offline-branch.js.map +1 -1
  187. package/dist/src/recovery/recover-cli.js +22 -3
  188. package/dist/src/recovery/recover-cli.js.map +1 -1
  189. package/dist/src/send-tools.d.ts +5 -9
  190. package/dist/src/send-tools.d.ts.map +1 -1
  191. package/dist/src/send-tools.js +40 -7
  192. package/dist/src/send-tools.js.map +1 -1
  193. package/dist/src/stored-injections.d.ts +2 -2
  194. package/dist/src/stored-injections.js +2 -2
  195. package/dist/src/stored-removal.d.ts +17 -4
  196. package/dist/src/stored-removal.d.ts.map +1 -1
  197. package/dist/src/stored-removal.js +29 -5
  198. package/dist/src/stored-removal.js.map +1 -1
  199. package/dist/src/tool-result-guard.d.ts +12 -1
  200. package/dist/src/tool-result-guard.d.ts.map +1 -1
  201. package/dist/src/tool-result-guard.js +42 -8
  202. package/dist/src/tool-result-guard.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +1159 -79
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +3709 -363
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/tune-out/tools.d.ts +114 -17
  208. package/dist/src/tune-out/tools.d.ts.map +1 -1
  209. package/dist/src/tune-out/tools.js +87 -27
  210. package/dist/src/tune-out/tools.js.map +1 -1
  211. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  212. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  213. package/dist/src/tune-out/window-anchor.js +202 -0
  214. package/dist/src/tune-out/window-anchor.js.map +1 -0
  215. package/dist/src/types/agent.d.ts +82 -20
  216. package/dist/src/types/agent.d.ts.map +1 -1
  217. package/dist/src/types/events.d.ts +2 -0
  218. package/dist/src/types/events.d.ts.map +1 -1
  219. package/dist/src/types/framework.d.ts +32 -12
  220. package/dist/src/types/framework.d.ts.map +1 -1
  221. package/dist/src/types/module.d.ts +180 -69
  222. package/dist/src/types/module.d.ts.map +1 -1
  223. package/dist/src/types/trace.d.ts +510 -36
  224. package/dist/src/types/trace.d.ts.map +1 -1
  225. package/package.json +3 -3
  226. package/src/admission/admit.ts +32 -3
  227. package/src/agent.ts +323 -120
  228. package/src/api/mcp-server-core.ts +54 -17
  229. package/src/api/server.ts +4 -23
  230. package/src/code-execution/isolate/scripts.ts +28 -3
  231. package/src/config-checks.ts +14 -0
  232. package/src/context-manager-errors.ts +37 -0
  233. package/src/context-options.ts +96 -0
  234. package/src/deferred-import.ts +239 -0
  235. package/src/dendrite/audience.ts +315 -0
  236. package/src/dendrite/index.ts +14 -2
  237. package/src/dendrite/presets.ts +18 -6
  238. package/src/dendrite/registry.ts +209 -12
  239. package/src/dendrite/types.ts +92 -8
  240. package/src/foreign-rounds.ts +199 -0
  241. package/src/framework.ts +7804 -3546
  242. package/src/gate/event-gate.ts +428 -65
  243. package/src/gate/index.ts +3 -0
  244. package/src/gate/rule-scope.ts +45 -0
  245. package/src/gate/suppress-script-worker.ts +89 -0
  246. package/src/gate/suppress-scripts.ts +414 -0
  247. package/src/gate/suppression-review.ts +517 -0
  248. package/src/gate/suppression-stats.ts +314 -0
  249. package/src/gate/types.ts +141 -7
  250. package/src/gate/wake-onboarding.ts +309 -0
  251. package/src/held-writes.ts +49 -60
  252. package/src/index.ts +9 -2
  253. package/src/jsonrpc-envelope.ts +81 -0
  254. package/src/mcpl/channel-registry.ts +353 -85
  255. package/src/mcpl/checkpoint-manager.ts +3 -2
  256. package/src/mcpl/feature-set-manager.ts +2 -1
  257. package/src/mcpl/inbound-source.ts +2 -1
  258. package/src/mcpl/inference-router.ts +7 -4
  259. package/src/mcpl/push-coalescer.ts +193 -34
  260. package/src/mcpl/push-handler.ts +10 -5
  261. package/src/mcpl/server-connection.ts +141 -60
  262. package/src/mcpl/tool-classes.ts +3 -0
  263. package/src/mcpl/types.ts +8 -2
  264. package/src/module-registry.ts +73 -17
  265. package/src/modules/discord/index.ts +229 -81
  266. package/src/modules/history/index.ts +439 -70
  267. package/src/modules/history/semantic.ts +15 -21
  268. package/src/modules/workspace/disk-agreement.ts +1 -1
  269. package/src/modules/workspace/index.ts +85 -23
  270. package/src/notice-tags.ts +10 -1
  271. package/src/operator-log.ts +36 -3
  272. package/src/presentation.ts +1676 -0
  273. package/src/prose-drafts.ts +16 -12
  274. package/src/read-marks.ts +328 -0
  275. package/src/recovery/offline-branch.ts +346 -32
  276. package/src/recovery/recover-cli.ts +21 -3
  277. package/src/send-tools.ts +40 -7
  278. package/src/stored-injections.ts +2 -2
  279. package/src/stored-removal.ts +31 -7
  280. package/src/tool-result-guard.ts +41 -7
  281. package/src/tune-out/coordinator.ts +4089 -457
  282. package/src/tune-out/tools.ts +160 -43
  283. package/src/tune-out/window-anchor.ts +277 -0
  284. package/src/types/agent.ts +86 -20
  285. package/src/types/events.ts +2 -0
  286. package/src/types/framework.ts +32 -12
  287. package/src/types/module.ts +181 -68
  288. package/src/types/trace.ts +542 -40
@@ -49,6 +49,8 @@ import type { InboundSource } from './inbound-source.js';
49
49
  import { McplRequestError, type McplServerConnection } from './server-connection.js';
50
50
  import { readSkipReplyWake, skipReplyNote, type SkipReplyEarlierWake } from './skip-reply-wake.js';
51
51
  import { isProseTargetToken } from './prose-grammar.js';
52
+ import { persistentReaderName, tuneOutHeldWords, tuneOutNoReaderAnswer, tuneOutReachWords, type TuneOutReader } from '../tune-out/tools.js';
53
+ import type { TraceEventInput } from '../types/trace.js';
52
54
 
53
55
  // ============================================================================
54
56
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
@@ -200,7 +202,7 @@ function substitutedChannel(
200
202
  */
201
203
  const CHANNEL_LABEL_HISTORY_LOG_ID = 'mcpl/channel-label-history';
202
204
 
203
- type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
205
+ export type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
204
206
 
205
207
  /**
206
208
  * Parameters of an active tune-out (issue #77), carried on the
@@ -275,8 +277,8 @@ function desiredStateRecord(item: unknown): {
275
277
 
276
278
  /**
277
279
  * The desired state `store`'s lifecycle log records for `channelId` on
278
- * `serverId`, for a reader with no registry yet, as a serving boot's
279
- * deferred-write flush is, before initializeMcpl: the last decision wins, as
280
+ * `serverId`, for a reader with no registry yet, as the boot's import of an
281
+ * earlier release's queued writes is, before initializeMcpl: the last decision wins, as
280
282
  * the registry's replay reads it (getDesiredState answers once there is a
281
283
  * registry). undefined when no decision names it.
282
284
  */
@@ -730,6 +732,34 @@ function channelOnServer(ref: ChannelRef): string {
730
732
  return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
731
733
  }
732
734
 
735
+ /** A channel_close's lead where its channel's server no longer registers it. */
736
+ function noLongerRegistered(ref: ChannelRef): string {
737
+ return `Server ${quotedValue(ref.serverId)} no longer registers channel ${quotedValue(ref.channelId)}, so no close was recorded or sent.`;
738
+ }
739
+
740
+ /** What a channel_close's bare name reached besides the channel it closed
741
+ * (ChannelRegistry.focusNamedBare): each by its id and server, which the
742
+ * bare name leaves out, and the channelId and serverId a call closes it
743
+ * with: its own id, which needn't be the name the call gave (a focus on
744
+ * `b:room` beside server b's `room`), so naming the server alone, the
745
+ * name kept, is no channel there. */
746
+ function notClosedText(refs: readonly ChannelRef[]): string {
747
+ const names = refs.map(channelOnServer);
748
+ const call = (ref: ChannelRef) => `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`;
749
+ return refs.length === 1
750
+ ? `Channel ${names[0]} was not closed: to close it, call again with ${call(refs[0]!)}.`
751
+ : `Channels ${names.slice(0, -1).join(', ')} and ${names.at(-1)} were not closed: to close one, call again with ` +
752
+ `${refs.slice(0, -1).map(call).join(', with ')}, or with ${call(refs.at(-1)!)}.`;
753
+ }
754
+
755
+ /** A tool result with a sentence added: a success's after its note (or as
756
+ * its note), a failure's after its error. */
757
+ function withSentence(result: ToolResult, sentence: string): ToolResult {
758
+ if (!result.success) return { ...result, error: result.error ? `${result.error} ${sentence}` : sentence };
759
+ const data = (result.data ?? {}) as Record<string, unknown>;
760
+ return { ...result, data: { ...data, note: typeof data.note === 'string' ? `${data.note} ${sentence}` : sentence } };
761
+ }
762
+
733
763
  const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
734
764
 
735
765
  /**
@@ -1011,16 +1041,31 @@ interface ChannelRegistryOptions {
1011
1041
  * this is ends through the cancel flow (release the backlog, record the
1012
1042
  * end) before the lifecycle state flips to closed. A
1013
1043
  * 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.
1044
+ * `unregistered`: the close names the channel after its server stopped
1045
+ * registering it, so no close is recorded or sent (handleToolClose).
1046
+ * Returns, where a hold excepts the channel, what became of that hold:
1047
+ * a function that says it in a sentence, read when the close's answer is
1048
+ * built, after its transport's round-trip, so the answer says what holds
1049
+ * when it arrives (a release that waited may have run by then). The
1050
+ * answer carries it whatever became of the close: as `note` on a success,
1051
+ * after the error on a failure. Throws, with the answer's text, when the
1052
+ * hold's release fails; one that ended the hold before it failed says so.
1016
1053
  */
1017
- onToolClose?: (serverId: string, channelId: string) => string | undefined;
1054
+ onToolClose?: ToolCloseHook;
1018
1055
  /**
1019
1056
  * The channel unprefixed speech lands in while an all-except hold is
1020
1057
  * active (its exception), else null. Consulted after a fork's home and
1021
1058
  * the turn's own delivery, before the legacy fallback.
1022
1059
  */
1023
1060
  focusChannelResolver?: () => ChannelRef | null;
1061
+ /**
1062
+ * Who reads a tuned-out channel's traffic (the framework's reader: the
1063
+ * persistent subconscious by its name, or reader forks), for the texts
1064
+ * that tell an agent where a tuned-out channel's messages go. Left out,
1065
+ * the texts name the persistent reader by its default name
1066
+ * (persistentReaderName): there is no hold without a reader (0.33.0).
1067
+ */
1068
+ tuneOutReader?: () => TuneOutReader;
1024
1069
  /**
1025
1070
  * Called when a text-only turn's speech was not delivered to its
1026
1071
  * conversational locus (`failed`), or may not have been (`unknown`), for
@@ -1099,6 +1144,15 @@ interface ChannelRegistryOptions {
1099
1144
  * server: that takes no fallback either.
1100
1145
  */
1101
1146
  activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1147
+ /**
1148
+ * Whether the agent's current turn has no channel of its own — a
1149
+ * heartbeat's, or one woken for a channel that names none — so it replies
1150
+ * to no incoming message (incomingFor). Left out, a null from
1151
+ * activeChannelResolver means that, as it always did; given, it separates
1152
+ * a turn with nowhere to speak (a locked turn pointed at drafts) from one
1153
+ * with nothing to answer.
1154
+ */
1155
+ channellessTurn?: (agentName: string) => boolean;
1102
1156
  /**
1103
1157
  * Whether a conversation takes the process-global fallback, the channel of
1104
1158
  * the most recent delivered inbound message: as where its plain speech lands
@@ -1127,6 +1181,33 @@ interface ChannelRegistryOptions {
1127
1181
  // ChannelRegistry
1128
1182
  // ============================================================================
1129
1183
 
1184
+ /** A channel_close call's input (ChannelRegistry.handleToolClose). */
1185
+ type ToolCloseInput = {
1186
+ channelId: string;
1187
+ serverId?: string;
1188
+ /** Machine callers name their decision source ('subscription-gc',
1189
+ * 'housekeeping') so the durable record carries honest provenance.
1190
+ * Honored ONLY for module-origin dispatch — tool input is untrusted,
1191
+ * so a model-origin call carrying this field is still recorded as
1192
+ * 'agent-tool' (the resident may close their own channel; they may
1193
+ * not attribute the act to housekeeping). A module-origin call
1194
+ * without it is the module's own close, MODULE_CLOSE_SOURCE. */
1195
+ source?: string;
1196
+ /** A machine close aimed at an explicitly-opened channel is refused
1197
+ * unless the caller certifies an explicit idle lease (a configured
1198
+ * per-channel budget is consent to close at that budget). A tuned-out
1199
+ * channel is refused even so, since its tune-out's cancel is what
1200
+ * ends it. Module origin only, like `source`. */
1201
+ overrideExplicitOpen?: boolean;
1202
+ };
1203
+
1204
+ /** ChannelRegistryOptions.onToolClose: a tool close's end of the all-except hold that excepts its channel. */
1205
+ export type ToolCloseHook = (
1206
+ serverId: string,
1207
+ channelId: string,
1208
+ opts?: { unregistered?: true },
1209
+ ) => (() => string) | undefined;
1210
+
1130
1211
  /**
1131
1212
  * Who is actually making a channel tool call, as established by the
1132
1213
  * framework's own dispatch — model-origin calls come through
@@ -1177,7 +1258,7 @@ export class ChannelRegistry {
1177
1258
  private serverRegistry: McplServerRegistry;
1178
1259
  private featureSetManager: FeatureSetManager;
1179
1260
  private pushEventFn: (event: ProcessEvent) => void;
1180
- private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
1261
+ private emitTraceFn: (event: TraceEventInput) => void;
1181
1262
  private sendTypingFn?: (
1182
1263
  serverId: string,
1183
1264
  channelId: string,
@@ -1186,8 +1267,9 @@ export class ChannelRegistry {
1186
1267
  ) => void;
1187
1268
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1188
1269
  private readonly latestInboundLocus: boolean;
1189
- private onToolClose?: (serverId: string, channelId: string) => string | undefined;
1270
+ private onToolClose?: ToolCloseHook;
1190
1271
  private focusChannelResolver?: () => ChannelRef | null;
1272
+ private readonly tuneOutReaderFn?: () => TuneOutReader;
1191
1273
  private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
1192
1274
  private onChannelAutoOpened?: (info: {
1193
1275
  conversationId?: string;
@@ -1197,6 +1279,7 @@ export class ChannelRegistry {
1197
1279
  }) => void;
1198
1280
  private homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
1199
1281
  private activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1282
+ private channellessTurn?: (agentName: string) => boolean;
1200
1283
  private takesGlobalFallback?: (conversationId: string) => boolean;
1201
1284
  private serverConnecting?: (serverId: string) => boolean;
1202
1285
  private store?: JsStore;
@@ -1478,17 +1561,17 @@ export class ChannelRegistry {
1478
1561
  (reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
1479
1562
  (failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
1480
1563
  }
1481
- // A tune-out sends the channel's messages to the subconscious, and
1564
+ // A tune-out holds the channel's messages for its reader, if any, and
1482
1565
  // channel_open refuses a tuned-out channel.
1483
1566
  if (action === 'open' ? now !== 'open' : now !== 'closed') {
1484
1567
  if (action === 'open' && now === 'tuned-out') {
1485
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
1568
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_open doesn't ` +
1486
1569
  `change that. ${this.tuneOutEndText(current)}`;
1487
1570
  }
1488
1571
  // channel_close refuses a tuned-out channel too, so calling it again
1489
1572
  // isn't the way: the tune-out's cancel is, and it delivers the backlog.
1490
1573
  if (action === 'close' && now === 'tuned-out') {
1491
- return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
1574
+ return `Channel ${channel} was tuned out meanwhile: its messages ${this.tuneOutHeld()}, and channel_close doesn't ` +
1492
1575
  `change that. ${this.tuneOutEndText(current, 'channel_close')}`;
1493
1576
  }
1494
1577
  // The later decision's own work settles the transport, and its call
@@ -1581,7 +1664,7 @@ export class ChannelRegistry {
1581
1664
  serverRegistry: McplServerRegistry,
1582
1665
  featureSetManager: FeatureSetManager,
1583
1666
  pushEventFn: (event: ProcessEvent) => void,
1584
- emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
1667
+ emitTraceFn: (event: TraceEventInput) => void,
1585
1668
  options?: ChannelRegistryOptions & {
1586
1669
  sendTypingFn?: (
1587
1670
  serverId: string,
@@ -1619,10 +1702,12 @@ export class ChannelRegistry {
1619
1702
  this.latestInboundLocus = options?.latestInboundLocus === true;
1620
1703
  this.onToolClose = options?.onToolClose;
1621
1704
  this.focusChannelResolver = options?.focusChannelResolver;
1705
+ this.tuneOutReaderFn = options?.tuneOutReader;
1622
1706
  this.onRouteFailure = options?.onRouteFailure;
1623
1707
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1624
1708
  this.homeChannelResolver = options?.homeChannelResolver;
1625
1709
  this.activeChannelResolver = options?.activeChannelResolver;
1710
+ this.channellessTurn = options?.channellessTurn;
1626
1711
  this.takesGlobalFallback = options?.takesGlobalFallback;
1627
1712
  this.serverConnecting = options?.serverConnecting;
1628
1713
  this.store = options?.store;
@@ -2303,8 +2388,9 @@ export class ChannelRegistry {
2303
2388
  }
2304
2389
 
2305
2390
  /** The explicit 'stop' for a channel whose typing ended, while its
2306
- * server still registers it. */
2391
+ * server still registers it and holds the grant its start needed. */
2307
2392
  private sendTypingStop(ref: ChannelRef): void {
2393
+ if (!this.typingGranted(ref)) return;
2308
2394
  if (this.sendTypingFn && this.entryAt(ref)) {
2309
2395
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
2310
2396
  }
@@ -2456,15 +2542,7 @@ export class ChannelRegistry {
2456
2542
  }, origin?.kind === 'agent' ? origin.own : undefined);
2457
2543
 
2458
2544
  case 'channel_close':
2459
- return this.handleToolClose(
2460
- input as {
2461
- channelId: string;
2462
- serverId?: string;
2463
- source?: string;
2464
- overrideExplicitOpen?: boolean;
2465
- },
2466
- origin,
2467
- );
2545
+ return this.handleToolClose(input as ToolCloseInput, origin);
2468
2546
 
2469
2547
  case 'channel_decline':
2470
2548
  return this.handleToolDecline(input as {
@@ -2520,7 +2598,9 @@ export class ChannelRegistry {
2520
2598
  * its home.
2521
2599
  */
2522
2600
  incomingFor(agentName?: string): { channel: ChannelRef; messageId: string; threadId?: string } | undefined {
2523
- if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2601
+ const channelless = agentName === undefined ? false
2602
+ : this.channellessTurn ? this.channellessTurn(agentName) : this.activeChannelResolver?.(agentName) === null;
2603
+ if (channelless && !this.homeChannelResolver?.(agentName!)) {
2524
2604
  return undefined;
2525
2605
  }
2526
2606
  // The latest delivered inbound itself, not the speech fallback
@@ -3159,7 +3239,7 @@ export class ChannelRegistry {
3159
3239
 
3160
3240
  /**
3161
3241
  * Make a tuned-out channel's transport match its tune-out, which keeps it
3162
- * open so its traffic reaches the subconscious (TuneOutParams). The
3242
+ * open so its traffic reaches its reader, or is held (TuneOutParams). The
3163
3243
  * tune-out is recorded first (enterTuneOut), and this writes no decision,
3164
3244
  * so the tune-out stands whatever becomes of the open. Lifecycle work
3165
3245
  * queued for the channel goes first, until none is left, since each
@@ -3171,8 +3251,8 @@ export class ChannelRegistry {
3171
3251
  * tune-out as open), and the answer waits for the work queued after it
3172
3252
  * too, such as a registration that came back, and comes from the state
3173
3253
  * that work leaves. Returns nothing where the transport is open, or, for
3174
- * tune_out's answer, why the channel's messages don't or may not reach the
3175
- * subconscious yet and when they will, or that the tune-out isn't in
3254
+ * tune_out's answer, why the channel's messages don't or may not reach its
3255
+ * reader yet, or be received, and when they will, or that the tune-out isn't in
3176
3256
  * effect any more. A call while this tune-out's open is in flight, as a
3177
3257
  * repeated tune_out makes, gets that open's answer rather than send
3178
3258
  * another.
@@ -3245,9 +3325,10 @@ export class ChannelRegistry {
3245
3325
  // that took effect would have its traffic diverted. `again` where calling
3246
3326
  // tune_out again tries the open now: the channel is registered here and
3247
3327
  // its server up.
3248
- const notYet = (later: string, why: string, again = false): string => withStop(
3249
- `The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, ` +
3250
- `which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
3328
+ // Where no reader runs, each answer is that alone (tuneOutNoReaderAnswer).
3329
+ const notYet = (later: string, why: string, again = false): string => tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(
3330
+ `The tune-out is recorded, but its messages ${this.tuneOutReach('may not reach')} until this host opens the ` +
3331
+ `channel, which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
3251
3332
  );
3252
3333
  const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
3253
3334
  'close its channels then';
@@ -3279,18 +3360,18 @@ export class ChannelRegistry {
3279
3360
  // established.
3280
3361
  const reach = this.serverReach(serverId);
3281
3362
  if (reach !== 'up') {
3282
- return withStop(
3283
- `The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
3284
- `connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
3363
+ return tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? withStop(
3364
+ `The tune-out is recorded, but its messages ${this.tuneOutReach("don't reach")} until the "${serverId}" server ` +
3365
+ `is connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
3285
3366
  (reach === 'gone' ? unreachableRemedy(reach) : ''),
3286
3367
  );
3287
3368
  }
3288
3369
  const transportOpen = this.tuneOutReadsOpen(entry);
3289
3370
  if (!this.directsLifecycle(serverId)) {
3290
3371
  return transportOpen ? undefined
3291
- : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
3292
- 'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
3293
- 'this host to open it, ask whoever runs this host.';
3372
+ : tuneOutNoReaderAnswer(this.tuneOutReaderNow()) ?? `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages ` +
3373
+ `${this.tuneOutReach('reach')} only as that server sends them, and calling tune_out again won't change ` +
3374
+ 'that. If you need this host to open it, ask whoever runs this host.';
3294
3375
  }
3295
3376
  if (!entry) return missing;
3296
3377
  // A flag a close that may have taken effect left gets an open.
@@ -3957,10 +4038,9 @@ export class ChannelRegistry {
3957
4038
  * (typing timer lifecycle is still managed for when the callback is wired).
3958
4039
  */
3959
4040
  private sendTypingNotification(ref: ChannelRef): void {
3960
- // §14.1: channels/typing requires channels.typing in the grant. Silent
3961
- // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
3962
- // tick would be noise.
3963
- if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing')) return;
4041
+ // Silent skip — a typing indicator is cosmetic and per-7s, so a
4042
+ // diagnostic per tick would be noise.
4043
+ if (!this.typingGranted(ref)) return;
3964
4044
  if (this.sendTypingFn) {
3965
4045
  this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
3966
4046
  }
@@ -3968,6 +4048,11 @@ export class ChannelRegistry {
3968
4048
  // sendTyping method, wire it here directly instead of using a callback.
3969
4049
  }
3970
4050
 
4051
+ /** §14.1: channels/typing, start and stop alike, requires channels.typing in the grant. */
4052
+ private typingGranted(ref: ChannelRef): boolean {
4053
+ return CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing');
4054
+ }
4055
+
3971
4056
  // ==========================================================================
3972
4057
  // Private: Channel Lookup
3973
4058
  // ==========================================================================
@@ -4048,6 +4133,21 @@ export class ChannelRegistry {
4048
4133
  return channelOnServer(ref);
4049
4134
  }
4050
4135
 
4136
+ /** The reader of a tuned-out channel's traffic, as the host says (tuneOutReader), or the default persistent reader. */
4137
+ private tuneOutReaderNow(): TuneOutReader {
4138
+ return this.tuneOutReaderFn?.() ?? { kind: 'persistent', name: persistentReaderName({}) };
4139
+ }
4140
+
4141
+ /** What a tuned-out channel's messages are, after "its messages" (tuneOutHeldWords). */
4142
+ private tuneOutHeld(): string {
4143
+ return tuneOutHeldWords(this.tuneOutReaderNow());
4144
+ }
4145
+
4146
+ /** Whether a tuned-out channel's messages get to its reader, after "its messages" (tuneOutReachWords). */
4147
+ private tuneOutReach(form: Parameters<typeof tuneOutReachWords>[1]): string {
4148
+ return tuneOutReachWords(this.tuneOutReaderNow(), form);
4149
+ }
4150
+
4051
4151
  /**
4052
4152
  * A tool's refusal of a tuned-out channel, whose decision is the tune-out:
4053
4153
  * its cancel is what ends it (tuneOutEndText), and a decision recorded over
@@ -4059,7 +4159,7 @@ export class ChannelRegistry {
4059
4159
  success: false,
4060
4160
  isError: true,
4061
4161
  error:
4062
- `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
4162
+ `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages ${this.tuneOutHeld()}, and ${tool} ` +
4063
4163
  `doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
4064
4164
  data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
4065
4165
  };
@@ -4080,7 +4180,7 @@ export class ChannelRegistry {
4080
4180
  return entry ? this.tuneOutEndText(entry) : undefined;
4081
4181
  }
4082
4182
 
4083
- private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
4183
+ private tuneOutEndText(entry: ChannelEntry, tool?: string, closeWith?: string): string {
4084
4184
  const close = tool === 'channel_close';
4085
4185
  // A name is written as a literal tune_out's channelId takes back
4086
4186
  // (quotedValue, main's #852); one no name picks alone, by its id and
@@ -4090,7 +4190,7 @@ export class ChannelRegistry {
4090
4190
  ? `channelId ${quotedValue(name)}`
4091
4191
  : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
4092
4192
  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.' : '.'}`;
4193
+ `attention; cancelling delivers the diverted backlog${close ? `, and channel_close${closeWith ? ` with ${closeWith}` : ''} closes it after that.` : '.'}`;
4094
4194
  }
4095
4195
 
4096
4196
  /**
@@ -5170,36 +5270,38 @@ export class ChannelRegistry {
5170
5270
  }
5171
5271
  }
5172
5272
 
5173
- private async handleToolClose(
5174
- input: {
5175
- channelId: string;
5176
- serverId?: string;
5177
- /** Machine callers name their decision source ('subscription-gc',
5178
- * 'housekeeping') so the durable record carries honest provenance.
5179
- * Honored ONLY for module-origin dispatch — tool input is untrusted,
5180
- * so a model-origin call carrying this field is still recorded as
5181
- * 'agent-tool' (the resident may close their own channel; they may
5182
- * not attribute the act to housekeeping). A module-origin call
5183
- * without it is the module's own close, MODULE_CLOSE_SOURCE. */
5184
- source?: string;
5185
- /** A machine close aimed at an explicitly-opened channel is refused
5186
- * unless the caller certifies an explicit idle lease (a configured
5187
- * per-channel budget is consent to close at that budget). A tuned-out
5188
- * channel is refused even so, since its tune-out's cancel is what
5189
- * ends it. Module origin only, like `source`. */
5190
- overrideExplicitOpen?: boolean;
5191
- },
5192
- origin?: ChannelToolOrigin,
5193
- ): Promise<ToolResult> {
5194
- const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
5195
- const entry = resolved.entry;
5196
- if (!entry) {
5197
- return {
5198
- success: false,
5199
- error: resolved.error,
5200
- isError: true,
5201
- };
5273
+ private async handleToolClose(input: ToolCloseInput, origin?: ChannelToolOrigin): Promise<ToolResult> {
5274
+ const own = origin?.kind === 'agent' ? origin.own : undefined;
5275
+ // An agent's bare name for the channel the all-except hold excepts means
5276
+ // that channel (focusNamedBare), whether its server still registers it
5277
+ // or not: another server's channel with the id, which the name would
5278
+ // reach otherwise, isn't closed, and the answer says so. A module's
5279
+ // close resolves its name as any other: housekeeping doesn't end the
5280
+ // resident's focus.
5281
+ const focused = origin?.kind === 'module' ? undefined : this.focusNamedBare(input.channelId, input.serverId, own);
5282
+ if (focused && 'refusal' in focused) return { success: false, error: focused.refusal, isError: true };
5283
+ if (focused) {
5284
+ const entry = this.entryAt(focused.focus);
5285
+ const result = entry
5286
+ ? await this.closeToolEntry(entry, input, origin)
5287
+ : this.unregisteredFocusClose(focused.focus) ?? { success: false, error: noLongerRegistered(focused.focus), isError: true };
5288
+ return focused.notClosed ? withSentence(result, focused.notClosed) : result;
5202
5289
  }
5290
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
5291
+ if (resolved.entry) return this.closeToolEntry(resolved.entry, input, origin);
5292
+ // The name, with its serverId or as the turn's own channel, means the
5293
+ // channel the all-except hold excepts, which its server no longer
5294
+ // registers (unregisteredFocusNamed). A module's close of such a name is
5295
+ // refused as any name no channel answers to.
5296
+ const gone = origin?.kind === 'module' ? undefined : this.unregisteredFocusNamed(input.channelId, input.serverId, own);
5297
+ return (gone && this.unregisteredFocusClose(gone)) || { success: false, error: resolved.error, isError: true };
5298
+ }
5299
+
5300
+ /**
5301
+ * A tool close of the channel `entry`, which its name resolved to
5302
+ * (handleToolClose).
5303
+ */
5304
+ private async closeToolEntry(entry: ChannelEntry, input: ToolCloseInput, origin?: ChannelToolOrigin): Promise<ToolResult> {
5203
5305
  // From here, the channel's own id, whichever name the call gave for it
5204
5306
  // (a server-qualified one included).
5205
5307
  input = { ...input, channelId: entry.descriptor.id };
@@ -5233,19 +5335,21 @@ export class ChannelRegistry {
5233
5335
  // Closed already, perhaps only by default: the agent's close is still
5234
5336
  // its decision, and is recorded as one (setDesiredState), so a later
5235
5337
  // registration's lift of a default leaves the channel closed.
5236
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
5338
+ const hook = this.toolCloseHook(refOf(entry));
5339
+ if ('error' in hook) return { success: false, error: hook.error, isError: true };
5340
+ const ended = hook.text;
5237
5341
  try {
5238
5342
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5239
5343
  } catch (err) {
5240
5344
  return {
5241
5345
  success: false,
5242
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`,
5346
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended()}` : ''}`,
5243
5347
  isError: true,
5244
5348
  };
5245
5349
  }
5246
5350
  return {
5247
5351
  success: true,
5248
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
5352
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed', ...(ended ? { note: ended() } : {}) },
5249
5353
  };
5250
5354
  }
5251
5355
 
@@ -5285,15 +5389,22 @@ export class ChannelRegistry {
5285
5389
  // A hold around this channel — the all-except scope it is the excepted
5286
5390
  // channel of — ends through the cancel flow: backlog released, end
5287
5391
  // recorded, never by overwriting state (#232 gap 9).
5288
- const ended = this.onToolClose?.(entry.serverId, input.channelId);
5392
+ const hook = this.toolCloseHook(refOf(entry));
5393
+ if ('error' in hook) return { success: false, error: hook.error, isError: true };
5394
+ const ended = hook.text;
5289
5395
 
5290
5396
  // A decision the log refused changed nothing for the channel, and the
5291
- // answer says so, and what the hook ended all the same.
5397
+ // answer says so, and what the hook ended all the same. Every answer
5398
+ // carries the hook's sentence, a success's as `note`: whether the close
5399
+ // went through says nothing of the focus, whose end may wait (an
5400
+ // operator's change to the store in progress). The sentence is read as
5401
+ // the answer is built, after the transport's round-trip, during which a
5402
+ // release that waited may have run.
5403
+ const withEnded = (text: string): string => (ended ? `${text} ${ended()}` : text);
5292
5404
  try {
5293
5405
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5294
5406
  } catch (err) {
5295
- const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
5296
- return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
5407
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, unrecordedDecision(err))), isError: true };
5297
5408
  }
5298
5409
  // This channel's typing, not every channel with its id (stopTyping's
5299
5410
  // fan-out of a bare name).
@@ -5302,7 +5413,7 @@ export class ChannelRegistry {
5302
5413
  // Refused now, the close stays recorded for the server's next
5303
5414
  // registration, and the text says so.
5304
5415
  if (!this.directsLifecycle(entry.serverId)) {
5305
- return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
5416
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId))), isError: true };
5306
5417
  }
5307
5418
 
5308
5419
  try {
@@ -5311,13 +5422,165 @@ export class ChannelRegistry {
5311
5422
  if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
5312
5423
  return {
5313
5424
  success: true,
5314
- data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
5425
+ data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed', ...(ended ? { note: ended() } : {}) },
5315
5426
  };
5316
5427
  } catch (err) {
5317
- return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
5428
+ return { success: false, error: withEnded(this.lifecycleFailureText('close', entry, err)), isError: true };
5318
5429
  }
5319
5430
  }
5320
5431
 
5432
+ /**
5433
+ * The tool-close hook (onToolClose) for a channel, its throw taken as the
5434
+ * answer's text: a release of the hold that failed is the close's answer,
5435
+ * as the registry's other failures are, never a rejected call.
5436
+ */
5437
+ private toolCloseHook(ref: ChannelRef, opts?: { unregistered?: true }): { text?: () => string } | { error: string } {
5438
+ try {
5439
+ return { text: this.onToolClose?.(ref.serverId, ref.channelId, opts) };
5440
+ } catch (err) {
5441
+ return { error: err instanceof Error ? err.message : String(err) };
5442
+ }
5443
+ }
5444
+
5445
+ /**
5446
+ * A close naming the channel the all-except hold excepts, which its server
5447
+ * no longer registers: the close has nothing left to close, and the hold
5448
+ * nothing left to speak into, so it ends as a close of the registered
5449
+ * channel ends it, what it held released, and the answer says so, as a
5450
+ * failure, since no close ran. Undefined where no hold excepts the channel
5451
+ * (onToolClose answers nothing).
5452
+ */
5453
+ private unregisteredFocusClose(gone: ChannelRef): ToolResult | undefined {
5454
+ const lead = noLongerRegistered(gone);
5455
+ const ended = this.toolCloseHook(gone, { unregistered: true });
5456
+ if ('error' in ended) return { success: false, error: `${lead} ${ended.error}`, isError: true };
5457
+ if (ended.text) return { success: false, error: `${lead} ${ended.text()}`, isError: true };
5458
+ return undefined;
5459
+ }
5460
+
5461
+ /**
5462
+ * The channel the all-except hold excepts (focusChannelResolver), where a
5463
+ * channel_close names it by its id or its server-qualified name alone: no
5464
+ * serverId, and no channel of the calling turn's own answering to the name
5465
+ * (ownChannelNamed, which a turn's name means first). Such a name means
5466
+ * the hold's channel, registered or not, and not another server's channel
5467
+ * with the id, as when the hold's channel's server stopped registering it
5468
+ * and another server registered the id: that one is closed only by a close
5469
+ * that names its server. With it, where other registered channels answer
5470
+ * to the name, the sentence saying they weren't closed (`notClosed`).
5471
+ *
5472
+ * Where the name is the hold channel's and a channel of the calling turn's
5473
+ * own, another channel, answers to it too, the name means neither: a
5474
+ * resident repeating a close that failed before its focus ended, in a turn
5475
+ * that has since opened another server's channel with the id, would
5476
+ * otherwise close that channel and leave the focus in force. That close is
5477
+ * refused, naming both (`refusal`, focusOrOwnRefusal), and a close naming
5478
+ * either's server reaches it. A turn's name for the hold's channel itself,
5479
+ * and a name the turn's channels on more than one server answer to
5480
+ * (ownNeitherRefusal), resolve as any other.
5481
+ *
5482
+ * Undefined where no hold is in force, where the name isn't the hold
5483
+ * channel's, and where the hold's record can't be read, which leaves the
5484
+ * name to resolve as any other (resolveToolChannelEntry).
5485
+ *
5486
+ * Another channel the name reaches that has a tune-out of its own is
5487
+ * refused by a close until that tune-out is cancelled (tunedOutRefusal),
5488
+ * so its sentence says it is tuned out, and names the cancel first, then
5489
+ * the close by its id and server.
5490
+ */
5491
+ private focusNamedBare(
5492
+ name: string,
5493
+ serverId: string | undefined,
5494
+ own: readonly ChannelRef[] | undefined,
5495
+ ): { focus: ChannelRef; notClosed?: string } | { refusal: string } | undefined {
5496
+ if (serverId) return undefined;
5497
+ const mine = this.ownChannelNamed(name, own);
5498
+ if (mine && 'neither' in mine) return undefined;
5499
+ let focus: ChannelRef | null | undefined;
5500
+ try {
5501
+ focus = this.focusChannelResolver?.();
5502
+ } catch {
5503
+ return undefined;
5504
+ }
5505
+ if (!focus || (name !== focus.channelId && name !== qualifiedChannelName(focus))) return undefined;
5506
+ if (mine) return sameChannel(mine.channel, focus) ? undefined : { refusal: this.focusOrOwnRefusal(name, focus, mine.channel, own) };
5507
+ const held = focus;
5508
+ const others = this.entriesNamed(name).filter((entry) => !sameChannel(refOf(entry), held));
5509
+ const tunedOut = others.filter((entry) => this.getDesiredState(entry.serverId, entry.descriptor.id) === 'tuned-out');
5510
+ const closable = others.filter((entry) => !tunedOut.includes(entry)).map(refOf);
5511
+ const sentences = [
5512
+ ...(closable.length > 0 ? [notClosedText(closable)] : []),
5513
+ ...tunedOut.map((entry) => {
5514
+ const ref = refOf(entry);
5515
+ return `Channel ${channelOnServer(ref)} was not closed: it is tuned out, and a close doesn't change that. ` +
5516
+ this.tuneOutEndText(entry, 'channel_close', `channelId ${quotedValue(ref.channelId)} and serverId ${quotedValue(ref.serverId)}`);
5517
+ }),
5518
+ ];
5519
+ return { focus: held, ...(sentences.length > 0 ? { notClosed: sentences.join(' ') } : {}) };
5520
+ }
5521
+
5522
+ /**
5523
+ * The refusal of a channel_close's bare name (focusNamedBare) that both the
5524
+ * channel the all-except hold excepts (`focus`) and another channel of the
5525
+ * calling turn's own (`mine`, ownChannelNamed) answer to, each named by
5526
+ * what reaches it alone. The hold's channel is reached by its
5527
+ * server-qualified name, registered or not (focusNamedBare), unless a
5528
+ * channel of the turn's answers to that too; the turn's channel as a
5529
+ * refusal of the turn's names shows it (ownCandidates), unless another
5530
+ * channel of the turn's has its qualified name for its id. A serverId goes
5531
+ * with the channel's own id, so it's offered only where the name is the id
5532
+ * of both, for the hold's server (registered or not, the close reaches its
5533
+ * channel: unregisteredFocusNamed) and for the turn's channel's where that
5534
+ * server still registers it. Where the name is the hold channel's
5535
+ * server-qualified name, which another of the turn's channels has for its
5536
+ * id, the hold's channel is reached by its id and server.
5537
+ */
5538
+ private focusOrOwnRefusal(name: string, focus: ChannelRef, mine: ChannelRef, own: readonly ChannelRef[] | undefined): string {
5539
+ const qualified = qualifiedChannelName(focus);
5540
+ const focusReaches = this.ownChannelNamed(qualified, own) === undefined;
5541
+ const [theirs] = this.ownCandidates([mine]);
5542
+ // The turn's channel's name, as the focus's: a bare id answers to the
5543
+ // turn's own channels first, so one of them with that id catches it.
5544
+ const theirsReaches = theirs!.reaches && this.ownChannelNamed(theirs!.text, own) === undefined;
5545
+ const parts = [
5546
+ { text: focusReaches ? quotedValue(qualified) : channelOnServer(focus), reaches: focusReaches },
5547
+ { text: theirsReaches ? quotedValue(theirs!.text) : theirs!.reaches ? channelOnServer(mine) : theirs!.text, reaches: theirsReaches },
5548
+ ];
5549
+ const named = parts.filter((p) => p.reaches).map((p) => p.text);
5550
+ const byName = named.length === 2 ? 'channelId set to one of them' : named.length === 1 ? `channelId ${named[0]}` : '';
5551
+ const servers = name === focus.channelId ? [focus.serverId, ...(this.entryAt(mine) ? [mine.serverId] : [])] : [];
5552
+ const byServer = servers.length > 0 ? `serverId set to ${servers.map((id) => JSON.stringify(id)).join(' or ')}` : '';
5553
+ const how = [byName, byServer].filter((h) => h !== '').join(', or with ');
5554
+ const focusRoute = name !== focus.channelId && !focusReaches
5555
+ ? ` For the channel the focus excepts, call again with channelId ${quotedValue(focus.channelId)} and serverId ${JSON.stringify(focus.serverId)}.`
5556
+ : '';
5557
+ return `Channel ${JSON.stringify(name)} names both the channel the all-except focus excepts (${parts[0]!.text}) ` +
5558
+ `and this turn's channel (${parts[1]!.text}), so nothing ran.${how ? ` Call again with ${how}.` : ''}${focusRoute}`;
5559
+ }
5560
+
5561
+ /**
5562
+ * The channel the all-except hold excepts (focusChannelResolver), where a
5563
+ * channel tool's name with a serverId, or the calling turn's own channel's
5564
+ * name, means it though its server no longer registers it: with a
5565
+ * serverId, that server's channel with the id; inside the calling turn,
5566
+ * the turn's own channel the name answers to by its id (ownChannelNamed).
5567
+ * A name given alone is focusNamedBare's. Undefined where no hold is in
5568
+ * force, where its channel is still registered, and where the hold's
5569
+ * record can't be read, which leaves the name's refusal as it was.
5570
+ */
5571
+ private unregisteredFocusNamed(name: string, serverId: string | undefined, own: readonly ChannelRef[] | undefined): ChannelRef | undefined {
5572
+ let focus: ChannelRef | null | undefined;
5573
+ try {
5574
+ focus = this.focusChannelResolver?.();
5575
+ } catch {
5576
+ return undefined;
5577
+ }
5578
+ if (!focus || this.entryAt(focus)) return undefined;
5579
+ if (serverId) return sameChannel(focus, { serverId, channelId: name }) ? focus : undefined;
5580
+ const mine = this.ownChannelNamed(name, own);
5581
+ return mine && 'channel' in mine && sameChannel(mine.channel, focus) ? focus : undefined;
5582
+ }
5583
+
5321
5584
  private async handleToolDecline(input: {
5322
5585
  channelId: string;
5323
5586
  serverId?: string;
@@ -5513,8 +5776,8 @@ export class ChannelRegistry {
5513
5776
  `diverted, and then call channel_close.${serverBack}`
5514
5777
  : `. If you still want it closed, call channel_close.${serverBack}`;
5515
5778
  closeError = decided === 'tuned-out'
5516
- ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
5517
- `the subconscious${step}`
5779
+ ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages ` +
5780
+ `${this.tuneOutHeld()}${step}`
5518
5781
  : `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
5519
5782
  } else if (closed) {
5520
5783
  // Closed for certain, whatever became of the decline's own close: one
@@ -6267,8 +6530,13 @@ export class ChannelRegistry {
6267
6530
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
6268
6531
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
6269
6532
  : 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}`
6533
+ ? (this.channellessTurn && !this.channellessTurn(caller)
6534
+ // The turn came from a channel, but its speech goes to none
6535
+ // (a locked turn held as drafts).
6536
+ ? `Nothing was posted: name the channel with channelId. Your plain speech goes to no channel now, so `
6537
+ + `channel_publish sends only to a channel it names. ${findIds}`
6538
+ : `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6539
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`)
6272
6540
  : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6273
6541
  isError: true,
6274
6542
  };