@animalabs/agent-framework 0.23.2 → 0.24.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 (338) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +60 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +92 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/engine.d.ts +177 -0
  10. package/dist/src/code-execution/engine.d.ts.map +1 -0
  11. package/dist/src/code-execution/engine.js +494 -0
  12. package/dist/src/code-execution/engine.js.map +1 -0
  13. package/dist/src/code-execution/index.d.ts +5 -10
  14. package/dist/src/code-execution/index.d.ts.map +1 -1
  15. package/dist/src/code-execution/index.js +5 -9
  16. package/dist/src/code-execution/index.js.map +1 -1
  17. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  18. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  19. package/dist/src/code-execution/isolate/calls.js +145 -0
  20. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  21. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  22. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  24. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  26. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-main.js +132 -0
  28. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  30. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child.js +315 -0
  32. package/dist/src/code-execution/isolate/child.js.map +1 -0
  33. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  34. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/frames.js +210 -0
  36. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  37. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  38. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/output.js +143 -0
  40. package/dist/src/code-execution/isolate/output.js.map +1 -0
  41. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  42. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/prelude.js +401 -0
  44. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  45. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  46. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/protocol.js +150 -0
  48. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  49. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  50. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/render.js +214 -0
  52. package/dist/src/code-execution/isolate/render.js.map +1 -0
  53. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  54. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/runner.js +854 -0
  56. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  57. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  58. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/scripts.js +357 -0
  60. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  61. package/dist/src/code-execution/quickjs.wasm +0 -0
  62. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  63. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  64. package/dist/src/code-execution/tool-definition.js +176 -142
  65. package/dist/src/code-execution/tool-definition.js.map +1 -1
  66. package/dist/src/deadline-timer.d.ts +3 -0
  67. package/dist/src/deadline-timer.d.ts.map +1 -1
  68. package/dist/src/deadline-timer.js +1 -1
  69. package/dist/src/deadline-timer.js.map +1 -1
  70. package/dist/src/framework.d.ts +1027 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4093 -2238
  73. package/dist/src/framework.js.map +1 -1
  74. package/dist/src/gate/event-gate.d.ts +28 -1
  75. package/dist/src/gate/event-gate.d.ts.map +1 -1
  76. package/dist/src/gate/event-gate.js +88 -10
  77. package/dist/src/gate/event-gate.js.map +1 -1
  78. package/dist/src/held-writes.d.ts +16 -3
  79. package/dist/src/held-writes.d.ts.map +1 -1
  80. package/dist/src/held-writes.js +16 -3
  81. package/dist/src/held-writes.js.map +1 -1
  82. package/dist/src/index.d.ts +6 -6
  83. package/dist/src/index.d.ts.map +1 -1
  84. package/dist/src/index.js +9 -8
  85. package/dist/src/index.js.map +1 -1
  86. package/dist/src/mcpl/channel-registry.d.ts +471 -75
  87. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  88. package/dist/src/mcpl/channel-registry.js +1673 -337
  89. package/dist/src/mcpl/channel-registry.js.map +1 -1
  90. package/dist/src/mcpl/conversation-router.d.ts +12 -7
  91. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  92. package/dist/src/mcpl/conversation-router.js +12 -7
  93. package/dist/src/mcpl/conversation-router.js.map +1 -1
  94. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  95. package/dist/src/mcpl/convert-block.js +9 -5
  96. package/dist/src/mcpl/convert-block.js.map +1 -1
  97. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  98. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  99. package/dist/src/mcpl/inbound-source.js +130 -0
  100. package/dist/src/mcpl/inbound-source.js.map +1 -1
  101. package/dist/src/mcpl/index.d.ts +2 -1
  102. package/dist/src/mcpl/index.d.ts.map +1 -1
  103. package/dist/src/mcpl/index.js +3 -2
  104. package/dist/src/mcpl/index.js.map +1 -1
  105. package/dist/src/mcpl/log-value.d.ts +6 -0
  106. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  107. package/dist/src/mcpl/log-value.js +8 -0
  108. package/dist/src/mcpl/log-value.js.map +1 -1
  109. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  110. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  111. package/dist/src/mcpl/modern-connection.js +168 -53
  112. package/dist/src/mcpl/modern-connection.js.map +1 -1
  113. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  114. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  115. package/dist/src/mcpl/prose-grammar.js +4 -5
  116. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  117. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  118. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  119. package/dist/src/mcpl/protocol-family.js +173 -16
  120. package/dist/src/mcpl/protocol-family.js.map +1 -1
  121. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  122. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  123. package/dist/src/mcpl/push-coalescer.js +227 -50
  124. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  125. package/dist/src/mcpl/push-handler.d.ts +6 -1
  126. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-handler.js +4 -1
  128. package/dist/src/mcpl/push-handler.js.map +1 -1
  129. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  130. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  131. package/dist/src/mcpl/reference-fetcher.js +4 -4
  132. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  133. package/dist/src/mcpl/server-connection.d.ts +50 -21
  134. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  135. package/dist/src/mcpl/server-connection.js +158 -74
  136. package/dist/src/mcpl/server-connection.js.map +1 -1
  137. package/dist/src/mcpl/tags.d.ts +24 -0
  138. package/dist/src/mcpl/tags.d.ts.map +1 -1
  139. package/dist/src/mcpl/tags.js +33 -0
  140. package/dist/src/mcpl/tags.js.map +1 -1
  141. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  142. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  143. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  144. package/dist/src/mcpl/transport.d.ts +55 -7
  145. package/dist/src/mcpl/transport.d.ts.map +1 -1
  146. package/dist/src/mcpl/transport.js +163 -34
  147. package/dist/src/mcpl/transport.js.map +1 -1
  148. package/dist/src/mcpl/types.d.ts +22 -6
  149. package/dist/src/mcpl/types.d.ts.map +1 -1
  150. package/dist/src/mcpl/types.js.map +1 -1
  151. package/dist/src/module-registry.d.ts +4 -2
  152. package/dist/src/module-registry.d.ts.map +1 -1
  153. package/dist/src/module-registry.js +100 -13
  154. package/dist/src/module-registry.js.map +1 -1
  155. package/dist/src/modules/discord/index.d.ts.map +1 -1
  156. package/dist/src/modules/discord/index.js +8 -13
  157. package/dist/src/modules/discord/index.js.map +1 -1
  158. package/dist/src/modules/history/index.d.ts.map +1 -1
  159. package/dist/src/modules/history/index.js +27 -4
  160. package/dist/src/modules/history/index.js.map +1 -1
  161. package/dist/src/modules/history/semantic.d.ts +15 -3
  162. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  163. package/dist/src/modules/history/semantic.js +27 -5
  164. package/dist/src/modules/history/semantic.js.map +1 -1
  165. package/dist/src/modules/index.d.ts +1 -1
  166. package/dist/src/modules/index.d.ts.map +1 -1
  167. package/dist/src/modules/index.js.map +1 -1
  168. package/dist/src/modules/unknown-tool.d.ts +4 -1
  169. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  170. package/dist/src/modules/unknown-tool.js +4 -1
  171. package/dist/src/modules/unknown-tool.js.map +1 -1
  172. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  173. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  174. package/dist/src/modules/workspace/branch-intent.js +3 -1
  175. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  176. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  177. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  178. package/dist/src/modules/workspace/edit-plan.js +794 -0
  179. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  180. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  181. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-text.js +584 -0
  183. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  184. package/dist/src/modules/workspace/effects.d.ts +5 -4
  185. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/effects.js +78 -25
  187. package/dist/src/modules/workspace/effects.js.map +1 -1
  188. package/dist/src/modules/workspace/index.d.ts +484 -61
  189. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/index.js +2173 -645
  191. package/dist/src/modules/workspace/index.js.map +1 -1
  192. package/dist/src/modules/workspace/observe.d.ts +213 -38
  193. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/observe.js +416 -36
  195. package/dist/src/modules/workspace/observe.js.map +1 -1
  196. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/reconcile.js +15 -8
  198. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  199. package/dist/src/modules/workspace/text.d.ts +32 -0
  200. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  201. package/dist/src/modules/workspace/text.js +67 -0
  202. package/dist/src/modules/workspace/text.js.map +1 -0
  203. package/dist/src/modules/workspace/types.d.ts +210 -6
  204. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/types.js.map +1 -1
  206. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  207. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  208. package/dist/src/modules/workspace/watcher.js.map +1 -1
  209. package/dist/src/operator-log.d.ts +52 -24
  210. package/dist/src/operator-log.d.ts.map +1 -1
  211. package/dist/src/operator-log.js.map +1 -1
  212. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  213. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  214. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  215. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  216. package/dist/src/recovery/offline-branch.d.ts +26 -1
  217. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  218. package/dist/src/recovery/offline-branch.js +164 -39
  219. package/dist/src/recovery/offline-branch.js.map +1 -1
  220. package/dist/src/recovery/recover-cli.js +23 -4
  221. package/dist/src/recovery/recover-cli.js.map +1 -1
  222. package/dist/src/send-tools.d.ts +11 -0
  223. package/dist/src/send-tools.d.ts.map +1 -0
  224. package/dist/src/send-tools.js +11 -0
  225. package/dist/src/send-tools.js.map +1 -0
  226. package/dist/src/tool-result-guard.d.ts +4 -3
  227. package/dist/src/tool-result-guard.d.ts.map +1 -1
  228. package/dist/src/tool-result-guard.js +5 -4
  229. package/dist/src/tool-result-guard.js.map +1 -1
  230. package/dist/src/tool-result-history.d.ts +1 -1
  231. package/dist/src/tool-result-history.js +1 -1
  232. package/dist/src/tune-out/coordinator.d.ts +21 -0
  233. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  234. package/dist/src/tune-out/coordinator.js +139 -37
  235. package/dist/src/tune-out/coordinator.js.map +1 -1
  236. package/dist/src/types/agent.d.ts +4 -0
  237. package/dist/src/types/agent.d.ts.map +1 -1
  238. package/dist/src/types/events.d.ts +62 -9
  239. package/dist/src/types/events.d.ts.map +1 -1
  240. package/dist/src/types/framework.d.ts +62 -58
  241. package/dist/src/types/framework.d.ts.map +1 -1
  242. package/dist/src/types/module.d.ts +40 -8
  243. package/dist/src/types/module.d.ts.map +1 -1
  244. package/dist/src/types/trace.d.ts +111 -10
  245. package/dist/src/types/trace.d.ts.map +1 -1
  246. package/dist/src/usage/types.d.ts +16 -0
  247. package/dist/src/usage/types.d.ts.map +1 -1
  248. package/dist/src/usage/usage-tracker.d.ts +14 -1
  249. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  250. package/dist/src/usage/usage-tracker.js +81 -41
  251. package/dist/src/usage/usage-tracker.js.map +1 -1
  252. package/package.json +6 -5
  253. package/src/agent.ts +93 -12
  254. package/src/api/server.ts +14 -12
  255. package/src/code-execution/engine.ts +595 -0
  256. package/src/code-execution/index.ts +5 -17
  257. package/src/code-execution/isolate/calls.ts +199 -0
  258. package/src/code-execution/isolate/child-entry.ts +5 -0
  259. package/src/code-execution/isolate/child-main.ts +132 -0
  260. package/src/code-execution/isolate/child.ts +327 -0
  261. package/src/code-execution/isolate/frames.ts +209 -0
  262. package/src/code-execution/isolate/output.ts +161 -0
  263. package/src/code-execution/isolate/prelude.ts +409 -0
  264. package/src/code-execution/isolate/protocol.ts +240 -0
  265. package/src/code-execution/isolate/render.ts +230 -0
  266. package/src/code-execution/isolate/runner.ts +911 -0
  267. package/src/code-execution/isolate/scripts.ts +479 -0
  268. package/src/code-execution/quickjs.wasm +0 -0
  269. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  270. package/src/code-execution/tool-definition.ts +236 -167
  271. package/src/deadline-timer.ts +1 -1
  272. package/src/framework.ts +4468 -2418
  273. package/src/gate/event-gate.ts +78 -11
  274. package/src/held-writes.ts +17 -3
  275. package/src/index.ts +30 -22
  276. package/src/mcpl/channel-registry.ts +1825 -368
  277. package/src/mcpl/conversation-router.ts +12 -7
  278. package/src/mcpl/convert-block.ts +9 -5
  279. package/src/mcpl/inbound-source.ts +131 -0
  280. package/src/mcpl/index.ts +4 -2
  281. package/src/mcpl/log-value.ts +9 -0
  282. package/src/mcpl/modern-connection.ts +162 -53
  283. package/src/mcpl/prose-grammar.ts +4 -5
  284. package/src/mcpl/protocol-family.ts +199 -21
  285. package/src/mcpl/push-coalescer.ts +306 -62
  286. package/src/mcpl/push-handler.ts +10 -2
  287. package/src/mcpl/reference-fetcher.ts +4 -4
  288. package/src/mcpl/server-connection.ts +180 -79
  289. package/src/mcpl/tags.ts +36 -0
  290. package/src/mcpl/tool-result-normalize.ts +2 -1
  291. package/src/mcpl/transport.ts +162 -33
  292. package/src/mcpl/types.ts +23 -6
  293. package/src/module-registry.ts +92 -13
  294. package/src/modules/discord/index.ts +8 -13
  295. package/src/modules/history/index.ts +25 -3
  296. package/src/modules/history/semantic.ts +29 -6
  297. package/src/modules/index.ts +1 -0
  298. package/src/modules/unknown-tool.ts +4 -1
  299. package/src/modules/workspace/branch-intent.ts +3 -1
  300. package/src/modules/workspace/edit-plan.ts +954 -0
  301. package/src/modules/workspace/edit-text.ts +618 -0
  302. package/src/modules/workspace/effects.ts +70 -19
  303. package/src/modules/workspace/index.ts +2247 -642
  304. package/src/modules/workspace/observe.ts +465 -56
  305. package/src/modules/workspace/reconcile.ts +14 -7
  306. package/src/modules/workspace/text.ts +68 -0
  307. package/src/modules/workspace/types.ts +211 -6
  308. package/src/modules/workspace/watcher.ts +3 -2
  309. package/src/operator-log.ts +47 -19
  310. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  311. package/src/recovery/offline-branch.ts +189 -55
  312. package/src/recovery/recover-cli.ts +25 -4
  313. package/src/send-tools.ts +11 -0
  314. package/src/tool-result-guard.ts +6 -5
  315. package/src/tool-result-history.ts +1 -1
  316. package/src/tune-out/coordinator.ts +163 -42
  317. package/src/types/agent.ts +4 -0
  318. package/src/types/events.ts +62 -9
  319. package/src/types/framework.ts +62 -58
  320. package/src/types/module.ts +40 -8
  321. package/src/types/trace.ts +121 -15
  322. package/src/usage/types.ts +16 -0
  323. package/src/usage/usage-tracker.ts +86 -43
  324. package/dist/src/code-execution/py-runner.d.ts +0 -191
  325. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  326. package/dist/src/code-execution/py-runner.js +0 -655
  327. package/dist/src/code-execution/py-runner.js.map +0 -1
  328. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  329. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  330. package/dist/src/code-execution/runtime-py.js +0 -677
  331. package/dist/src/code-execution/runtime-py.js.map +0 -1
  332. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  333. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  334. package/dist/src/mcpl/prose-stream-router.js +0 -291
  335. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  336. package/src/code-execution/py-runner.ts +0 -791
  337. package/src/code-execution/runtime-py.ts +0 -677
  338. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -96,6 +96,13 @@ export interface CoalescedOccurrence<E = unknown> {
96
96
  /** Set by the coalescer: deliver into this agent only (§3.2 audience may
97
97
  * narrow, never widen — a replacement or notice follows the prior delivery). */
98
98
  deliverTo?: string;
99
+ /** Set by the coalescer when the delivery is the agent's first view of
100
+ * the subject: a plain occurrence that replaces one that was the agent's
101
+ * first view (its `initial` birth, or a replacement so marked) and that
102
+ * the agent may never have been shown (CoalescerHost.isUnshown), or the
103
+ * first delivery of a batch that opened on a first view (its render, or
104
+ * a plain occurrence that drops it). No other path sets it. */
105
+ firstView?: boolean;
99
106
  }
