@animalabs/agent-framework 0.29.0 → 0.31.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 (236) hide show
  1. package/README.md +71 -8
  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 +50 -9
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +125 -23
  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 +10 -3
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/author-presentation.d.ts +50 -0
  14. package/dist/src/author-presentation.d.ts.map +1 -0
  15. package/dist/src/author-presentation.js +58 -0
  16. package/dist/src/author-presentation.js.map +1 -0
  17. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/render.js +7 -6
  19. package/dist/src/code-execution/isolate/render.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +5 -4
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/dendrite/index.d.ts +6 -0
  24. package/dist/src/dendrite/index.d.ts.map +1 -0
  25. package/dist/src/dendrite/index.js +3 -0
  26. package/dist/src/dendrite/index.js.map +1 -0
  27. package/dist/src/dendrite/presets.d.ts +60 -0
  28. package/dist/src/dendrite/presets.d.ts.map +1 -0
  29. package/dist/src/dendrite/presets.js +117 -0
  30. package/dist/src/dendrite/presets.js.map +1 -0
  31. package/dist/src/dendrite/registry.d.ts +260 -0
  32. package/dist/src/dendrite/registry.d.ts.map +1 -0
  33. package/dist/src/dendrite/registry.js +742 -0
  34. package/dist/src/dendrite/registry.js.map +1 -0
  35. package/dist/src/dendrite/types.d.ts +392 -0
  36. package/dist/src/dendrite/types.d.ts.map +1 -0
  37. package/dist/src/dendrite/types.js +24 -0
  38. package/dist/src/dendrite/types.js.map +1 -0
  39. package/dist/src/framework.d.ts +806 -118
  40. package/dist/src/framework.d.ts.map +1 -1
  41. package/dist/src/framework.js +4180 -1121
  42. package/dist/src/framework.js.map +1 -1
  43. package/dist/src/gate/event-gate.d.ts +36 -21
  44. package/dist/src/gate/event-gate.d.ts.map +1 -1
  45. package/dist/src/gate/event-gate.js +223 -149
  46. package/dist/src/gate/event-gate.js.map +1 -1
  47. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  48. package/dist/src/gate/recipe-reconcile.js +6 -5
  49. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  50. package/dist/src/host-presentation.d.ts +4 -0
  51. package/dist/src/host-presentation.d.ts.map +1 -1
  52. package/dist/src/host-presentation.js +12 -10
  53. package/dist/src/host-presentation.js.map +1 -1
  54. package/dist/src/index.d.ts +6 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/index.js +7 -1
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/inference-bounds.d.ts +37 -0
  59. package/dist/src/inference-bounds.d.ts.map +1 -0
  60. package/dist/src/inference-bounds.js +84 -0
  61. package/dist/src/inference-bounds.js.map +1 -0
  62. package/dist/src/line-reader.d.ts +7 -1
  63. package/dist/src/line-reader.d.ts.map +1 -1
  64. package/dist/src/line-reader.js +17 -6
  65. package/dist/src/line-reader.js.map +1 -1
  66. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  67. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  68. package/dist/src/mcpl/channel-registry.js +136 -58
  69. package/dist/src/mcpl/channel-registry.js.map +1 -1
  70. package/dist/src/mcpl/errors.d.ts +1 -0
  71. package/dist/src/mcpl/errors.d.ts.map +1 -1
  72. package/dist/src/mcpl/errors.js +3 -0
  73. package/dist/src/mcpl/errors.js.map +1 -1
  74. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  75. package/dist/src/mcpl/inbound-source.js +2 -2
  76. package/dist/src/mcpl/inference-router.d.ts +11 -1
  77. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/inference-router.js +65 -9
  79. package/dist/src/mcpl/inference-router.js.map +1 -1
  80. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  81. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  82. package/dist/src/mcpl/modern-connection.js +85 -74
  83. package/dist/src/mcpl/modern-connection.js.map +1 -1
  84. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  85. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  86. package/dist/src/mcpl/protocol-family.js +45 -3
  87. package/dist/src/mcpl/protocol-family.js.map +1 -1
  88. package/dist/src/mcpl/push-coalescer.d.ts +11 -7
  89. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  90. package/dist/src/mcpl/push-coalescer.js +21 -0
  91. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  92. package/dist/src/mcpl/references.d.ts +1 -2
  93. package/dist/src/mcpl/references.d.ts.map +1 -1
  94. package/dist/src/mcpl/references.js +3 -3
  95. package/dist/src/mcpl/references.js.map +1 -1
  96. package/dist/src/mcpl/server-connection.d.ts +14 -0
  97. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/server-connection.js +26 -22
  99. package/dist/src/mcpl/server-connection.js.map +1 -1
  100. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  101. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  102. package/dist/src/mcpl/tool-signature.js +16 -11
  103. package/dist/src/mcpl/tool-signature.js.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  105. package/dist/src/mcpl/toolsets-module.js +7 -0
  106. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  107. package/dist/src/mcpl/transport.d.ts +44 -21
  108. package/dist/src/mcpl/transport.d.ts.map +1 -1
  109. package/dist/src/mcpl/transport.js +283 -64
  110. package/dist/src/mcpl/transport.js.map +1 -1
  111. package/dist/src/mcpl/types.d.ts +17 -1
  112. package/dist/src/mcpl/types.d.ts.map +1 -1
  113. package/dist/src/mcpl/types.js.map +1 -1
  114. package/dist/src/module-registry.d.ts +32 -4
  115. package/dist/src/module-registry.d.ts.map +1 -1
  116. package/dist/src/module-registry.js +189 -25
  117. package/dist/src/module-registry.js.map +1 -1
  118. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.js +24 -16
  120. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  121. package/dist/src/modules/sender-name.d.ts +5 -4
  122. package/dist/src/modules/sender-name.d.ts.map +1 -1
  123. package/dist/src/modules/sender-name.js +7 -5
  124. package/dist/src/modules/sender-name.js.map +1 -1
  125. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  126. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  127. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  128. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  129. package/dist/src/modules/workspace/index.d.ts +6 -3
  130. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/index.js +61 -15
  132. package/dist/src/modules/workspace/index.js.map +1 -1
  133. package/dist/src/modules/workspace/observe.d.ts +9 -1
  134. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  135. package/dist/src/modules/workspace/observe.js +50 -10
  136. package/dist/src/modules/workspace/observe.js.map +1 -1
  137. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  138. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/reconcile.js +19 -8
  140. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  141. package/dist/src/modules/workspace/types.d.ts +8 -0
  142. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  143. package/dist/src/modules/workspace/types.js.map +1 -1
  144. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  145. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/watcher.js +7 -5
  147. package/dist/src/modules/workspace/watcher.js.map +1 -1
  148. package/dist/src/notice-tags.d.ts +2 -0
  149. package/dist/src/notice-tags.d.ts.map +1 -1
  150. package/dist/src/notice-tags.js +2 -0
  151. package/dist/src/notice-tags.js.map +1 -1
  152. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  153. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  154. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  155. package/dist/src/silent-heartbeat.d.ts +4 -2
  156. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  157. package/dist/src/silent-heartbeat.js +7 -4
  158. package/dist/src/silent-heartbeat.js.map +1 -1
  159. package/dist/src/stored-injections.d.ts +4 -0
  160. package/dist/src/stored-injections.d.ts.map +1 -1
  161. package/dist/src/stored-injections.js +6 -0
  162. package/dist/src/stored-injections.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +2 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -1
  165. package/dist/src/supplied-text.js +16 -7
  166. package/dist/src/supplied-text.js.map +1 -1
  167. package/dist/src/tool-result-guard.js +1 -1
  168. package/dist/src/tool-result-guard.js.map +1 -1
  169. package/dist/src/tune-out/coordinator.d.ts +368 -16
  170. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  171. package/dist/src/tune-out/coordinator.js +1224 -136
  172. package/dist/src/tune-out/coordinator.js.map +1 -1
  173. package/dist/src/tune-out/tools.d.ts +115 -7
  174. package/dist/src/tune-out/tools.d.ts.map +1 -1
  175. package/dist/src/tune-out/tools.js +55 -18
  176. package/dist/src/tune-out/tools.js.map +1 -1
  177. package/dist/src/types/agent.d.ts +27 -1
  178. package/dist/src/types/agent.d.ts.map +1 -1
  179. package/dist/src/types/framework.d.ts +153 -15
  180. package/dist/src/types/framework.d.ts.map +1 -1
  181. package/dist/src/types/module.d.ts +42 -3
  182. package/dist/src/types/module.d.ts.map +1 -1
  183. package/dist/src/types/trace.d.ts +140 -3
  184. package/dist/src/types/trace.d.ts.map +1 -1
  185. package/package.json +4 -4
  186. package/src/admission/admit.ts +113 -0
  187. package/src/agent.ts +138 -31
  188. package/src/api/mcp-server-core.ts +10 -3
  189. package/src/author-presentation.ts +81 -0
  190. package/src/code-execution/isolate/render.ts +7 -6
  191. package/src/code-execution/isolate/scripts.ts +5 -4
  192. package/src/dendrite/index.ts +32 -0
  193. package/src/dendrite/presets.ts +149 -0
  194. package/src/dendrite/registry.ts +808 -0
  195. package/src/dendrite/types.ts +397 -0
  196. package/src/framework.ts +4344 -1052
  197. package/src/gate/event-gate.ts +251 -150
  198. package/src/gate/recipe-reconcile.ts +7 -5
  199. package/src/host-presentation.ts +14 -10
  200. package/src/index.ts +16 -2
  201. package/src/inference-bounds.ts +111 -0
  202. package/src/line-reader.ts +22 -5
  203. package/src/mcpl/channel-registry.ts +170 -60
  204. package/src/mcpl/errors.ts +4 -0
  205. package/src/mcpl/inbound-source.ts +2 -2
  206. package/src/mcpl/inference-router.ts +67 -10
  207. package/src/mcpl/modern-connection.ts +83 -68
  208. package/src/mcpl/protocol-family.ts +37 -4
  209. package/src/mcpl/push-coalescer.ts +21 -7
  210. package/src/mcpl/references.ts +3 -4
  211. package/src/mcpl/server-connection.ts +30 -21
  212. package/src/mcpl/tool-signature.ts +18 -11
  213. package/src/mcpl/toolsets-module.ts +7 -0
  214. package/src/mcpl/transport.ts +261 -60
  215. package/src/mcpl/types.ts +18 -1
  216. package/src/module-registry.ts +178 -25
  217. package/src/modules/discord/discord-js-client.ts +33 -29
  218. package/src/modules/sender-name.ts +8 -5
  219. package/src/modules/workspace/disk-agreement.ts +39 -15
  220. package/src/modules/workspace/index.ts +65 -14
  221. package/src/modules/workspace/observe.ts +51 -10
  222. package/src/modules/workspace/reconcile.ts +22 -8
  223. package/src/modules/workspace/types.ts +5 -0
  224. package/src/modules/workspace/watcher.ts +8 -6
  225. package/src/notice-tags.ts +2 -0
  226. package/src/recovery/discord-awareness-outbox.ts +1 -11
  227. package/src/silent-heartbeat.ts +7 -4
  228. package/src/stored-injections.ts +7 -0
  229. package/src/supplied-text.ts +17 -6
  230. package/src/tool-result-guard.ts +1 -1
  231. package/src/tune-out/coordinator.ts +1374 -141
  232. package/src/tune-out/tools.ts +166 -13
  233. package/src/types/agent.ts +30 -1
  234. package/src/types/framework.ts +157 -15
  235. package/src/types/module.ts +42 -3
  236. package/src/types/trace.ts +154 -3
