@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
@@ -15,7 +15,10 @@
15
15
  * lifecycle facts (the [Gate: …] precedent).
16
16
  */
17
17
 
18
+ import { NOTICE_TAG } from '../notice-tags.js';
19
+ import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js';
18
20
  import { randomUUID } from 'node:crypto';
21
+ import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js';
19
22
  import type { ContentBlock } from '@animalabs/membrane';
20
23
  import type { StoredMessage } from '@animalabs/context-manager';
21
24
  import type { ChannelRef, ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
@@ -23,6 +26,9 @@ import type { McplServerRegistry } from '../mcpl/server-registry.js';
23
26
  import { CapabilityGrant } from '../mcpl/capability-grant.js';
24
27
  import { armAfter, armAt } from '../deadline-timer.js';
25
28
 
29
+ /** The options a tune-out call can give. */
30
+ type TuneOutOption = 'durationSeconds' | 'cadenceSeconds' | 'backlogCap' | 'maxWakes';
31
+
26
32
  /** Defaults for tune_out tool params. */
27
33
  export const TUNE_OUT_DEFAULTS = {
28
34
  cadenceSeconds: 1800,
@@ -34,18 +40,123 @@ export const TUNE_OUT_DEFAULTS = {
34
40
  * subconscious turn, and max-wakes counts invocations, not messages. */
35
41
  const WAKE_COALESCE_MS = 2_000;
36
42
 
43
+ /**
44
+ * One backlog line: author, then the message's blocks in order — text as
45
+ * is, anything else named by kind (`[image]`, `[document]`), so a release
46
+ * never drops a block silently (#232 gap 4). The author is named once: a
47
+ * server that renders `Name: body` into the text itself is not doubled.
48
+ * A name the line adds is a value someone supplied, kept to one line with
49
+ * its notice openings marked (authorLine), as main's dump writes it.
50
+ */
51
+ export function renderHeldLine(m: Pick<StoredMessage, 'participant' | 'content' | 'metadata'>): string {
52
+ const author = authorNamed(m.metadata) ?? m.participant;
53
+ const parts = m.content.map((b: ContentBlock) =>
54
+ b.type === 'text' ? (b as Extract<ContentBlock, { type: 'text' }>).text : `[${b.type}]`,
55
+ );
56
+ const line = parts.filter((p) => p.length > 0).join(' ');
57
+ const firstText = m.content.find((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')?.text;
58
+ return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line);
59
+ }
60
+
61
+ /** One reader invocation under `reader: 'forks'` (see SubconsciousConfig). */
62
+ export interface ReaderInvocation {
63
+ serverId: string;
64
+ channelId: string;
65
+ epochId: string;
66
+ trigger: 'cadence' | 'wake' | 'cancel';
67
+ /** The host-framed notice for this invocation (bracket style). */
68
+ notice: string;
69
+ /** Held messages not yet handed to any reader fork, oldest first. */
70
+ backlog: StoredMessage[];
71
+ /**
72
+ * Held messages not yet handed to any reader fork that are still queued
73
+ * behind the resident's turn (admitted, not yet stored), oldest first.
74
+ * They come after `backlog` in arrival order. A reader is most useful
75
+ * while the resident is busy, which is exactly when its traffic is here
76
+ * rather than in the store.
77
+ */
78
+ queued: QueuedHeldMessage[];
79
+ /** Standing dispositions, as text, or null when there are none. */
80
+ dispositions: string | null;
81
+ }
82
+
83
+ /**
84
+ * A held message admitted for the resident but not yet in its store: it was
85
+ * deferred while a turn was alive. `writeId` is the deferred write's durable
86
+ * id, which the stored row carries as `metadata.deferredWriteId` once it
87
+ * lands.
88
+ */
89
+ export type QueuedHeldMessage = Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & {
90
+ writeId: string;
91
+ /** When the framework first held the write, in ms since the epoch. */
92
+ heldAt?: number;
93
+ };
94
+
95
+ /** The deferred write a stored row landed from, when it came through the queue. */
96
+ function writeIdOf(m: Pick<StoredMessage, 'metadata'>): string | undefined {
97
+ const writeId = (m.metadata as { deferredWriteId?: unknown } | undefined)?.deferredWriteId;
98
+ return typeof writeId === 'string' ? writeId : undefined;
99
+ }
100
+
101
+ /**
102
+ * Whether a held message is one of this channel's: by its channel id, and by
103
+ * its server where it records one, since two servers can register the same
104
+ * channel id. A message recording neither is taken as the channel's, as
105
+ * before servers were recorded.
106
+ */
107
+ function inChannelOf(serverId: string, channelId: string): (m: Pick<StoredMessage, 'metadata'>) => boolean {
108
+ return (m) => {
109
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
110
+ return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
111
+ };
112
+ }
113
+
114
+ /**
115
+ * The all-except scope (issue #232 §3.2; focus folded into tune-out): a
116
+ * resident-level policy epoch holding every channel but one, for a bounded
117
+ * time. It covers DMs and channels registered after entry, since the
118
+ * decision is "not the exception" rather than a per-channel state.
119
+ */
120
+ export interface AllExceptParams {
121
+ epochId: string;
122
+ except: { serverId: string; channelId: string };
123
+ cadenceSeconds: number;
124
+ /** Per held channel, at release. */
125
+ backlogCap: number;
126
+ /** Reader wakes before the reader is paused (the hold itself stays). */
127
+ maxWakes: number;
128
+ startedAtSequence: number;
129
+ startedAtMs: number;
130
+ /** Mandatory: the scope always ends on its own. */
131
+ expiresAtMs: number;
132
+ source: string;
133
+ }
134
+
135
+ export interface AllExceptState {
136
+ params: AllExceptParams;
137
+ wakeCount: number;
138
+ }
139
+
140
+ /** `check`: what is held right now, without releasing anything. */
141
+ export interface TuneOutCheck {
142
+ allExcept: { epochId: string; except: { serverId: string; channelId: string }; expiresAtMs: number; wakeCount: number; held: Record<string, number> } | null;
143
+ channels: Array<{ serverId: string; channelId: string; epochId: string; held: number; expiresAtMs?: number }>;
144
+ /** Up to `peekLimit` newest held lines of the peeked channel, oldest first. */
145
+ peek?: string[];
146
+ }
147
+
37
148
  /** How long after an end time whose cancel threw the cancel is tried again. */
38
149
  const EXPIRY_RETRY_MS = 60_000;
39
150
 
40
151
  /** Ends a backlog line cut to fit in one of the residents' messages. */
41
- const CUT_MARK = " [cut to fit; the whole message is in the channel's history]";
152
+ const CUT_MARK = ` [${NOTICE_TAG.cutToFit}; the whole message is in the channel's history]`;
42
153
 
43
154
  /**
44
155
  * tune_out's refusal of a channel already tuned out: its settings, then what
45
156
  * its transport is now where the repeat tried to open it again (`transport`,
46
157
  * from the framework's dispatch), then how to change the settings.
47
158
  */
48
- export function alreadyTunedOutText(shown: string, p: TuneOutParams, transport?: string): string {
159
+ export function alreadyTunedOutText(quoted: string, p: TuneOutParams, transport?: string): string {
49
160
  // A duration so long its end is past what a Date can hold has no time to show.
50
161
  const expires = p.expiresAtMs !== undefined ? new Date(p.expiresAtMs) : undefined;
51
162
  const until = expires && Number.isFinite(expires.getTime())
@@ -53,21 +164,13 @@ export function alreadyTunedOutText(shown: string, p: TuneOutParams, transport?:
53
164
  : '';
54
165
  // A thrown error's words may end without a stop.
55
166
  const now = transport === undefined ? '' : /[.!?]$/.test(transport) ? `${transport} ` : `${transport}. `;
56
- return `Nothing changed${transport !== undefined ? ' in the tune-out' : ''}: ${JSON.stringify(shown)} is already `
167
+ return `Nothing changed${transport !== undefined ? ' in the tune-out' : ''}: ${quoted} is already `
57
168
  + `tuned out, with cadenceSeconds ${p.cadenceSeconds}, backlogCap ${p.backlogCap} and maxWakes ${p.maxWakes}${until}. `
58
169
  + now
59
170
  + 'To change those, cancel it first with tune_out mode "cancel", which delivers its backlog to you now, then call '
60
171
  + 'tune_out again with the new settings.';
61
172
  }
62
173
 
63
- /** The first `length` code units of `text`, one fewer where the last would be
64
- * the first half of a surrogate pair. */
65
- function prefixOf(text: string, length: number): string {
66
- const cut = text.slice(0, Math.max(0, length));
67
- const last = cut.charCodeAt(cut.length - 1);
68
- return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
69
- }
70
-
71
174
  /** What a thrown value says, without throwing: an Error's message, or the
72
175
  * value as text, which String() can't give for an object with no prototype. */
73
176
  function thrownReason(error: unknown): string {
@@ -88,8 +191,20 @@ export interface TuneOutFrameworkHooks {
88
191
  ) => string;
89
192
  /** Queue an inference request for an agent. */
90
193
  requestInference: (agentName: string, reason: string, source: string) => void;
91
- /** The subconscious's registry/participant name. */
194
+ /** The persistent subconscious's registry/participant name, or null. */
92
195
  subconsciousName: () => string | null;
196
+ /**
197
+ * `reader: 'forks'`: derive and run one reader fork. Absent in persistent
198
+ * mode. The framework owns the fork's lifecycle; the coordinator owns the
199
+ * epoch.
200
+ */
201
+ invokeReader?: (invocation: ReaderInvocation) => void;
202
+ /**
203
+ * `reader: 'forks'`: deliver a reader fork's own words to the resident
204
+ * as attributed mail (its name, its incarnation). Absent in persistent
205
+ * mode, where the subconscious writes under its own name directly.
206
+ */
207
+ deliverFromReader?: (reader: string, content: ContentBlock[], metadata: Record<string, unknown>) => void;
93
208
  /** The primary resident's registry name. */
94
209
  primaryName: () => string | null;
95
210
  /** Read the primary's stored messages (shared slot, unfiltered). */
@@ -99,7 +214,19 @@ export interface TuneOutFrameworkHooks {
99
214
  * while a turn was alive), oldest first. A cancel reads them too, so a
100
215
  * message held mid-turn is in the backlog it belongs to (#232 gap 7).
101
216
  */
102
- getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>>;
217
+ getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { writeId?: string; heldAt?: number }>;
218
+ /**
219
+ * Make what was just delivered durable, including anything the delivery
220
+ * left in the deferred queue: a release flips durable epoch state, so its
221
+ * dump must survive a crash in the same window (#232 gap 6).
222
+ */
223
+ commitDeliveries?: () => void;
224
+ /**
225
+ * The history-reading tool the resident actually has (e.g. a server's
226
+ * `fetch_history`), or null. A truncation note names it only when it exists
227
+ * (#232 gap 11).
228
+ */
229
+ historyTool?: () => string | null;
103
230
  /**
104
231
  * The most characters of text every reader of the residents' shared slot
105
232
  * is shown whole in one message: the smallest of their strategies'
@@ -118,6 +245,19 @@ export interface TuneOutFrameworkHooks {
118
245
  isPrivilegedAuthor: (authorId: string | null | undefined) => boolean;
119
246
  /** speak_in_channel master switch (recipe-side, canary-gated). */
120
247
  allowChannelSpeech: () => boolean;
248
+ /**
249
+ * Whether a tune-out may hold a channel with no reader at all — a silent
250
+ * hold (issue #232 §3.3), which the host opts into (`tuneOut.enabled`).
251
+ * Left out, or false, a hold with no reader is refused.
252
+ */
253
+ holdWithoutReader?: () => boolean;
254
+ /**
255
+ * Post a line into a channel under the resident's name. Used for the
256
+ * all-except scope's entry and release announcements in the excepted
257
+ * channel (Linn, 2026-10-12: the room should hear the door shut, not
258
+ * discover it). Absent when `tuneOut.announce` is off.
259
+ */
260
+ announce?: (serverId: string, channelId: string, text: string) => Promise<{ success: boolean; error?: string }>;
121
261
  emitTrace: (event: { type: string; [key: string]: unknown }) => void;
122
262
  }
123
263
 
@@ -131,8 +271,60 @@ export class TuneOutCoordinator {
131
271
  private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
132
272
  /** Channels with a wake invocation pending inside the coalescing window. */
133
273
  private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
134
- /** Diverted-message tallies since the last subconscious invocation, per channel key. */
274
+ /**
275
+ * Held messages the channel's reader hasn't looked at, per channel key:
276
+ * what makes a cadence period worth a look. Counted up as messages are
277
+ * held and back to zero at each look; rebuilt from the held rows where
278
+ * memory can't know it (rebuildTally): after a restart, and when a
279
+ * channel's own tune-out ends while the all-except hold still holds it.
280
+ * A failed reader fork's messages count again (readerFailed).
281
+ */
135
282
  private divertedSinceInvocation = new Map<string, number>();
283
+ /**
284
+ * The persistent subconscious's last look at each held channel, per epoch:
285
+ * the store's sequence when the look was asked for. Durable, so that after
286
+ * a restart its next look counts only what was held since (rebuildTally).
287
+ * Reader forks need none: what they were handed is in memory, and after a
288
+ * restart the first fork of an epoch is handed all it has held.
289
+ */
290
+ private lastLooksCache: Record<string, Record<string, number>> | undefined;
291
+ /**
292
+ * `reader: 'forks'`: the highest sequence of a held message already
293
+ * handed to a reader fork, per epoch and channel (handOffKey). Each held
294
+ * message is delivered to exactly one fork. A channel the all-except hold
295
+ * holds can be taken by a tune-out of its own and given back, and each
296
+ * epoch's readers read that epoch's messages, so what one epoch's readers
297
+ * were handed never moves or clears the other's. In-memory: after a
298
+ * restart the first fork of an epoch is handed everything the epoch has
299
+ * held, which is the right side to err on.
300
+ */
301
+ private readerCursor = new Map<string, number>();
302
+ /**
303
+ * `reader: 'forks'`: the deferred writes handed to a reader fork while
304
+ * still queued, per epoch and channel. Each lands in the store later as a row
305
+ * stamped with the same id (`metadata.deferredWriteId`), past the cursor;
306
+ * that row is not handed again. Cleared with the cursor.
307
+ */
308
+ private readerHandedWrites = new Map<string, Set<string>>();
309
+ /**
310
+ * `reader: 'forks'`: held messages a reader fork was handed but never read,
311
+ * per epoch and channel: its derive failed, or its run threw before it reported
312
+ * (readerFailed). The next invocation is handed them again, cursor or no:
313
+ * stored rows by id, and by write id those that were queued when handed
314
+ * (stored or still queued now). Cleared with the cursor.
315
+ */
316
+ private readerReturned = new Map<string, { ids: Set<string>; writeIds: Set<string> }>();
317
+ /**
318
+ * `reader: 'forks'`: the late forks derived for an ended epoch's messages
319
+ * (readerFailed), so that a late fork's own failure derives no other.
320
+ */
321
+ private lateForks = new WeakSet<object>();
322
+ /** The all-except scope's durable record, cached (undefined = not read yet). */
323
+ private scopeCache: AllExceptState | null | undefined;
324
+ /** The epoch whose release this process delivered and whose hold is not yet recorded as cleared. */
325
+ private scopeReleased: string | undefined;
326
+ /** Timer key for the scope's cadence and expiry. */
327
+ private static readonly SCOPE_KEY = '\0all-except';
136
328
 
137
329
  constructor(
138
330
  private readonly channelRegistry: ChannelRegistry,
@@ -144,6 +336,28 @@ export class TuneOutCoordinator {
144
336
  return `${serverId}\0${channelId}`;
145
337
  }
146
338
 
339
+ /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
340
+ private handOffKey(epochId: string, serverId: string, channelId: string): string {
341
+ return `${epochId}\0${serverId}\0${channelId}`;
342
+ }
343
+
344
+ /** An ended epoch's hand-off bookkeeping goes with it, every channel's. */
345
+ private dropHandOffs(epochId: string): void {
346
+ for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) {
347
+ for (const key of map.keys()) if (key.startsWith(`${epochId}\0`)) map.delete(key);
348
+ }
349
+ }
350
+
351
+ /** A reader exists: the persistent subconscious, or forks on demand. */
352
+ private readerAvailable(): boolean {
353
+ return this.hooks.subconsciousName() != null || typeof this.hooks.invokeReader === 'function';
354
+ }
355
+
356
+ /** Reader forks are the configured reader. */
357
+ private get forkReader(): boolean {
358
+ return this.hooks.subconsciousName() == null && typeof this.hooks.invokeReader === 'function';
359
+ }
360
+
147
361
  /** A channel as the resident and the subconscious are shown it
148
362
  * (ChannelRegistry.nameShown): its id, or, where another server registers
149
363
  * the same id, its server-qualified name, which tune_out and
@@ -153,6 +367,10 @@ export class TuneOutCoordinator {
153
367
  return this.channelRegistry.nameShown({ serverId, channelId });
154
368
  }
155
369
 
370
+ private quotedShown(serverId: string, channelId: string): string {
371
+ return this.channelRegistry.quotedShown({ serverId, channelId });
372
+ }
373
+
156
374
  /** Restart cadence + expiry timers and re-anchor after a process restart. */
157
375
  resumeActiveEpochs(): void {
158
376
  let oldestAnchor: number | null = null;
@@ -170,9 +388,39 @@ export class TuneOutCoordinator {
170
388
  oldestAnchor = params.startedAtSequence;
171
389
  }
172
390
  }
391
+ const scope = this.getAllExcept();
392
+ if (scope) {
393
+ if (scope.params.expiresAtMs <= Date.now()) {
394
+ this.cancelAllExcept('duration', 'duration elapsed');
395
+ } else {
396
+ this.armScopeTimers(scope.params);
397
+ if (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor) {
398
+ oldestAnchor = scope.params.startedAtSequence;
399
+ }
400
+ }
401
+ }
173
402
  if (oldestAnchor !== null) {
174
403
  this.hooks.setSubconsciousAnchor(oldestAnchor);
175
404
  }
405
+ // What each held channel's reader hasn't looked at was counted in
406
+ // memory, so it is counted again from the held rows: without it, a
407
+ // channel held across the restart gets no cadence look until something
408
+ // new arrives.
409
+ // Stored and still queued alike: at a quiesced boot, restored writes stay
410
+ // queued until the resume flushes them.
411
+ const held = new Set<string>();
412
+ for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) {
413
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; serverId?: string; channelId?: string } | undefined;
414
+ const epochId = meta?.tuneOut?.epochId;
415
+ if (!meta || !epochId || !meta.channelId) continue;
416
+ const key = this.key(meta.serverId ?? '', meta.channelId);
417
+ if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId) held.add(key);
418
+ }
419
+ for (const key of held) {
420
+ const [serverId, channelId] = key.split('\0') as [string, string];
421
+ this.rebuildTally(serverId, channelId);
422
+ }
423
+ this.pruneLooks();
176
424
  }
177
425
 
178
426
  private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void {
@@ -227,13 +475,65 @@ export class TuneOutCoordinator {
227
475
  // Enter / cancel
228
476
  // ==========================================================================
229
477
 
478
+ /**
479
+ * The options a tune-out call gives, read as numbers before anything
480
+ * changes (F76). A tool's input reaches here as the model wrote it, and
481
+ * the store can't keep a NaN or an Infinity: its append would throw after
482
+ * the registry had marked the channel tuned out. So each option is read
483
+ * as a number first, a numeric string included, as the sleep tool reads
484
+ * its seconds; null counts as left out. A value that gives no finite
485
+ * number is refused, naming the option and, where it can be left out,
486
+ * what leaving it out means; so is a duration whose end time is too far
487
+ * off to store. `now` is the clock the end time was counted from. One
488
+ * rule for a channel's tune-out and the all-except hold.
489
+ */
490
+ private readOptions(
491
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
492
+ leftOut: Record<TuneOutOption, string | null>,
493
+ ): { ok: true; given: Partial<Record<TuneOutOption, number>>; now: number; expiresAtMs: number | undefined } | { ok: false; error: string } {
494
+ const given: Partial<Record<TuneOutOption, number>> = {};
495
+ for (const name of Object.keys(leftOut) as TuneOutOption[]) {
496
+ const value: unknown = opts[name];
497
+ if (value == null) continue;
498
+ const n = Number(value);
499
+ if (!Number.isFinite(n)) {
500
+ const got = typeof value === 'number'
501
+ ? String(value)
502
+ : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
503
+ return {
504
+ ok: false,
505
+ error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
506
+ + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
507
+ };
508
+ }
509
+ given[name] = n;
510
+ }
511
+ const now = Date.now();
512
+ const expiresAtMs = given.durationSeconds === undefined
513
+ ? undefined
514
+ : now + Math.max(60, given.durationSeconds) * 1000;
515
+ if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
516
+ return {
517
+ ok: false,
518
+ error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
519
+ + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
520
+ };
521
+ }
522
+ return { ok: true, given, now, expiresAtMs };
523
+ }
524
+
230
525
  enter(
231
526
  serverId: string,
232
527
  channelId: string,
233
- opts: { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number },
528
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
234
529
  source: string,
235
530
  ): { ok: true; params: TuneOutParams } | { ok: false; error: string; alreadyTunedOut?: true } {
236
- if (!this.hooks.subconsciousName()) {
531
+ // No reader (issue #232 §3.3): a silent hold, where the host opted into
532
+ // one. Nothing summarizes, nothing wakes, mentions are still
533
+ // acknowledged, the backlog is released on cancel; the resident is told
534
+ // so in the tool result. A host that didn't opt in has no tune_out
535
+ // without a reader.
536
+ if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) {
237
537
  return {
238
538
  ok: false,
239
539
  error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
@@ -244,53 +544,26 @@ export class TuneOutCoordinator {
244
544
  if (this.hooks.isForkBound({ serverId, channelId })) {
245
545
  return {
246
546
  ok: false,
247
- error: `Nothing changed: a conversation is bound to ${JSON.stringify(this.shown(serverId, channelId))}, so its messages go to that `
547
+ error: `Nothing changed: a conversation is bound to ${this.quotedShown(serverId, channelId)}, so its messages go to that `
248
548
  + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
249
549
  + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
250
550
  + 'out a different channel, or ask whoever runs this host if this one should be diverted.',
251
551
  };
252
552
  }
253
553
  const current = this.channelRegistry.getTuneOutState(serverId, channelId);
254
- if (current) return { ok: false, error: alreadyTunedOutText(this.shown(serverId, channelId), current.params), alreadyTunedOut: true };
255
- // A tool's input reaches here as the model wrote it, and the store can't
256
- // keep a NaN or an Infinity: its append would throw after the registry
257
- // had marked the channel tuned out. So each option is read as a number
258
- // before anything changes, a numeric string included, as the sleep tool
259
- // reads its seconds; null counts as left out. A value that gives no
260
- // finite number is refused.
261
- const leftOut = {
554
+ if (current) return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
555
+ const scope = this.getAllExcept();
556
+ if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
557
+ return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
558
+ }
559
+ const read = this.readOptions(opts, {
262
560
  durationSeconds: 'to stay tuned out until you cancel',
263
561
  cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
264
562
  backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
265
563
  maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
266
- } as const;
267
- const given: Partial<Record<keyof typeof leftOut, number>> = {};
268
- for (const name of Object.keys(leftOut) as Array<keyof typeof leftOut>) {
269
- const value: unknown = opts[name];
270
- if (value == null) continue;
271
- const n = Number(value);
272
- if (!Number.isFinite(n)) {
273
- const got = typeof value === 'number'
274
- ? String(value)
275
- : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
276
- return {
277
- ok: false,
278
- error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
279
- + `and it was ${got}. Leave it out ${leftOut[name]}.`,
280
- };
281
- }
282
- given[name] = n;
283
- }
284
- const expiresAtMs = given.durationSeconds === undefined
285
- ? undefined
286
- : Date.now() + Math.max(60, given.durationSeconds) * 1000;
287
- if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
288
- return {
289
- ok: false,
290
- error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. `
291
- + `Leave it out ${leftOut.durationSeconds}.`,
292
- };
293
- }
564
+ });
565
+ if (!read.ok) return read;
566
+ const { given, expiresAtMs } = read;
294
567
  const params: TuneOutParams = {
295
568
  epochId: randomUUID(),
296
569
  cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
@@ -315,11 +588,18 @@ export class TuneOutCoordinator {
315
588
  this.hooks.setSubconsciousAnchor(oldest);
316
589
 
317
590
  this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
318
- this.startCadence(serverId, channelId, params.cadenceSeconds);
319
- this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId });
591
+ if (this.readerAvailable()) this.startCadence(serverId, channelId, params.cadenceSeconds);
592
+ this.hooks.emitTrace({
593
+ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
594
+ });
320
595
  return { ok: true, params };
321
596
  }
322
597
 
598
+ /** Whether a reader (persistent subconscious or reader forks) attends holds. */
599
+ hasReader(): boolean {
600
+ return this.readerAvailable();
601
+ }
602
+
323
603
  /**
324
604
  * End a tune-out: durable flip, system-framed backlog dump into the
325
605
  * resident's window (capped), lifecycle notice, one wake for the
@@ -333,11 +613,15 @@ export class TuneOutCoordinator {
333
613
  source: string,
334
614
  reason: string,
335
615
  subconsciousNote?: string,
616
+ /** `reader: 'forks'`: the reader fork whose note this is. */
617
+ noteAuthor?: string,
618
+ /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
619
+ nextState: 'open' | 'closed' = 'open',
336
620
  ): { ok: true } | { ok: false; error: string } {
337
- const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
621
+ const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
338
622
  if (!ended) {
339
623
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
340
- return { ok: false, error: this.notTunedOut(this.shown(serverId, channelId), 'you') };
624
+ return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
341
625
  }
342
626
  this.clearTimers(serverId, channelId);
343
627
  this.divertedSinceInvocation.delete(this.key(serverId, channelId));
@@ -360,22 +644,23 @@ export class TuneOutCoordinator {
360
644
  // start at 0 and take them all.
361
645
  const cap = Math.floor(ended.params.backlogCap);
362
646
  const authorOf = (m: Pick<StoredMessage, 'participant' | 'metadata'>): string =>
363
- (m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant;
364
- const lineOf = (m: Pick<StoredMessage, 'participant' | 'content' | 'metadata'>): string => {
365
- const text = m.content
366
- .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
367
- .map((b) => b.text)
368
- .join('\n');
369
- return `${authorOf(m)}: ${text}`;
370
- };
647
+ suppliedValue((m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant);
648
+ // A line names its author as the resident knows them and marks what a
649
+ // text line can't carry (`[image]`), so the dump says what it leaves out.
650
+ const lineOf = renderHeldLine;
651
+ // The tool the truncation note names (#232 gap 11): the one the resident
652
+ // has, when the host says; main's `fetch_history` where no host says.
653
+ const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
371
654
  // The channel as the resident and the subconscious are shown it.
372
655
  const name = this.shown(serverId, channelId);
373
- const lifecycle = `[Tune-out: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
656
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
374
657
  const render = (lines: string[], truncated: number): string => {
375
658
  const attrs = [
376
- `channel="${name}"`,
659
+ `channel=${this.quotedShown(serverId, channelId)}`,
377
660
  `messages=${backlog.length}`,
378
- ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []),
661
+ ...(truncated > 0
662
+ ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`]
663
+ : []),
379
664
  ];
380
665
  return `${lifecycle}\n\n<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
381
666
  };
@@ -417,7 +702,7 @@ export class TuneOutCoordinator {
417
702
  // What of the newest line's text fits beside the frame and the mark. A
418
703
  // cut shows some of it or none: a room under one character, or one
419
704
  // that would split a surrogate pair, leaves the head empty.
420
- const head = prefixOf(lines.at(-1) ?? '', room - frameShowing(1) - CUT_MARK.length);
705
+ const head = cutToFit(lines.at(-1) ?? '', room - frameShowing(1) - CUT_MARK.length);
421
706
  if (kept === 0 && head !== '') {
422
707
  lines = [head + CUT_MARK];
423
708
  shown = shown.slice(-1);
@@ -446,6 +731,8 @@ export class TuneOutCoordinator {
446
731
  [{ type: 'text', text: render(lines, truncated) }],
447
732
  { system: true, kind: 'tune-out-cancel', channelId, epochId },
448
733
  );
734
+ // The epoch is already ended durably; the release must be as durable.
735
+ this.hooks.commitDeliveries?.();
449
736
  // The subconscious's own words, under its own name, verbatim.
450
737
  const subName = this.hooks.subconsciousName();
451
738
  if (subconsciousNote && subName) {
@@ -456,6 +743,14 @@ export class TuneOutCoordinator {
456
743
  // reads (repinChannel), where another server's channel can share the id.
457
744
  { kind: 'subconscious-note', channelId, serverId, epochId },
458
745
  );
746
+ } else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) {
747
+ // A reader fork's note: attributed mail, under the fork's name and
748
+ // incarnation, never the resident's voice.
749
+ this.hooks.deliverFromReader(
750
+ noteAuthor,
751
+ [{ type: 'text', text: subconsciousNote }],
752
+ { kind: 'subconscious-note', channelId, epochId },
753
+ );
459
754
  }
460
755
  const primary = this.hooks.primaryName();
461
756
  if (primary) {
@@ -480,7 +775,7 @@ export class TuneOutCoordinator {
480
775
  : '';
481
776
  this.hooks.addMessage(
482
777
  'user',
483
- [{ type: 'text', text: `[Tune-out cancelled: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }],
778
+ [{ type: 'text', text: `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }],
484
779
  { system: true, kind: 'tune-out-cancel-report', channelId },
485
780
  subName,
486
781
  );
@@ -495,7 +790,34 @@ export class TuneOutCoordinator {
495
790
  );
496
791
  }
497
792
  this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
793
+ } else if (!subName && this.forkReader) {
794
+ // Forks are the reader: one last fork for the epoch, handed what no
795
+ // fork has been handed yet, stored or still queued behind the
796
+ // resident's turn, plus the cancel notice; its report follows the dump.
797
+ // Once the epoch ends, a message still queued lands in the store
798
+ // view-filtered, where nothing else reads it, so it goes to this fork
799
+ // now. On a reader's own cancel, the reader has read what it was
800
+ // handed, so a fork follows only when messages arrived after that.
801
+ const stored = this.hooks.getStoredMessages().filter(ofEpoch);
802
+ const queued = this.queuedHeld(ofEpoch);
803
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
804
+ const unread = rest.backlog.length + rest.queued.length;
805
+ if (!selfCancel || unread > 0) {
806
+ const truncNote = truncated > 0
807
+ ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
808
+ : '';
809
+ const lateNote = selfCancel
810
+ ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
811
+ : '';
812
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued);
813
+ }
498
814
  }
815
+ this.dropHandOffs(epochId);
816
+ this.dropLooks(epochId);
817
+ // Where the all-except hold still holds the channel, its own messages
818
+ // there that its reader hasn't looked at are counted again, as the
819
+ // channel's own tune-out's count stood in for theirs.
820
+ this.rebuildTally(serverId, channelId);
499
821
  this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
500
822
  return { ok: true };
501
823
  }
@@ -520,9 +842,377 @@ export class TuneOutCoordinator {
520
842
  authorId?: string,
521
843
  gatePass = true,
522
844
  ): { epochId: string } | null {
845
+ const hold = this.holdFor(serverId, channelId);
846
+ if (!hold) return null;
847
+ this.recordHeld(serverId, channelId, messageId, tags, authorId, gatePass);
848
+ return hold;
849
+ }
850
+
851
+ /** The epoch holding this channel, or null. Pure: the admission stage's lookup. */
852
+ holdFor(serverId: string, channelId: string): { epochId: string } | null {
853
+ // Precedence: a per-channel epoch keeps its own hold (cadence, budget,
854
+ // stamp); the all-except scope holds whatever else is not the exception.
523
855
  const state = this.channelRegistry.getTuneOutState(serverId, channelId);
524
- if (!state) return null;
856
+ if (state) return { epochId: state.params.epochId };
857
+ const scope = this.getAllExcept();
858
+ if (scope && !(scope.params.except.serverId === serverId && scope.params.except.channelId === channelId)) {
859
+ return { epochId: scope.params.epochId };
860
+ }
861
+ return null;
862
+ }
863
+
864
+ /** The channel unprefixed speech lands in while an all-except hold is active, with its server, else null. */
865
+ focusChannel(): ChannelRef | null {
866
+ const except = this.getAllExcept()?.params.except;
867
+ return except ? { serverId: except.serverId, channelId: except.channelId } : null;
868
+ }
869
+
870
+ // ==========================================================================
871
+ // All-except scope
872
+ // ==========================================================================
873
+
874
+ private get scopeStateId(): string {
875
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`;
876
+ }
877
+
878
+ getAllExcept(): AllExceptState | null {
879
+ if (this.scopeCache !== undefined) return this.scopeCache;
880
+ // A record that can't be read, or isn't a hold's, is not taken for no
881
+ // hold: the error goes to the caller and nothing is cached, so the next
882
+ // read tries again. At create() that refuses the start, as an
883
+ // unreadable registry does.
884
+ const raw = this.channelRegistry.readCoordinatorState(this.scopeStateId) as AllExceptState | null;
885
+ if (raw !== null && raw !== undefined && !(typeof raw === 'object' && raw.params && typeof raw.params.epochId === 'string')) {
886
+ throw new Error(`the all-except hold's stored record (${this.scopeStateId}) is not a hold's record; it was left as it is`);
887
+ }
888
+ this.scopeCache = raw ?? null;
889
+ return this.scopeCache;
890
+ }
891
+
892
+ private writeScope(state: AllExceptState | null): void {
893
+ // Durable first: a write that fails leaves memory as the store has it.
894
+ this.channelRegistry.writeCoordinatorState(this.scopeStateId, state);
895
+ this.scopeCache = state;
896
+ }
897
+
898
+ enterAllExcept(
899
+ except: { serverId: string; channelId: string },
900
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
901
+ source: string,
902
+ ): { ok: true; params: AllExceptParams } | { ok: false; error: string } {
903
+ const read = this.readOptions(opts, {
904
+ durationSeconds: null,
905
+ cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
906
+ backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
907
+ maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
908
+ });
909
+ if (!read.ok) return read;
910
+ const { given, expiresAtMs } = read;
911
+ if (expiresAtMs === undefined) {
912
+ return { ok: false, error: 'an all-except hold needs `durationSeconds`: it always ends on its own' };
913
+ }
914
+ if (this.getAllExcept()) {
915
+ return { ok: false, error: 'an all-except hold is already active (cancel it first)' };
916
+ }
917
+ if (this.channelRegistry.getTuneOutState(except.serverId, except.channelId)) {
918
+ return { ok: false, error: `channel ${except.channelId} is tuned out; it cannot be the exception` };
919
+ }
920
+ if (this.hooks.isForkBound(except)) {
921
+ return { ok: false, error: `channel ${except.channelId} is bound to a conversation fork` };
922
+ }
923
+ const params: AllExceptParams = {
924
+ epochId: randomUUID(),
925
+ except,
926
+ cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
927
+ // Whole messages, as a channel's cap is counted: a cap below one shows
928
+ // none, where slice(-0.5) would take them all.
929
+ backlogCap: Math.max(0, Math.floor(given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap)),
930
+ maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
931
+ startedAtSequence: this.hooks.currentSequence(),
932
+ startedAtMs: read.now,
933
+ expiresAtMs,
934
+ source,
935
+ };
936
+ this.writeScope({ params, wakeCount: 0 });
937
+ const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
938
+ this.hooks.setSubconsciousAnchor(oldest);
939
+ this.armScopeTimers(params);
940
+ this.hooks.emitTrace({
941
+ type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId,
942
+ scope: 'all-except', reader: this.readerAvailable(), expiresAtMs: params.expiresAtMs,
943
+ });
944
+ const minutes = Math.max(1, Math.round((params.expiresAtMs - params.startedAtMs) / 60_000));
945
+ const until = new Date(params.expiresAtMs).toISOString().slice(11, 16);
946
+ this.announce(except, params.epochId, 'entered',
947
+ `[${NOTICE_TAG.focus}: ${this.hooks.primaryName() ?? 'the resident'} is here for ${minutes} min, until ${until} UTC; other channels are held meanwhile.]`);
948
+ return { ok: true, params };
949
+ }
950
+
951
+ /** Whether entry and release of the all-except scope are announced in the excepted channel. */
952
+ announces(): boolean {
953
+ return typeof this.hooks.announce === 'function';
954
+ }
955
+
956
+ /**
957
+ * The excepted channel hears the scope open and close: one bracketed line
958
+ * under the resident's name, posted through the same path as a publish.
959
+ * Fire-and-forget; the outcome is traced, never awaited by the epoch flip.
960
+ */
961
+ private announce(
962
+ target: { serverId: string; channelId: string },
963
+ epochId: string,
964
+ kind: 'entered' | 'released',
965
+ text: string,
966
+ ): void {
967
+ if (!this.hooks.announce) return;
968
+ const base = { serverId: target.serverId, channelId: target.channelId, epochId, kind };
969
+ this.hooks.announce(target.serverId, target.channelId, text).then(
970
+ (r) => this.hooks.emitTrace(r.success
971
+ ? { type: 'tune-out:announced', ...base }
972
+ : { type: 'tune-out:announce-failed', ...base, error: r.error ?? 'publish failed' }),
973
+ (err: unknown) => this.hooks.emitTrace({ type: 'tune-out:announce-failed', ...base, error: (err as Error).message }),
974
+ );
975
+ }
976
+
977
+ /** The scope's end time and cadence, on timers no longer than one holds
978
+ * (deadline-timer), as a channel's tune-out arms its own. */
979
+ private armScopeTimers(params: AllExceptParams): void {
980
+ const key = TuneOutCoordinator.SCOPE_KEY;
981
+ const expiry = this.expiryTimers.get(key);
982
+ if (expiry) clearTimeout(expiry);
983
+ // As a channel's timers: the next turn is armed before this one's work,
984
+ // and a throw is reported (timerFailed) rather than left to end the host.
985
+ const { serverId: exceptServer, channelId: exceptChannel } = params.except;
986
+ const holdExpiry = (timer: ReturnType<typeof setTimeout>): void => { this.expiryTimers.set(key, timer); };
987
+ const end = (): void => {
988
+ try {
989
+ holdExpiry(setTimeout(end, EXPIRY_RETRY_MS));
990
+ if (!this.cancelAllExcept('duration', 'duration elapsed').ok) this.clearScopeTimers();
991
+ } catch (err) {
992
+ this.timerFailed('expiry', exceptServer, exceptChannel, err);
993
+ }
994
+ };
995
+ armAt(params.expiresAtMs, holdExpiry, end);
996
+ if (!this.readerAvailable()) return;
997
+ const cadence = this.cadenceTimers.get(key);
998
+ if (cadence) clearTimeout(cadence);
999
+ const hold = (timer: ReturnType<typeof setTimeout>): void => { this.cadenceTimers.set(key, timer); };
1000
+ const tick = () => {
1001
+ try {
1002
+ const scope = this.getAllExcept();
1003
+ if (!scope) return;
1004
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
1005
+ // One look per held channel with new traffic since the last look.
1006
+ for (const [channelKey, diverted] of this.divertedSinceInvocation) {
1007
+ if (diverted <= 0) continue;
1008
+ const [serverId, channelId] = channelKey.split('\0') as [string, string];
1009
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; // a per-channel epoch's own cadence
1010
+ this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
1011
+ }
1012
+ } catch (err) {
1013
+ this.timerFailed('cadence', exceptServer, exceptChannel, err);
1014
+ }
1015
+ };
1016
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
1017
+ }
1018
+
1019
+ private clearScopeTimers(): void {
1020
+ const key = TuneOutCoordinator.SCOPE_KEY;
1021
+ const cadence = this.cadenceTimers.get(key);
1022
+ if (cadence) clearTimeout(cadence);
1023
+ this.cadenceTimers.delete(key);
1024
+ const expiry = this.expiryTimers.get(key);
1025
+ if (expiry) clearTimeout(expiry);
1026
+ this.expiryTimers.delete(key);
1027
+ }
1028
+
1029
+ /** Channels held by the scope (not by their own epoch) with the count held under it. */
1030
+ private scopeHeldCounts(epochId: string): Map<string, { serverId: string; channelId: string; count: number }> {
1031
+ const counts = new Map<string, { serverId: string; channelId: string; count: number }>();
1032
+ const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])];
1033
+ for (const m of all) {
1034
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
1035
+ if (meta?.tuneOut?.epochId !== epochId) continue;
1036
+ const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)';
1037
+ const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
1038
+ const k = this.key(serverId, channelId);
1039
+ const entry = counts.get(k) ?? { serverId, channelId, count: 0 };
1040
+ entry.count += 1;
1041
+ counts.set(k, entry);
1042
+ }
1043
+ return counts;
1044
+ }
1045
+
1046
+ /**
1047
+ * End the all-except hold: durable flip, one release notice with a
1048
+ * per-channel backlog (each capped), the reader's note if any, one wake
1049
+ * for the resident, and the reader's own look at what it had not seen.
1050
+ */
1051
+ cancelAllExcept(
1052
+ source: string,
1053
+ reason: string,
1054
+ note?: string,
1055
+ noteAuthor?: string,
1056
+ ): { ok: true } | { ok: false; error: string } {
1057
+ const scope = this.getAllExcept();
1058
+ if (!scope) return { ok: false, error: 'no all-except hold is active' };
1059
+ const { epochId, except, backlogCap } = scope.params;
1060
+
1061
+ const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1062
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
1063
+ const all = [...this.hooks.getStoredMessages().filter(ofEpoch), ...(this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch)];
1064
+ const byChannel = new Map<string, { serverId: string; channelId: string; backlog: Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>> }>();
1065
+ for (const m of all) {
1066
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
1067
+ const serverId = meta?.serverId ?? '';
1068
+ const channelId = meta?.channelId ?? '(unknown channel)';
1069
+ const k = this.key(serverId, channelId);
1070
+ const entry = byChannel.get(k) ?? { serverId, channelId, backlog: [] };
1071
+ entry.backlog.push(m);
1072
+ byChannel.set(k, entry);
1073
+ }
1074
+ const historyTool = this.hooks.historyTool?.() ?? null;
1075
+ const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
1076
+ // Floored here too: a hold stored before entry floored its cap can hold
1077
+ // a fraction.
1078
+ const cap = Math.floor(backlogCap);
1079
+ const shown = cap > 0 ? backlog.slice(-cap) : [];
1080
+ const truncated = backlog.length - shown.length;
1081
+ const attrs = [
1082
+ `channel=${quotedValue(this.shown(serverId, channelId))}`,
1083
+ `messages=${backlog.length}`,
1084
+ ...(truncated > 0 ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] : []),
1085
+ ];
1086
+ return `<tuned-out-backlog ${attrs.join(' ')}>\n${shown.map(renderHeldLine).join('\n')}\n</tuned-out-backlog>`;
1087
+ });
1088
+ const summary = byChannel.size === 0
1089
+ ? 'Nothing was held.'
1090
+ : `${all.length} message${all.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
1091
+ if (this.scopeReleased !== epochId) {
1092
+ this.hooks.addMessage(
1093
+ 'user',
1094
+ [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }],
1095
+ { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId },
1096
+ );
1097
+ this.hooks.commitDeliveries?.();
1098
+ this.scopeReleased = epochId;
1099
+ }
1100
+ // The release is recorded before the hold is: a failure past this point
1101
+ // leaves the hold in force with its release already delivered. A retry in
1102
+ // this process records only the hold's end; a restart between the two
1103
+ // delivers the release again, where the other order lost it outright.
1104
+ this.writeScope(null);
1105
+ this.scopeReleased = undefined;
1106
+ this.clearScopeTimers();
1107
+ for (const [key, pending] of this.pendingWakes) {
1108
+ const [serverId, channelId] = key.split('\0') as [string, string];
1109
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue;
1110
+ clearTimeout(pending.timer);
1111
+ this.pendingWakes.delete(key);
1112
+ }
1113
+ const subName = this.hooks.subconsciousName();
1114
+ if (note && subName) {
1115
+ this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
1116
+ } else if (note && noteAuthor && this.hooks.deliverFromReader) {
1117
+ this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
1118
+ }
1119
+ const primary = this.hooks.primaryName();
1120
+ if (primary) this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
1121
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`;
1122
+ if (source !== 'subconscious' && subName) {
1123
+ this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName);
1124
+ this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out');
1125
+ } else if (!subName && this.forkReader) {
1126
+ // One last look per channel with messages no fork was handed, stored or
1127
+ // still queued behind the resident's turn. That holds for a reader's
1128
+ // own release too: the reader that released read one channel's
1129
+ // messages, and every other channel's would otherwise reach no reader.
1130
+ // A channel is its server and id here, as the readers' bookkeeping is.
1131
+ const channels = new Map<string, ChannelRef>();
1132
+ for (const m of all) {
1133
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
1134
+ const ref = { serverId: meta?.serverId ?? '', channelId: meta?.channelId ?? '(unknown channel)' };
1135
+ channels.set(this.key(ref.serverId, ref.channelId), ref);
1136
+ }
1137
+ for (const { serverId, channelId } of channels.values()) {
1138
+ const ofChannel = inChannelOf(serverId, channelId);
1139
+ const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean => ofEpoch(m) && ofChannel(m);
1140
+ const stored = this.hooks.getStoredMessages().filter(inChannel);
1141
+ const queued = this.queuedHeld(inChannel);
1142
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
1143
+ if (rest.backlog.length + rest.queued.length > 0) {
1144
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued);
1145
+ }
1146
+ }
1147
+ }
1148
+ for (const key of this.divertedSinceInvocation.keys()) {
1149
+ const [serverId, channelId] = key.split('\0') as [string, string];
1150
+ if (!this.channelRegistry.getTuneOutState(serverId, channelId)) this.divertedSinceInvocation.delete(key);
1151
+ }
1152
+ this.dropHandOffs(epochId);
1153
+ this.dropLooks(epochId);
1154
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount });
1155
+ this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`);
1156
+ return { ok: true };
1157
+ }
525
1158
 
1159
+ /** What is held right now — per-channel epochs and the scope — without releasing. */
1160
+ check(opts: { peek?: { serverId?: string; channelId: string }; peekLimit?: number } = {}): TuneOutCheck {
1161
+ const stored = this.hooks.getStoredMessages();
1162
+ const deferred = this.hooks.getDeferredMessages?.() ?? [];
1163
+ const countOf = (epochId: string) => [...stored, ...deferred].filter(
1164
+ (m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId,
1165
+ ).length;
1166
+ const channels = this.listActive().map(({ serverId, channelId, params }) => ({
1167
+ serverId, channelId, epochId: params.epochId, held: countOf(params.epochId),
1168
+ ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}),
1169
+ }));
1170
+ const scope = this.getAllExcept();
1171
+ let allExcept: TuneOutCheck['allExcept'] = null;
1172
+ if (scope) {
1173
+ const held: Record<string, number> = {};
1174
+ for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values()) held[this.shown(serverId, channelId)] = count;
1175
+ allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held };
1176
+ }
1177
+ const result: TuneOutCheck = { allExcept, channels };
1178
+ if (opts.peek) {
1179
+ const limit = Math.max(1, Math.min(50, opts.peekLimit ?? 5));
1180
+ // Only what is held NOW: the channel's own epoch, or the scope's. A
1181
+ // released epoch's messages are delivered, not held; showing them here
1182
+ // would be a false declaration (first live run, 2026-10-12, finding B).
1183
+ const active = new Set<string>();
1184
+ for (const e of channels) {
1185
+ if (e.channelId === opts.peek.channelId && (!opts.peek.serverId || e.serverId === opts.peek.serverId)) active.add(e.epochId);
1186
+ }
1187
+ if (scope) active.add(scope.params.epochId);
1188
+ const lines = [...stored, ...deferred]
1189
+ .filter((m) => {
1190
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
1191
+ return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId)
1192
+ && meta.channelId === opts.peek!.channelId
1193
+ && (!opts.peek!.serverId || meta.serverId === opts.peek!.serverId);
1194
+ })
1195
+ .slice(-limit)
1196
+ .map(renderHeldLine);
1197
+ result.peek = lines;
1198
+ }
1199
+ return result;
1200
+ }
1201
+
1202
+ /**
1203
+ * Bookkeeping for one held message, after admission decided the hold:
1204
+ * the cadence tally, the deterministic acknowledgement of a suppressed
1205
+ * mention, and the reader wake when the resident's gate would have woken
1206
+ * them (`gatePass` is that verdict, probed without side effects).
1207
+ */
1208
+ recordHeld(
1209
+ serverId: string,
1210
+ channelId: string,
1211
+ messageId: string,
1212
+ tags: string[] | undefined,
1213
+ authorId?: string,
1214
+ gatePass = true,
1215
+ ): void {
526
1216
  const key = this.key(serverId, channelId);
527
1217
  this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
528
1218
 
@@ -543,10 +1233,20 @@ export class TuneOutCoordinator {
543
1233
  // standing mute to its subject.
544
1234
  void this.acknowledgeSuppressed(serverId, channelId, messageId);
545
1235
  }
546
- if (gatePass && (addressed || privileged)) {
1236
+ if (gatePass && (addressed || privileged) && this.readerAvailable()) {
547
1237
  this.scheduleWakeInvocation(serverId, channelId);
548
1238
  }
549
- return { epochId: state.params.epochId };
1239
+ }
1240
+
1241
+ /** Reader forks that reported through `deliver_summary` (so their trailing prose is not a second report). */
1242
+ private readerReports = new Set<string>();
1243
+
1244
+ readerReported(reader: string): boolean {
1245
+ return this.readerReports.has(reader);
1246
+ }
1247
+
1248
+ forgetReader(reader: string): void {
1249
+ this.readerReports.delete(reader);
550
1250
  }
551
1251
 
552
1252
  private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise<void> {
@@ -627,19 +1327,7 @@ export class TuneOutCoordinator {
627
1327
  try {
628
1328
  const entry = this.pendingWakes.get(key);
629
1329
  this.pendingWakes.delete(key);
630
- // Count ONE durable wake per coalesced invocation.
631
- const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
632
- if (!counted) return; // cancelled inside the window
633
- if (counted.wakeCount > counted.params.maxWakes) {
634
- this.cancel(
635
- serverId,
636
- channelId,
637
- 'tune-out-max-wakes',
638
- `wake budget exhausted (${counted.params.maxWakes})`,
639
- );
640
- return;
641
- }
642
- this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
1330
+ this.fireWake(serverId, channelId, entry?.count ?? 1);
643
1331
  } catch (err) {
644
1332
  this.timerFailed('wake', serverId, channelId, err);
645
1333
  }
@@ -647,6 +1335,41 @@ export class TuneOutCoordinator {
647
1335
  this.pendingWakes.set(key, { count: 1, timer });
648
1336
  }
649
1337
 
1338
+ /** One coalesced wake: counted durably against the epoch that holds the channel. */
1339
+ private fireWake(serverId: string, channelId: string, count: number): void {
1340
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1341
+ // Count ONE durable wake per coalesced invocation.
1342
+ const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
1343
+ if (!counted) return; // cancelled inside the window
1344
+ if (counted.wakeCount > counted.params.maxWakes) {
1345
+ this.cancel(
1346
+ serverId,
1347
+ channelId,
1348
+ 'tune-out-max-wakes',
1349
+ `wake budget exhausted (${counted.params.maxWakes})`,
1350
+ );
1351
+ return;
1352
+ }
1353
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1354
+ return;
1355
+ }
1356
+ const scope = this.getAllExcept();
1357
+ if (!scope) return; // released inside the window
1358
+ // The all-except scope: exhausting the budget pauses the reader, it does
1359
+ // not end the hold (#232 §3.2 — the hold is the resident's bounded
1360
+ // commitment; the deadline ends it).
1361
+ const wakeCount = scope.wakeCount + 1;
1362
+ this.writeScope({ ...scope, wakeCount });
1363
+ if (wakeCount > scope.params.maxWakes) {
1364
+ this.hooks.emitTrace({
1365
+ type: 'tune-out:wake-budget-paused', epochId: scope.params.epochId, scope: 'all-except',
1366
+ serverId, channelId, wakeCount, maxWakes: scope.params.maxWakes,
1367
+ });
1368
+ return;
1369
+ }
1370
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1371
+ }
1372
+
650
1373
  /**
651
1374
  * One subconscious turn: a system-framed trigger notice (bracket style —
652
1375
  * what happened, never what to say) plus standing dispositions, then an
@@ -659,13 +1382,38 @@ export class TuneOutCoordinator {
659
1382
  count: number,
660
1383
  ): void {
661
1384
  const subName = this.hooks.subconsciousName();
662
- if (!subName) return;
1385
+ if (!subName && !this.forkReader) return;
663
1386
  const key = this.key(serverId, channelId);
664
1387
 
665
1388
  const shown = this.shown(serverId, channelId);
666
1389
  const notice = trigger === 'wake'
667
- ? `[Tune-out wake: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
668
- : `[Tune-out cadence: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
1390
+ ? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
1391
+ : `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
1392
+ if (!subName) {
1393
+ const hold = this.holdFor(serverId, channelId);
1394
+ if (!hold) return;
1395
+ const ofChannel = inChannelOf(serverId, channelId);
1396
+ const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1397
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1398
+ // Stored and still-queued alike: while the resident's turn runs, what
1399
+ // arrives waits in the deferred queue, and that is the traffic a reader
1400
+ // is there for.
1401
+ this.invokeReaderFork(
1402
+ serverId,
1403
+ channelId,
1404
+ hold.epochId,
1405
+ trigger,
1406
+ notice,
1407
+ this.hooks.getStoredMessages().filter(inChannel),
1408
+ this.queuedHeld(inChannel),
1409
+ );
1410
+ // The count restarts once the fork has been handed what was held, as it
1411
+ // does below once the subconscious's turn has been asked for. Otherwise
1412
+ // every later cadence period still sees this one's count and derives a
1413
+ // fork with nothing new to read.
1414
+ this.divertedSinceInvocation.set(key, 0);
1415
+ return;
1416
+ }
669
1417
  this.hooks.addMessage(
670
1418
  'user',
671
1419
  [{ type: 'text', text: notice }],
@@ -676,6 +1424,280 @@ export class TuneOutCoordinator {
676
1424
  // Counted from here only once the turn has been asked for, so a turn that
677
1425
  // throws leaves its messages for the next period's turn.
678
1426
  this.divertedSinceInvocation.set(key, 0);
1427
+ const epochId = this.holdFor(serverId, channelId)?.epochId;
1428
+ if (epochId) this.markLook(epochId, serverId, channelId);
1429
+ }
1430
+
1431
+ /**
1432
+ * Count again, from the held messages, stored or still queued behind the
1433
+ * resident's turn, those the reader of the epoch holding this channel now
1434
+ * hasn't looked at: for reader forks, those no fork was handed; for the
1435
+ * persistent subconscious, the stored ones held since its last look
1436
+ * (lastLooks) and every queued one, which no view of it can show yet.
1437
+ * Nothing holding the channel leaves no count.
1438
+ */
1439
+ private rebuildTally(serverId: string, channelId: string): void {
1440
+ const key = this.key(serverId, channelId);
1441
+ const hold = this.holdFor(serverId, channelId);
1442
+ if (!hold || !this.readerAvailable()) {
1443
+ this.divertedSinceInvocation.delete(key);
1444
+ return;
1445
+ }
1446
+ const ofChannel = inChannelOf(serverId, channelId);
1447
+ const inEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1448
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1449
+ const rows = this.hooks.getStoredMessages().filter(inEpoch);
1450
+ const queued = this.queuedHeld(inEpoch);
1451
+ let count: number;
1452
+ if (this.forkReader) {
1453
+ const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued);
1454
+ count = unread.backlog.length + unread.queued.length;
1455
+ } else {
1456
+ const mark = this.lastLooks()?.[hold.epochId]?.[key];
1457
+ count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length;
1458
+ }
1459
+ this.divertedSinceInvocation.set(key, count);
1460
+ }
1461
+
1462
+ private get lastLooksStateId(): string {
1463
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/last-looks`;
1464
+ }
1465
+
1466
+ /**
1467
+ * The stored last looks. Where they can't be read, none: every held row
1468
+ * counts, and nothing is cached, so the next read tries again. Nor is
1469
+ * anything written back over the stored record then (markLook, dropLooks,
1470
+ * pruneLooks), which would erase the other epochs' and channels' looks.
1471
+ */
1472
+ private lastLooks(): Record<string, Record<string, number>> | undefined {
1473
+ if (this.lastLooksCache !== undefined) return this.lastLooksCache;
1474
+ let raw: unknown;
1475
+ try {
1476
+ raw = this.channelRegistry.readCoordinatorState(this.lastLooksStateId);
1477
+ } catch (error) {
1478
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be read:`, error);
1479
+ return undefined;
1480
+ }
1481
+ const looks: Record<string, Record<string, number>> = {};
1482
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1483
+ for (const [epochId, channels] of Object.entries(raw as Record<string, unknown>)) {
1484
+ if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue;
1485
+ const marks = Object.entries(channels as Record<string, unknown>).filter((e): e is [string, number] => typeof e[1] === 'number');
1486
+ if (marks.length > 0) looks[epochId] = Object.fromEntries(marks);
1487
+ }
1488
+ }
1489
+ this.lastLooksCache = looks;
1490
+ return looks;
1491
+ }
1492
+
1493
+ private writeLooks(looks: Record<string, Record<string, number>>): void {
1494
+ this.lastLooksCache = looks;
1495
+ try {
1496
+ this.channelRegistry.writeCoordinatorState(this.lastLooksStateId, looks);
1497
+ } catch (error) {
1498
+ // The look itself stands. After a restart, this look's messages would
1499
+ // count as unseen at the next one.
1500
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be stored:`, error);
1501
+ }
1502
+ }
1503
+
1504
+ private markLook(epochId: string, serverId: string, channelId: string): void {
1505
+ const looks = this.lastLooks();
1506
+ if (!looks) return;
1507
+ this.writeLooks({ ...looks, [epochId]: { ...looks[epochId], [this.key(serverId, channelId)]: this.hooks.currentSequence() } });
1508
+ }
1509
+
1510
+ /** An ended epoch's last looks go with it. */
1511
+ private dropLooks(epochId: string): void {
1512
+ if (this.hooks.subconsciousName() == null) return;
1513
+ const looks = this.lastLooks();
1514
+ if (!looks || !(epochId in looks)) return;
1515
+ const { [epochId]: _ended, ...rest } = looks;
1516
+ this.writeLooks(rest);
1517
+ }
1518
+
1519
+ /** At boot: the last looks of epochs no longer active go. */
1520
+ private pruneLooks(): void {
1521
+ if (this.hooks.subconsciousName() == null) return;
1522
+ const looks = this.lastLooks();
1523
+ if (!looks) return;
1524
+ const active = new Set(this.listActive().map((e) => e.params.epochId));
1525
+ const scope = this.getAllExcept();
1526
+ if (scope) active.add(scope.params.epochId);
1527
+ const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
1528
+ if (Object.keys(kept).length !== Object.keys(looks).length) this.writeLooks(kept);
1529
+ }
1530
+
1531
+ /**
1532
+ * `reader: 'forks'`: hand the framework one invocation. The backlog is
1533
+ * the epoch's held messages not yet handed to any fork — each is
1534
+ * delivered to exactly one reader — stored ones first, then those still
1535
+ * queued behind the resident's turn, plus the standing dispositions. A
1536
+ * cadence look with nothing new is skipped. True when a fork was asked for.
1537
+ */
1538
+ private invokeReaderFork(
1539
+ serverId: string,
1540
+ channelId: string,
1541
+ epochId: string,
1542
+ trigger: ReaderInvocation['trigger'],
1543
+ notice: string,
1544
+ epochBacklog: StoredMessage[],
1545
+ epochQueued: QueuedHeldMessage[] = [],
1546
+ ): boolean {
1547
+ if (!this.hooks.invokeReader) return false;
1548
+ const key = this.handOffKey(epochId, serverId, channelId);
1549
+ const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued);
1550
+ if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0) return false;
1551
+ // The cursor only moves forward: a returned row handed again can be
1552
+ // behind it.
1553
+ const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
1554
+ if (backlog.length > 0) this.readerCursor.set(key, cursor);
1555
+ const handed = this.readerHandedWrites.get(key) ?? new Set<string>();
1556
+ for (const m of queued) handed.add(m.writeId);
1557
+ // A handed write whose row has landed behind the cursor can no longer be
1558
+ // handed by sequence, so its id has done its work.
1559
+ for (const m of epochBacklog) {
1560
+ const writeId = writeIdOf(m);
1561
+ if (writeId !== undefined && m.sequence <= cursor) handed.delete(writeId);
1562
+ }
1563
+ if (handed.size > 0) this.readerHandedWrites.set(key, handed);
1564
+ else this.readerHandedWrites.delete(key);
1565
+ const returned = this.readerReturned.get(key);
1566
+ if (returned) {
1567
+ for (const m of backlog) {
1568
+ returned.ids.delete(m.id);
1569
+ const writeId = writeIdOf(m);
1570
+ if (writeId !== undefined) returned.writeIds.delete(writeId);
1571
+ }
1572
+ for (const m of queued) returned.writeIds.delete(m.writeId);
1573
+ if (returned.ids.size === 0 && returned.writeIds.size === 0) this.readerReturned.delete(key);
1574
+ }
1575
+ const entries = Object.entries(this.readDispositions());
1576
+ this.hooks.invokeReader({
1577
+ serverId,
1578
+ channelId,
1579
+ epochId,
1580
+ trigger,
1581
+ notice,
1582
+ backlog,
1583
+ queued,
1584
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1585
+ });
1586
+ return true;
1587
+ }
1588
+
1589
+ /**
1590
+ * What no reader fork has read yet, of the given held messages: the stored
1591
+ * ones past the epoch's cursor for the channel, less those a fork was handed while they
1592
+ * were still queued, plus those a failed fork returned, oldest first; then
1593
+ * the queued ones no fork was handed, or a failed fork returned.
1594
+ */
1595
+ private unhandedBacklog(
1596
+ key: string,
1597
+ stored: StoredMessage[],
1598
+ queued: QueuedHeldMessage[],
1599
+ ): { backlog: StoredMessage[]; queued: QueuedHeldMessage[] } {
1600
+ const cursor = this.readerCursor.get(key) ?? -1;
1601
+ const handed = this.readerHandedWrites.get(key);
1602
+ const returned = this.readerReturned.get(key);
1603
+ const wasReturned = (id: string | undefined, writeId: string | undefined): boolean =>
1604
+ returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId)));
1605
+ const unread = (m: StoredMessage): boolean => {
1606
+ const writeId = writeIdOf(m);
1607
+ if (wasReturned(m.id, writeId)) return true;
1608
+ return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId));
1609
+ };
1610
+ return {
1611
+ backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence),
1612
+ queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)),
1613
+ };
1614
+ }
1615
+
1616
+ /**
1617
+ * A reader fork that never read what it was handed: its derive failed, or
1618
+ * its run threw before it reported. While the epoch that held the
1619
+ * messages is still active, they go back to that epoch's backlog for the
1620
+ * channel, and its next invocation for the channel is handed them: the
1621
+ * scope's while the scope is active, though the channel's own tune-out
1622
+ * has taken the channel meanwhile, and a channel's own tune-out's while it
1623
+ * holds the channel. Once the epoch has ended, its cancel's last fork was
1624
+ * handed only what no fork had been, so a fork still running then is the
1625
+ * only reader its messages had: one late fork is handed them instead
1626
+ * (lateFork). Returns how many went back or to the late fork.
1627
+ */
1628
+ readerFailed(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
1629
+ const { serverId, channelId, epochId } = invocation;
1630
+ const active = this.getAllExcept()?.params.epochId === epochId
1631
+ || this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
1632
+ if (!active) return this.lateFork(invocation);
1633
+ const key = this.handOffKey(epochId, serverId, channelId);
1634
+ const returned = this.readerReturned.get(key) ?? { ids: new Set<string>(), writeIds: new Set<string>() };
1635
+ for (const m of invocation.backlog) returned.ids.add(m.id);
1636
+ for (const m of invocation.queued) returned.writeIds.add(m.writeId);
1637
+ const count = invocation.backlog.length + invocation.queued.length;
1638
+ if (count > 0) {
1639
+ this.readerReturned.set(key, returned);
1640
+ // They count again toward the next cadence look of the epoch holding
1641
+ // the channel, which hands them back. Where another epoch holds it
1642
+ // now, they're counted again when the channel comes back to theirs
1643
+ // (rebuildTally), and its release hands them in any case.
1644
+ const tally = this.key(serverId, channelId);
1645
+ if (this.holdFor(serverId, channelId)?.epochId === epochId) {
1646
+ this.divertedSinceInvocation.set(tally, (this.divertedSinceInvocation.get(tally) ?? 0) + count);
1647
+ }
1648
+ }
1649
+ return count;
1650
+ }
1651
+
1652
+ /**
1653
+ * A fork that failed after its epoch ended: one late fork is handed what it
1654
+ * was, with a notice saying a reader handed them failed after the tune-out
1655
+ * ended. Those still queued when it was handed that have landed since are
1656
+ * handed as stored. A late fork's own failure derives no other, so a
1657
+ * reader that keeps failing costs one more fork, not a chain of them.
1658
+ * Returns how many the late fork was handed.
1659
+ */
1660
+ private lateFork(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
1661
+ if (!this.hooks.invokeReader || this.lateForks.has(invocation)) return 0;
1662
+ const { serverId, channelId, epochId } = invocation;
1663
+ const landed = new Map<string, StoredMessage>();
1664
+ for (const m of this.hooks.getStoredMessages()) {
1665
+ const writeId = writeIdOf(m);
1666
+ if (writeId !== undefined) landed.set(writeId, m);
1667
+ }
1668
+ const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])]
1669
+ .sort((a, b) => a.sequence - b.sequence);
1670
+ const queued = invocation.queued.filter((m) => !landed.has(m.writeId));
1671
+ const count = backlog.length + queued.length;
1672
+ if (count === 0) return 0;
1673
+ const these = count === 1 ? 'This message was' : `These ${count} messages were`;
1674
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${these} handed to a reader that `
1675
+ + `failed after the tune-out ended, so no reader has read ${count === 1 ? 'it' : 'them'} yet.]`;
1676
+ const entries = Object.entries(this.readDispositions());
1677
+ const late: ReaderInvocation = {
1678
+ serverId,
1679
+ channelId,
1680
+ epochId,
1681
+ trigger: 'cancel',
1682
+ notice,
1683
+ backlog,
1684
+ queued,
1685
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1686
+ };
1687
+ this.lateForks.add(late);
1688
+ this.hooks.invokeReader(late);
1689
+ return count;
1690
+ }
1691
+
1692
+ /**
1693
+ * The held messages still queued behind the resident's turn that `keep`
1694
+ * keeps, oldest first, with their write ids. One the host gives no id for
1695
+ * is left to reach a reader once it is stored: without the id, its stored
1696
+ * row couldn't be told from a message no reader has had.
1697
+ */
1698
+ private queuedHeld(keep: (m: Pick<StoredMessage, 'metadata'>) => boolean): QueuedHeldMessage[] {
1699
+ return (this.hooks.getDeferredMessages?.() ?? [])
1700
+ .filter((m): m is QueuedHeldMessage => typeof m.writeId === 'string' && keep(m));
679
1701
  }
680
1702
 
681
1703
  // ==========================================================================
@@ -709,7 +1731,7 @@ export class TuneOutCoordinator {
709
1731
  position: 'system',
710
1732
  content: [{
711
1733
  type: 'text',
712
- text: `[Standing dispositions]\n${entries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}`,
1734
+ text: `[${NOTICE_TAG.standingDispositions}]\n${entries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}`,
713
1735
  }],
714
1736
  };
715
1737
  }
@@ -756,6 +1778,11 @@ export class TuneOutCoordinator {
756
1778
  input: Record<string, unknown>,
757
1779
  /** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */
758
1780
  own: readonly ChannelRef[] = [],
1781
+ /** The calling agent: the persistent subconscious, or a reader fork. */
1782
+ caller?: string,
1783
+ /** A reader fork, with the epoch it was derived to read, if any: it ends and posts only through that epoch's
1784
+ * hold (forkRefusal). The persistent subconscious, which reads every epoch, passes none. */
1785
+ fork?: { epochId?: string },
759
1786
  ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
760
1787
  switch (name) {
761
1788
  case 'deliver_summary': {
@@ -770,49 +1797,105 @@ export class TuneOutCoordinator {
770
1797
  };
771
1798
  }
772
1799
  const subName = this.hooks.subconsciousName();
773
- if (!subName) return { success: false, error: 'subconscious not configured', isError: true };
774
- // Verbatim, under its own participant name, into the resident's window,
775
- // tagged with the channel the name means and its server, since the
776
- // framework reads a conversational row's channel (repinChannel). The
777
- // name means what it means to every channel tool: inside the calling
778
- // turn, the id of one of the turn's own channels means that channel,
779
- // refused where its server no longer registers it or the turn has
780
- // channels with that id on two servers (ownChannelFor); otherwise a
781
- // name several registered channels answer to is refused with them
782
- // (sharedNameRefusalFor). So no row is left for a re-pin to read as
783
- // another server's channel. A name no registered channel answers to
784
- // is kept as given, with no server, which a re-pin reads by name when
785
- // it weighs the row, as it reads any message without its server.
1800
+ // Verbatim, under its own participant name, into the resident's window
1801
+ // (or, from a reader fork, as its attributed mail), tagged with the
1802
+ // channel the name means and its server, since the framework reads a
1803
+ // conversational row's channel (repinChannel). The name means what it
1804
+ // means to every channel tool: inside the calling turn, the id of one
1805
+ // of the turn's own channels means that channel, refused where its
1806
+ // server no longer registers it or the turn has channels with that id
1807
+ // on two servers (ownChannelFor); otherwise a name several registered
1808
+ // channels answer to is refused with them (sharedNameRefusalFor). So
1809
+ // no row is left for a re-pin to read as another server's channel. A
1810
+ // name no registered channel answers to is kept as given, with no
1811
+ // server, which a re-pin reads by name when it weighs the row, as it
1812
+ // reads any message without its server.
1813
+ if (!subName && !(caller && this.hooks.deliverFromReader)) {
1814
+ return { success: false, error: 'subconscious not configured', isError: true };
1815
+ }
786
1816
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver');
787
1817
  if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
788
1818
  const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(channelId, 'nothing was delivered');
789
1819
  if (shared) return { success: false, error: shared, isError: true };
790
1820
  const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
791
- this.hooks.addMessage(subName, [{ type: 'text', text }], {
1821
+ const metadata = {
792
1822
  kind: 'subconscious-summary',
793
1823
  ...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }),
794
- });
1824
+ };
1825
+ if (subName) {
1826
+ this.hooks.addMessage(subName, [{ type: 'text', text }], metadata);
1827
+ return { success: true, data: { delivered: true } };
1828
+ }
1829
+ // A reader fork's report: attributed mail under its own name. The fork
1830
+ // counts as having reported only once the mail is held: a delivery
1831
+ // that throws is an error result to the fork, and its prose is then
1832
+ // still delivered as its report when it ends (#546, probe R14).
1833
+ this.hooks.deliverFromReader!(caller!, [{ type: 'text', text }], metadata);
1834
+ this.readerReports.add(caller!);
795
1835
  return { success: true, data: { delivered: true } };
796
1836
  }
797
1837
  case 'cancel_tuneout': {
798
1838
  const name = String(input.channelId ?? '');
1839
+ const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
1840
+ const endScope = (): { success: boolean; data?: unknown; error?: string; isError?: boolean } => {
1841
+ const scope = this.getAllExcept();
1842
+ const refused = fork && scope
1843
+ ? this.forkRefusal(fork, { epochId: scope.params.epochId }, 'cancel', "the all-except hold isn't the hold you were started to read")
1844
+ : undefined;
1845
+ if (refused) return { success: false, error: refused, isError: true };
1846
+ const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller);
1847
+ return result.ok
1848
+ ? { success: true, data: { cancelled: true, scope: 'all-except' } }
1849
+ : { success: false, error: result.error, isError: true };
1850
+ };
1851
+ // A call naming the all-except scope ends that hold outright, as the
1852
+ // tool describes it and as the resident's tune_out cancel does: the
1853
+ // channel it names, tuned out on its own or not, doesn't turn it
1854
+ // into that channel's cancel, and with no hold it ends nothing.
1855
+ if (input.scope === 'all-except') return endScope();
1856
+ const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
799
1857
  // The name means what it means to every channel tool, the
800
- // resident's tune_out included: inside the calling turn, the id of
801
- // one of the turn's own channels means that channel (ownChannelFor);
802
- // otherwise a name several registered channels answer to is refused
1858
+ // resident's tune_out included: with a serverId, that server's
1859
+ // channel with the id; inside the calling turn, the id of one of the
1860
+ // turn's own channels means that channel (ownChannelFor); otherwise a
1861
+ // name several registered channels answer to picks none of them
803
1862
  // (channelsNamed), whichever of them is tuned out, never taken to be
804
- // the tuned-out one.
805
- const mine = this.channelRegistry.ownChannelFor(name, own);
806
- if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
807
- const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
808
- const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
809
- if (shared) return { success: false, error: shared, isError: true };
810
- const channel = named[0];
1863
+ // the tuned-out one. A name that picks no channel, such a name or one
1864
+ // of the turn's own channels that ownChannelFor refuses, is refused,
1865
+ // unless the call names the all-except scope, which ends that hold
1866
+ // below.
1867
+ const mine = serverId ? undefined : this.channelRegistry.ownChannelFor(name, own);
1868
+ const refusal = mine && 'error' in mine ? mine.error
1869
+ : serverId || mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
1870
+ const named = serverId ? [{ serverId, channelId: name }]
1871
+ : mine && 'channel' in mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
1872
+ const channel = refusal ? undefined : named[0];
811
1873
  if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) {
812
- return { success: false, error: this.notTunedOut(name, 'the resident'), isError: true };
1874
+ // The reader keeps its latitude over the all-except hold too: a
1875
+ // channel it holds (or the scope named as the channel) ends the
1876
+ // scope. A call naming the scope "channel" ends only a channel's
1877
+ // own tune-out, so on a channel only the scope holds it ends
1878
+ // nothing.
1879
+ const scope = this.getAllExcept();
1880
+ const heldByScope = scope && (name === 'all-except'
1881
+ || (channel !== undefined && this.holdFor(channel.serverId, channel.channelId)?.epochId === scope.params.epochId));
1882
+ if (!heldByScope) return { success: false, error: refusal ?? this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
1883
+ if (input.scope === 'channel') {
1884
+ const shown = channel ? this.quotedShown(channel.serverId, channel.channelId) : JSON.stringify(name);
1885
+ return {
1886
+ success: false,
1887
+ error: `Nothing was cancelled: ${shown} isn't tuned out on its own, and scope "channel" ends only a channel's own `
1888
+ + 'tune-out. The all-except hold holds it, and calling cancel_tuneout with scope "all-except" ends that hold.',
1889
+ isError: true,
1890
+ };
1891
+ }
1892
+ return endScope();
813
1893
  }
814
- const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
815
- const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note);
1894
+ const held = this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)!;
1895
+ const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel',
1896
+ `the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`);
1897
+ if (refused) return { success: false, error: refused, isError: true };
1898
+ const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller);
816
1899
  return result.ok