100
107
 
101
108
  export interface DeferredNotice { eventId: string; timestamp: string; data?: unknown }
@@ -117,6 +124,13 @@ interface DeferredBatch<E> {
117
124
  dropped: number;
118
125
  /** Set on a batch restored from a snapshot whose render may have started. */
119
126
  noRender?: boolean;
127
+ /** The batch opened on the agent's first view of its subject: a fresh
128
+ * `initial` birth, or a first-view occupant it displaced that nothing had
129
+ * shown. Its first delivery is a first view too, while nothing of the
130
+ * subject has been read since and no other version of it is placed. It is
131
+ * cleared when the subject's first view ends while the batch is pending
132
+ * or rendering (occupantLeaves). */
133
+ firstView?: true;
120
134
  }
121
135
 
122
136
  interface Rendering<E> {
@@ -139,25 +153,65 @@ export interface AssemblyResult {
139
153
  * read: empty or blank render (§5.2), cancelled, revoked before the render
140
154
  * or at its response, a render-start that could not be made durable (the
141
155
  * batch then waits for its recovery wake), a batch whose audience doesn't
142
- * include the agent, a render placed in another agent's deferred queue, a
143
- * render another assembly froze (its compile may show it, as that
144
- * request's), or a cause whose batch was withdrawn, displaced or consumed
145
- * by another request. */
156
+ * include the agent, a render placed in a deferred queue (another
157
+ * agent's, or this agent's own behind a write held there), a render
158
+ * another assembly froze (its compile may show it, as that request's), or
159
+ * a cause whose batch was withdrawn, displaced or consumed by another
160
+ * request. */
146
161
  materialized: Set<string>;
147
162
  }
148
163
 
164
+ /** What holds a subject's single slot (§3.2). A render describes what changed
165
+ * since the server's previous render (§5.2), so the renders before it that
166
+ * are still unread stay where they were placed, and the newest carries them
167
+ * as `before` (§3.3, vector 49). A plain occurrence displaces the run and a
168
+ * retraction removes it (§6), newest first (removeOccupant). */
169
+ interface Occupant {
170
+ eventId: string;
171
+ placement: CoalescingPlacement;
172
+ /** A materialized batch (its result or its fallback), which later renders build on. */
173
+ rendered?: true;
174
+ /** This occupant was the agent's first view of the subject when it was
175
+ * placed, as `firstView` defines it. It hands that view on only while
176
+ * nothing of the subject has been read and nothing of its run may have
177
+ * been shown (occupantLeaves). Unknown counts as not one. A plain
178
+ * occurrence can carry it, and of a run's renders only the oldest, since a
179
+ * render that joins a run finds a version placed. In memory only: a
180
+ * snapshot's occupant comes back as read. */
181
+ firstView?: true;
182
+ /** The run's earlier renders, oldest first, each placed ahead of this one. */
183
+ before?: Array<Omit<Occupant, 'before'>>;
184
+ }
185
+
186
+ /** The occupant holding a run's renders, given oldest first: the newest,
187
+ * carrying the rest as `before`. */
188
+ function runOf(members: Array<Omit<Occupant, 'before'>>): Occupant | undefined {
189
+ if (members.length === 0) return undefined;
190
+ const newest = members[members.length - 1];
191
+ return members.length > 1 ? { ...newest, before: members.slice(0, -1) } : newest;
192
+ }
193
+
149
194
  interface SubjectState<E> {
150
195
  history: 'none' | 'some' | 'unknown';
151
- occupant?: { eventId: string; placement: CoalescingPlacement };
196
+ occupant?: Occupant;
152
197
  batch?: DeferredBatch<E>;
153
198
  rendering?: Rendering<E>;
154
199
  consumedEventId?: string;
155
200
  identity?: CoalescedOccurrence['identity'];
156
201
  /** Agent that received the last delivery; replacements and notices follow it. */
157
202
  audienceAgent?: string;
203
+ /** The agent's first view of the subject has ended: an occupant that may
204
+ * have been shown left it without handing a first view on
205
+ * (occupantLeaves, endFirstView). No later batch or birth of the subject
206
+ * is a first view while the coalescer keeps this record. An idle
207
+ * subject's record can be evicted (prune), and an `initial` occurrence
208
+ * after that starts the subject afresh. */
209
+ firstViewEnded?: true;
158
210
  touchedAt: number;
159
211
  }
160
212
 
213
+ interface ReceiptEntry { result: PushEventResult; at: number; committed: boolean; firstView?: true }
214
+
161
215
  export interface CoalescingSnapshot {
162
216
  version: 1;
163
217
  receipts: Array<[string, { result: PushEventResult; at: number }]>;
@@ -168,7 +222,17 @@ export interface CoalescingSnapshot {
168
222
  identity?: CoalescedOccurrence['identity'];
169
223
  occupant?: { eventId: string; placement: CoalescingPlacement };
170
224
  audienceAgent?: string;
171
- batch?: { latest: CoalescedOccurrence<unknown>; notices: DeferredNotice[]; dropped: number; rendering?: boolean };
225
+ /** The subject's first view has ended (SubjectState.firstViewEnded). A
226
+ * snapshot written before this field existed has none and reads as not
227
+ * ended. So after an upgrade, a subject whose first view had ended
228
+ * without a read is granted a first view again. Until the agent is shown
229
+ * or reads a version of it, each delivery that would mark a first view (a
230
+ * render, or a plain occurrence that replaces an unread version or a
231
+ * pending notice) carries a spurious `firstView`. */
232
+ firstViewEnded?: true;
233
+ /** `firstView` only on a batch that wasn't rendering: one that was may
234
+ * have been shown. */
235
+ batch?: { latest: CoalescedOccurrence<unknown>; notices: DeferredNotice[]; dropped: number; rendering?: boolean; firstView?: true };
172
236
  }>;
173
237
  }
174
238
  /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */
@@ -179,6 +243,11 @@ export interface CoalescingReceiptRecord {
179
243
  subject: string;
180
244
  /** History the subject was created with, for a subject first seen by this receipt. */
181
245
  born?: SubjectState<unknown>['history'];
246
+ /** The subject's first view had ended by this acceptance
247
+ * (SubjectState.firstViewEnded), so a crash before the snapshot flush
248
+ * can't forget the end. A record written before this field existed has
249
+ * none and reads as not ended, as a snapshot does. */
250
+ firstViewEnded?: true;
182
251
  /** The subject's pending batch AFTER this acceptance: the accepted work
183
252
  * itself (so a crash before the snapshot flush cannot lose it while keeping
184
253
  * the receipt that suppresses its retry), or `null` when the acceptance
@@ -188,8 +257,20 @@ export interface CoalescingReceiptRecord {
188
257
  }
189
258
 
190
259
  export interface CoalescerHost<E> {
191
- /** True iff the placement still exists and no model request has included it. */
260
+ /** True iff the placement still exists and, as far as the host can tell,
261
+ * no model request has included it. The host can't always tell: a
262
+ * message injected into a stream that didn't say whether it carries
263
+ * injections, as no membrane 0.5.x stream does, counts as unread though
264
+ * a request may have included it. */
192
265
  isUnread(placement: CoalescingPlacement): boolean;
266
+ /** True iff nothing may have shown the placement to the agent: it is still
267
+ * in the deferred queue and known not to be stored already, or it is
268
+ * stored where no committing compile has begun to assemble it and no tool
269
+ * boundary has injected past it. Unlike
270
+ * isUnread, a compile counts from its start, and a boundary counts
271
+ * whether or not its stream says it carries injected messages: a doubt
272
+ * counts as shown. */
273
+ isUnshown(placement: CoalescingPlacement): boolean;
193
274
  /** False when the unread occurrence could not be removed (it may still be read). */
194
275
  remove(placement: CoalescingPlacement): boolean;
195
276
  /** Ordinary delivery of the event; undefined when routing delivered nowhere.
@@ -200,9 +281,9 @@ export interface CoalescerHost<E> {
200
281
  wakeForBatch(occurrence: CoalescedOccurrence<E>): Promise<void>;
201
282
  /** Queue an ordinary push wake for `agent`, under the batch's own wake
202
283
  * policy, to read a render of the batch placed in its deferred queue that
203
- * no request consumed. It carries no subject, so the subject's other wakes,
204
- * and later withdrawals of them, leave it. */
205
- wakeToRead(occurrence: CoalescedOccurrence<E>, agent: string): Promise<void>;
284
+ * no request consumed, at `deferredId`. It carries no subject, so the
285
+ * subject's other wakes, and later withdrawals of them, leave it. */
286
+ wakeToRead(occurrence: CoalescedOccurrence<E>, agent: string, deferredId?: string): Promise<void>;
206
287
  /** Withdraw unstarted wakes whose sole cause is this subject. */
207
288
  cancelWake(subject: string): void;
208
289
  /** The batch's audience still holds the authority it was admitted under. */
@@ -326,7 +407,7 @@ function showsAsItself(b: Record<string, unknown>): boolean {
326
407
  if (b.type === 'resource') return classifyBlock(b).kind === 'reference';
327
408
  if (b.type !== 'image' && b.type !== 'audio') return false;
328
409
  if (isInlineContradiction(b)) return false;
329
- if (isFilledString(b.uri) && b.disposition) return classifyBlock(b).kind === 'reference';
410
+ if (isFilledString(b.uri) && b.disposition !== undefined) return classifyBlock(b).kind === 'reference';
330
411
  if (isFilledString(b.data) && isFilledString(b.mimeType)) return true;
331
412
  return b.type === 'image' && isFilledString(b.uri);
332
413
  }
@@ -367,8 +448,17 @@ export class PushCoalescer<E = unknown> {
367
448
  private readonly subjects = new Map<string, SubjectState<E>>();
368
449
  /** `committed`: the durability barrier succeeded after this receipt was
369
450
  * written. An uncommitted receipt deduplicates effects but is not an
370
- * acknowledgement until a commit succeeds. */
371
- private readonly receipts = new Map<string, { result: PushEventResult; at: number; committed: boolean }>();
451
+ * acknowledgement until a commit succeeds. `firstView`: the admission it
452
+ * records handed its occurrence to the host's delivery marked as the
453
+ * agent's first view (`firstView`), whether or not the host placed
454
+ * it, so a retry answered from it says so too. Kept in memory only: a
455
+ * receipt restored from a snapshot or the crash-window bridge reads as no
456
+ * first view. */
457
+ private readonly receipts = new Map<string, ReceiptEntry>();
458
+ /** Set by a delivery that carries `firstView`. admit() clears it before
459
+ * an admission's effects and reads it after them; every delivery runs
460
+ * under the lock, so between the two it is that admission's alone. */
461
+ private deliveredFirstView = false;
372
462
  private readonly retryWindowMs: number;
373
463
  private readonly maxSubjects: number;
374
464
  private readonly maxNotices: number;
@@ -424,12 +514,26 @@ export class PushCoalescer<E = unknown> {
424
514
  return (this.rendersInFlight.get(serverId) ?? 0) > 0;
425
515
  }
426
516
 
517
+ /** The host's delivery, noting one that carries `firstView` for the
518
+ * receipt of the admission it belongs to (deliveredFirstView). */
519
+ private deliver(...args: Parameters<CoalescerHost<E>['deliver']>): ReturnType<CoalescerHost<E>['deliver']> {
520
+ if (args[0].firstView) this.deliveredFirstView = true;
521
+ return this.host.deliver(...args);
522
+ }
523
+
427
524
  receipt(serverId: string, binding: string, eventId: string): PushEventResult | undefined {
428
525
  const entry = this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId));
429
526
  return entry && structuredClone(entry.result);
430
527
  }
431
528
 
432
- private receiptEntry(key: string): { result: PushEventResult; at: number; committed: boolean } | undefined {
529
+ /** Whether the admission this receipt records delivered its occurrence as
530
+ * the agent's first view: for the host's own bookkeeping, such as the
531
+ * channel registry's reply anchor, and never part of the wire result. */
532
+ receiptFirstView(serverId: string, binding: string, eventId: string): boolean {
533
+ return this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId))?.firstView === true;
534
+ }
535
+
536
+ private receiptEntry(key: string): ReceiptEntry | undefined {
433
537
  const entry = this.receipts.get(key);
434
538
  if (!entry) return undefined;
435
539
  if (this.now() - entry.at > this.retryWindowMs) { this.receipts.delete(key); return undefined; }
@@ -439,7 +543,7 @@ export class PushCoalescer<E = unknown> {
439
543
  /** Acknowledge a receipt: durable first. A receipt whose commit failed is
440
544
  * committed now (no effects are repeated); if that fails again, so does
441
545
  * the acknowledgement, and the producer retries once more. */
442
- private async acknowledge(key: string, entry: { result: PushEventResult; at: number; committed: boolean }, subject: string, eventId: string): Promise<PushEventResult> {
546
+ private async acknowledge(key: string, entry: ReceiptEntry, subject: string, eventId: string): Promise<PushEventResult> {
443
547
  if (!entry.committed) {
444
548
  try {
445
549
  await this.host.commit?.();
@@ -480,10 +584,14 @@ export class PushCoalescer<E = unknown> {
480
584
  const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' as const : 'unknown' as const);
481
585
  const state = this.subjectFor(subject, occurrence);
482
586
  const occupantUnread = this.refreshOccupant(state);
483
- const priorEventId = state.occupant?.eventId ?? state.batch?.latest.eventId ?? state.rendering?.batch.latest.eventId ?? state.consumedEventId;
587
+ // A notice leaves an unread render in place (acceptDeferred): neither
588
+ // displaced nor found consumed, it isn't the prior (§3.3).
589
+ const occupantPrior = occurrence.deferred && state.occupant?.rendered ? undefined : state.occupant?.eventId;
590
+ const priorEventId = occupantPrior ?? state.batch?.latest.eventId ?? state.rendering?.batch.latest.eventId ?? state.consumedEventId;
484
591
  this.host.audit({ kind: 'received', subject, eventId: occurrence.eventId, retract: occurrence.retract, deferred: occurrence.deferred });
485
592
 
486
593
  let outcome: CoalesceOutcome;
594
+ this.deliveredFirstView = false;
487
595
  if (occurrence.retract) {
488
596
  outcome = await this.retract(subject, state, occurrence, occupantUnread);
489
597
  } else if (occurrence.deferred) {
@@ -497,10 +605,11 @@ export class PushCoalescer<E = unknown> {
497
605
  state.touchedAt = this.now();
498
606
  const result: PushEventResult = { accepted: true, coalesce: { outcome, ...(priorEventId ? { priorEventId } : {}) } };
499
607
  const receiptKey = coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId);
500
- const entry = { result: structuredClone(result), at: this.now(), committed: false };
608
+ const entry: ReceiptEntry = { result: structuredClone(result), at: this.now(), committed: false, ...(this.deliveredFirstView ? { firstView: true as const } : {}) };
501
609
  try {
502
610
  this.host.recordReceipt?.({
503
611
  key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}),
612
+ ...(state.firstViewEnded ? { firstViewEnded: true as const } : {}),
504
613
  batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence<unknown>, notices: state.batch.notices, dropped: state.batch.dropped }) : null,
505
614
  });
506
615
  } catch (error) {
@@ -534,16 +643,80 @@ export class PushCoalescer<E = unknown> {
534
643
  return state;
535
644
  }
536
645
 
537
- /** Re-check the occupant against the host: consumed occupants become history. */
646
+ /**
647
+ * Re-check the occupant against the host, member by member: a member the
648
+ * host counts as read, or that is gone (isUnread is false either way),
649
+ * becomes history, and the run keeps the members still unread. Returns
650
+ * whether any are.
651
+ */
538
652
  private refreshOccupant(state: SubjectState<E>): boolean {
539
- if (!state.occupant) return false;
540
- if (this.host.isUnread(state.occupant.placement)) return true;
653
+ const occupant = state.occupant;
654
+ if (!occupant) return false;
655
+ const { before = [], ...newest } = occupant;
656
+ const members = [...before, newest];
657
+ const unread = members.filter((member) => this.host.isUnread(member.placement));
658
+ if (unread.length === members.length) return true;
541
659
  state.history = 'some';
542
- state.consumedEventId = state.occupant.eventId;
543
- state.occupant = undefined;
660
+ state.consumedEventId = members.filter((member) => !unread.includes(member)).at(-1)!.eventId;
661
+ state.occupant = runOf(unread);
662
+ return unread.length > 0;
663
+ }
664
+
665
+ /**
666
+ * Remove the occupant and the run it builds on, newest first, auditing each
667
+ * as `kind`. Every member is unread: refreshOccupant has just run, under the
668
+ * lock. The host's removal may still refuse one, such as a held write the
669
+ * host can't tell isn't already stored. A member the host
670
+ * can't remove may be read, so the walk stops there, leaving it and the
671
+ * renders it builds on, and the history becomes `unknown`.
672
+ * Returns false then. It doesn't touch `state.occupant`, which still names
673
+ * the members it removed: each caller clears it, so what the walk left is
674
+ * history from then on, as an occupant whose removal failed always was.
675
+ */
676
+ private removeOccupant(subject: string, state: SubjectState<E>, kind: 'removed' | 'displaced'): boolean {
677
+ const occupant = state.occupant!;
678
+ for (const member of [occupant, ...[...(occupant.before ?? [])].reverse()]) {
679
+ this.host.audit({ kind, subject, eventId: member.eventId });
680
+ if (!this.host.remove(member.placement)) {
681
+ // Still in context and possibly about to be read.
682
+ this.host.audit({ kind: 'remove-failed', subject, eventId: member.eventId });
683
+ state.history = 'unknown';
684
+ state.consumedEventId = member.eventId;
685
+ return false;
686
+ }
687
+ }
688
+ return true;
689
+ }
690
+
691
+ /**
692
+ * An unread occupant leaves the subject: a notice or a plain occurrence
693
+ * displaces it, or a retraction withdraws it, with any run of renders it
694
+ * carries. Returns whether it was a first view that nothing may have shown
695
+ * (isUnshown), which a replacement carries on: for a run, its oldest render
696
+ * carries the mark, and no render of it may have been shown. Nor may
697
+ * anything of the subject have been read: a member a request has read, or
698
+ * that is gone, leaves the run before this (refreshOccupant), and only the
699
+ * subject's history still says so. Any other occupant may have been seen,
700
+ * and this is the last point where the host can say so, since the removal
701
+ * takes its placements: the subject's first view ends here (endFirstView).
702
+ */
703
+ private occupantLeaves(state: SubjectState<E>, occupant: Occupant): boolean {
704
+ const { before = [], ...newest } = occupant;
705
+ const members = [...before, newest];
706
+ if (state.history === 'none' && members[0].firstView === true && members.every((member) => this.host.isUnshown(member.placement))) return true;
707
+ this.endFirstView(state);
544
708
  return false;
545
709
  }
546
710
 
711
+ /** The subject's first view ends: for a batch pending or rendering, whose
712
+ * first delivery would come after it, and for every later batch and
713
+ * birth (firstViewEnded). */
714
+ private endFirstView(state: SubjectState<E>): void {
715
+ state.firstViewEnded = true;
716
+ if (state.batch) delete state.batch.firstView;
717
+ if (state.rendering) delete state.rendering.batch.firstView;
718
+ }
719
+
547
720
  private scheduleRecovery(subject: string, state: SubjectState<E>): void {
548
721
  const prior = this.recovery.get(subject);
549
722
  if (prior?.timer) clearTimeout(prior.timer);
@@ -593,12 +766,10 @@ export class PushCoalescer<E = unknown> {
593
766
 
594
767
  private async retract(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
595
768
  if (occupantUnread && state.occupant) {
596
- this.host.audit({ kind: 'removed', subject, eventId: state.occupant.eventId });
597
- if (!this.host.remove(state.occupant.placement)) {
598
- // Still in context and possibly about to be read: say so, append the notice.
599
- this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId });
600
- state.history = 'unknown';
601
- }
769
+ // Asked of the whole run before the walk takes its placements.
770
+ this.occupantLeaves(state, state.occupant);
771
+ // Whatever the walk leaves may be read, and the history it sets says so (§6).
772
+ this.removeOccupant(subject, state, 'removed');
602
773
  }
603
774
  state.occupant = undefined;
604
775
  this.dropBatches(subject, state);
@@ -611,7 +782,7 @@ export class PushCoalescer<E = unknown> {
611
782
  // The notice is an ordinary occurrence: it rides the normal delivery path
612
783
  // (tags → gate policy → wake) and is consumed like any message — in the
613
784
  // context that read a version, never a new one (§3.2).
614
- const placement = await this.host.deliver({ ...occurrence, deliverTo: state.audienceAgent });
785
+ const placement = await this.deliver({ ...occurrence, deliverTo: state.audienceAgent });
615
786
  if (placement) {
616
787
  state.occupant = { eventId: occurrence.eventId, placement };
617
788
  state.audienceAgent = placement.agent;
@@ -620,42 +791,68 @@ export class PushCoalescer<E = unknown> {
620
791
  }
621
792
 
622
793
  private async acceptPlain(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
794
+ // A batch this occurrence drops delivered nothing, so when it opened on
795
+ // the agent's first view, this occurrence takes that view.
796
+ const batchFirstView = (state.batch ?? state.rendering?.batch)?.firstView === true;
623
797
  const hadBatch = this.dropBatches(subject, state);
624
798
  if (occupantUnread && state.occupant) {
625
799
  const prior = state.occupant;
626
- this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId });
627
- if (this.host.remove(prior.placement)) {
800
+ // The replacement is the agent's first view only when the run it
801
+ // displaces held one and nothing may have shown any of it to the agent.
802
+ // Otherwise the subject's first view ends here (occupantLeaves).
803
+ const firstView = this.occupantLeaves(state, prior);
804
+ // A plain occurrence is a complete snapshot (rule 6): it displaces the run.
805
+ if (this.removeOccupant(subject, state, 'displaced')) {
628
806
  this.host.cancelWake(subject);
629
807
  // The replacement follows the prior delivery's audience (§3.2).
630
- const placement = await this.host.deliver({ ...occurrence, deliverTo: prior.placement.agent });
631
- state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
808
+ const placement = await this.deliver({
809
+ ...occurrence, deliverTo: prior.placement.agent,
810
+ ...(firstView ? { firstView: true } : {}),
811
+ });
812
+ state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined;
632
813
  if (placement) state.audienceAgent = placement.agent;
633
814
  return 'replaced';
634
815
  }
635
- // Could not remove: the prior may still be read. Append, as for consumed.
636
- this.host.audit({ kind: 'remove-failed', subject, eventId: prior.eventId });
637
- state.history = 'unknown';
638
- state.consumedEventId = prior.eventId;
816
+ // The walk stopped: what it left may be read. Append, as for consumed.
639
817
  state.occupant = undefined;
640
818
  }
641
819
  if (hadBatch) this.host.cancelWake(subject);
642
- const placement = await this.host.deliver(occurrence);
643
- state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined;
820
+ const outcome: CoalesceOutcome = hadBatch ? 'replaced' : state.consumedEventId || state.history !== 'none' ? 'appended' : 'first';
821
+ // The subject's `initial` birth is the agent's first view of it unless
822
+ // that view has ended, and so is an occurrence that takes a first-view
823
+ // batch's place (a batch's own first view ends with the subject's).
824
+ const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first' && !state.firstViewEnded;
825
+ const placement = await this.deliver(hadBatch && firstView ? { ...occurrence, firstView: true } : occurrence);
826
+ state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined;
644
827
  if (placement) state.audienceAgent = placement.agent;
645
- if (hadBatch) return 'replaced';
646
- return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first';
828
+ return outcome;
647
829
  }
648
830
 
649
831
  private async acceptDeferred(subject: string, state: SubjectState<E>, occurrence: CoalescedOccurrence<E>, occupantUnread: boolean): Promise<CoalesceOutcome> {
650
832
  let outcome: CoalesceOutcome = 'first';
651
- if (occupantUnread && state.occupant) {
833
+ // Whether a batch this notice opens is the agent's first view of the
834
+ // subject: nothing of it read, its first view not ended, no run of
835
+ // renders placed (the run holds the first view, and the batch's render
836
+ // joins it), and a first-view occupant this notice displaces that nothing
837
+ // has shown (occupantLeaves, which otherwise ends the first view for the
838
+ // batches already open too). A render still under way is weighed when a
839
+ // batch's first delivery is.
840
+ let firstView = state.history === 'none' && !state.firstViewEnded;
841
+ if (occupantUnread && state.occupant?.rendered) {
842
+ // An unread render is a delta the next render builds on (§5.2), not a
843
+ // plain occurrence: it stays, and the batch follows its agent (§3.2), so
844
+ // the next render reaches the context that holds it (§3.3, vector 49).
845
+ occurrence = { ...occurrence, deliverTo: state.occupant.placement.agent };
846
+ firstView = false;
847
+ } else if (occupantUnread && state.occupant) {
652
848
  // §5.1: a notice displaces an unread plain occurrence and opens a batch
653
849
  // — in the audience that held the plain occurrence (§3.2).
850
+ if (!this.occupantLeaves(state, state.occupant)) firstView = false;
654
851
  this.host.audit({ kind: 'displaced', subject, eventId: state.occupant.eventId });
655
852
  if (this.host.remove(state.occupant.placement)) {
656
853
  outcome = 'replaced';
657
854
  occurrence = { ...occurrence, deliverTo: state.occupant.placement.agent };
658
- } else { this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId }); state.history = 'unknown'; }
855
+ } else { this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId }); state.history = 'unknown'; firstView = false; }
659
856
  this.host.cancelWake(subject);
660
857
  state.occupant = undefined;
661
858
  } else if (state.batch?.latest.deliverTo) {
@@ -670,7 +867,7 @@ export class PushCoalescer<E = unknown> {
670
867
  outcome = 'replaced';
671
868
  } else {
672
869
  // Rule 1: a notice during a render opens a NEW batch ("first").
673
- state.batch = { latest: occurrence, notices: [notice], dropped: 0 };
870
+ state.batch = { latest: occurrence, notices: [notice], dropped: 0, ...(firstView ? { firstView: true as const } : {}) };
674
871
  }
675
872
  await this.host.wakeForBatch(occurrence);
676
873
  return outcome;
@@ -855,21 +1052,46 @@ export class PushCoalescer<E = unknown> {
855
1052
  const empty = isVisiblyEmptyContent(content);
856
1053
  this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty });
857
1054
  if (empty) return; // §5.2: nothing happened (blank text included)
858
- const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
1055
+ // A render describes what changed since the server's previous render
1056
+ // (§5.2), so the renders before it stay where they are, and this one
1057
+ // joins their run after them (§3.3, vector 49). Any a request has
1058
+ // read by then, refreshOccupant drops at its next look. The batch's
1059
+ // first delivery is the agent's first view while nothing of the
1060
+ // subject has been read and no other version of it is placed, so only
1061
+ // a run's oldest render can be one.
1062
+ const run = state.occupant?.rendered ? state.occupant : undefined;
1063
+ const firstView = batch.firstView === true && state.history === 'none' && !state.occupant;
1064
+ const placement = await this.deliver({ ...occurrence, timestamp, content, ...(firstView ? { firstView: true } : {}) }, content, assemblingFor);
859
1065
  if (placement?.deferredId) {
860
- // Landed in another agent's deferred queue (its turn is alive): still
861
- // unread there, so it stays replaceable and withdrawable. The turn
862
- // being assembled doesn't read it, so it isn't materialized for that
863
- // turn, and no request has consumed it. The freeze withdrew the
864
- // queue's agent's wake for this batch; give it an ordinary wake, as
865
- // any delivery during its turn has, beside any wakes a newer batch
866
- // has, so that its next turn reads the content.
867
- state.occupant = { eventId: occurrence.eventId, placement };
1066
+ // Landed in a deferred queue: another agent's, whose turn is alive,
1067
+ // or the assembling agent's own, behind a write held there (the
1068
+ // host's bypassDeferralFor), or after its assembly ended, as any
1069
+ // write during its turn. Still unread
1070
+ // there, so it stays replaceable and withdrawable. The turn being
1071
+ // assembled doesn't read it, so it isn't materialized for that turn,
1072
+ // and no request has consumed it. The freeze withdrew the queue's
1073
+ // agent's wake for this batch; give it an ordinary wake, as any
1074
+ // delivery during its turn has, beside any wakes a newer batch has,
1075
+ // so that its next turn reads the content (the wake starts no turn
1076
+ // once the agent has read it, or it's removed).
1077
+ // The run's renders keep their own records, whatever else an
1078
+ // occupant carries, a first view's mark included.
1079
+ let before: Occupant['before'];
1080
+ if (run) { const { before: earlier, ...member } = run; before = [...(earlier ?? []), member]; }
1081
+ state.occupant = { eventId: occurrence.eventId, placement, rendered: true, ...(firstView ? { firstView: true as const } : {}), ...(before ? { before } : {}) };
1082
+ // A batch opened while this render was under way (§5.4 rule 1)
1083
+ // follows the run's agent from here, wakes included, as a later
1084
+ // notice's does (acceptDeferred), so the run stays in one context.
1085
+ if (state.batch && !state.batch.latest.deliverTo) {
1086
+ state.batch.latest = { ...state.batch.latest, deliverTo: placement.agent };
1087
+ await this.host.wakeForBatch(state.batch.latest);
1088
+ }
868
1089
  state.audienceAgent = placement.agent;
869
- await this.host.wakeToRead(occurrence, placement.agent);
1090
+ await this.host.wakeToRead(occurrence, placement.agent, placement.deferredId);
870
1091
  return;
871
1092
  }
872
- // The materialized occurrence is consumed by the request being assembled.
1093
+ // The materialized occurrence is consumed by the request being
1094
+ // assembled, and becomes history with the run placed ahead of it.
873
1095
  rendering.materialized = true;
874
1096
  state.history = 'some';
875
1097
  state.consumedEventId = occurrence.eventId;
@@ -899,13 +1121,17 @@ export class PushCoalescer<E = unknown> {
899
1121
  consumedEventId: s.occupant?.eventId ?? s.consumedEventId,
900
1122
  identity: s.identity,
901
1123
  audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent,
1124
+ ...(s.firstViewEnded ? { firstViewEnded: true as const } : {}),
902
1125
  touchedAt: now,
903
1126
  };
904
1127
  if (s.batch) {
905
1128
  // An interrupted render is not replayed (§3.2): a batch that was
906
1129
  // RENDERING keeps its fallback; a batch that was only pending renders
907
1130
  // normally at the next assembly.
908
- state.batch = { latest: s.batch.latest as CoalescedOccurrence<E>, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) };
1131
+ state.batch = {
1132
+ latest: s.batch.latest as CoalescedOccurrence<E>, notices: s.batch.notices, dropped: s.batch.dropped,
1133
+ ...(s.batch.rendering ? { noRender: true } : s.batch.firstView ? { firstView: true as const } : {}),
1134
+ };
909
1135
  }
910
1136
  this.subjects.set(s.subject, state);
911
1137
  }
@@ -935,9 +1161,24 @@ export class PushCoalescer<E = unknown> {
935
1161
  restoreReceipts(records: CoalescingReceiptRecord[]): void {
936
1162
  const now = this.now();
937
1163
  for (const r of records) {
938
- if (now - r.at > this.retryWindowMs) continue;
939
- this.receipts.set(r.key, { result: r.result, at: r.at, committed: true });
940
- if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now });
1164
+ const fresh = now - r.at <= this.retryWindowMs;
1165
+ if (fresh) {
1166
+ this.receipts.set(r.key, { result: r.result, at: r.at, committed: true });
1167
+ if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now });
1168
+ }
1169
+ // A first view's end is a fact about the subject, which holds while the
1170
+ // coalescer keeps it, not about the receipt, whose retry window says
1171
+ // nothing about it. So a record that carries it ends the subject's
1172
+ // first view whatever its age, for a batch the snapshot brought back
1173
+ // too. A subject this restore doesn't hold yet existed when the record
1174
+ // was written and may have been shown, so it comes back with that end
1175
+ // and an unknown history.
1176
+ if (r.firstViewEnded) {
1177
+ const ended = this.subjects.get(r.subject);
1178
+ if (ended) this.endFirstView(ended);
1179
+ else this.subjects.set(r.subject, { history: 'unknown', touchedAt: now, firstViewEnded: true });
1180
+ }
1181
+ if (!fresh) continue;
941
1182
  // A birth fact is not proof the subject is still unread: the plain
942
1183
  // occurrence this record acknowledged is in context (durable before the
943
1184
  // record) and counts as read from recovery on, like a snapshot occupant.
@@ -967,12 +1208,15 @@ export class PushCoalescer<E = unknown> {
967
1208
  snapshot(): CoalescingSnapshot {
968
1209
  return {
969
1210
  version: 1,
970
- receipts: [...this.receipts],
1211
+ receipts: [...this.receipts].map(([key, { result, at }]): [string, { result: PushEventResult; at: number }] => [key, { result, at }]),
971
1212
  subjects: [...this.subjects].map(([subject, s]) => ({
972
1213
  subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity,
973
1214
  ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}),
974
- ...(s.occupant ? { occupant: s.occupant } : {}),
975
- ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence<unknown>, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } }
1215
+ ...(s.firstViewEnded ? { firstViewEnded: true as const } : {}),
1216
+ // restore() reads only these. After a restart nothing of the subject
1217
+ // is tracked as unread, a run included: its history counts as read.
1218
+ ...(s.occupant ? { occupant: { eventId: s.occupant.eventId, placement: s.occupant.placement } } : {}),
1219
+ ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence<unknown>, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : s.batch.firstView ? { firstView: true as const } : {}) } }
976
1220
  : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence<unknown>, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } }
977
1221
  : {}),
978
1222
  })),
@@ -48,8 +48,13 @@ export interface McplPushEvent {
48
48
  targetAgents?: string[];
49
49
  /** RFC-006: set when the coalescer delivers; rides the wake it queues. */
50
50
  coalescingSubject?: string;
51
- /** RFC-006 assembly: materialized for this agent's turn (store directly). */
51
+ /** RFC-006 assembly: materialized for this agent's turn, stored directly
52
+ * while its assembly runs, after what is queued for it (the framework's
53
+ * addMessage, bypassDeferralFor). */
52
54
  assemblingFor?: string;
55
+ /** The coalescer's mark: the agent's first view of the subject (see
56
+ * CoalescedOccurrence.firstView). */
57
+ firstView?: boolean;
53
58
  /** Host acceptance time (epoch ms), stamped where the push is admitted. */
54
59
  acceptedAt?: number;
55
60
  /** Framework-owned source envelope, frozen at a coalesced occurrence's
@@ -224,12 +229,15 @@ export class PushHandler {
224
229
  .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
225
230
  .map((b) => b.text)
226
231
  .join('\n');
232
+ // The host's own fields come after the server's origin, as a channel
233
+ // message's do, so an origin can't give the gate another server's id,
234
+ // feature set, event id or event type.
227
235
  const metadata: Record<string, unknown> = {
236
+ ...(params.origin ?? {}),
228
237
  serverId,
229
238
  featureSet: params.featureSet,
230
239
  eventId: params.eventId,
231
240
  eventType: 'mcpl:push-event',
232
- ...(params.origin ?? {}),
233
241
  ...(params.tags ? { tags: params.tags } : {}),
234
242
  };
235
243
  triggerInference = this.shouldTriggerInference(textContent, metadata);
@@ -224,7 +224,7 @@ export class ReferenceFetcher {
224
224
  constructor(
225
225
  private readonly resolveServer: (serverId: string) => ServerResolution | undefined,
226
226
  /** Persist the payload; returns a mount-prefixed path or null when no
227
- * writable workspace exists. File name is host-generated (§7.3). */
227
+ * writable mount without an include exists. File name is host-generated (§7.3). */
228
228
  private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
229
229
  ) {}
230
230
 
@@ -463,9 +463,9 @@ export class ReferenceFetcher {
463
463
  const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
464
464
  const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
465
465
  // §7.3: host-generated storage name; the server's `name` is display-only.
466
- // A null from the save hook covers no workspace, no writable mount and
467
- // a failed write, and the hook logs which. A throw is the save's failure
468
- // too, not the fetch's, so it's logged here.
466
+ // A null from the save hook covers no workspace, no writable mount
467
+ // without an include and a failed write, and the hook logs which. A
468
+ // throw is the save's failure too, not the fetch's, so it's logged here.
469
469
  const fileName = `${record.refId}.${ext}`;
470
470
  let path: string | null;
471
471
  try {