@@ -15,8 +15,9 @@
15
15
  * lifecycle facts (the [Gate: …] precedent).
16
16
  */
17
17
  import { NOTICE_TAG } from '../notice-tags.js';
18
- import { cutToFit, suppliedValue } from '../supplied-text.js';
18
+ import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js';
19
19
  import { randomUUID } from 'node:crypto';
20
+ import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js';
20
21
  import { CapabilityGrant } from '../mcpl/capability-grant.js';
21
22
  import { armAfter, armAt } from '../deadline-timer.js';
22
23
  /** Defaults for tune_out tool params. */
@@ -28,7 +29,87 @@ export const TUNE_OUT_DEFAULTS = {
28
29
  /** Coalescing window for wake invocations: a mention storm becomes one
29
30
  * subconscious turn, and max-wakes counts invocations, not messages. */
30
31
  const WAKE_COALESCE_MS = 2_000;
31
- /** How long after an end time whose cancel threw the cancel is tried again. */
32
+ /**
33
+ * One backlog line: author, then the message's blocks in order — text as
34
+ * is, anything else named by kind (`[image]`, `[document]`), so a release
35
+ * never drops a block silently (#232 gap 4). The author is named once: a
36
+ * server that renders `Name: body` into the text itself is not doubled.
37
+ * A name the line adds is a value someone supplied, kept to one line with
38
+ * its notice openings marked (authorLine), as main's dump writes it.
39
+ */
40
+ export function renderHeldLine(m) {
41
+ const author = authorNamed(m.metadata) ?? m.participant;
42
+ const parts = m.content.map((b) => b.type === 'text' ? b.text : `[${b.type}]`);
43
+ const line = parts.filter((p) => p.length > 0).join(' ');
44
+ const firstText = m.content.find((b) => b.type === 'text')?.text;
45
+ return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line);
46
+ }
47
+ /** The deferred write a stored row landed from, when it came through the queue. */
48
+ function writeIdOf(m) {
49
+ const writeId = m.metadata?.deferredWriteId;
50
+ return typeof writeId === 'string' ? writeId : undefined;
51
+ }
52
+ function keysOf(rows) {
53
+ const keys = { ids: [], writeIds: [] };
54
+ for (const m of rows) {
55
+ const writeId = m.writeId ?? writeIdOf(m);
56
+ if (writeId !== undefined)
57
+ keys.writeIds.push(writeId);
58
+ else if (m.id !== undefined)
59
+ keys.ids.push(m.id);
60
+ }
61
+ return keys;
62
+ }
63
+ function keyedBy(keys) {
64
+ const ids = new Set(keys.flatMap((k) => k.ids));
65
+ const writeIds = new Set(keys.flatMap((k) => k.writeIds));
66
+ return (m) => {
67
+ const writeId = m.writeId ?? writeIdOf(m);
68
+ return writeId !== undefined ? writeIds.has(writeId) : m.id !== undefined && ids.has(m.id);
69
+ };
70
+ }
71
+ const isStrings = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
72
+ const isRowKeys = (value) => typeof value === 'object' && value !== null && isStrings(value.ids) && isStrings(value.writeIds);
73
+ function isReleaseRecord(value) {
74
+ if (!isRowKeys(value))
75
+ return false;
76
+ const record = value;
77
+ return typeof record.total === 'number' && typeof record.truncated === 'number' && isRowKeys(record.report);
78
+ }
79
+ function releaseRecordsOf(rows, epochId) {
80
+ const records = [];
81
+ for (const m of rows) {
82
+ const meta = m.metadata;
83
+ if (meta?.kind !== 'tune-out-cancel' || meta.epochId !== epochId)
84
+ continue;
85
+ if (isReleaseRecord(meta.released) && !records.some((r) => JSON.stringify(r) === JSON.stringify(meta.released))) {
86
+ records.push(meta.released);
87
+ }
88
+ }
89
+ return records;
90
+ }
91
+ function truncationNote(records) {
92
+ const truncated = records.reduce((n, r) => n + r.truncated, 0);
93
+ if (truncated === 0)
94
+ return '';
95
+ const total = records.reduce((n, r) => n + r.total, 0);
96
+ return records.length === 1
97
+ ? ` ${truncated} of ${total} diverted messages, the oldest, weren't delivered raw to the resident.`
98
+ : ` ${truncated} of ${total} diverted messages across ${records.length} dumps, the oldest in each dump that left any out, weren't delivered raw to the resident.`;
99
+ }
100
+ /**
101
+ * Whether a held message is one of this channel's: by its channel id, and by
102
+ * its server where it records one, since two servers can register the same
103
+ * channel id. A message recording neither is taken as the channel's, as
104
+ * before servers were recorded.
105
+ */
106
+ function inChannelOf(serverId, channelId) {
107
+ return (m) => {
108
+ const meta = m.metadata;
109
+ return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
110
+ };
111
+ }
112
+ /** How long after an end time's or a wake budget's cancel threw the cancel is tried again. */
32
113
  const EXPIRY_RETRY_MS = 60_000;
33
114
  /** Ends a backlog line cut to fit in one of the residents' messages. */
34
115
  const CUT_MARK = ` [${NOTICE_TAG.cutToFit}; the whole message is in the channel's history]`;
