@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
@@ -16,8 +16,9 @@
16
16
  */
17
17
 
18
18
  import { NOTICE_TAG } from '../notice-tags.js';
19
- import { cutToFit, suppliedValue } from '../supplied-text.js';
19
+ import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js';
20
20
  import { randomUUID } from 'node:crypto';
21
+ import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js';
21
22
  import type { ContentBlock } from '@animalabs/membrane';
22
23
  import type { StoredMessage } from '@animalabs/context-manager';
23
24
  import type { ChannelRef, ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
@@ -25,6 +26,9 @@ import type { McplServerRegistry } from '../mcpl/server-registry.js';
25
26
  import { CapabilityGrant } from '../mcpl/capability-grant.js';
26
27
  import { armAfter, armAt } from '../deadline-timer.js';
27
28
 
29
+ /** The options a tune-out call can give. */
30
+ type TuneOutOption = 'durationSeconds' | 'cadenceSeconds' | 'backlogCap' | 'maxWakes';
31
+
28
32
  /** Defaults for tune_out tool params. */
29
33
  export const TUNE_OUT_DEFAULTS = {
30
34
  cadenceSeconds: 1800,
@@ -36,7 +40,175 @@ export const TUNE_OUT_DEFAULTS = {
36
40
  * subconscious turn, and max-wakes counts invocations, not messages. */
37
41
  const WAKE_COALESCE_MS = 2_000;
38
42
 
39
- /** How long after an end time whose cancel threw the cancel is tried again. */
43
+ /**
44
+ * One backlog line: author, then the message's blocks in order — text as
45
+ * is, anything else named by kind (`[image]`, `[document]`), so a release
46
+ * never drops a block silently (#232 gap 4). The author is named once: a
47
+ * server that renders `Name: body` into the text itself is not doubled.
48
+ * A name the line adds is a value someone supplied, kept to one line with
49
+ * its notice openings marked (authorLine), as main's dump writes it.
50
+ */
51
+ export function renderHeldLine(m: Pick<StoredMessage, 'participant' | 'content' | 'metadata'>): string {
52
+ const author = authorNamed(m.metadata) ?? m.participant;
53
+ const parts = m.content.map((b: ContentBlock) =>
54
+ b.type === 'text' ? (b as Extract<ContentBlock, { type: 'text' }>).text : `[${b.type}]`,
55
+ );
56
+ const line = parts.filter((p) => p.length > 0).join(' ');
57
+ const firstText = m.content.find((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')?.text;
58
+ return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line);
59
+ }
60
+
61
+ /** One reader invocation under `reader: 'forks'` (see SubconsciousConfig). */
62
+ export interface ReaderInvocation {
63
+ serverId: string;
64
+ channelId: string;
65
+ epochId: string;
66
+ trigger: 'cadence' | 'wake' | 'cancel';
67
+ /** The host-framed notice for this invocation (bracket style). */
68
+ notice: string;
69
+ /** Held messages not yet handed to any reader fork, oldest first. */
70
+ backlog: StoredMessage[];
71
+ /**
72
+ * Held messages not yet handed to any reader fork that are still queued
73
+ * behind the resident's turn (admitted, not yet stored), oldest first.
74
+ * They come after `backlog` in arrival order. A reader is most useful
75
+ * while the resident is busy, which is exactly when its traffic is here
76
+ * rather than in the store.
77
+ */
78
+ queued: QueuedHeldMessage[];
79
+ /** Standing dispositions, as text, or null when there are none. */
80
+ dispositions: string | null;
81
+ }
82
+
83
+ /**
84
+ * A held message admitted for the resident but not yet in its store: it was
85
+ * deferred while a turn was alive. `writeId` is the deferred write's durable
86
+ * id, which the stored row carries as `metadata.deferredWriteId` once it
87
+ * lands.
88
+ */
89
+ export type QueuedHeldMessage = Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & {
90
+ writeId: string;
91
+ /** When the framework first held the write, in ms since the epoch. */
92
+ heldAt?: number;
93
+ };
94
+
95
+ /** The deferred write a stored row landed from, when it came through the queue. */
96
+ function writeIdOf(m: Pick<StoredMessage, 'metadata'>): string | undefined {
97
+ const writeId = (m.metadata as { deferredWriteId?: unknown } | undefined)?.deferredWriteId;
98
+ return typeof writeId === 'string' ? writeId : undefined;
99
+ }
100
+
101
+ type HeldRow = Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { id?: string; writeId?: string };
102
+
103
+ interface RowKeys {
104
+ ids: string[];
105
+ writeIds: string[];
106
+ }
107
+
108
+ interface ReleaseRecord extends RowKeys {
109
+ total: number;
110
+ truncated: number;
111
+ report: RowKeys;
112
+ }
113
+
114
+ function keysOf(rows: readonly HeldRow[]): RowKeys {
115
+ const keys: RowKeys = { ids: [], writeIds: [] };
116
+ for (const m of rows) {
117
+ const writeId = m.writeId ?? writeIdOf(m);
118
+ if (writeId !== undefined) keys.writeIds.push(writeId);
119
+ else if (m.id !== undefined) keys.ids.push(m.id);
120
+ }
121
+ return keys;
122
+ }
123
+
124
+ function keyedBy(keys: readonly RowKeys[]): (m: HeldRow) => boolean {
125
+ const ids = new Set(keys.flatMap((k) => k.ids));
126
+ const writeIds = new Set(keys.flatMap((k) => k.writeIds));
127
+ return (m) => {
128
+ const writeId = m.writeId ?? writeIdOf(m);
129
+ return writeId !== undefined ? writeIds.has(writeId) : m.id !== undefined && ids.has(m.id);
130
+ };
131
+ }
132
+
133
+ const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((v) => typeof v === 'string');
134
+ const isRowKeys = (value: unknown): value is RowKeys =>
135
+ typeof value === 'object' && value !== null && isStrings((value as RowKeys).ids) && isStrings((value as RowKeys).writeIds);
136
+
137
+ function isReleaseRecord(value: unknown): value is ReleaseRecord {
138
+ if (!isRowKeys(value)) return false;
139
+ const record = value as Partial<ReleaseRecord>;
140
+ return typeof record.total === 'number' && typeof record.truncated === 'number' && isRowKeys(record.report);
141
+ }
142
+
143
+ function releaseRecordsOf(rows: readonly HeldRow[], epochId: string): ReleaseRecord[] {
144
+ const records: ReleaseRecord[] = [];
145
+ for (const m of rows) {
146
+ const meta = m.metadata as { kind?: unknown; epochId?: unknown; released?: unknown } | undefined;
147
+ if (meta?.kind !== 'tune-out-cancel' || meta.epochId !== epochId) continue;
148
+ if (isReleaseRecord(meta.released) && !records.some((r) => JSON.stringify(r) === JSON.stringify(meta.released))) {
149
+ records.push(meta.released);
150
+ }
151
+ }
152
+ return records;
153
+ }
154
+
155
+ function truncationNote(records: readonly ReleaseRecord[]): string {
156
+ const truncated = records.reduce((n, r) => n + r.truncated, 0);
157
+ if (truncated === 0) return '';
158
+ const total = records.reduce((n, r) => n + r.total, 0);
159
+ return records.length === 1
160
+ ? ` ${truncated} of ${total} diverted messages, the oldest, weren't delivered raw to the resident.`
161
+ : ` ${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.`;
162
+ }
163
+
164
+ /**
165
+ * Whether a held message is one of this channel's: by its channel id, and by
166
+ * its server where it records one, since two servers can register the same
167
+ * channel id. A message recording neither is taken as the channel's, as
168
+ * before servers were recorded.
169
+ */
170
+ function inChannelOf(serverId: string, channelId: string): (m: Pick<StoredMessage, 'metadata'>) => boolean {
171
+ return (m) => {
172
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
173
+ return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The all-except scope (issue #232 §3.2; focus folded into tune-out): a
179
+ * resident-level policy epoch holding every channel but one, for a bounded
180
+ * time. It covers DMs and channels registered after entry, since the
181
+ * decision is "not the exception" rather than a per-channel state.
182
+ */
183
+ export interface AllExceptParams {
184
+ epochId: string;
185
+ except: { serverId: string; channelId: string };
186
+ cadenceSeconds: number;
187
+ /** Per held channel, at release. */
188
+ backlogCap: number;
189
+ /** Reader wakes before the reader is paused (the hold itself stays). */
190
+ maxWakes: number;
191
+ startedAtSequence: number;
192
+ startedAtMs: number;
193
+ /** Mandatory: the scope always ends on its own. */
194
+ expiresAtMs: number;
195
+ source: string;
196
+ }
197
+
198
+ export interface AllExceptState {
199
+ params: AllExceptParams;
200
+ wakeCount: number;
201
+ }
202
+
203
+ /** `check`: what is held right now, without releasing anything. */
204
+ export interface TuneOutCheck {
205
+ allExcept: { epochId: string; except: { serverId: string; channelId: string }; expiresAtMs: number; wakeCount: number; held: Record<string, number> } | null;
206
+ channels: Array<{ serverId: string; channelId: string; epochId: string; held: number; expiresAtMs?: number }>;
207
+ /** Up to `peekLimit` newest held lines of the peeked channel, oldest first. */
208
+ peek?: string[];
209
+ }
210
+
211
+ /** How long after an end time's or a wake budget's cancel threw the cancel is tried again. */
40
212
  const EXPIRY_RETRY_MS = 60_000;
41
213
 
42
214
  /** Ends a backlog line cut to fit in one of the residents' messages. */
@@ -82,8 +254,20 @@ export interface TuneOutFrameworkHooks {
82
254
  ) => string;
83
255
  /** Queue an inference request for an agent. */
84
256
  requestInference: (agentName: string, reason: string, source: string) => void;
85
- /** The subconscious's registry/participant name. */
257
+ /** The persistent subconscious's registry/participant name, or null. */
86
258
  subconsciousName: () => string | null;
259
+ /**
260
+ * `reader: 'forks'`: derive and run one reader fork. Absent in persistent
261
+ * mode. The framework owns the fork's lifecycle; the coordinator owns the
262
+ * epoch.
263
+ */
264
+ invokeReader?: (invocation: ReaderInvocation) => void;
265
+ /**
266
+ * `reader: 'forks'`: deliver a reader fork's own words to the resident
267
+ * as attributed mail (its name, its incarnation). Absent in persistent
268
+ * mode, where the subconscious writes under its own name directly.
269
+ */
270
+ deliverFromReader?: (reader: string, content: ContentBlock[], metadata: Record<string, unknown>) => void;
87
271
  /** The primary resident's registry name. */
88
272
  primaryName: () => string | null;
89
273
  /** Read the primary's stored messages (shared slot, unfiltered). */
@@ -93,7 +277,14 @@ export interface TuneOutFrameworkHooks {
93
277
  * while a turn was alive), oldest first. A cancel reads them too, so a
94
278
  * message held mid-turn is in the backlog it belongs to (#232 gap 7).
95
279
  */
96
- getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>>;
280
+ getDeferredMessages?: () => Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'> & { writeId?: string; heldAt?: number }>;
281
+ commitDeliveries?: () => boolean;
282
+ /**
283
+ * The history-reading tool the resident actually has (e.g. a server's
284
+ * `fetch_history`), or null. A truncation note names it only when it exists
285
+ * (#232 gap 11).
286
+ */
287
+ historyTool?: () => string | null;
97
288
  /**
98
289
  * The most characters of text every reader of the residents' shared slot
99
290
  * is shown whole in one message: the smallest of their strategies'
@@ -112,6 +303,19 @@ export interface TuneOutFrameworkHooks {
112
303
  isPrivilegedAuthor: (authorId: string | null | undefined) => boolean;
113
304
  /** speak_in_channel master switch (recipe-side, canary-gated). */
114
305
  allowChannelSpeech: () => boolean;
306
+ /**
307
+ * Whether a tune-out may hold a channel with no reader at all — a silent
308
+ * hold (issue #232 §3.3), which the host opts into (`tuneOut.enabled`).
309
+ * Left out, or false, a hold with no reader is refused.
310
+ */
311
+ holdWithoutReader?: () => boolean;
312
+ /**
313
+ * Post a line into a channel under the resident's name. Used for the
314
+ * all-except scope's entry and release announcements in the excepted
315
+ * channel (Linn, 2026-10-12: the room should hear the door shut, not
316
+ * discover it). Absent when `tuneOut.announce` is off.
317
+ */
318
+ announce?: (serverId: string, channelId: string, text: string) => Promise<{ success: boolean; error?: string }>;
115
319
  emitTrace: (event: { type: string; [key: string]: unknown }) => void;
116
320
  }
117
321
 
@@ -125,8 +329,58 @@ export class TuneOutCoordinator {
125
329
  private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
126
330
  /** Channels with a wake invocation pending inside the coalescing window. */
127
331
  private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
128
- /** Diverted-message tallies since the last subconscious invocation, per channel key. */
332
+ /**
333
+ * Held messages the channel's reader hasn't looked at, per channel key:
334
+ * what makes a cadence period worth a look. Counted up as messages are
335
+ * held and back to zero at each look; rebuilt from the held rows where
336
+ * memory can't know it (rebuildTally): after a restart, and when a
337
+ * channel's own tune-out ends while the all-except hold still holds it.
338
+ * A failed reader fork's messages count again (readerFailed).
339
+ */
129
340
  private divertedSinceInvocation = new Map<string, number>();
341
+ /**
342
+ * The persistent subconscious's last look at each held channel, per epoch:
343
+ * the store's sequence when the look was asked for. Durable, so that after
344
+ * a restart its next look counts only what was held since (rebuildTally).
345
+ * Reader forks need none: what they were handed is in memory, and after a
346
+ * restart the first fork of an epoch is handed all it has held.
347
+ */
348
+ private lastLooksCache: Record<string, Record<string, number>> | undefined;
349
+ /**
350
+ * `reader: 'forks'`: the highest sequence of a held message already
351
+ * handed to a reader fork, per epoch and channel (handOffKey). Each held
352
+ * message is delivered to exactly one fork. A channel the all-except hold
353
+ * holds can be taken by a tune-out of its own and given back, and each
354
+ * epoch's readers read that epoch's messages, so what one epoch's readers
355
+ * were handed never moves or clears the other's. In-memory: after a
356
+ * restart the first fork of an epoch is handed everything the epoch has
357
+ * held, which is the right side to err on.
358
+ */
359
+ private readerCursor = new Map<string, number>();
360
+ /**
361
+ * `reader: 'forks'`: the deferred writes handed to a reader fork while
362
+ * still queued, per epoch and channel. Each lands in the store later as a row
363
+ * stamped with the same id (`metadata.deferredWriteId`), past the cursor;
364
+ * that row is not handed again. Cleared with the cursor.
365
+ */
366
+ private readerHandedWrites = new Map<string, Set<string>>();
367
+ /**
368
+ * `reader: 'forks'`: held messages a reader fork was handed but never read,
369
+ * per epoch and channel: its derive failed, or its run threw before it reported
370
+ * (readerFailed). The next invocation is handed them again, cursor or no:
371
+ * stored rows by id, and by write id those that were queued when handed
372
+ * (stored or still queued now). Cleared with the cursor.
373
+ */
374
+ private readerReturned = new Map<string, { ids: Set<string>; writeIds: Set<string> }>();
375
+ /**
376
+ * `reader: 'forks'`: the late forks derived for an ended epoch's messages
377
+ * (readerFailed), so that a late fork's own failure derives no other.
378
+ */
379
+ private lateForks = new WeakSet<object>();
380
+ /** The all-except scope's durable record, cached (undefined = not read yet). */
381
+ private scopeCache: AllExceptState | null | undefined;
382
+ /** Timer key for the scope's cadence and expiry. */
383
+ private static readonly SCOPE_KEY = '\0all-except';
130
384
 
131
385
  constructor(
132
386
  private readonly channelRegistry: ChannelRegistry,
@@ -138,6 +392,28 @@ export class TuneOutCoordinator {
138
392
  return `${serverId}\0${channelId}`;
139
393
  }
140
394
 
395
+ /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
396
+ private handOffKey(epochId: string, serverId: string, channelId: string): string {
397
+ return `${epochId}\0${serverId}\0${channelId}`;
398
+ }
399
+
400
+ /** An ended epoch's hand-off bookkeeping goes with it, every channel's. */
401
+ private dropHandOffs(epochId: string): void {
402
+ for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) {
403
+ for (const key of map.keys()) if (key.startsWith(`${epochId}\0`)) map.delete(key);
404
+ }
405
+ }
406
+
407
+ /** A reader exists: the persistent subconscious, or forks on demand. */
408
+ private readerAvailable(): boolean {
409
+ return this.hooks.subconsciousName() != null || typeof this.hooks.invokeReader === 'function';
410
+ }
411
+
412
+ /** Reader forks are the configured reader. */
413
+ private get forkReader(): boolean {
414
+ return this.hooks.subconsciousName() == null && typeof this.hooks.invokeReader === 'function';
415
+ }
416
+
141
417
  /** A channel as the resident and the subconscious are shown it
142
418
  * (ChannelRegistry.nameShown): its id, or, where another server registers
143
419
  * the same id, its server-qualified name, which tune_out and
@@ -158,19 +434,77 @@ export class TuneOutCoordinator {
158
434
  // An epoch whose deadline passed while the process was down cancels
159
435
  // immediately — the resident gets their backlog on the schedule they
160
436
  // committed to, not whenever the host happened to restart.
161
- if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
162
- this.cancel(serverId, channelId, 'duration', 'duration elapsed');
163
- continue;
437
+ const inForce = (): boolean => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === params.epochId;
438
+ const expired = params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now();
439
+ if (expired) {
440
+ try {
441
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
442
+ continue;
443
+ } catch (err) {
444
+ if (!inForce()) {
445
+ 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);
446
+ continue;
447
+ }
448
+ 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);
449
+ }
164
450
  }
165
451
  this.startCadence(serverId, channelId, params.cadenceSeconds);
166
- this.armExpiry(serverId, channelId, params);
452
+ const wakeCount = this.channelRegistry.getTuneOutState(serverId, channelId)?.wakeCount ?? 0;
453
+ if (wakeCount > params.maxWakes) {
454
+ this.cancelPastBudget(serverId, channelId, params.maxWakes, 'start');
455
+ if (!inForce()) continue;
456
+ } else {
457
+ this.armExpiry(serverId, channelId, expired ? { ...params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS } : params);
458
+ }
167
459
  if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
168
460
  oldestAnchor = params.startedAtSequence;
169
461
  }
170
462
  }
463
+ const scope = this.getAllExcept();
464
+ if (scope) {
465
+ let inForce = true;
466
+ if (scope.params.expiresAtMs <= Date.now()) {
467
+ try {
468
+ this.cancelAllExcept('duration', 'duration elapsed');
469
+ inForce = false;
470
+ } catch (err) {
471
+ if (this.getAllExcept()?.params.epochId !== scope.params.epochId) {
472
+ 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);
473
+ inForce = false;
474
+ } else {
475
+ 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);
476
+ this.armScopeTimers({ ...scope.params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS });
477
+ }
478
+ }
479
+ } else {
480
+ this.armScopeTimers(scope.params);
481
+ }
482
+ if (inForce && (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor)) {
483
+ oldestAnchor = scope.params.startedAtSequence;
484
+ }
485
+ }
171
486
  if (oldestAnchor !== null) {
172
487
  this.hooks.setSubconsciousAnchor(oldestAnchor);
173
488
  }
489
+ // What each held channel's reader hasn't looked at was counted in
490
+ // memory, so it is counted again from the held rows: without it, a
491
+ // channel held across the restart gets no cadence look until something
492
+ // new arrives.
493
+ // Stored and still queued alike: at a quiesced boot, restored writes stay
494
+ // queued until the resume flushes them.
495
+ const held = new Set<string>();
496
+ for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) {
497
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; serverId?: string; channelId?: string } | undefined;
498
+ const epochId = meta?.tuneOut?.epochId;
499
+ if (!meta || !epochId || !meta.channelId) continue;
500
+ const key = this.key(meta.serverId ?? '', meta.channelId);
501
+ if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId) held.add(key);
502
+ }
503
+ for (const key of held) {
504
+ const [serverId, channelId] = key.split('\0') as [string, string];
505
+ this.rebuildTally(serverId, channelId);
506
+ }
507
+ this.pruneLooks();
174
508
  }
175
509
 
176
510
  private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void {
@@ -225,13 +559,65 @@ export class TuneOutCoordinator {
225
559
  // Enter / cancel
226
560
  // ==========================================================================
227
561
 
562
+ /**
563
+ * The options a tune-out call gives, read as numbers before anything
564
+ * changes (F76). A tool's input reaches here as the model wrote it, and
565
+ * the store can't keep a NaN or an Infinity: its append would throw after
566
+ * the registry had marked the channel tuned out. So each option is read
567
+ * as a number first, a numeric string included, as the sleep tool reads
568
+ * its seconds; null counts as left out. A value that gives no finite
569
+ * number is refused, naming the option and, where it can be left out,
570
+ * what leaving it out means; so is a duration whose end time is too far
571
+ * off to store. `now` is the clock the end time was counted from. One
572
+ * rule for a channel's tune-out and the all-except hold.
573
+ */
574
+ private readOptions(
575
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
576
+ leftOut: Record<TuneOutOption, string | null>,
577
+ ): { ok: true; given: Partial<Record<TuneOutOption, number>>; now: number; expiresAtMs: number | undefined } | { ok: false; error: string } {
578
+ const given: Partial<Record<TuneOutOption, number>> = {};
579
+ for (const name of Object.keys(leftOut) as TuneOutOption[]) {
580
+ const value: unknown = opts[name];
581
+ if (value == null) continue;
582
+ const n = Number(value);
583
+ if (!Number.isFinite(n)) {
584
+ const got = typeof value === 'number'
585
+ ? String(value)
586
+ : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
587
+ return {
588
+ ok: false,
589
+ error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
590
+ + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`,
591
+ };
592
+ }
593
+ given[name] = n;
594
+ }
595
+ const now = Date.now();
596
+ const expiresAtMs = given.durationSeconds === undefined
597
+ ? undefined
598
+ : now + Math.max(60, given.durationSeconds) * 1000;
599
+ if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
600
+ return {
601
+ ok: false,
602
+ error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.`
603
+ + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''),
604
+ };
605
+ }
606
+ return { ok: true, given, now, expiresAtMs };
607
+ }
608
+
228
609
  enter(
229
610
  serverId: string,
230
611
  channelId: string,
231
- opts: { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number },
612
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
232
613
  source: string,
233
614
  ): { ok: true; params: TuneOutParams } | { ok: false; error: string; alreadyTunedOut?: true } {
234
- if (!this.hooks.subconsciousName()) {
615
+ // No reader (issue #232 §3.3): a silent hold, where the host opted into
616
+ // one. Nothing summarizes, nothing wakes, mentions are still
617
+ // acknowledged, the backlog is released on cancel; the resident is told
618
+ // so in the tool result. A host that didn't opt in has no tune_out
619
+ // without a reader.
620
+ if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) {
235
621
  return {
236
622
  ok: false,
237
623
  error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out '
@@ -250,45 +636,18 @@ export class TuneOutCoordinator {
250
636
  }
251
637
  const current = this.channelRegistry.getTuneOutState(serverId, channelId);
252
638
  if (current) return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true };
253
- // A tool's input reaches here as the model wrote it, and the store can't
254
- // keep a NaN or an Infinity: its append would throw after the registry
255
- // had marked the channel tuned out. So each option is read as a number
256
- // before anything changes, a numeric string included, as the sleep tool
257
- // reads its seconds; null counts as left out. A value that gives no
258
- // finite number is refused.
259
- const leftOut = {
639
+ const scope = this.getAllExcept();
640
+ if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) {
641
+ return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` };
642
+ }
643
+ const read = this.readOptions(opts, {
260
644
  durationSeconds: 'to stay tuned out until you cancel',
261
645
  cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
262
646
  backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
263
647
  maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
264
- } as const;
265
- const given: Partial<Record<keyof typeof leftOut, number>> = {};
266
- for (const name of Object.keys(leftOut) as Array<keyof typeof leftOut>) {
267
- const value: unknown = opts[name];
268
- if (value == null) continue;
269
- const n = Number(value);
270
- if (!Number.isFinite(n)) {
271
- const got = typeof value === 'number'
272
- ? String(value)
273
- : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
274
- return {
275
- ok: false,
276
- error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
277
- + `and it was ${got}. Leave it out ${leftOut[name]}.`,
278
- };
279
- }
280
- given[name] = n;
281
- }
282
- const expiresAtMs = given.durationSeconds === undefined
283
- ? undefined
284
- : Date.now() + Math.max(60, given.durationSeconds) * 1000;
285
- if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
286
- return {
287
- ok: false,
288
- error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. `
289
- + `Leave it out ${leftOut.durationSeconds}.`,
290
- };
291
- }
648
+ });
649
+ if (!read.ok) return read;
650
+ const { given, expiresAtMs } = read;
292
651
  const params: TuneOutParams = {
293
652
  epochId: randomUUID(),
294
653
  cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
@@ -313,67 +672,66 @@ export class TuneOutCoordinator {
313
672
  this.hooks.setSubconsciousAnchor(oldest);
314
673
 
315
674
  this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
316
- this.startCadence(serverId, channelId, params.cadenceSeconds);
317
- this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId });
675
+ if (this.readerAvailable()) this.startCadence(serverId, channelId, params.cadenceSeconds);
676
+ this.hooks.emitTrace({
677
+ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(),
678
+ });
318
679
  return { ok: true, params };
319
680
  }
320
681
 
321
- /**
322
- * End a tune-out: durable flip, system-framed backlog dump into the
323
- * resident's window (capped), lifecycle notice, one wake for the
324
- * resident. `subconsciousNote` is the subconscious's own text (from its
325
- * cancel_tuneout call) — delivered verbatim under its name, never
326
- * synthesized here.
327
- */
682
+ /** Whether a reader (persistent subconscious or reader forks) attends holds. */
683
+ hasReader(): boolean {
684
+ return this.readerAvailable();
685
+ }
686
+
328
687
  cancel(
329
688
  serverId: string,
330
689
  channelId: string,
331
690
  source: string,
332
691
  reason: string,
333
692
  subconsciousNote?: string,
693
+ /** `reader: 'forks'`: the reader fork whose note this is. */
694
+ noteAuthor?: string,
695
+ /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
696
+ nextState: 'open' | 'closed' = 'open',
334
697
  ): { ok: true } | { ok: false; error: string } {
335
- const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
336
- if (!ended) {
698
+ const ending = this.channelRegistry.getTuneOutState(serverId, channelId);
699
+ if (!ending) {
337
700
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
338
701
  return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
339
702
  }
340
- this.clearTimers(serverId, channelId);
341
- this.divertedSinceInvocation.delete(this.key(serverId, channelId));
342
703
 
343
- // Backlog: every message held under this epoch. The stored ones come in
344
- // store order, then those still in the deferred queue (held mid-turn,
345
- // not yet written), oldest first. That's arrival order, except when a
346
- // held message stays queued while a later one is stored: a store write
347
- // refused at a turn-start flush leaves it queued, and that turn's
348
- // assembly can then store later coalesced messages directly.
349
- const epochId = ended.params.epochId;
704
+ const epochId = ending.params.epochId;
350
705
  const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
351
706
  (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
352
- const held = (this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch);
353
- const backlog: Array<Pick<StoredMessage, 'participant' | 'content' | 'metadata'>> = [
354
- ...this.hooks.getStoredMessages().filter(ofEpoch),
355
- ...held,
356
- ];
707
+ const storedRows: HeldRow[] = this.hooks.getStoredMessages();
708
+ const queuedRows: HeldRow[] = this.hooks.getDeferredMessages?.() ?? [];
709
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
710
+ const dumpedEarlier = keyedBy(earlier);
711
+ const unreleased = (m: HeldRow): boolean => ofEpoch(m) && !dumpedEarlier(m);
712
+ const held = queuedRows.filter(unreleased);
713
+ const backlog: HeldRow[] = [...storedRows.filter(unreleased), ...held];
357
714
  // Whole messages: a cap below one shows none, where slice(-0.5) would
358
715
  // start at 0 and take them all.
359
- const cap = Math.floor(ended.params.backlogCap);
716
+ const cap = Math.floor(ending.params.backlogCap);
360
717
  const authorOf = (m: Pick<StoredMessage, 'participant' | 'metadata'>): string =>
361
718
  suppliedValue((m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant);
362
- const lineOf = (m: Pick<StoredMessage, 'participant' | 'content' | 'metadata'>): string => {
363
- const text = m.content
364
- .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
365
- .map((b) => b.text)
366
- .join('\n');
367
- return `${authorOf(m)}: ${text}`;
368
- };
719
+ // A line names its author as the resident knows them and marks what a
720
+ // text line can't carry (`[image]`), so the dump says what it leaves out.
721
+ const lineOf = renderHeldLine;
722
+ // The tool the truncation note names (#232 gap 11): the one the resident
723
+ // has, when the host says; main's `fetch_history` where no host says.
724
+ const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
369
725
  // The channel as the resident and the subconscious are shown it.
370
726
  const name = this.shown(serverId, channelId);
371
- const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
727
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`;
372
728
  const render = (lines: string[], truncated: number): string => {
373
729
  const attrs = [
374
730
  `channel=${this.quotedShown(serverId, channelId)}`,
375
731
  `messages=${backlog.length}`,
376
- ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []),
732
+ ...(truncated > 0
733
+ ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`]
734
+ : []),
377
735
  ];
378
736
  return `${lifecycle}\n\n<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
379
737
  };
@@ -438,12 +796,30 @@ export class TuneOutCoordinator {
438
796
  m.content.some((b) => b.type !== 'text') || (newestCut && m === shown[0])),
439
797
  ];
440
798
 
441
- // Host-framed system artifacts — never voiced as anyone.
442
- this.hooks.addMessage(
443
- 'user',
444
- [{ type: 'text', text: render(lines, truncated) }],
445
- { system: true, kind: 'tune-out-cancel', channelId, epochId },
446
- );
799
+ const records = [...earlier];
800
+ if (earlier.length === 0 || backlog.length > 0) {
801
+ const record: ReleaseRecord = { ...keysOf(backlog), total: backlog.length, truncated, report: keysOf(heldForReport) };
802
+ this.hooks.addMessage(
803
+ 'user',
804
+ [{ type: 'text', text: render(lines, truncated) }],
805
+ { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record },
806
+ );
807
+ records.push(record);
808
+ }
809
+ if (this.hooks.commitDeliveries?.() === false) {
810
+ throw new Error(`the backlog of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
811
+ }
812
+ this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
813
+ this.clearTimers(serverId, channelId);
814
+ this.divertedSinceInvocation.delete(this.key(serverId, channelId));
815
+ const reportedEarlier = keyedBy(earlier.map((r) => r.report));
816
+ const release = {
817
+ truncated: records.reduce((n, r) => n + r.truncated, 0),
818
+ heldForReport: [
819
+ ...[...storedRows, ...queuedRows].filter((m) => ofEpoch(m) && reportedEarlier(m)),
820
+ ...(records.length > earlier.length ? heldForReport : []),
821
+ ],
822
+ };
447
823
  // The subconscious's own words, under its own name, verbatim.
448
824
  const subName = this.hooks.subconsciousName();
449
825
  if (subconsciousNote && subName) {
@@ -454,6 +830,14 @@ export class TuneOutCoordinator {
454
830
  // reads (repinChannel), where another server's channel can share the id.
455
831
  { kind: 'subconscious-note', channelId, serverId, epochId },
456
832
  );
833
+ } else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) {
834
+ // A reader fork's note: attributed mail, under the fork's name and
835
+ // incarnation, never the resident's voice.
836
+ this.hooks.deliverFromReader(
837
+ noteAuthor,
838
+ [{ type: 'text', text: subconsciousNote }],
839
+ { kind: 'subconscious-note', channelId, epochId },
840
+ );
457
841
  }
458
842
  const primary = this.hooks.primaryName();
459
843
  if (primary) {
@@ -469,12 +853,10 @@ export class TuneOutCoordinator {
469
853
  // and when it wrote no note and the dump left any message out, since
470
854
  // nothing else then covers it. Otherwise its note speaks for what it saw.
471
855
  const selfCancel = source === 'subconscious';
472
- if (subName && (!selfCancel || heldForReport.length > 0 || (!subconsciousNote && truncated > 0))) {
473
- const truncNote = truncated > 0
474
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
475
- : '';
476
- const heldNote = heldForReport.length > 0
477
- ? ` Still held at the cancel, so your view may not have them yet: the ${heldForReport.length} message${heldForReport.length === 1 ? '' : 's'} after this one.`
856
+ if (subName && (!selfCancel || release.heldForReport.length > 0 || (!subconsciousNote && release.truncated > 0))) {
857
+ const truncNote = truncationNote(records);
858
+ const heldNote = release.heldForReport.length > 0
859
+ ? ` 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.`
478
860
  : '';
479
861
  this.hooks.addMessage(
480
862
  'user',
@@ -484,7 +866,7 @@ export class TuneOutCoordinator {
484
866
  );
485
867
  // Each held message is a row of its own, so a per-message cap cuts each
486
868
  // one on its own, its author's label included, not the batch's newest.
487
- for (const m of heldForReport) {
869
+ for (const m of release.heldForReport) {
488
870
  this.hooks.addMessage(
489
871
  'user',
490
872
  [{ type: 'text', text: `${authorOf(m)}:` }, ...m.content],
@@ -493,8 +875,33 @@ export class TuneOutCoordinator {
493
875
  );
494
876
  }
495
877
  this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
878
+ } else if (!subName && this.forkReader) {
879
+ // Forks are the reader: one last fork for the epoch, handed what no
880
+ // fork has been handed yet, stored or still queued behind the
881
+ // resident's turn, plus the cancel notice; its report follows the dump.
882
+ // Once the epoch ends, a message still queued lands in the store
883
+ // view-filtered, where nothing else reads it, so it goes to this fork
884
+ // now. On a reader's own cancel, the reader has read what it was
885
+ // handed, so a fork follows only when messages arrived after that.
886
+ const stored = this.hooks.getStoredMessages().filter(ofEpoch);
887
+ const queued = this.queuedHeld(ofEpoch);
888
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
889
+ const unread = rest.backlog.length + rest.queued.length;
890
+ if (!selfCancel || unread > 0) {
891
+ const truncNote = truncationNote(records);
892
+ const lateNote = selfCancel
893
+ ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
894
+ : '';
895
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued);
896
+ }
496
897
  }
497
- this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
898
+ this.dropHandOffs(epochId);
899
+ this.dropLooks(epochId);
900
+ // Where the all-except hold still holds the channel, its own messages
901
+ // there that its reader hasn't looked at are counted again, as the
902
+ // channel's own tune-out's count stood in for theirs.
903
+ this.rebuildTally(serverId, channelId);
904
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ending.wakeCount });
498
905
  return { ok: true };
499
906
  }
500
907
 
@@ -518,9 +925,376 @@ export class TuneOutCoordinator {
518
925
  authorId?: string,
519
926
  gatePass = true,
520
927
  ): { epochId: string } | null {
928
+ const hold = this.holdFor(serverId, channelId);
929
+ if (!hold) return null;
930
+ this.recordHeld(serverId, channelId, messageId, tags, authorId, gatePass);
931
+ return hold;
932
+ }
933
+
934
+ /** The epoch holding this channel, or null. Pure: the admission stage's lookup. */
935
+ holdFor(serverId: string, channelId: string): { epochId: string } | null {
936
+ // Precedence: a per-channel epoch keeps its own hold (cadence, budget,
937
+ // stamp); the all-except scope holds whatever else is not the exception.
521
938
  const state = this.channelRegistry.getTuneOutState(serverId, channelId);
522
- if (!state) return null;
939
+ if (state) return { epochId: state.params.epochId };
940
+ const scope = this.getAllExcept();
941
+ if (scope && !(scope.params.except.serverId === serverId && scope.params.except.channelId === channelId)) {
942
+ return { epochId: scope.params.epochId };
943
+ }
944
+ return null;
945
+ }
946
+
947
+ /** The channel unprefixed speech lands in while an all-except hold is active, with its server, else null. */
948
+ focusChannel(): ChannelRef | null {
949
+ const except = this.getAllExcept()?.params.except;
950
+ return except ? { serverId: except.serverId, channelId: except.channelId } : null;
951
+ }
952
+
953
+ // ==========================================================================
954
+ // All-except scope
955
+ // ==========================================================================
956
+
957
+ private get scopeStateId(): string {
958
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`;
959
+ }
960
+
961
+ getAllExcept(): AllExceptState | null {
962
+ if (this.scopeCache !== undefined) return this.scopeCache;
963
+ // A record that can't be read, or isn't a hold's, is not taken for no
964
+ // hold: the error goes to the caller and nothing is cached, so the next
965
+ // read tries again. At create() that refuses the start, as an
966
+ // unreadable registry does.
967
+ const raw = this.channelRegistry.readCoordinatorState(this.scopeStateId) as AllExceptState | null;
968
+ if (raw !== null && raw !== undefined && !(typeof raw === 'object' && raw.params && typeof raw.params.epochId === 'string')) {
969
+ throw new Error(`the all-except hold's stored record (${this.scopeStateId}) is not a hold's record; it was left as it is`);
970
+ }
971
+ this.scopeCache = raw ?? null;
972
+ return this.scopeCache;
973
+ }
974
+
975
+ private writeScope(state: AllExceptState | null): void {
976
+ // Durable first: a write that fails leaves memory as the store has it.
977
+ this.channelRegistry.writeCoordinatorState(this.scopeStateId, state);
978
+ this.scopeCache = state;
979
+ }
980
+
981
+ enterAllExcept(
982
+ except: { serverId: string; channelId: string },
983
+ opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown },
984
+ source: string,
985
+ ): { ok: true; params: AllExceptParams } | { ok: false; error: string } {
986
+ const read = this.readOptions(opts, {
987
+ durationSeconds: null,
988
+ cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
989
+ backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
990
+ maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
991
+ });
992
+ if (!read.ok) return read;
993
+ const { given, expiresAtMs } = read;
994
+ if (expiresAtMs === undefined) {
995
+ return { ok: false, error: 'an all-except hold needs `durationSeconds`: it always ends on its own' };
996
+ }
997
+ if (this.getAllExcept()) {
998
+ return { ok: false, error: 'an all-except hold is already active (cancel it first)' };
999
+ }
1000
+ if (this.channelRegistry.getTuneOutState(except.serverId, except.channelId)) {
1001
+ return { ok: false, error: `channel ${except.channelId} is tuned out; it cannot be the exception` };
1002
+ }
1003
+ if (this.hooks.isForkBound(except)) {
1004
+ return { ok: false, error: `channel ${except.channelId} is bound to a conversation fork` };
1005
+ }
1006
+ const params: AllExceptParams = {
1007
+ epochId: randomUUID(),
1008
+ except,
1009
+ cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
1010
+ // Whole messages, as a channel's cap is counted: a cap below one shows
1011
+ // none, where slice(-0.5) would take them all.
1012
+ backlogCap: Math.max(0, Math.floor(given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap)),
1013
+ maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
1014
+ startedAtSequence: this.hooks.currentSequence(),
1015
+ startedAtMs: read.now,
1016
+ expiresAtMs,
1017
+ source,
1018
+ };
1019
+ this.writeScope({ params, wakeCount: 0 });
1020
+ const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence);
1021
+ this.hooks.setSubconsciousAnchor(oldest);
1022
+ this.armScopeTimers(params);
1023
+ this.hooks.emitTrace({
1024
+ type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId,
1025
+ scope: 'all-except', reader: this.readerAvailable(), expiresAtMs: params.expiresAtMs,
1026
+ });
1027
+ const minutes = Math.max(1, Math.round((params.expiresAtMs - params.startedAtMs) / 60_000));
1028
+ const until = new Date(params.expiresAtMs).toISOString().slice(11, 16);
1029
+ this.announce(except, params.epochId, 'entered',
1030
+ `[${NOTICE_TAG.focus}: ${this.hooks.primaryName() ?? 'the resident'} is here for ${minutes} min, until ${until} UTC; other channels are held meanwhile.]`);
1031
+ return { ok: true, params };
1032
+ }
1033
+
1034
+ /** Whether entry and release of the all-except scope are announced in the excepted channel. */
1035
+ announces(): boolean {
1036
+ return typeof this.hooks.announce === 'function';
1037
+ }
1038
+
1039
+ /**
1040
+ * The excepted channel hears the scope open and close: one bracketed line
1041
+ * under the resident's name, posted through the same path as a publish.
1042
+ * Fire-and-forget; the outcome is traced, never awaited by the epoch flip.
1043
+ */
1044
+ private announce(
1045
+ target: { serverId: string; channelId: string },
1046
+ epochId: string,
1047
+ kind: 'entered' | 'released',
1048
+ text: string,
1049
+ ): void {
1050
+ if (!this.hooks.announce) return;
1051
+ const base = { serverId: target.serverId, channelId: target.channelId, epochId, kind };
1052
+ this.hooks.announce(target.serverId, target.channelId, text).then(
1053
+ (r) => this.hooks.emitTrace(r.success
1054
+ ? { type: 'tune-out:announced', ...base }
1055
+ : { type: 'tune-out:announce-failed', ...base, error: r.error ?? 'publish failed' }),
1056
+ (err: unknown) => this.hooks.emitTrace({ type: 'tune-out:announce-failed', ...base, error: (err as Error).message }),
1057
+ );
1058
+ }
1059
+
1060
+ /** The scope's end time and cadence, on timers no longer than one holds
1061
+ * (deadline-timer), as a channel's tune-out arms its own. */
1062
+ private armScopeTimers(params: AllExceptParams): void {
1063
+ const key = TuneOutCoordinator.SCOPE_KEY;
1064
+ const expiry = this.expiryTimers.get(key);
1065
+ if (expiry) clearTimeout(expiry);
1066
+ // As a channel's timers: the next turn is armed before this one's work,
1067
+ // and a throw is reported (timerFailed) rather than left to end the host.
1068
+ const { serverId: exceptServer, channelId: exceptChannel } = params.except;
1069
+ const holdExpiry = (timer: ReturnType<typeof setTimeout>): void => { this.expiryTimers.set(key, timer); };
1070
+ const end = (): void => {
1071
+ try {
1072
+ holdExpiry(setTimeout(end, EXPIRY_RETRY_MS));
1073
+ if (!this.cancelAllExcept('duration', 'duration elapsed').ok) this.clearScopeTimers();
1074
+ } catch (err) {
1075
+ this.timerFailed('expiry', exceptServer, exceptChannel, err);
1076
+ }
1077
+ };
1078
+ armAt(params.expiresAtMs, holdExpiry, end);
1079
+ if (!this.readerAvailable()) return;
1080
+ const cadence = this.cadenceTimers.get(key);
1081
+ if (cadence) clearTimeout(cadence);
1082
+ const hold = (timer: ReturnType<typeof setTimeout>): void => { this.cadenceTimers.set(key, timer); };
1083
+ const tick = () => {
1084
+ try {
1085
+ const scope = this.getAllExcept();
1086
+ if (!scope) return;
1087
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
1088
+ // One look per held channel with new traffic since the last look.
1089
+ for (const [channelKey, diverted] of this.divertedSinceInvocation) {
1090
+ if (diverted <= 0) continue;
1091
+ const [serverId, channelId] = channelKey.split('\0') as [string, string];
1092
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; // a per-channel epoch's own cadence
1093
+ this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
1094
+ }
1095
+ } catch (err) {
1096
+ this.timerFailed('cadence', exceptServer, exceptChannel, err);
1097
+ }
1098
+ };
1099
+ armAfter(params.cadenceSeconds * 1000, hold, tick);
1100
+ }
1101
+
1102
+ private clearScopeTimers(): void {
1103
+ const key = TuneOutCoordinator.SCOPE_KEY;
1104
+ const cadence = this.cadenceTimers.get(key);
1105
+ if (cadence) clearTimeout(cadence);
1106
+ this.cadenceTimers.delete(key);
1107
+ const expiry = this.expiryTimers.get(key);
1108
+ if (expiry) clearTimeout(expiry);
1109
+ this.expiryTimers.delete(key);
1110
+ }
523
1111
 
1112
+ /** Channels held by the scope (not by their own epoch) with the count held under it. */
1113
+ private scopeHeldCounts(epochId: string): Map<string, { serverId: string; channelId: string; count: number }> {
1114
+ const counts = new Map<string, { serverId: string; channelId: string; count: number }>();
1115
+ const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])];
1116
+ for (const m of all) {
1117
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
1118
+ if (meta?.tuneOut?.epochId !== epochId) continue;
1119
+ const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)';
1120
+ const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
1121
+ const k = this.key(serverId, channelId);
1122
+ const entry = counts.get(k) ?? { serverId, channelId, count: 0 };
1123
+ entry.count += 1;
1124
+ counts.set(k, entry);
1125
+ }
1126
+ return counts;
1127
+ }
1128
+
1129
+ cancelAllExcept(
1130
+ source: string,
1131
+ reason: string,
1132
+ note?: string,
1133
+ noteAuthor?: string,
1134
+ ): { ok: true } | { ok: false; error: string } {
1135
+ const scope = this.getAllExcept();
1136
+ if (!scope) return { ok: false, error: 'no all-except hold is active' };
1137
+ const { epochId, except, backlogCap } = scope.params;
1138
+
1139
+ const ofEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1140
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
1141
+ const storedRows: HeldRow[] = this.hooks.getStoredMessages();
1142
+ const queuedRows: HeldRow[] = this.hooks.getDeferredMessages?.() ?? [];
1143
+ const all: HeldRow[] = [...storedRows.filter(ofEpoch), ...queuedRows.filter(ofEpoch)];
1144
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
1145
+ const releasedEarlier = keyedBy(earlier);
1146
+ const fresh = all.filter((m) => !releasedEarlier(m));
1147
+ const byChannel = new Map<string, { serverId: string; channelId: string; backlog: HeldRow[] }>();
1148
+ for (const m of fresh) {
1149
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
1150
+ const serverId = meta?.serverId ?? '';
1151
+ const channelId = meta?.channelId ?? '(unknown channel)';
1152
+ const k = this.key(serverId, channelId);
1153
+ const entry = byChannel.get(k) ?? { serverId, channelId, backlog: [] };
1154
+ entry.backlog.push(m);
1155
+ byChannel.set(k, entry);
1156
+ }
1157
+ const historyTool = this.hooks.historyTool?.() ?? null;
1158
+ let truncatedInAll = 0;
1159
+ const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
1160
+ // Floored here too: a hold stored before entry floored its cap can hold
1161
+ // a fraction.
1162
+ const cap = Math.floor(backlogCap);
1163
+ const shown = cap > 0 ? backlog.slice(-cap) : [];
1164
+ const truncated = backlog.length - shown.length;
1165
+ truncatedInAll += truncated;
1166
+ const attrs = [
1167
+ `channel=${quotedValue(this.shown(serverId, channelId))}`,
1168
+ `messages=${backlog.length}`,
1169
+ ...(truncated > 0 ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] : []),
1170
+ ];
1171
+ return `<tuned-out-backlog ${attrs.join(' ')}>\n${shown.map(renderHeldLine).join('\n')}\n</tuned-out-backlog>`;
1172
+ });
1173
+ const summary = byChannel.size === 0
1174
+ ? 'Nothing was held.'
1175
+ : `${fresh.length} message${fresh.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
1176
+ if (earlier.length === 0 || fresh.length > 0) {
1177
+ const record: ReleaseRecord = { ...keysOf(fresh), total: fresh.length, truncated: truncatedInAll, report: { ids: [], writeIds: [] } };
1178
+ this.hooks.addMessage(
1179
+ 'user',
1180
+ [{ 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') : ''}` }],
1181
+ { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record },
1182
+ );
1183
+ }
1184
+ if (this.hooks.commitDeliveries?.() === false) {
1185
+ throw new Error('the all-except release could not be made durable, so the hold stays in force');
1186
+ }
1187
+ this.writeScope(null);
1188
+ this.clearScopeTimers();
1189
+ for (const [key, pending] of this.pendingWakes) {
1190
+ const [serverId, channelId] = key.split('\0') as [string, string];
1191
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue;
1192
+ clearTimeout(pending.timer);
1193
+ this.pendingWakes.delete(key);
1194
+ }
1195
+ const subName = this.hooks.subconsciousName();
1196
+ if (note && subName) {
1197
+ this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
1198
+ } else if (note && noteAuthor && this.hooks.deliverFromReader) {
1199
+ this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId });
1200
+ }
1201
+ const primary = this.hooks.primaryName();
1202
+ if (primary) this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
1203
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`;
1204
+ if (source !== 'subconscious' && subName) {
1205
+ this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName);
1206
+ this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out');
1207
+ } else if (!subName && this.forkReader) {
1208
+ // One last look per channel with messages no fork was handed, stored or
1209
+ // still queued behind the resident's turn. That holds for a reader's
1210
+ // own release too: the reader that released read one channel's
1211
+ // messages, and every other channel's would otherwise reach no reader.
1212
+ // A channel is its server and id here, as the readers' bookkeeping is.
1213
+ const channels = new Map<string, ChannelRef>();
1214
+ for (const m of all) {
1215
+ const meta = m.metadata as { channelId?: string; serverId?: string } | undefined;
1216
+ const ref = { serverId: meta?.serverId ?? '', channelId: meta?.channelId ?? '(unknown channel)' };
1217
+ channels.set(this.key(ref.serverId, ref.channelId), ref);
1218
+ }
1219
+ for (const { serverId, channelId } of channels.values()) {
1220
+ const ofChannel = inChannelOf(serverId, channelId);
1221
+ const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean => ofEpoch(m) && ofChannel(m);
1222
+ const stored = this.hooks.getStoredMessages().filter(inChannel);
1223
+ const queued = this.queuedHeld(inChannel);
1224
+ const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
1225
+ if (rest.backlog.length + rest.queued.length > 0) {
1226
+ this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued);
1227
+ }
1228
+ }
1229
+ }
1230
+ for (const key of this.divertedSinceInvocation.keys()) {
1231
+ const [serverId, channelId] = key.split('\0') as [string, string];
1232
+ if (!this.channelRegistry.getTuneOutState(serverId, channelId)) this.divertedSinceInvocation.delete(key);
1233
+ }
1234
+ this.dropHandOffs(epochId);
1235
+ this.dropLooks(epochId);
1236
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount });
1237
+ this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`);
1238
+ return { ok: true };
1239
+ }
1240
+
1241
+ /** What is held right now — per-channel epochs and the scope — without releasing. */
1242
+ check(opts: { peek?: { serverId?: string; channelId: string }; peekLimit?: number } = {}): TuneOutCheck {
1243
+ const stored = this.hooks.getStoredMessages();
1244
+ const deferred = this.hooks.getDeferredMessages?.() ?? [];
1245
+ const countOf = (epochId: string) => [...stored, ...deferred].filter(
1246
+ (m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId,
1247
+ ).length;
1248
+ const channels = this.listActive().map(({ serverId, channelId, params }) => ({
1249
+ serverId, channelId, epochId: params.epochId, held: countOf(params.epochId),
1250
+ ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}),
1251
+ }));
1252
+ const scope = this.getAllExcept();
1253
+ let allExcept: TuneOutCheck['allExcept'] = null;
1254
+ if (scope) {
1255
+ const held: Record<string, number> = {};
1256
+ for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values()) held[this.shown(serverId, channelId)] = count;
1257
+ allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held };
1258
+ }
1259
+ const result: TuneOutCheck = { allExcept, channels };
1260
+ if (opts.peek) {
1261
+ const limit = Math.max(1, Math.min(50, opts.peekLimit ?? 5));
1262
+ // Only what is held NOW: the channel's own epoch, or the scope's. A
1263
+ // released epoch's messages are delivered, not held; showing them here
1264
+ // would be a false declaration (first live run, 2026-10-12, finding B).
1265
+ const active = new Set<string>();
1266
+ for (const e of channels) {
1267
+ if (e.channelId === opts.peek.channelId && (!opts.peek.serverId || e.serverId === opts.peek.serverId)) active.add(e.epochId);
1268
+ }
1269
+ if (scope) active.add(scope.params.epochId);
1270
+ const lines = [...stored, ...deferred]
1271
+ .filter((m) => {
1272
+ const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined;
1273
+ return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId)
1274
+ && meta.channelId === opts.peek!.channelId
1275
+ && (!opts.peek!.serverId || meta.serverId === opts.peek!.serverId);
1276
+ })
1277
+ .slice(-limit)
1278
+ .map(renderHeldLine);
1279
+ result.peek = lines;
1280
+ }
1281
+ return result;
1282
+ }
1283
+
1284
+ /**
1285
+ * Bookkeeping for one held message, after admission decided the hold:
1286
+ * the cadence tally, the deterministic acknowledgement of a suppressed
1287
+ * mention, and the reader wake when the resident's gate would have woken
1288
+ * them (`gatePass` is that verdict, probed without side effects).
1289
+ */
1290
+ recordHeld(
1291
+ serverId: string,
1292
+ channelId: string,
1293
+ messageId: string,
1294
+ tags: string[] | undefined,
1295
+ authorId?: string,
1296
+ gatePass = true,
1297
+ ): void {
524
1298
  const key = this.key(serverId, channelId);
525
1299
  this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
526
1300
 
@@ -541,10 +1315,20 @@ export class TuneOutCoordinator {
541
1315
  // standing mute to its subject.
542
1316
  void this.acknowledgeSuppressed(serverId, channelId, messageId);
543
1317
  }
544
- if (gatePass && (addressed || privileged)) {
1318
+ if (gatePass && (addressed || privileged) && this.readerAvailable()) {
545
1319
  this.scheduleWakeInvocation(serverId, channelId);
546
1320
  }
547
- return { epochId: state.params.epochId };
1321
+ }
1322
+
1323
+ /** Reader forks that reported through `deliver_summary` (so their trailing prose is not a second report). */
1324
+ private readerReports = new Set<string>();
1325
+
1326
+ readerReported(reader: string): boolean {
1327
+ return this.readerReports.has(reader);
1328
+ }
1329
+
1330
+ forgetReader(reader: string): void {
1331
+ this.readerReports.delete(reader);
548
1332
  }
549
1333
 
550
1334
  private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise<void> {
@@ -625,19 +1409,7 @@ export class TuneOutCoordinator {
625
1409
  try {
626
1410
  const entry = this.pendingWakes.get(key);
627
1411
  this.pendingWakes.delete(key);
628
- // Count ONE durable wake per coalesced invocation.
629
- const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
630
- if (!counted) return; // cancelled inside the window
631
- if (counted.wakeCount > counted.params.maxWakes) {
632
- this.cancel(
633
- serverId,
634
- channelId,
635
- 'tune-out-max-wakes',
636
- `wake budget exhausted (${counted.params.maxWakes})`,
637
- );
638
- return;
639
- }
640
- this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
1412
+ this.fireWake(serverId, channelId, entry?.count ?? 1);
641
1413
  } catch (err) {
642
1414
  this.timerFailed('wake', serverId, channelId, err);
643
1415
  }
@@ -645,6 +1417,61 @@ export class TuneOutCoordinator {
645
1417
  this.pendingWakes.set(key, { count: 1, timer });
646
1418
  }
647
1419
 
1420
+ private cancelPastBudget(serverId: string, channelId: string, maxWakes: number, from: 'wake' | 'start' = 'wake'): void {
1421
+ const key = this.key(serverId, channelId);
1422
+ const existing = this.expiryTimers.get(key);
1423
+ if (existing) clearTimeout(existing);
1424
+ const end = (first: boolean): void => {
1425
+ try {
1426
+ this.expiryTimers.set(key, setTimeout(() => end(false), EXPIRY_RETRY_MS));
1427
+ if (!this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${maxWakes})`).ok) {
1428
+ this.clearTimers(serverId, channelId);
1429
+ }
1430
+ } catch (err) {
1431
+ if (first && from === 'start') {
1432
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1433
+ 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);
1434
+ } else {
1435
+ 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);
1436
+ }
1437
+ } else {
1438
+ this.timerFailed('wake', serverId, channelId, err);
1439
+ }
1440
+ }
1441
+ };
1442
+ end(true);
1443
+ }
1444
+
1445
+ /** One coalesced wake: counted durably against the epoch that holds the channel. */
1446
+ private fireWake(serverId: string, channelId: string, count: number): void {
1447
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1448
+ // Count ONE durable wake per coalesced invocation.
1449
+ const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
1450
+ if (!counted) return; // cancelled inside the window
1451
+ if (counted.wakeCount > counted.params.maxWakes) {
1452
+ this.cancelPastBudget(serverId, channelId, counted.params.maxWakes);
1453
+ return;
1454
+ }
1455
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1456
+ return;
1457
+ }
1458
+ const scope = this.getAllExcept();
1459
+ if (!scope) return; // released inside the window
1460
+ // The all-except scope: exhausting the budget pauses the reader, it does
1461
+ // not end the hold (#232 §3.2 — the hold is the resident's bounded
1462
+ // commitment; the deadline ends it).
1463
+ const wakeCount = scope.wakeCount + 1;
1464
+ this.writeScope({ ...scope, wakeCount });
1465
+ if (wakeCount > scope.params.maxWakes) {
1466
+ this.hooks.emitTrace({
1467
+ type: 'tune-out:wake-budget-paused', epochId: scope.params.epochId, scope: 'all-except',
1468
+ serverId, channelId, wakeCount, maxWakes: scope.params.maxWakes,
1469
+ });
1470
+ return;
1471
+ }
1472
+ this.invokeSubconscious(serverId, channelId, 'wake', count);
1473
+ }
1474
+
648
1475
  /**
649
1476
  * One subconscious turn: a system-framed trigger notice (bracket style —
650
1477
  * what happened, never what to say) plus standing dispositions, then an
@@ -657,13 +1484,38 @@ export class TuneOutCoordinator {
657
1484
  count: number,
658
1485
  ): void {
659
1486
  const subName = this.hooks.subconsciousName();
660
- if (!subName) return;
1487
+ if (!subName && !this.forkReader) return;
661
1488
  const key = this.key(serverId, channelId);
662
1489
 
663
1490
  const shown = this.shown(serverId, channelId);
664
1491
  const notice = trigger === 'wake'
665
1492
  ? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]`
666
1493
  : `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
1494
+ if (!subName) {
1495
+ const hold = this.holdFor(serverId, channelId);
1496
+ if (!hold) return;
1497
+ const ofChannel = inChannelOf(serverId, channelId);
1498
+ const inChannel = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1499
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1500
+ // Stored and still-queued alike: while the resident's turn runs, what
1501
+ // arrives waits in the deferred queue, and that is the traffic a reader
1502
+ // is there for.
1503
+ this.invokeReaderFork(
1504
+ serverId,
1505
+ channelId,
1506
+ hold.epochId,
1507
+ trigger,
1508
+ notice,
1509
+ this.hooks.getStoredMessages().filter(inChannel),
1510
+ this.queuedHeld(inChannel),
1511
+ );
1512
+ // The count restarts once the fork has been handed what was held, as it
1513
+ // does below once the subconscious's turn has been asked for. Otherwise
1514
+ // every later cadence period still sees this one's count and derives a
1515
+ // fork with nothing new to read.
1516
+ this.divertedSinceInvocation.set(key, 0);
1517
+ return;
1518
+ }
667
1519
  this.hooks.addMessage(
668
1520
  'user',
669
1521
  [{ type: 'text', text: notice }],
@@ -674,6 +1526,280 @@ export class TuneOutCoordinator {
674
1526
  // Counted from here only once the turn has been asked for, so a turn that
675
1527
  // throws leaves its messages for the next period's turn.
676
1528
  this.divertedSinceInvocation.set(key, 0);
1529
+ const epochId = this.holdFor(serverId, channelId)?.epochId;
1530
+ if (epochId) this.markLook(epochId, serverId, channelId);
1531
+ }
1532
+
1533
+ /**
1534
+ * Count again, from the held messages, stored or still queued behind the
1535
+ * resident's turn, those the reader of the epoch holding this channel now
1536
+ * hasn't looked at: for reader forks, those no fork was handed; for the
1537
+ * persistent subconscious, the stored ones held since its last look
1538
+ * (lastLooks) and every queued one, which no view of it can show yet.
1539
+ * Nothing holding the channel leaves no count.
1540
+ */
1541
+ private rebuildTally(serverId: string, channelId: string): void {
1542
+ const key = this.key(serverId, channelId);
1543
+ const hold = this.holdFor(serverId, channelId);
1544
+ if (!hold || !this.readerAvailable()) {
1545
+ this.divertedSinceInvocation.delete(key);
1546
+ return;
1547
+ }
1548
+ const ofChannel = inChannelOf(serverId, channelId);
1549
+ const inEpoch = (m: Pick<StoredMessage, 'metadata'>): boolean =>
1550
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m);
1551
+ const rows = this.hooks.getStoredMessages().filter(inEpoch);
1552
+ const queued = this.queuedHeld(inEpoch);
1553
+ let count: number;
1554
+ if (this.forkReader) {
1555
+ const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued);
1556
+ count = unread.backlog.length + unread.queued.length;
1557
+ } else {
1558
+ const mark = this.lastLooks()?.[hold.epochId]?.[key];
1559
+ count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length;
1560
+ }
1561
+ this.divertedSinceInvocation.set(key, count);
1562
+ }
1563
+
1564
+ private get lastLooksStateId(): string {
1565
+ return `tune-out/${this.hooks.primaryName() ?? 'default'}/last-looks`;
1566
+ }
1567
+
1568
+ /**
1569
+ * The stored last looks. Where they can't be read, none: every held row
1570
+ * counts, and nothing is cached, so the next read tries again. Nor is
1571
+ * anything written back over the stored record then (markLook, dropLooks,
1572
+ * pruneLooks), which would erase the other epochs' and channels' looks.
1573
+ */
1574
+ private lastLooks(): Record<string, Record<string, number>> | undefined {
1575
+ if (this.lastLooksCache !== undefined) return this.lastLooksCache;
1576
+ let raw: unknown;
1577
+ try {
1578
+ raw = this.channelRegistry.readCoordinatorState(this.lastLooksStateId);
1579
+ } catch (error) {
1580
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be read:`, error);
1581
+ return undefined;
1582
+ }
1583
+ const looks: Record<string, Record<string, number>> = {};
1584
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1585
+ for (const [epochId, channels] of Object.entries(raw as Record<string, unknown>)) {
1586
+ if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue;
1587
+ const marks = Object.entries(channels as Record<string, unknown>).filter((e): e is [string, number] => typeof e[1] === 'number');
1588
+ if (marks.length > 0) looks[epochId] = Object.fromEntries(marks);
1589
+ }
1590
+ }
1591
+ this.lastLooksCache = looks;
1592
+ return looks;
1593
+ }
1594
+
1595
+ private writeLooks(looks: Record<string, Record<string, number>>): void {
1596
+ this.lastLooksCache = looks;
1597
+ try {
1598
+ this.channelRegistry.writeCoordinatorState(this.lastLooksStateId, looks);
1599
+ } catch (error) {
1600
+ // The look itself stands. After a restart, this look's messages would
1601
+ // count as unseen at the next one.
1602
+ console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be stored:`, error);
1603
+ }
1604
+ }
1605
+
1606
+ private markLook(epochId: string, serverId: string, channelId: string): void {
1607
+ const looks = this.lastLooks();
1608
+ if (!looks) return;
1609
+ this.writeLooks({ ...looks, [epochId]: { ...looks[epochId], [this.key(serverId, channelId)]: this.hooks.currentSequence() } });
1610
+ }
1611
+
1612
+ /** An ended epoch's last looks go with it. */
1613
+ private dropLooks(epochId: string): void {
1614
+ if (this.hooks.subconsciousName() == null) return;
1615
+ const looks = this.lastLooks();
1616
+ if (!looks || !(epochId in looks)) return;
1617
+ const { [epochId]: _ended, ...rest } = looks;
1618
+ this.writeLooks(rest);
1619
+ }
1620
+
1621
+ /** At boot: the last looks of epochs no longer active go. */
1622
+ private pruneLooks(): void {
1623
+ if (this.hooks.subconsciousName() == null) return;
1624
+ const looks = this.lastLooks();
1625
+ if (!looks) return;
1626
+ const active = new Set(this.listActive().map((e) => e.params.epochId));
1627
+ const scope = this.getAllExcept();
1628
+ if (scope) active.add(scope.params.epochId);
1629
+ const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
1630
+ if (Object.keys(kept).length !== Object.keys(looks).length) this.writeLooks(kept);
1631
+ }
1632
+
1633
+ /**
1634
+ * `reader: 'forks'`: hand the framework one invocation. The backlog is
1635
+ * the epoch's held messages not yet handed to any fork — each is
1636
+ * delivered to exactly one reader — stored ones first, then those still
1637
+ * queued behind the resident's turn, plus the standing dispositions. A
1638
+ * cadence look with nothing new is skipped. True when a fork was asked for.
1639
+ */
1640
+ private invokeReaderFork(
1641
+ serverId: string,
1642
+ channelId: string,
1643
+ epochId: string,
1644
+ trigger: ReaderInvocation['trigger'],
1645
+ notice: string,
1646
+ epochBacklog: StoredMessage[],
1647
+ epochQueued: QueuedHeldMessage[] = [],
1648
+ ): boolean {
1649
+ if (!this.hooks.invokeReader) return false;
1650
+ const key = this.handOffKey(epochId, serverId, channelId);
1651
+ const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued);
1652
+ if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0) return false;
1653
+ // The cursor only moves forward: a returned row handed again can be
1654
+ // behind it.
1655
+ const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
1656
+ if (backlog.length > 0) this.readerCursor.set(key, cursor);
1657
+ const handed = this.readerHandedWrites.get(key) ?? new Set<string>();
1658
+ for (const m of queued) handed.add(m.writeId);
1659
+ // A handed write whose row has landed behind the cursor can no longer be
1660
+ // handed by sequence, so its id has done its work.
1661
+ for (const m of epochBacklog) {
1662
+ const writeId = writeIdOf(m);
1663
+ if (writeId !== undefined && m.sequence <= cursor) handed.delete(writeId);
1664
+ }
1665
+ if (handed.size > 0) this.readerHandedWrites.set(key, handed);
1666
+ else this.readerHandedWrites.delete(key);
1667
+ const returned = this.readerReturned.get(key);
1668
+ if (returned) {
1669
+ for (const m of backlog) {
1670
+ returned.ids.delete(m.id);
1671
+ const writeId = writeIdOf(m);
1672
+ if (writeId !== undefined) returned.writeIds.delete(writeId);
1673
+ }
1674
+ for (const m of queued) returned.writeIds.delete(m.writeId);
1675
+ if (returned.ids.size === 0 && returned.writeIds.size === 0) this.readerReturned.delete(key);
1676
+ }
1677
+ const entries = Object.entries(this.readDispositions());
1678
+ this.hooks.invokeReader({
1679
+ serverId,
1680
+ channelId,
1681
+ epochId,
1682
+ trigger,
1683
+ notice,
1684
+ backlog,
1685
+ queued,
1686
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1687
+ });
1688
+ return true;
1689
+ }
1690
+
1691
+ /**
1692
+ * What no reader fork has read yet, of the given held messages: the stored
1693
+ * ones past the epoch's cursor for the channel, less those a fork was handed while they
1694
+ * were still queued, plus those a failed fork returned, oldest first; then
1695
+ * the queued ones no fork was handed, or a failed fork returned.
1696
+ */
1697
+ private unhandedBacklog(
1698
+ key: string,
1699
+ stored: StoredMessage[],
1700
+ queued: QueuedHeldMessage[],
1701
+ ): { backlog: StoredMessage[]; queued: QueuedHeldMessage[] } {
1702
+ const cursor = this.readerCursor.get(key) ?? -1;
1703
+ const handed = this.readerHandedWrites.get(key);
1704
+ const returned = this.readerReturned.get(key);
1705
+ const wasReturned = (id: string | undefined, writeId: string | undefined): boolean =>
1706
+ returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId)));
1707
+ const unread = (m: StoredMessage): boolean => {
1708
+ const writeId = writeIdOf(m);
1709
+ if (wasReturned(m.id, writeId)) return true;
1710
+ return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId));
1711
+ };
1712
+ return {
1713
+ backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence),
1714
+ queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)),
1715
+ };
1716
+ }
1717
+
1718
+ /**
1719
+ * A reader fork that never read what it was handed: its derive failed, or
1720
+ * its run threw before it reported. While the epoch that held the
1721
+ * messages is still active, they go back to that epoch's backlog for the
1722
+ * channel, and its next invocation for the channel is handed them: the
1723
+ * scope's while the scope is active, though the channel's own tune-out
1724
+ * has taken the channel meanwhile, and a channel's own tune-out's while it
1725
+ * holds the channel. Once the epoch has ended, its cancel's last fork was
1726
+ * handed only what no fork had been, so a fork still running then is the
1727
+ * only reader its messages had: one late fork is handed them instead
1728
+ * (lateFork). Returns how many went back or to the late fork.
1729
+ */
1730
+ readerFailed(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
1731
+ const { serverId, channelId, epochId } = invocation;
1732
+ const active = this.getAllExcept()?.params.epochId === epochId
1733
+ || this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
1734
+ if (!active) return this.lateFork(invocation);
1735
+ const key = this.handOffKey(epochId, serverId, channelId);
1736
+ const returned = this.readerReturned.get(key) ?? { ids: new Set<string>(), writeIds: new Set<string>() };
1737
+ for (const m of invocation.backlog) returned.ids.add(m.id);
1738
+ for (const m of invocation.queued) returned.writeIds.add(m.writeId);
1739
+ const count = invocation.backlog.length + invocation.queued.length;
1740
+ if (count > 0) {
1741
+ this.readerReturned.set(key, returned);
1742
+ // They count again toward the next cadence look of the epoch holding
1743
+ // the channel, which hands them back. Where another epoch holds it
1744
+ // now, they're counted again when the channel comes back to theirs
1745
+ // (rebuildTally), and its release hands them in any case.
1746
+ const tally = this.key(serverId, channelId);
1747
+ if (this.holdFor(serverId, channelId)?.epochId === epochId) {
1748
+ this.divertedSinceInvocation.set(tally, (this.divertedSinceInvocation.get(tally) ?? 0) + count);
1749
+ }
1750
+ }
1751
+ return count;
1752
+ }
1753
+
1754
+ /**
1755
+ * A fork that failed after its epoch ended: one late fork is handed what it
1756
+ * was, with a notice saying a reader handed them failed after the tune-out
1757
+ * ended. Those still queued when it was handed that have landed since are
1758
+ * handed as stored. A late fork's own failure derives no other, so a
1759
+ * reader that keeps failing costs one more fork, not a chain of them.
1760
+ * Returns how many the late fork was handed.
1761
+ */
1762
+ private lateFork(invocation: Pick<ReaderInvocation, 'serverId' | 'channelId' | 'epochId' | 'backlog' | 'queued'>): number {
1763
+ if (!this.hooks.invokeReader || this.lateForks.has(invocation)) return 0;
1764
+ const { serverId, channelId, epochId } = invocation;
1765
+ const landed = new Map<string, StoredMessage>();
1766
+ for (const m of this.hooks.getStoredMessages()) {
1767
+ const writeId = writeIdOf(m);
1768
+ if (writeId !== undefined) landed.set(writeId, m);
1769
+ }
1770
+ const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])]
1771
+ .sort((a, b) => a.sequence - b.sequence);
1772
+ const queued = invocation.queued.filter((m) => !landed.has(m.writeId));
1773
+ const count = backlog.length + queued.length;
1774
+ if (count === 0) return 0;
1775
+ const these = count === 1 ? 'This message was' : `These ${count} messages were`;
1776
+ const notice = `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${these} handed to a reader that `
1777
+ + `failed after the tune-out ended, so no reader has read ${count === 1 ? 'it' : 'them'} yet.]`;
1778
+ const entries = Object.entries(this.readDispositions());
1779
+ const late: ReaderInvocation = {
1780
+ serverId,
1781
+ channelId,
1782
+ epochId,
1783
+ trigger: 'cancel',
1784
+ notice,
1785
+ backlog,
1786
+ queued,
1787
+ dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null,
1788
+ };
1789
+ this.lateForks.add(late);
1790
+ this.hooks.invokeReader(late);
1791
+ return count;
1792
+ }
1793
+
1794
+ /**
1795
+ * The held messages still queued behind the resident's turn that `keep`
1796
+ * keeps, oldest first, with their write ids. One the host gives no id for
1797
+ * is left to reach a reader once it is stored: without the id, its stored
1798
+ * row couldn't be told from a message no reader has had.
1799
+ */
1800
+ private queuedHeld(keep: (m: Pick<StoredMessage, 'metadata'>) => boolean): QueuedHeldMessage[] {
1801
+ return (this.hooks.getDeferredMessages?.() ?? [])
1802
+ .filter((m): m is QueuedHeldMessage => typeof m.writeId === 'string' && keep(m));
677
1803
  }
678
1804
 
679
1805
  // ==========================================================================
@@ -754,6 +1880,11 @@ export class TuneOutCoordinator {
754
1880
  input: Record<string, unknown>,
755
1881
  /** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */
756
1882
  own: readonly ChannelRef[] = [],
1883
+ /** The calling agent: the persistent subconscious, or a reader fork. */
1884
+ caller?: string,
1885
+ /** A reader fork, with the epoch it was derived to read, if any: it ends and posts only through that epoch's
1886
+ * hold (forkRefusal). The persistent subconscious, which reads every epoch, passes none. */
1887
+ fork?: { epochId?: string },
757
1888
  ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
758
1889
  switch (name) {
759
1890
  case 'deliver_summary': {
@@ -768,49 +1899,105 @@ export class TuneOutCoordinator {
768
1899
  };
769
1900
  }
770
1901
  const subName = this.hooks.subconsciousName();
771
- if (!subName) return { success: false, error: 'subconscious not configured', isError: true };
772
- // Verbatim, under its own participant name, into the resident's window,
773
- // tagged with the channel the name means and its server, since the
774
- // framework reads a conversational row's channel (repinChannel). The
775
- // name means what it means to every channel tool: inside the calling
776
- // turn, the id of one of the turn's own channels means that channel,
777
- // refused where its server no longer registers it or the turn has
778
- // channels with that id on two servers (ownChannelFor); otherwise a
779
- // name several registered channels answer to is refused with them
780
- // (sharedNameRefusalFor). So no row is left for a re-pin to read as
781
- // another server's channel. A name no registered channel answers to
782
- // is kept as given, with no server, which a re-pin reads by name when
783
- // it weighs the row, as it reads any message without its server.
1902
+ // Verbatim, under its own participant name, into the resident's window
1903
+ // (or, from a reader fork, as its attributed mail), tagged with the
1904
+ // channel the name means and its server, since the framework reads a
1905
+ // conversational row's channel (repinChannel). The name means what it
1906
+ // means to every channel tool: inside the calling turn, the id of one
1907
+ // of the turn's own channels means that channel, refused where its
1908
+ // server no longer registers it or the turn has channels with that id
1909
+ // on two servers (ownChannelFor); otherwise a name several registered
1910
+ // channels answer to is refused with them (sharedNameRefusalFor). So
1911
+ // no row is left for a re-pin to read as another server's channel. A
1912
+ // name no registered channel answers to is kept as given, with no
1913
+ // server, which a re-pin reads by name when it weighs the row, as it
1914
+ // reads any message without its server.
1915
+ if (!subName && !(caller && this.hooks.deliverFromReader)) {
1916
+ return { success: false, error: 'subconscious not configured', isError: true };
1917
+ }
784
1918
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver');
785
1919
  if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
786
1920
  const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(channelId, 'nothing was delivered');
787
1921
  if (shared) return { success: false, error: shared, isError: true };
788
1922
  const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
789
- this.hooks.addMessage(subName, [{ type: 'text', text }], {
1923
+ const metadata = {
790
1924
  kind: 'subconscious-summary',
791
1925
  ...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }),
792
- });
1926
+ };
1927
+ if (subName) {
1928
+ this.hooks.addMessage(subName, [{ type: 'text', text }], metadata);
1929
+ return { success: true, data: { delivered: true } };
1930
+ }
1931
+ // A reader fork's report: attributed mail under its own name. The fork
1932
+ // counts as having reported only once the mail is held: a delivery
1933
+ // that throws is an error result to the fork, and its prose is then
1934
+ // still delivered as its report when it ends (#546, probe R14).
1935
+ this.hooks.deliverFromReader!(caller!, [{ type: 'text', text }], metadata);
1936
+ this.readerReports.add(caller!);
793
1937
  return { success: true, data: { delivered: true } };
794
1938
  }
795
1939
  case 'cancel_tuneout': {
796
1940
  const name = String(input.channelId ?? '');
1941
+ const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
1942
+ const endScope = (): { success: boolean; data?: unknown; error?: string; isError?: boolean } => {
1943
+ const scope = this.getAllExcept();
1944
+ const refused = fork && scope
1945
+ ? this.forkRefusal(fork, { epochId: scope.params.epochId }, 'cancel', "the all-except hold isn't the hold you were started to read")
1946
+ : undefined;
1947
+ if (refused) return { success: false, error: refused, isError: true };
1948
+ const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller);
1949
+ return result.ok
1950
+ ? { success: true, data: { cancelled: true, scope: 'all-except' } }
1951
+ : { success: false, error: result.error, isError: true };
1952
+ };
1953
+ // A call naming the all-except scope ends that hold outright, as the
1954
+ // tool describes it and as the resident's tune_out cancel does: the
1955
+ // channel it names, tuned out on its own or not, doesn't turn it
1956
+ // into that channel's cancel, and with no hold it ends nothing.
1957
+ if (input.scope === 'all-except') return endScope();
1958
+ const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
797
1959
  // The name means what it means to every channel tool, the
798
- // resident's tune_out included: inside the calling turn, the id of
799
- // one of the turn's own channels means that channel (ownChannelFor);
800
- // otherwise a name several registered channels answer to is refused
1960
+ // resident's tune_out included: with a serverId, that server's
1961
+ // channel with the id; inside the calling turn, the id of one of the
1962
+ // turn's own channels means that channel (ownChannelFor); otherwise a
1963
+ // name several registered channels answer to picks none of them
801
1964
  // (channelsNamed), whichever of them is tuned out, never taken to be
802
- // the tuned-out one.
803
- const mine = this.channelRegistry.ownChannelFor(name, own);
804
- if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
805
- const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
806
- const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
807
- if (shared) return { success: false, error: shared, isError: true };
808
- const channel = named[0];
1965
+ // the tuned-out one. A name that picks no channel, such a name or one
1966
+ // of the turn's own channels that ownChannelFor refuses, is refused,
1967
+ // unless the call names the all-except scope, which ends that hold
1968
+ // below.
1969
+ const mine = serverId ? undefined : this.channelRegistry.ownChannelFor(name, own);
1970
+ const refusal = mine && 'error' in mine ? mine.error
1971
+ : serverId || mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name);
1972
+ const named = serverId ? [{ serverId, channelId: name }]
1973
+ : mine && 'channel' in mine ? [mine.channel] : this.channelRegistry.channelsNamed(name);
1974
+ const channel = refusal ? undefined : named[0];
809
1975
  if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) {
810
- return { success: false, error: this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
1976
+ // The reader keeps its latitude over the all-except hold too: a
1977
+ // channel it holds (or the scope named as the channel) ends the
1978
+ // scope. A call naming the scope "channel" ends only a channel's
1979
+ // own tune-out, so on a channel only the scope holds it ends
1980
+ // nothing.
1981
+ const scope = this.getAllExcept();
1982
+ const heldByScope = scope && (name === 'all-except'
1983
+ || (channel !== undefined && this.holdFor(channel.serverId, channel.channelId)?.epochId === scope.params.epochId));
1984
+ if (!heldByScope) return { success: false, error: refusal ?? this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true };
1985
+ if (input.scope === 'channel') {
1986
+ const shown = channel ? this.quotedShown(channel.serverId, channel.channelId) : JSON.stringify(name);
1987
+ return {
1988
+ success: false,
1989
+ error: `Nothing was cancelled: ${shown} isn't tuned out on its own, and scope "channel" ends only a channel's own `
1990
+ + 'tune-out. The all-except hold holds it, and calling cancel_tuneout with scope "all-except" ends that hold.',
1991
+ isError: true,
1992
+ };
1993
+ }
1994
+ return endScope();
811
1995
  }
812
- const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
813
- const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note);
1996
+ const held = this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)!;
1997
+ const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel',
1998
+ `the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`);
1999
+ if (refused) return { success: false, error: refused, isError: true };
2000
+ const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller);
814
2001
  return result.ok
815
2002
  ? { success: true, data: { cancelled: true } }
816
2003
  : { success: false, error: result.error, isError: true };
@@ -864,13 +2051,47 @@ export class TuneOutCoordinator {
864
2051
  // means that channel, as for every channel tool (ownChannelFor).
865
2052
  const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish');
866
2053
  if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true };
867
- return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
2054
+ if (fork) {
2055
+ const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId);
2056
+ const at = named.length === 1 ? named[0] : undefined;
2057
+ const hold = at ? this.holdFor(at.serverId, at.channelId) : null;
2058
+ const shown = at ? this.quotedShown(at.serverId, at.channelId) : JSON.stringify(channelId);
2059
+ const refused = (at || fork.epochId === undefined) && this.forkRefusal(fork, hold, 'speech', hold
2060
+ ? `${shown} is held by a hold other than the one you were started to read`
2061
+ : `${shown} isn't held now`);
2062
+ if (refused) return { success: false, error: refused, isError: true };
2063
+ }
2064
+ return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious');
868
2065
  }
869
2066
  default:
870
2067
  return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
871
2068
  }
872
2069
  }
873
2070
 
2071
+ /**
2072
+ * Why a reader fork may not act through `hold` (the epoch holding what its
2073
+ * call names, or null): a fork ends and posts only through the hold of the
2074
+ * epoch it was derived to read, and one derived for none acts through no
2075
+ * hold. Undefined where the hold is its own. `why` says, for the fork, how
2076
+ * what its call names isn't its own hold; its reports stay open to it.
2077
+ */
2078
+ private forkRefusal(
2079
+ fork: { epochId?: string },
2080
+ hold: { epochId: string } | null,
2081
+ act: 'cancel' | 'speech',
2082
+ why: string,
2083
+ ): string | undefined {
2084
+ if (fork.epochId !== undefined && hold?.epochId === fork.epochId) return undefined;
2085
+ const nothing = act === 'cancel' ? 'Nothing was cancelled' : 'Nothing was posted';
2086
+ const report = `Your report can say what you would ${act === 'cancel' ? 'judge of it' : 'have posted'}, and the resident reads it.`;
2087
+ if (fork.epochId === undefined) {
2088
+ return `${nothing}: you weren't started to read a tune-out, so there's no hold you can `
2089
+ + `${act === 'cancel' ? 'end' : 'post through'}. ${report}`;
2090
+ }
2091
+ const rule = act === 'cancel' ? 'a reader ends only its own' : 'a reader posts only into a channel its own hold holds';
2092
+ return `${nothing}: ${why}, and ${rule}. ${report}`;
2093
+ }
2094
+
874
2095
  /**
875
2096
  * A cancel of a channel that isn't tuned out, by the name, quoted, the caller gave
876
2097
  * or knows it by: which channels are, so the next call can name one, and
@@ -892,6 +2113,18 @@ export class TuneOutCoordinator {
892
2113
  + `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`;
893
2114
  }
894
2115
 
2116
+ /**
2117
+ * The reader ended, not the framework: its turns stop (cadence and
2118
+ * coalescing wakes), and every hold keeps its end time, which releases it
2119
+ * to the resident with no reader.
2120
+ */
2121
+ stopReader(): void {
2122
+ for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
2123
+ this.cadenceTimers.clear();
2124
+ for (const { timer } of this.pendingWakes.values()) clearTimeout(timer);
2125
+ this.pendingWakes.clear();
2126
+ }
2127
+
895
2128
  /** Stop all timers (framework shutdown). */
896
2129
  stop(): void {
897
2130
  for (const timer of this.cadenceTimers.values()) clearTimeout(timer);