817
1900
  ? { success: true, data: { cancelled: true } }
818
1901
  : { success: false, error: result.error, isError: true };
@@ -866,7 +1949,17 @@ export class TuneOutCoordinator {
866
1949
  // means that channel, as for every channel tool (ownChannelFor).
867
1950
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish');
868
1951
  if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
869
- return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
1952
+ if (fork) {
1953
+ const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
1954
+ const at = named.length === 1 ? named[0] : undefined;
1955
+ const hold = at ? this.holdFor(at.serverId, at.channelId) : null;
1956
+ const shown = at ? this.quotedShown(at.serverId, at.channelId) : JSON.stringify(channelId);
1957
+ const refused = (at || fork.epochId === undefined) && this.forkRefusal(fork, hold, 'speech', hold
1958
+ ? `${shown} is held by a hold other than the one you were started to read`
1959
+ : `${shown} isn't held now`);
1960
+ if (refused) return { success: false, error: refused, isError: true };
1961
+ }
1962
+ return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious');
870
1963
  }
871
1964
  default:
872
1965
  return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
@@ -874,7 +1967,31 @@ export class TuneOutCoordinator {
874
1967
  }
875
1968
 
876
1969
  /**
877
- * A cancel of a channel that isn't tuned out, by the name the caller gave
1970
+ * Why a reader fork may not act through `hold` (the epoch holding what its
1971
+ * call names, or null): a fork ends and posts only through the hold of the
1972
+ * epoch it was derived to read, and one derived for none acts through no
1973
+ * hold. Undefined where the hold is its own. `why` says, for the fork, how
1974
+ * what its call names isn't its own hold; its reports stay open to it.
1975
+ */
1976
+ private forkRefusal(
1977
+ fork: { epochId?: string },
1978
+ hold: { epochId: string } | null,
1979
+ act: 'cancel' | 'speech',
1980
+ why: string,
1981
+ ): string | undefined {
1982
+ if (fork.epochId !== undefined && hold?.epochId === fork.epochId) return undefined;
1983
+ const nothing = act === 'cancel' ? 'Nothing was cancelled' : 'Nothing was posted';
1984
+ const report = `Your report can say what you would ${act === 'cancel' ? 'judge of it' : 'have posted'}, and the resident reads it.`;
1985
+ if (fork.epochId === undefined) {
1986
+ return `${nothing}: you weren't started to read a tune-out, so there's no hold you can `
1987
+ + `${act === 'cancel' ? 'end' : 'post through'}. ${report}`;
1988
+ }
1989
+ const rule = act === 'cancel' ? 'a reader ends only its own' : 'a reader posts only into a channel its own hold holds';
1990
+ return `${nothing}: ${why}, and ${rule}. ${report}`;
1991
+ }
1992
+
1993
+ /**
1994
+ * A cancel of a channel that isn't tuned out, by the name, quoted, the caller gave
878
1995
  * or knows it by: which channels are, so the next call can name one, and
879
1996
  * how a tune-out ends without one. `backlogTo`
880
1997
  * names the resident as the message's reader knows it: "you" to the
@@ -884,16 +2001,28 @@ export class TuneOutCoordinator {
884
2001
  * neither this list nor the name's lookup (channelsNamed, which reads the
885
2002
  * registered channels too), and the text claims nothing about it.
886
2003
  */