@@ -74,8 +155,58 @@ export class TuneOutCoordinator {
74
155
  expiryTimers = new Map();
75
156
  /** Channels with a wake invocation pending inside the coalescing window. */
76
157
  pendingWakes = new Map();
77
- /** Diverted-message tallies since the last subconscious invocation, per channel key. */
158
+ /**
159
+ * Held messages the channel's reader hasn't looked at, per channel key:
160
+ * what makes a cadence period worth a look. Counted up as messages are
161
+ * held and back to zero at each look; rebuilt from the held rows where
162
+ * memory can't know it (rebuildTally): after a restart, and when a
163
+ * channel's own tune-out ends while the all-except hold still holds it.
164
+ * A failed reader fork's messages count again (readerFailed).
165
+ */
78
166
  divertedSinceInvocation = new Map();
167
+ /**
168
+ * The persistent subconscious's last look at each held channel, per epoch:
169
+ * the store's sequence when the look was asked for. Durable, so that after
170
+ * a restart its next look counts only what was held since (rebuildTally).
171
+ * Reader forks need none: what they were handed is in memory, and after a
172
+ * restart the first fork of an epoch is handed all it has held.
173
+ */
174
+ lastLooksCache;
175
+ /**
176
+ * `reader: 'forks'`: the highest sequence of a held message already
177
+ * handed to a reader fork, per epoch and channel (handOffKey). Each held
178
+ * message is delivered to exactly one fork. A channel the all-except hold
179
+ * holds can be taken by a tune-out of its own and given back, and each
180
+ * epoch's readers read that epoch's messages, so what one epoch's readers
181
+ * were handed never moves or clears the other's. In-memory: after a
182
+ * restart the first fork of an epoch is handed everything the epoch has
183
+ * held, which is the right side to err on.
184
+ */
185
+ readerCursor = new Map();
186
+ /**
187
+ * `reader: 'forks'`: the deferred writes handed to a reader fork while
188
+ * still queued, per epoch and channel. Each lands in the store later as a row
189
+ * stamped with the same id (`metadata.deferredWriteId`), past the cursor;
190
+ * that row is not handed again. Cleared with the cursor.
191
+ */
192
+ readerHandedWrites = new Map();
193
+ /**
194
+ * `reader: 'forks'`: held messages a reader fork was handed but never read,
195
+ * per epoch and channel: its derive failed, or its run threw before it reported
196
+ * (readerFailed). The next invocation is handed them again, cursor or no:
197
+ * stored rows by id, and by write id those that were queued when handed
198
+ * (stored or still queued now). Cleared with the cursor.
199
+ */
200
+ readerReturned = new Map();
201
+ /**
202
+ * `reader: 'forks'`: the late forks derived for an ended epoch's messages
203
+ * (readerFailed), so that a late fork's own failure derives no other.
204
+ */
205
+ lateForks = new WeakSet();
206
+ /** The all-except scope's durable record, cached (undefined = not read yet). */
207
+ scopeCache;
208
+ /** Timer key for the scope's cadence and expiry. */
209
+ static SCOPE_KEY = '\0all-except';
79
210
  constructor(channelRegistry, serverRegistry, hooks) {
80
211
  this.channelRegistry = channelRegistry;
81
212
  this.serverRegistry = serverRegistry;
@@ -84,6 +215,26 @@ export class TuneOutCoordinator {
84
215
  key(serverId, channelId) {
85
216
  return `${serverId}\0${channelId}`;
86
217
  }
218
+ /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
219
+ handOffKey(epochId, serverId, channelId) {
220
+ return `${epochId}\0${serverId}\0${channelId}`;
221
+ }
222
+ /** An ended epoch's hand-off bookkeeping goes with it, every channel's. */
223
+ dropHandOffs(epochId) {
224
+ for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) {
225
+ for (const key of map.keys())
226
+ if (key.startsWith(`${epochId}\0`))
227
+ map.delete(key);
228
+ }
229
+ }
230
+ /** A reader exists: the persistent subconscious, or forks on demand. */
231
+ readerAvailable() {
232
+ return this.hooks.subconsciousName() != null || typeof this.hooks.invokeReader === 'function';
233
+ }
234
+ /** Reader forks are the configured reader. */
235
+ get forkReader() {
236
+ return this.hooks.subconsciousName() == null && typeof this.hooks.invokeReader === 'function';
237
+ }
87
238
  /** A channel as the resident and the subconscious are shown it
88
239
  * (ChannelRegistry.nameShown): its id, or, where another server registers
89
240
  * the same id, its server-qualified name, which tune_out and
@@ -102,19 +253,85 @@ export class TuneOutCoordinator {
102
253
  // An epoch whose deadline passed while the process was down cancels
103
254
  // immediately — the resident gets their backlog on the schedule they
104
255
  // committed to, not whenever the host happened to restart.
105
- if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
106
- this.cancel(serverId, channelId, 'duration', 'duration elapsed');
107
- continue;
256
+ const inForce = () => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === params.epochId;
257
+ const expired = params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now();
258
+ if (expired) {
259
+ try {
260
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
261
+ continue;
262
+ }
263
+ catch (err) {
264
+ if (!inForce()) {
265
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down and its cancel ended it, but a step after the end failed:`, err);
266
+ continue;
267
+ }
268
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
269
+ }
108
270
  }
109
271
  this.startCadence(serverId, channelId, params.cadenceSeconds);
110
- this.armExpiry(serverId, channelId, params);
272
+ const wakeCount = this.channelRegistry.getTuneOutState(serverId, channelId)?.wakeCount ?? 0;
273
+ if (wakeCount > params.maxWakes) {
274
+ this.cancelPastBudget(serverId, channelId, params.maxWakes, 'start');
275
+ if (!inForce())
276
+ continue;
277
+ }
278
+ else {
279
+ this.armExpiry(serverId, channelId, expired ? { ...params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS } : params);
280
+ }
111
281
  if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
112
282
  oldestAnchor = params.startedAtSequence;
113
283
  }
114
284
  }
285
+ const scope = this.getAllExcept();
286
+ if (scope) {
287
+ let inForce = true;
288
+ if (scope.params.expiresAtMs <= Date.now()) {
289
+ try {
290
+ this.cancelAllExcept('duration', 'duration elapsed');
291
+ inForce = false;
292
+ }
293
+ catch (err) {
294
+ if (this.getAllExcept()?.params.epochId !== scope.params.epochId) {
295
+ console.error('[tune-out] the all-except hold reached its end time while the process was down and its release ended it, but a step after the end failed:', err);
296
+ inForce = false;
297
+ }
298
+ else {
299
+ console.error(`[tune-out] the all-except hold reached its end time while the process was down, and its release failed, so it stays in force and its release is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
300
+ this.armScopeTimers({ ...scope.params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS });
301
+ }
302
+ }
303
+ }
304
+ else {
305
+ this.armScopeTimers(scope.params);
306
+ }
307
+ if (inForce && (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor)) {
308
+ oldestAnchor = scope.params.startedAtSequence;
309
+ }
310
+ }
115
311
  if (oldestAnchor !== null) {
116
312
  this.hooks.setSubconsciousAnchor(oldestAnchor);
117
313
  }
314
+ // What each held channel's reader hasn't looked at was counted in
315
+ // memory, so it is counted again from the held rows: without it, a
316
+ // channel held across the restart gets no cadence look until something
317
+ // new arrives.
318
+ // Stored and still queued alike: at a quiesced boot, restored writes stay
319
+ // queued until the resume flushes them.
320
+ const held = new Set();
321
+ for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) {
322
+ const meta = m.metadata;
323
+ const epochId = meta?.tuneOut?.epochId;
324
+ if (!meta || !epochId || !meta.channelId)
325
+ continue;
326
+ const key = this.key(meta.serverId ?? '', meta.channelId);
327
+ if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId)
328
+ held.add(key);
329
+ }
330
+ for (const key of held) {
331
+ const [serverId, channelId] = key.split('\0');
332
+ this.rebuildTally(serverId, channelId);
333
+ }
334
+ this.pruneLooks();
118
335
  }
119
336
  armExpiry(serverId, channelId, params) {
120
337
  if (params.expiresAtMs === undefined)
@@ -167,39 +384,19 @@ export class TuneOutCoordinator {
167
384
  // ==========================================================================
168
385
  // Enter / cancel
169
386
  // ==========================================================================
170
- enter(serverId, channelId, opts, source) {
171
- if (!this.hooks.subconsciousName()) {
172
- return {
173
- ok: false,
174
- error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
175
- + "isn't available here, and calling it again won't change that. If you need channels diverted, ask "
176
- + 'whoever runs this host.',
177
- };
178
- }
179
- if (this.hooks.isForkBound({ serverId, channelId })) {
180
- return {
181
- ok: false,
182
- error: `Nothing changed: a conversation is bound to ${this.quotedShown(serverId, channelId)}, so its messages go to that `
183
- + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
184
- + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
185
- + 'out a different channel, or ask whoever runs this host if this one should be diverted.',
186
- };
187
- }
188
- const current = this.channelRegistry.getTuneOutState(serverId, channelId);
189
- if (current)
190
- return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
191
- // A tool's input reaches here as the model wrote it, and the store can't
192
- // keep a NaN or an Infinity: its append would throw after the registry
193
- // had marked the channel tuned out. So each option is read as a number
194
- // before anything changes, a numeric string included, as the sleep tool
195
- // reads its seconds; null counts as left out. A value that gives no
196
- // finite number is refused.
197
- const leftOut = {
198
- durationSeconds: 'to stay tuned out until you cancel',
199
- cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
200
- backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
201
- maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
202
- };
387
+ /**
388
+ * The options a tune-out call gives, read as numbers before anything
389
+ * changes (F76). A tool's input reaches here as the model wrote it, and
390
+ * the store can't keep a NaN or an Infinity: its append would throw after
391
+ * the registry had marked the channel tuned out. So each option is read
392
+ * as a number first, a numeric string included, as the sleep tool reads
393
+ * its seconds; null counts as left out. A value that gives no finite
394
+ * number is refused, naming the option and, where it can be left out,
395
+ * what leaving it out means; so is a duration whose end time is too far
396
+ * off to store. `now` is the clock the end time was counted from. One
397
+ * rule for a channel's tune-out and the all-except hold.
398
+ */
399
+ readOptions(opts, leftOut) {
203
400
  const given = {};
204
401
  for (const name of Object.keys(leftOut)) {
205
402
  const value = opts[name];
@@ -213,21 +410,63 @@ export class TuneOutCoordinator {
213
410
  return {
214
411
  ok: false,
215
412
  error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
216
- + `and it was ${got}. Leave it out ${leftOut[name]}.`,
413
+ + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
217
414
  };
218
415
  }
219
416
  given[name] = n;
220
417
  }
418
+ const now = Date.now();
221
419
  const expiresAtMs = given.durationSeconds === undefined
222
420
  ? undefined
223
- : Date.now() + Math.max(60, given.durationSeconds) * 1000;
421
+ : now + Math.max(60, given.durationSeconds) * 1000;
224
422
  if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
225
423
  return {
226
424
  ok: false,
227
- error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. `
228
- + `Leave it out ${leftOut.durationSeconds}.`,
425
+ error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
426
+ + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
229
427
  };
230
428
  }
429
+ return { ok: true, given, now, expiresAtMs };
430
+ }
431
+ enter(serverId, channelId, opts, source) {
432
+ // No reader (issue #232 §3.3): a silent hold, where the host opted into
433
+ // one. Nothing summarizes, nothing wakes, mentions are still
434
+ // acknowledged, the backlog is released on cancel; the resident is told
435
+ // so in the tool result. A host that didn't opt in has no tune_out
436
+ // without a reader.
437
+ if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) {
438
+ return {
439
+ ok: false,
440
+ error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
441
+ + "isn't available here, and calling it again won't change that. If you need channels diverted, ask "
442
+ + 'whoever runs this host.',
443
+ };
444
+ }
445
+ if (this.hooks.isForkBound({ serverId, channelId })) {
446
+ return {
447
+ ok: false,
448
+ error: `Nothing changed: a conversation is bound to ${this.quotedShown(serverId, channelId)}, so its messages go to that `
449
+ + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change "
450
+ + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune '
451
+ + 'out a different channel, or ask whoever runs this host if this one should be diverted.',
452
+ };
453
+ }
454
+ const current = this.channelRegistry.getTuneOutState(serverId, channelId);
455
+ if (current)
456
+ return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
457
+ const scope = this.getAllExcept();
458
+ if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
459
+ return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
460
+ }
461
+ const read = this.readOptions(opts, {
462
+ durationSeconds: 'to stay tuned out until you cancel',
463
+ cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
464
+ backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
465
+ maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
466
+ });
467
+ if (!read.ok)
468
+ return read;
469
+ const { given, expiresAtMs } = read;
231
470
  const params = {
232
471
  epochId: randomUUID(),
233
472
  cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
@@ -247,57 +486,56 @@ export class TuneOutCoordinator {
247
486
  const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
248
487
  this.hooks.setSubconsciousAnchor(oldest);
249
488
  this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
250
- this.startCadence(serverId, channelId, params.cadenceSeconds);
251
- this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId });
489
+ if (this.readerAvailable())
490
+ this.startCadence(serverId, channelId, params.cadenceSeconds);
491
+ this.hooks.emitTrace({
492
+ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
493
+ });
252
494
  return { ok: true, params };
253
495
  }
254
- /**
255
- * End a tune-out: durable flip, system-framed backlog dump into the
256
- * resident's window (capped), lifecycle notice, one wake for the
257
- * resident. `subconsciousNote` is the subconscious's own text (from its
258
- * cancel_tuneout call) — delivered verbatim under its name, never
259
- * synthesized here.
260
- */
261
- cancel(serverId, channelId, source, reason, subconsciousNote) {
262
- const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
263
- if (!ended) {
496
+ /** Whether a reader (persistent subconscious or reader forks) attends holds. */
497
+ hasReader() {
498
+ return this.readerAvailable();
499
+ }
500
+ cancel(serverId, channelId, source, reason, subconsciousNote,
501
+ /** `reader: 'forks'`: the reader fork whose note this is. */
502
+ noteAuthor,
503
+ /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
504
+ nextState = 'open') {
505
+ const ending = this.channelRegistry.getTuneOutState(serverId, channelId);
506
+ if (!ending) {
264
507
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
265
508
  return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
266
509
  }
267
- this.clearTimers(serverId, channelId);
268
- this.divertedSinceInvocation.delete(this.key(serverId, channelId));
269
- // Backlog: every message held under this epoch. The stored ones come in
270
- // store order, then those still in the deferred queue (held mid-turn,
271
- // not yet written), oldest first. That's arrival order, except when a
272
- // held message stays queued while a later one is stored: a store write
273
- // refused at a turn-start flush leaves it queued, and that turn's
274
- // assembly can then store later coalesced messages directly.
275
- const epochId = ended.params.epochId;
510
+ const epochId = ending.params.epochId;
276
511
  const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
277
- const held = (this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch);
278
- const backlog = [
279
- ...this.hooks.getStoredMessages().filter(ofEpoch),
280
- ...held,
281
- ];
512
+ const storedRows = this.hooks.getStoredMessages();
513
+ const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
514
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
515
+ const dumpedEarlier = keyedBy(earlier);
516
+ const unreleased = (m) => ofEpoch(m) && !dumpedEarlier(m);
517
+ const held = queuedRows.filter(unreleased);
518
+ const backlog = [...storedRows.filter(unreleased), ...held];
282
519
  // Whole messages: a cap below one shows none, where slice(-0.5) would
283
520
  // start at 0 and take them all.
284
- const cap = Math.floor(ended.params.backlogCap);
521
+ const cap = Math.floor(ending.params.backlogCap);
285
522
  const authorOf = (m) => suppliedValue(m.metadata?.author?.name ?? m.participant);
286
- const lineOf = (m) => {
287
- const text = m.content
288
- .filter((b) => b.type === 'text')
289
- .map((b) => b.text)
290
- .join('\n');
291
- return `${authorOf(m)}: ${text}`;
292
- };
523
+ // A line names its author as the resident knows them and marks what a
524
+ // text line can't carry (`[image]`), so the dump says what it leaves out.
525
+ const lineOf = renderHeldLine;
526
+ // The tool the truncation note names (#232 gap 11): the one the resident
527
+ // has, when the host says; main's `fetch_history` where no host says.
528
+ const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
293
529
  // The channel as the resident and the subconscious are shown it.
294
530
  const name = this.shown(serverId, channelId);
295
- const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
531
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`;
296
532
  const render = (lines, truncated) => {
297
533
  const attrs = [
298
534
  `channel=${this.quotedShown(serverId, channelId)}`,
299
535
  `messages=${backlog.length}`,
300
- ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []),
536
+ ...(truncated > 0
537
+ ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`]
538
+ : []),
301
539
  ];
302
540
  return `${lifecycle}\n\n<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
303
541
  };
@@ -361,8 +599,26 @@ export class TuneOutCoordinator {
361
599
  ...heldLeftOut,
362
600
  ...held.slice(heldLeftOut.length).filter((m) => m.content.some((b) => b.type !== 'text') || (newestCut && m === shown[0])),
363
601
  ];
364
- // Host-framed system artifacts — never voiced as anyone.
365
- this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId });
602
+ const records = [...earlier];
603
+ if (earlier.length === 0 || backlog.length > 0) {
604
+ const record = { ...keysOf(backlog), total: backlog.length, truncated, report: keysOf(heldForReport) };
605
+ this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record });
606
+ records.push(record);
607
+ }
608
+ if (this.hooks.commitDeliveries?.() === false) {
609
+ throw new Error(`the backlog of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
610
+ }
611
+ this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
612
+ this.clearTimers(serverId, channelId);
613
+ this.divertedSinceInvocation.delete(this.key(serverId, channelId));
614
+ const reportedEarlier = keyedBy(earlier.map((r) => r.report));
615
+ const release = {
616
+ truncated: records.reduce((n, r) => n + r.truncated, 0),
617
+ heldForReport: [
618
+ ...[...storedRows, ...queuedRows].filter((m) => ofEpoch(m) && reportedEarlier(m)),
619
+ ...(records.length > earlier.length ? heldForReport : []),
620
+ ],
621
+ };
366
622
  // The subconscious's own words, under its own name, verbatim.
367
623
  const subName = this.hooks.subconsciousName();
368
624
  if (subconsciousNote && subName) {
@@ -371,6 +627,11 @@ export class TuneOutCoordinator {
371
627
  // reads (repinChannel), where another server's channel can share the id.
372
628
  { kind: 'subconscious-note', channelId, serverId, epochId });
373
629
  }
630
+ else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) {
631
+ // A reader fork's note: attributed mail, under the fork's name and
632
+ // incarnation, never the resident's voice.
633
+ this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: subconsciousNote }], { kind: 'subconscious-note', channelId, epochId });
634
+ }
374
635
  const primary = this.hooks.primaryName();
375
636
  if (primary) {
376
637
  this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
@@ -385,22 +646,46 @@ export class TuneOutCoordinator {
385
646
  // and when it wrote no note and the dump left any message out, since
386
647
  // nothing else then covers it. Otherwise its note speaks for what it saw.
387
648
  const selfCancel = source === 'subconscious';
388
- if (subName && (!selfCancel || heldForReport.length > 0 || (!subconsciousNote && truncated > 0))) {
389
- const truncNote = truncated > 0
390
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
391
- : '';
392
- const heldNote = heldForReport.length > 0
393
- ? ` Still held at the cancel, so your view may not have them yet: the ${heldForReport.length} message${heldForReport.length === 1 ? '' : 's'} after this one.`
649
+ if (subName && (!selfCancel || release.heldForReport.length > 0 || (!subconsciousNote && release.truncated > 0))) {
650
+ const truncNote = truncationNote(records);
651
+ const heldNote = release.heldForReport.length > 0
652
+ ? ` Still held at the cancel, so your view may not have them yet: the ${release.heldForReport.length} message${release.heldForReport.length === 1 ? '' : 's'} after this one.`
394
653
  : '';
395
654
  this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }], { system: true, kind: 'tune-out-cancel-report', channelId }, subName);
396
655
  // Each held message is a row of its own, so a per-message cap cuts each
397
656
  // one on its own, its author's label included, not the batch's newest.
398
- for (const m of heldForReport) {
657
+ for (const m of release.heldForReport) {
399
658
  this.hooks.addMessage('user', [{ type: 'text', text: `${authorOf(m)}:` }, ...m.content], { system: true, kind: 'tune-out-cancel-held', channelId }, subName);
400
659
  }
401
660
  this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
402
661
  }
403
- this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
662
+ else if (!subName && this.forkReader) {
663
+ // Forks are the reader: one last fork for the epoch, handed what no
664
+ // fork has been handed yet, stored or still queued behind the
665
+ // resident's turn, plus the cancel notice; its report follows the dump.
666
+ // Once the epoch ends, a message still queued lands in the store
667
+ // view-filtered, where nothing else reads it, so it goes to this fork
668
+ // now. On a reader's own cancel, the reader has read what it was
669
+ // handed, so a fork follows only when messages arrived after that.
670
+ const stored = this.hooks.getStoredMessages().filter(ofEpoch);
671
+ const queued = this.queuedHeld(ofEpoch);
672
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
673
+ const unread = rest.backlog.length + rest.queued.length;
674
+ if (!selfCancel || unread > 0) {
675
+ const truncNote = truncationNote(records);
676
+ const lateNote = selfCancel
677
+ ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
678
+ : '';
679
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued);
680
+ }
681
+ }
682
+ this.dropHandOffs(epochId);
683
+ this.dropLooks(epochId);
684
+ // Where the all-except hold still holds the channel, its own messages
685
+ // there that its reader hasn't looked at are counted again, as the
686
+ // channel's own tune-out's count stood in for theirs.
687
+ this.rebuildTally(serverId, channelId);
688
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ending.wakeCount });
404
689
  return { ok: true };
405
690
  }
406
691
  // ==========================================================================
@@ -415,9 +700,354 @@ export class TuneOutCoordinator {
415
700
  * suppression ack, possible auto-cancel).
416
701
  */
417
702
  onIncoming(serverId, channelId, messageId, tags, authorId, gatePass = true) {
418
- const state = this.channelRegistry.getTuneOutState(serverId, channelId);
419
- if (!state)
703
+ const hold = this.holdFor(serverId, channelId);
704
+ if (!hold)
420
705
  return null;
706
+ this.recordHeld(serverId, channelId, messageId, tags, authorId, gatePass);
707
+ return hold;
708
+ }
709
+ /** The epoch holding this channel, or null. Pure: the admission stage's lookup. */
710
+ holdFor(serverId, channelId) {
711
+ // Precedence: a per-channel epoch keeps its own hold (cadence, budget,
712
+ // stamp); the all-except scope holds whatever else is not the exception.
713
+ const state = this.channelRegistry.getTuneOutState(serverId, channelId);
714
+ if (state)
715
+ return { epochId: state.params.epochId };
716
+ const scope = this.getAllExcept();
717
+ if (scope && !(scope.params.except.serverId === serverId && scope.params.except.channelId === channelId)) {
718
+ return { epochId: scope.params.epochId };
719
+ }
720
+ return null;
721
+ }
722
+ /** The channel unprefixed speech lands in while an all-except hold is active, with its server, else null. */
723
+ focusChannel() {
724
+ const except = this.getAllExcept()?.params.except;
725
+ return except ? { serverId: except.serverId, channelId: except.channelId } : null;
726
+ }
727
+ // ==========================================================================
728
+ // All-except scope
729
+ // ==========================================================================
730
+ get scopeStateId() {
731
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`;
732
+ }
733
+ getAllExcept() {
734
+ if (this.scopeCache !== undefined)
735
+ return this.scopeCache;
736
+ // A record that can't be read, or isn't a hold's, is not taken for no
737
+ // hold: the error goes to the caller and nothing is cached, so the next
738
+ // read tries again. At create() that refuses the start, as an
739
+ // unreadable registry does.
740
+ const raw = this.channelRegistry.readCoordinatorState(this.scopeStateId);
741
+ if (raw !== null && raw !== undefined && !(typeof raw === 'object' && raw.params && typeof raw.params.epochId === 'string')) {
742
+ throw new Error(`the all-except hold's stored record (${this.scopeStateId}) is not a hold's record; it was left as it is`);
743
+ }
744
+ this.scopeCache = raw ?? null;
745
+ return this.scopeCache;
746
+ }
747
+ writeScope(state) {
748
+ // Durable first: a write that fails leaves memory as the store has it.
749
+ this.channelRegistry.writeCoordinatorState(this.scopeStateId, state);
750
+ this.scopeCache = state;
751
+ }
752
+ enterAllExcept(except, opts, source) {
753
+ const read = this.readOptions(opts, {
754
+ durationSeconds: null,
755
+ cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
756
+ backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
757
+ maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
758
+ });
759
+ if (!read.ok)
760
+ return read;
761
+ const { given, expiresAtMs } = read;
762
+ if (expiresAtMs === undefined) {
763
+ return { ok: false, error: 'an all-except hold needs `durationSeconds`: it always ends on its own' };
764
+ }
765
+ if (this.getAllExcept()) {
766
+ return { ok: false, error: 'an all-except hold is already active (cancel it first)' };
767
+ }
768
+ if (this.channelRegistry.getTuneOutState(except.serverId, except.channelId)) {
769
+ return { ok: false, error: `channel ${except.channelId} is tuned out; it cannot be the exception` };
770
+ }
771
+ if (this.hooks.isForkBound(except)) {
772
+ return { ok: false, error: `channel ${except.channelId} is bound to a conversation fork` };
773
+ }
774
+ const params = {
775
+ epochId: randomUUID(),
776
+ except,
777
+ cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
778
+ // Whole messages, as a channel's cap is counted: a cap below one shows
779
+ // none, where slice(-0.5) would take them all.
780
+ backlogCap: Math.max(0, Math.floor(given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap)),
781
+ maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
782
+ startedAtSequence: this.hooks.currentSequence(),
783
+ startedAtMs: read.now,
784
+ expiresAtMs,
785
+ source,
786
+ };
787
+ this.writeScope({ params, wakeCount: 0 });
788
+ const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
789
+ this.hooks.setSubconsciousAnchor(oldest);
790
+ this.armScopeTimers(params);
791
+ this.hooks.emitTrace({
792
+ type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId,
793
+ scope: 'all-except', reader: this.readerAvailable(), expiresAtMs: params.expiresAtMs,
794
+ });
795
+ const minutes = Math.max(1, Math.round((params.expiresAtMs - params.startedAtMs) / 60_000));
796
+ const until = new Date(params.expiresAtMs).toISOString().slice(11, 16);
797
+ this.announce(except, params.epochId, 'entered', `[${NOTICE_TAG.focus}: ${this.hooks.primaryName() ?? 'the resident'} is here for ${minutes} min, until ${until} UTC; other channels are held meanwhile.]`);
798
+ return { ok: true, params };
799
+ }
800
+ /** Whether entry and release of the all-except scope are announced in the excepted channel. */
801
+ announces() {
802
+ return typeof this.hooks.announce === 'function';
803
+ }
804
+ /**
805
+ * The excepted channel hears the scope open and close: one bracketed line
806
+ * under the resident's name, posted through the same path as a publish.
807
+ * Fire-and-forget; the outcome is traced, never awaited by the epoch flip.
808
+ */
809
+ announce(target, epochId, kind, text) {
810
+ if (!this.hooks.announce)
811
+ return;
812
+ const base = { serverId: target.serverId, channelId: target.channelId, epochId, kind };
813
+ this.hooks.announce(target.serverId, target.channelId, text).then((r) => this.hooks.emitTrace(r.success
814
+ ? { type: 'tune-out:announced', ...base }
815
+ : { type: 'tune-out:announce-failed', ...base, error: r.error ?? 'publish failed' }), (err) => this.hooks.emitTrace({ type: 'tune-out:announce-failed', ...base, error: err.message }));
816
+ }
817
+ /** The scope's end time and cadence, on timers no longer than one holds
818
+ * (deadline-timer), as a channel's tune-out arms its own. */
819
+ armScopeTimers(params) {
820
+ const key = TuneOutCoordinator.SCOPE_KEY;
821
+ const expiry = this.expiryTimers.get(key);
822
+ if (expiry)
823
+ clearTimeout(expiry);
824
+ // As a channel's timers: the next turn is armed before this one's work,
825
+ // and a throw is reported (timerFailed) rather than left to end the host.
826
+ const { serverId: exceptServer, channelId: exceptChannel } = params.except;
827
+ const holdExpiry = (timer) => { this.expiryTimers.set(key, timer); };
828
+ const end = () => {
829
+ try {
830
+ holdExpiry(setTimeout(end, EXPIRY_RETRY_MS));
831
+ if (!this.cancelAllExcept('duration', 'duration elapsed').ok)
832
+ this.clearScopeTimers();
833
+ }
834
+ catch (err) {
835
+ this.timerFailed('expiry', exceptServer, exceptChannel, err);
836
+ }
837
+ };
838
+ armAt(params.expiresAtMs, holdExpiry, end);
839
+ if (!this.readerAvailable())
840
+ return;
841
+ const cadence = this.cadenceTimers.get(key);
842
+ if (cadence)
843
+ clearTimeout(cadence);
844
+ const hold = (timer) => { this.cadenceTimers.set(key, timer); };
845
+ const tick = () => {
846
+ try {
847
+ const scope = this.getAllExcept();
848
+ if (!scope)
849
+ return;
850
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
851
+ // One look per held channel with new traffic since the last look.
852
+ for (const [channelKey, diverted] of this.divertedSinceInvocation) {
853
+ if (diverted <= 0)
854
+ continue;
855
+ const [serverId, channelId] = channelKey.split('\0');
856
+ if (this.channelRegistry.getTuneOutState(serverId, channelId))
857
+ continue; // a per-channel epoch's own cadence
858
+ this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
859
+ }
860
+ }
861
+ catch (err) {
862
+ this.timerFailed('cadence', exceptServer, exceptChannel, err);
863
+ }
864
+ };
865
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
866
+ }
867
+ clearScopeTimers() {
868
+ const key = TuneOutCoordinator.SCOPE_KEY;
869
+ const cadence = this.cadenceTimers.get(key);
870
+ if (cadence)
871
+ clearTimeout(cadence);
872
+ this.cadenceTimers.delete(key);
873
+ const expiry = this.expiryTimers.get(key);
874
+ if (expiry)
875
+ clearTimeout(expiry);
876
+ this.expiryTimers.delete(key);
877
+ }
878
+ /** Channels held by the scope (not by their own epoch) with the count held under it. */
879
+ scopeHeldCounts(epochId) {
880
+ const counts = new Map();
881
+ const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])];
882
+ for (const m of all) {
883
+ const meta = m.metadata;
884
+ if (meta?.tuneOut?.epochId !== epochId)
885
+ continue;
886
+ const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)';
887
+ const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
888
+ const k = this.key(serverId, channelId);
889
+ const entry = counts.get(k) ?? { serverId, channelId, count: 0 };
890
+ entry.count += 1;
891
+ counts.set(k, entry);
892
+ }
893
+ return counts;
894
+ }
895
+ cancelAllExcept(source, reason, note, noteAuthor) {
896
+ const scope = this.getAllExcept();
897
+ if (!scope)
898
+ return { ok: false, error: 'no all-except hold is active' };
899
+ const { epochId, except, backlogCap } = scope.params;
900
+ const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
901
+ const storedRows = this.hooks.getStoredMessages();
902
+ const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
903
+ const all = [...storedRows.filter(ofEpoch), ...queuedRows.filter(ofEpoch)];
904
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
905
+ const releasedEarlier = keyedBy(earlier);
906
+ const fresh = all.filter((m) => !releasedEarlier(m));
907
+ const byChannel = new Map();
908
+ for (const m of fresh) {
909
+ const meta = m.metadata;
910
+ const serverId = meta?.serverId ?? '';
911
+ const channelId = meta?.channelId ?? '(unknown channel)';
912
+ const k = this.key(serverId, channelId);
913
+ const entry = byChannel.get(k) ?? { serverId, channelId, backlog: [] };
914
+ entry.backlog.push(m);
915
+ byChannel.set(k, entry);
916
+ }
917
+ const historyTool = this.hooks.historyTool?.() ?? null;
918
+ let truncatedInAll = 0;
919
+ const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
920
+ // Floored here too: a hold stored before entry floored its cap can hold
921
+ // a fraction.
922
+ const cap = Math.floor(backlogCap);
923
+ const shown = cap > 0 ? backlog.slice(-cap) : [];
924
+ const truncated = backlog.length - shown.length;
925
+ truncatedInAll += truncated;
926
+ const attrs = [
927
+ `channel=${quotedValue(this.shown(serverId, channelId))}`,
928
+ `messages=${backlog.length}`,
929
+ ...(truncated > 0 ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] : []),
930
+ ];
931
+ return `<tuned-out-backlog ${attrs.join(' ')}>\n${shown.map(renderHeldLine).join('\n')}\n</tuned-out-backlog>`;
932
+ });
933
+ const summary = byChannel.size === 0
934
+ ? 'Nothing was held.'
935
+ : `${fresh.length} message${fresh.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
936
+ if (earlier.length === 0 || fresh.length > 0) {
937
+ const record = { ...keysOf(fresh), total: fresh.length, truncated: truncatedInAll, report: { ids: [], writeIds: [] } };
938
+ this.hooks.addMessage('user', [{ 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') : ''}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record });
939
+ }
940
+ if (this.hooks.commitDeliveries?.() === false) {
941
+ throw new Error('the all-except release could not be made durable, so the hold stays in force');
942
+ }
943
+ this.writeScope(null);
944
+ this.clearScopeTimers();
945
+ for (const [key, pending] of this.pendingWakes) {
946
+ const [serverId, channelId] = key.split('\0');
947
+ if (this.channelRegistry.getTuneOutState(serverId, channelId))
948
+ continue;
949
+ clearTimeout(pending.timer);
950
+ this.pendingWakes.delete(key);
951
+ }
952
+ const subName = this.hooks.subconsciousName();
953
+ if (note && subName) {
954
+ this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
955
+ }
956
+ else if (note && noteAuthor && this.hooks.deliverFromReader) {
957
+ this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
958
+ }
959
+ const primary = this.hooks.primaryName();
960
+ if (primary)
961
+ this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
962
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`;
963
+ if (source !== 'subconscious' && subName) {
964
+ this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName);
965
+ this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out');
966
+ }
967
+ else if (!subName && this.forkReader) {
968
+ // One last look per channel with messages no fork was handed, stored or
969
+ // still queued behind the resident's turn. That holds for a reader's
970
+ // own release too: the reader that released read one channel's
971
+ // messages, and every other channel's would otherwise reach no reader.
972
+ // A channel is its server and id here, as the readers' bookkeeping is.
973
+ const channels = new Map();
974
+ for (const m of all) {
975
+ const meta = m.metadata;
976
+ const ref = { serverId: meta?.serverId ?? '', channelId: meta?.channelId ?? '(unknown channel)' };
977
+ channels.set(this.key(ref.serverId, ref.channelId), ref);
978
+ }
979
+ for (const { serverId, channelId } of channels.values()) {
980
+ const ofChannel = inChannelOf(serverId, channelId);
981
+ const inChannel = (m) => ofEpoch(m) && ofChannel(m);
982
+ const stored = this.hooks.getStoredMessages().filter(inChannel);
983
+ const queued = this.queuedHeld(inChannel);
984
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
985
+ if (rest.backlog.length + rest.queued.length > 0) {
986
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued);
987
+ }
988
+ }
989
+ }
990
+ for (const key of this.divertedSinceInvocation.keys()) {
991
+ const [serverId, channelId] = key.split('\0');
992
+ if (!this.channelRegistry.getTuneOutState(serverId, channelId))
993
+ this.divertedSinceInvocation.delete(key);
994
+ }
995
+ this.dropHandOffs(epochId);
996
+ this.dropLooks(epochId);
997
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount });
998
+ this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`);
999
+ return { ok: true };
1000
+ }
1001
+ /** What is held right now — per-channel epochs and the scope — without releasing. */
1002
+ check(opts = {}) {
1003
+ const stored = this.hooks.getStoredMessages();
1004
+ const deferred = this.hooks.getDeferredMessages?.() ?? [];
1005
+ const countOf = (epochId) => [...stored, ...deferred].filter((m) => m.metadata?.tuneOut?.epochId === epochId).length;
1006
+ const channels = this.listActive().map(({ serverId, channelId, params }) => ({
1007
+ serverId, channelId, epochId: params.epochId, held: countOf(params.epochId),
1008
+ ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}),
1009
+ }));
1010
+ const scope = this.getAllExcept();
1011
+ let allExcept = null;
1012
+ if (scope) {
1013
+ const held = {};
1014
+ for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values())
1015
+ held[this.shown(serverId, channelId)] = count;
1016
+ allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held };
1017
+ }
1018
+ const result = { allExcept, channels };
1019
+ if (opts.peek) {
1020
+ const limit = Math.max(1, Math.min(50, opts.peekLimit ?? 5));
1021
+ // Only what is held NOW: the channel's own epoch, or the scope's. A
1022
+ // released epoch's messages are delivered, not held; showing them here
1023
+ // would be a false declaration (first live run, 2026-10-12, finding B).
1024
+ const active = new Set();
1025
+ for (const e of channels) {
1026
+ if (e.channelId === opts.peek.channelId && (!opts.peek.serverId || e.serverId === opts.peek.serverId))
1027
+ active.add(e.epochId);
1028
+ }
1029
+ if (scope)
1030
+ active.add(scope.params.epochId);
1031
+ const lines = [...stored, ...deferred]
1032
+ .filter((m) => {
1033
+ const meta = m.metadata;
1034
+ return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId)
1035
+ && meta.channelId === opts.peek.channelId
1036
+ && (!opts.peek.serverId || meta.serverId === opts.peek.serverId);
1037
+ })
1038
+ .slice(-limit)
1039
+ .map(renderHeldLine);
1040
+ result.peek = lines;
1041
+ }
1042
+ return result;
1043
+ }
1044
+ /**
1045
+ * Bookkeeping for one held message, after admission decided the hold:
1046
+ * the cadence tally, the deterministic acknowledgement of a suppressed
1047
+ * mention, and the reader wake when the resident's gate would have woken
1048
+ * them (`gatePass` is that verdict, probed without side effects).
1049
+ */
1050
+ recordHeld(serverId, channelId, messageId, tags, authorId, gatePass = true) {
421
1051
  const key = this.key(serverId, channelId);
422
1052
  this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
423
1053
  // Wake classification: the resident's wake gate composes as a
@@ -437,10 +1067,17 @@ export class TuneOutCoordinator {
437
1067
  // standing mute to its subject.
438
1068
  void this.acknowledgeSuppressed(serverId, channelId, messageId);
439
1069
  }
440
- if (gatePass && (addressed || privileged)) {
1070
+ if (gatePass && (addressed || privileged) && this.readerAvailable()) {
441
1071
  this.scheduleWakeInvocation(serverId, channelId);
442
1072
  }
443
- return { epochId: state.params.epochId };
1073
+ }
1074
+ /** Reader forks that reported through `deliver_summary` (so their trailing prose is not a second report). */
1075
+ readerReports = new Set();
1076
+ readerReported(reader) {
1077
+ return this.readerReports.has(reader);
1078
+ }
1079
+ forgetReader(reader) {
1080
+ this.readerReports.delete(reader);
444
1081
  }
445
1082
  async acknowledgeSuppressed(serverId, channelId, messageId) {
446
1083
  try {
@@ -522,15 +1159,7 @@ export class TuneOutCoordinator {
522
1159
  try {
523
1160
  const entry = this.pendingWakes.get(key);
524
1161
  this.pendingWakes.delete(key);
525
- // Count ONE durable wake per coalesced invocation.
526
- const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
527
- if (!counted)
528
- return; // cancelled inside the window
529
- if (counted.wakeCount > counted.params.maxWakes) {
530
- this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${counted.params.maxWakes})`);
531
- return;
532
- }
533
- this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
1162
+ this.fireWake(serverId, channelId, entry?.count ?? 1);
534
1163
  }
535
1164
  catch (err) {
536
1165
  this.timerFailed('wake', serverId, channelId, err);
@@ -538,6 +1167,65 @@ export class TuneOutCoordinator {
538
1167
  }, WAKE_COALESCE_MS);
539
1168
  this.pendingWakes.set(key, { count: 1, timer });
540
1169
  }
1170
+ cancelPastBudget(serverId, channelId, maxWakes, from = 'wake') {
1171
+ const key = this.key(serverId, channelId);
1172
+ const existing = this.expiryTimers.get(key);
1173
+ if (existing)
1174
+ clearTimeout(existing);
1175
+ const end = (first) => {
1176
+ try {
1177
+ this.expiryTimers.set(key, setTimeout(() => end(false), EXPIRY_RETRY_MS));
1178
+ if (!this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${maxWakes})`).ok) {
1179
+ this.clearTimers(serverId, channelId);
1180
+ }
1181
+ }
1182
+ catch (err) {
1183
+ if (first && from === 'start') {
1184
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1185
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
1186
+ }
1187
+ else {
1188
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start and its cancel ended it, but a step after the end failed:`, err);
1189
+ }
1190
+ }
1191
+ else {
1192
+ this.timerFailed('wake', serverId, channelId, err);
1193
+ }
1194
+ }
1195
+ };
1196
+ end(true);
1197
+ }
1198
+ /** One coalesced wake: counted durably against the epoch that holds the channel. */
1199
+ fireWake(serverId, channelId, count) {
1200
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1201
+ // Count ONE durable wake per coalesced invocation.
1202
+ const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
1203
+ if (!counted)
1204
+ return; // cancelled inside the window
1205
+ if (counted.wakeCount > counted.params.maxWakes) {
1206
+ this.cancelPastBudget(serverId, channelId, counted.params.maxWakes);
1207
+ return;
1208
+ }
1209
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1210
+ return;
1211
+ }
1212
+ const scope = this.getAllExcept();
1213
+ if (!scope)
1214
+ return; // released inside the window
1215
+ // The all-except scope: exhausting the budget pauses the reader, it does
1216
+ // not end the hold (#232 §3.2 — the hold is the resident's bounded
1217
+ // commitment; the deadline ends it).
1218
+ const wakeCount = scope.wakeCount + 1;
1219
+ this.writeScope({ ...scope, wakeCount });
1220
+ if (wakeCount > scope.params.maxWakes) {
1221
+ this.hooks.emitTrace({
1222
+ type: 'tune-out:wake-budget-paused', epochId: scope.params.epochId, scope: 'all-except',
1223
+ serverId, channelId, wakeCount, maxWakes: scope.params.maxWakes,
1224
+ });
1225
+ return;
1226
+ }
1227
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1228
+ }
541
1229
  /**
542
1230
  * One subconscious turn: a system-framed trigger notice (bracket style —
543
1231
  * what happened, never what to say) plus standing dispositions, then an
@@ -545,18 +1233,314 @@ export class TuneOutCoordinator {
545
1233
  */