887
- private notTunedOut(name: string, backlogTo: 'you' | 'the resident'): string {
888
- const active = this.listActive().map((e) => JSON.stringify(this.shown(e.serverId, e.channelId)));
2004
+ private notTunedOut(quoted: string, backlogTo: 'you' | 'the resident'): string {
2005
+ const active = this.listActive().map((e) => this.quotedShown(e.serverId, e.channelId));
889
2006
  const opening = active.length === 0
890
2007
  ? 'no connected channel is tuned out now.'
891
- : `no connected channel ${JSON.stringify(name)} is tuned out. Connected channels tuned out now: `
2008
+ : `no connected channel ${quoted} is tuned out. Connected channels tuned out now: `
892
2009
  + `${active.join(', ')}.`;
893
2010
  return `Nothing was cancelled: ${opening} A tune-out also ends by itself when its duration runs out, or at the `
894
2011
  + `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`;
895
2012
  }
896
2013
 
2014
+ /**
2015
+ * The reader ended, not the framework: its turns stop (cadence and
2016
+ * coalescing wakes), and every hold keeps its end time, which releases it
2017
+ * to the resident with no reader.
2018
+ */
2019
+ stopReader(): void {
2020
+ for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
2021
+ this.cadenceTimers.clear();
2022
+ for (const { timer } of this.pendingWakes.values()) clearTimeout(timer);
2023
+ this.pendingWakes.clear();
2024
+ }
2025
+
897
2026
  /** Stop all timers (framework shutdown). */
898
2027
  stop(): void {
899
2028
  for (const timer of this.cadenceTimers.values()) clearTimeout(timer);