546
1234
  invokeSubconscious(serverId, channelId, trigger, count) {
547
1235
  const subName = this.hooks.subconsciousName();
548
- if (!subName)
1236
+ if (!subName && !this.forkReader)
549
1237
  return;
550
1238
  const key = this.key(serverId, channelId);
551
1239
  const shown = this.shown(serverId, channelId);
552
1240
  const notice = trigger === 'wake'
553
1241
  ? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
554
1242
  : `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
1243
+ if (!subName) {
1244
+ const hold = this.holdFor(serverId, channelId);
1245
+ if (!hold)
1246
+ return;
1247
+ const ofChannel = inChannelOf(serverId, channelId);
1248
+ const inChannel = (m) => m.metadata?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1249
+ // Stored and still-queued alike: while the resident's turn runs, what
1250
+ // arrives waits in the deferred queue, and that is the traffic a reader
1251
+ // is there for.
1252
+ this.invokeReaderFork(serverId, channelId, hold.epochId, trigger, notice, this.hooks.getStoredMessages().filter(inChannel), this.queuedHeld(inChannel));
1253
+ // The count restarts once the fork has been handed what was held, as it
1254
+ // does below once the subconscious's turn has been asked for. Otherwise
1255
+ // every later cadence period still sees this one's count and derives a
1256
+ // fork with nothing new to read.
1257
+ this.divertedSinceInvocation.set(key, 0);
1258
+ return;
1259
+ }
555
1260
  this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: `tune-out-${trigger}`, channelId }, subName);
556
1261
  this.hooks.requestInference(subName, `tune-out ${trigger}: ${shown}`, 'tune-out');
557
1262
  // Counted from here only once the turn has been asked for, so a turn that
558
1263
  // throws leaves its messages for the next period's turn.
559
1264
  this.divertedSinceInvocation.set(key, 0);
1265
+ const epochId = this.holdFor(serverId, channelId)?.epochId;
1266
+ if (epochId)
1267
+ this.markLook(epochId, serverId, channelId);
1268
+ }
1269
+ /**
1270
+ * Count again, from the held messages, stored or still queued behind the
1271
+ * resident's turn, those the reader of the epoch holding this channel now
1272
+ * hasn't looked at: for reader forks, those no fork was handed; for the
1273
+ * persistent subconscious, the stored ones held since its last look
1274
+ * (lastLooks) and every queued one, which no view of it can show yet.
1275
+ * Nothing holding the channel leaves no count.
1276
+ */
1277
+ rebuildTally(serverId, channelId) {
1278
+ const key = this.key(serverId, channelId);
1279
+ const hold = this.holdFor(serverId, channelId);
1280
+ if (!hold || !this.readerAvailable()) {
1281
+ this.divertedSinceInvocation.delete(key);
1282
+ return;
1283
+ }
1284
+ const ofChannel = inChannelOf(serverId, channelId);
1285
+ const inEpoch = (m) => m.metadata?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1286
+ const rows = this.hooks.getStoredMessages().filter(inEpoch);
1287
+ const queued = this.queuedHeld(inEpoch);
1288
+ let count;
1289
+ if (this.forkReader) {
1290
+ const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued);
1291
+ count = unread.backlog.length + unread.queued.length;
1292
+ }
1293
+ else {
1294
+ const mark = this.lastLooks()?.[hold.epochId]?.[key];
1295
+ count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length;
1296
+ }
1297
+ this.divertedSinceInvocation.set(key, count);
1298
+ }
1299
+ get lastLooksStateId() {
1300
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/last-looks`;
1301
+ }
1302
+ /**
1303
+ * The stored last looks. Where they can't be read, none: every held row
1304
+ * counts, and nothing is cached, so the next read tries again. Nor is
1305
+ * anything written back over the stored record then (markLook, dropLooks,
1306
+ * pruneLooks), which would erase the other epochs' and channels' looks.
1307
+ */
1308
+ lastLooks() {
1309
+ if (this.lastLooksCache !== undefined)
1310
+ return this.lastLooksCache;
1311
+ let raw;
1312
+ try {
1313
+ raw = this.channelRegistry.readCoordinatorState(this.lastLooksStateId);
1314
+ }
1315
+ catch (error) {
1316
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be read:`, error);
1317
+ return undefined;
1318
+ }
1319
+ const looks = {};
1320
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1321
+ for (const [epochId, channels] of Object.entries(raw)) {
1322
+ if (!channels || typeof channels !== 'object' || Array.isArray(channels))
1323
+ continue;
1324
+ const marks = Object.entries(channels).filter((e) => typeof e[1] === 'number');
1325
+ if (marks.length > 0)
1326
+ looks[epochId] = Object.fromEntries(marks);
1327
+ }
1328
+ }
1329
+ this.lastLooksCache = looks;
1330
+ return looks;
1331
+ }
1332
+ writeLooks(looks) {
1333
+ this.lastLooksCache = looks;
1334
+ try {
1335
+ this.channelRegistry.writeCoordinatorState(this.lastLooksStateId, looks);
1336
+ }
1337
+ catch (error) {
1338
+ // The look itself stands. After a restart, this look's messages would
1339
+ // count as unseen at the next one.
1340
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be stored:`, error);
1341
+ }
1342
+ }
1343
+ markLook(epochId, serverId, channelId) {
1344
+ const looks = this.lastLooks();
1345
+ if (!looks)
1346
+ return;
1347
+ this.writeLooks({ ...looks, [epochId]: { ...looks[epochId], [this.key(serverId, channelId)]: this.hooks.currentSequence() } });
1348
+ }
1349
+ /** An ended epoch's last looks go with it. */
1350
+ dropLooks(epochId) {
1351
+ if (this.hooks.subconsciousName() == null)
1352
+ return;
1353
+ const looks = this.lastLooks();
1354
+ if (!looks || !(epochId in looks))
1355
+ return;
1356
+ const { [epochId]: _ended, ...rest } = looks;
1357
+ this.writeLooks(rest);
1358
+ }
1359
+ /** At boot: the last looks of epochs no longer active go. */
1360
+ pruneLooks() {
1361
+ if (this.hooks.subconsciousName() == null)
1362
+ return;
1363
+ const looks = this.lastLooks();
1364
+ if (!looks)
1365
+ return;
1366
+ const active = new Set(this.listActive().map((e) => e.params.epochId));
1367
+ const scope = this.getAllExcept();
1368
+ if (scope)
1369
+ active.add(scope.params.epochId);
1370
+ const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
1371
+ if (Object.keys(kept).length !== Object.keys(looks).length)
1372
+ this.writeLooks(kept);
1373
+ }
1374
+ /**
1375
+ * `reader: 'forks'`: hand the framework one invocation. The backlog is
1376
+ * the epoch's held messages not yet handed to any fork — each is
1377
+ * delivered to exactly one reader — stored ones first, then those still
1378
+ * queued behind the resident's turn, plus the standing dispositions. A
1379
+ * cadence look with nothing new is skipped. True when a fork was asked for.
1380
+ */
1381
+ invokeReaderFork(serverId, channelId, epochId, trigger, notice, epochBacklog, epochQueued = []) {
1382
+ if (!this.hooks.invokeReader)
1383
+ return false;
1384
+ const key = this.handOffKey(epochId, serverId, channelId);
1385
+ const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued);
1386
+ if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0)
1387
+ return false;
1388
+ // The cursor only moves forward: a returned row handed again can be
1389
+ // behind it.
1390
+ const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
1391
+ if (backlog.length > 0)
1392
+ this.readerCursor.set(key, cursor);
1393
+ const handed = this.readerHandedWrites.get(key) ?? new Set();
1394
+ for (const m of queued)
1395
+ handed.add(m.writeId);
1396
+ // A handed write whose row has landed behind the cursor can no longer be
1397
+ // handed by sequence, so its id has done its work.
1398
+ for (const m of epochBacklog) {
1399
+ const writeId = writeIdOf(m);
1400
+ if (writeId !== undefined && m.sequence <= cursor)
1401
+ handed.delete(writeId);
1402
+ }
1403
+ if (handed.size > 0)
1404
+ this.readerHandedWrites.set(key, handed);
1405
+ else
1406
+ this.readerHandedWrites.delete(key);
1407
+ const returned = this.readerReturned.get(key);
1408
+ if (returned) {
1409
+ for (const m of backlog) {
1410
+ returned.ids.delete(m.id);
1411
+ const writeId = writeIdOf(m);
1412
+ if (writeId !== undefined)
1413
+ returned.writeIds.delete(writeId);
1414
+ }
1415
+ for (const m of queued)
1416
+ returned.writeIds.delete(m.writeId);
1417
+ if (returned.ids.size === 0 && returned.writeIds.size === 0)
1418
+ this.readerReturned.delete(key);
1419
+ }
1420
+ const entries = Object.entries(this.readDispositions());
1421
+ this.hooks.invokeReader({
1422
+ serverId,
1423
+ channelId,
1424
+ epochId,
1425
+ trigger,
1426
+ notice,
1427
+ backlog,
1428
+ queued,
1429
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1430
+ });
1431
+ return true;
1432
+ }
1433
+ /**
1434
+ * What no reader fork has read yet, of the given held messages: the stored
1435
+ * ones past the epoch's cursor for the channel, less those a fork was handed while they
1436
+ * were still queued, plus those a failed fork returned, oldest first; then
1437
+ * the queued ones no fork was handed, or a failed fork returned.
1438
+ */
1439
+ unhandedBacklog(key, stored, queued) {
1440
+ const cursor = this.readerCursor.get(key) ?? -1;
1441
+ const handed = this.readerHandedWrites.get(key);
1442
+ const returned = this.readerReturned.get(key);
1443
+ const wasReturned = (id, writeId) => returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId)));
1444
+ const unread = (m) => {
1445
+ const writeId = writeIdOf(m);
1446
+ if (wasReturned(m.id, writeId))
1447
+ return true;
1448
+ return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId));
1449
+ };
1450
+ return {
1451
+ backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence),
1452
+ queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)),
1453
+ };
1454
+ }
1455
+ /**
1456
+ * A reader fork that never read what it was handed: its derive failed, or
1457
+ * its run threw before it reported. While the epoch that held the
1458
+ * messages is still active, they go back to that epoch's backlog for the
1459
+ * channel, and its next invocation for the channel is handed them: the
1460
+ * scope's while the scope is active, though the channel's own tune-out
1461
+ * has taken the channel meanwhile, and a channel's own tune-out's while it
1462
+ * holds the channel. Once the epoch has ended, its cancel's last fork was
1463
+ * handed only what no fork had been, so a fork still running then is the
1464
+ * only reader its messages had: one late fork is handed them instead
1465
+ * (lateFork). Returns how many went back or to the late fork.
1466
+ */
1467
+ readerFailed(invocation) {
1468
+ const { serverId, channelId, epochId } = invocation;
1469
+ const active = this.getAllExcept()?.params.epochId === epochId
1470
+ || this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
1471
+ if (!active)
1472
+ return this.lateFork(invocation);
1473
+ const key = this.handOffKey(epochId, serverId, channelId);
1474
+ const returned = this.readerReturned.get(key) ?? { ids: new Set(), writeIds: new Set() };
1475
+ for (const m of invocation.backlog)
1476
+ returned.ids.add(m.id);
1477
+ for (const m of invocation.queued)
1478
+ returned.writeIds.add(m.writeId);
1479
+ const count = invocation.backlog.length + invocation.queued.length;
1480
+ if (count > 0) {
1481
+ this.readerReturned.set(key, returned);
1482
+ // They count again toward the next cadence look of the epoch holding
1483
+ // the channel, which hands them back. Where another epoch holds it
1484
+ // now, they're counted again when the channel comes back to theirs
1485
+ // (rebuildTally), and its release hands them in any case.
1486
+ const tally = this.key(serverId, channelId);
1487
+ if (this.holdFor(serverId, channelId)?.epochId === epochId) {
1488
+ this.divertedSinceInvocation.set(tally, (this.divertedSinceInvocation.get(tally) ?? 0) + count);
1489
+ }
1490
+ }
1491
+ return count;
1492
+ }
1493
+ /**
1494
+ * A fork that failed after its epoch ended: one late fork is handed what it
1495
+ * was, with a notice saying a reader handed them failed after the tune-out
1496
+ * ended. Those still queued when it was handed that have landed since are
1497
+ * handed as stored. A late fork's own failure derives no other, so a
1498
+ * reader that keeps failing costs one more fork, not a chain of them.
1499
+ * Returns how many the late fork was handed.
1500
+ */
1501
+ lateFork(invocation) {
1502
+ if (!this.hooks.invokeReader || this.lateForks.has(invocation))
1503
+ return 0;
1504
+ const { serverId, channelId, epochId } = invocation;
1505
+ const landed = new Map();
1506
+ for (const m of this.hooks.getStoredMessages()) {
1507
+ const writeId = writeIdOf(m);
1508
+ if (writeId !== undefined)
1509
+ landed.set(writeId, m);
1510
+ }
1511
+ const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])]
1512
+ .sort((a, b) => a.sequence - b.sequence);
1513
+ const queued = invocation.queued.filter((m) => !landed.has(m.writeId));
1514
+ const count = backlog.length + queued.length;
1515
+ if (count === 0)
1516
+ return 0;
1517
+ const these = count === 1 ? 'This message was' : `These ${count} messages were`;
1518
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${these} handed to a reader that `
1519
+ + `failed after the tune-out ended, so no reader has read ${count === 1 ? 'it' : 'them'} yet.]`;
1520
+ const entries = Object.entries(this.readDispositions());
1521
+ const late = {
1522
+ serverId,
1523
+ channelId,
1524
+ epochId,
1525
+ trigger: 'cancel',
1526
+ notice,
1527
+ backlog,
1528
+ queued,
1529
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1530
+ };
1531
+ this.lateForks.add(late);
1532
+ this.hooks.invokeReader(late);
1533
+ return count;
1534
+ }
1535
+ /**
1536
+ * The held messages still queued behind the resident's turn that `keep`
1537
+ * keeps, oldest first, with their write ids. One the host gives no id for
1538
+ * is left to reach a reader once it is stored: without the id, its stored
1539
+ * row couldn't be told from a message no reader has had.
1540
+ */
1541
+ queuedHeld(keep) {
1542
+ return (this.hooks.getDeferredMessages?.() ?? [])
1543
+ .filter((m) => typeof m.writeId === 'string' && keep(m));
560
1544
  }
561
1545
  // ==========================================================================
562
1546
  // Dispositions (standing notes, snapshot slot)
@@ -627,7 +1611,12 @@ export class TuneOutCoordinator {
627
1611
  // ==========================================================================
628
1612
  async handleSubconsciousTool(name, input,
629
1613
  /** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */
630
- own = []) {
1614
+ own = [],
1615
+ /** The calling agent: the persistent subconscious, or a reader fork. */
1616
+ caller,
1617
+ /** A reader fork, with the epoch it was derived to read, if any: it ends and posts only through that epoch's
1618
+ * hold (forkRefusal). The persistent subconscious, which reads every epoch, passes none. */
1619
+ fork) {
631
1620
  switch (name) {
632
1621
  case 'deliver_summary': {
633
1622
  const text = String(input.text ?? '').trim();
@@ -641,20 +1630,22 @@ export class TuneOutCoordinator {
641
1630
  };
642
1631
  }
643
1632
  const subName = this.hooks.subconsciousName();
644
- if (!subName)
1633
+ // Verbatim, under its own participant name, into the resident's window
1634
+ // (or, from a reader fork, as its attributed mail), tagged with the
1635
+ // channel the name means and its server, since the framework reads a
1636
+ // conversational row's channel (repinChannel). The name means what it
1637
+ // means to every channel tool: inside the calling turn, the id of one
1638
+ // of the turn's own channels means that channel, refused where its
1639
+ // server no longer registers it or the turn has channels with that id
1640
+ // on two servers (ownChannelFor); otherwise a name several registered
1641
+ // channels answer to is refused with them (sharedNameRefusalFor). So
1642
+ // no row is left for a re-pin to read as another server's channel. A
1643
+ // name no registered channel answers to is kept as given, with no
1644
+ // server, which a re-pin reads by name when it weighs the row, as it
1645
+ // reads any message without its server.
1646
+ if (!subName && !(caller && this.hooks.deliverFromReader)) {
645
1647
  return { success: false, error: 'subconscious not configured', isError: true };
646
- // Verbatim, under its own participant name, into the resident's window,
647
- // tagged with the channel the name means and its server, since the
648
- // framework reads a conversational row's channel (repinChannel). The
649
- // name means what it means to every channel tool: inside the calling
650
- // turn, the id of one of the turn's own channels means that channel,
651
- // refused where its server no longer registers it or the turn has
652
- // channels with that id on two servers (ownChannelFor); otherwise a
653
- // name several registered channels answer to is refused with them
654
- // (sharedNameRefusalFor). So no row is left for a re-pin to read as
655
- // another server's channel. A name no registered channel answers to
656
- // is kept as given, with no server, which a re-pin reads by name when
657
- // it weighs the row, as it reads any message without its server.
1648
+ }
658
1649
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver');
659
1650
  if (mine && 'error' in mine)
660
1651
  return { success: false, error: mine.error, isError: true };
@@ -662,33 +1653,87 @@ export class TuneOutCoordinator {
662
1653
  if (shared)
663
1654
  return { success: false, error: shared, isError: true };
664
1655
  const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
665
- this.hooks.addMessage(subName, [{ type: 'text', text }], {
1656
+ const metadata = {
666
1657
  kind: 'subconscious-summary',
667
1658
  ...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }),
668
- });
1659
+ };
1660
+ if (subName) {
1661
+ this.hooks.addMessage(subName, [{ type: 'text', text }], metadata);
1662
+ return { success: true, data: { delivered: true } };
1663
+ }
1664
+ // A reader fork's report: attributed mail under its own name. The fork
1665
+ // counts as having reported only once the mail is held: a delivery
1666
+ // that throws is an error result to the fork, and its prose is then
1667
+ // still delivered as its report when it ends (#546, probe R14).
1668
+ this.hooks.deliverFromReader(caller, [{ type: 'text', text }], metadata);
1669
+ this.readerReports.add(caller);
669
1670
  return { success: true, data: { delivered: true } };
670
1671
  }
671
1672
  case 'cancel_tuneout': {
672
1673
  const name = String(input.channelId ?? '');
1674
+ const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
1675
+ const endScope = () => {
1676
+ const scope = this.getAllExcept();
1677
+ const refused = fork && scope
1678
+ ? this.forkRefusal(fork, { epochId: scope.params.epochId }, 'cancel', "the all-except hold isn't the hold you were started to read")
1679
+ : undefined;
1680
+ if (refused)
1681
+ return { success: false, error: refused, isError: true };
1682
+ const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller);
1683
+ return result.ok
1684
+ ? { success: true, data: { cancelled: true, scope: 'all-except' } }
1685
+ : { success: false, error: result.error, isError: true };
1686
+ };
1687
+ // A call naming the all-except scope ends that hold outright, as the
1688
+ // tool describes it and as the resident's tune_out cancel does: the
1689
+ // channel it names, tuned out on its own or not, doesn't turn it
1690
+ // into that channel's cancel, and with no hold it ends nothing.
1691
+ if (input.scope === 'all-except')
1692
+ return endScope();
1693
+ const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
673
1694
  // The name means what it means to every channel tool, the
674
- // resident's tune_out included: inside the calling turn, the id of
675
- // one of the turn's own channels means that channel (ownChannelFor);
676
- // otherwise a name several registered channels answer to is refused
1695
+ // resident's tune_out included: with a serverId, that server's
1696
+ // channel with the id; inside the calling turn, the id of one of the
1697
+ // turn's own channels means that channel (ownChannelFor); otherwise a
1698
+ // name several registered channels answer to picks none of them
677
1699
  // (channelsNamed), whichever of them is tuned out, never taken to be
678
- // the tuned-out one.
679
- const mine = this.channelRegistry.ownChannelFor(name, own);
680
- if (mine && 'error' in mine)
681
- return { success: false, error: mine.error, isError: true };
682
- const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
683
- const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
684
- if (shared)
685
- return { success: false, error: shared, isError: true };
686
- const channel = named[0];
1700
+ // the tuned-out one. A name that picks no channel, such a name or one
1701
+ // of the turn's own channels that ownChannelFor refuses, is refused,
1702
+ // unless the call names the all-except scope, which ends that hold
1703
+ // below.
1704
+ const mine = serverId ? undefined : this.channelRegistry.ownChannelFor(name, own);
1705
+ const refusal = mine && 'error' in mine ? mine.error
1706
+ : serverId || mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
1707
+ const named = serverId ? [{ serverId, channelId: name }]
1708
+ : mine && 'channel' in mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
1709
+ const channel = refusal ? undefined : named[0];
687
1710
  if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) {
688
- return { success: false, error: this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
1711
+ // The reader keeps its latitude over the all-except hold too: a
1712
+ // channel it holds (or the scope named as the channel) ends the
1713
+ // scope. A call naming the scope "channel" ends only a channel's
1714
+ // own tune-out, so on a channel only the scope holds it ends
1715
+ // nothing.
1716
+ const scope = this.getAllExcept();
1717
+ const heldByScope = scope && (name === 'all-except'
1718
+ || (channel !== undefined && this.holdFor(channel.serverId, channel.channelId)?.epochId === scope.params.epochId));
1719
+ if (!heldByScope)
1720
+ return { success: false, error: refusal ?? this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
1721
+ if (input.scope === 'channel') {
1722
+ const shown = channel ? this.quotedShown(channel.serverId, channel.channelId) : JSON.stringify(name);
1723
+ return {
1724
+ success: false,
1725
+ error: `Nothing was cancelled: ${shown} isn't tuned out on its own, and scope "channel" ends only a channel's own `
1726
+ + 'tune-out. The all-except hold holds it, and calling cancel_tuneout with scope "all-except" ends that hold.',
1727
+ isError: true,
1728
+ };
1729
+ }
1730
+ return endScope();
689
1731
  }
690
- const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
691
- const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note);
1732
+ const held = this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId);
1733
+ const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel', `the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`);
1734
+ if (refused)
1735
+ return { success: false, error: refused, isError: true };
1736
+ const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller);
692
1737
  return result.ok
693
1738
  ? { success: true, data: { cancelled: true } }
694
1739
  : { success: false, error: result.error, isError: true };
@@ -744,12 +1789,42 @@ export class TuneOutCoordinator {
744
1789
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish');
745
1790
  if (mine && 'error' in mine)
746
1791
  return { success: false, error: mine.error, isError: true };
747
- return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
1792
+ if (fork) {
1793
+ const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
1794
+ const at = named.length === 1 ? named[0] : undefined;
1795
+ const hold = at ? this.holdFor(at.serverId, at.channelId) : null;
1796
+ const shown = at ? this.quotedShown(at.serverId, at.channelId) : JSON.stringify(channelId);
1797
+ const refused = (at || fork.epochId === undefined) && this.forkRefusal(fork, hold, 'speech', hold
1798
+ ? `${shown} is held by a hold other than the one you were started to read`
1799
+ : `${shown} isn't held now`);
1800
+ if (refused)
1801
+ return { success: false, error: refused, isError: true };
1802
+ }
1803
+ return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious');
748
1804
  }
749
1805
  default:
750
1806
  return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
751
1807
  }
752
1808
  }
1809
+ /**
1810
+ * Why a reader fork may not act through `hold` (the epoch holding what its
1811
+ * call names, or null): a fork ends and posts only through the hold of the
1812
+ * epoch it was derived to read, and one derived for none acts through no
1813
+ * hold. Undefined where the hold is its own. `why` says, for the fork, how
1814
+ * what its call names isn't its own hold; its reports stay open to it.
1815
+ */
1816
+ forkRefusal(fork, hold, act, why) {
1817
+ if (fork.epochId !== undefined && hold?.epochId === fork.epochId)
1818
+ return undefined;
1819
+ const nothing = act === 'cancel' ? 'Nothing was cancelled' : 'Nothing was posted';
1820
+ const report = `Your report can say what you would ${act === 'cancel' ? 'judge of it' : 'have posted'}, and the resident reads it.`;
1821
+ if (fork.epochId === undefined) {
1822
+ return `${nothing}: you weren't started to read a tune-out, so there's no hold you can `
1823
+ + `${act === 'cancel' ? 'end' : 'post through'}. ${report}`;
1824
+ }
1825
+ const rule = act === 'cancel' ? 'a reader ends only its own' : 'a reader posts only into a channel its own hold holds';
1826
+ return `${nothing}: ${why}, and ${rule}. ${report}`;
1827
+ }
753
1828
  /**
754
1829
  * A cancel of a channel that isn't tuned out, by the name, quoted, the caller gave
755
1830
  * or knows it by: which channels are, so the next call can name one, and
@@ -770,6 +1845,19 @@ export class TuneOutCoordinator {
770
1845
  return `Nothing was cancelled: ${opening} A tune-out also ends by itself when its duration runs out, or at the `
771
1846
  + `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`;
772
1847
  }
1848
+ /**
1849
+ * The reader ended, not the framework: its turns stop (cadence and
1850
+ * coalescing wakes), and every hold keeps its end time, which releases it
1851
+ * to the resident with no reader.
1852
+ */
1853
+ stopReader() {
1854
+ for (const timer of this.cadenceTimers.values())
1855
+ clearTimeout(timer);
1856
+ this.cadenceTimers.clear();
1857
+ for (const { timer } of this.pendingWakes.values())
1858
+ clearTimeout(timer);
1859
+ this.pendingWakes.clear();
1860
+ }
773
1861
  /** Stop all timers (framework shutdown). */
774
1862
  stop() {
775
1863
  for (const timer of this.cadenceTimers.values())