@animalabs/agent-framework 0.21.1 → 0.22.1

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 (348) hide show
  1. package/README.md +54 -4
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +105 -43
  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 +41 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +22 -1
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +64 -9
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +215 -38
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/framework.d.ts +569 -59
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3091 -542
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +21 -0
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +40 -7
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/index.d.ts +7 -2
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +26 -3
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +17 -0
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  37. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  38. package/dist/src/mcpl/feature-set-manager.js +31 -7
  39. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  40. package/dist/src/mcpl/index.d.ts +4 -0
  41. package/dist/src/mcpl/index.d.ts.map +1 -1
  42. package/dist/src/mcpl/index.js +1 -0
  43. package/dist/src/mcpl/index.js.map +1 -1
  44. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  45. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  46. package/dist/src/mcpl/prose-stream-router.js +85 -22
  47. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  48. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  49. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  50. package/dist/src/mcpl/push-coalescer.js +46 -13
  51. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  52. package/dist/src/mcpl/push-handler.d.ts +5 -3
  53. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  54. package/dist/src/mcpl/push-handler.js +41 -11
  55. package/dist/src/mcpl/push-handler.js.map +1 -1
  56. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  57. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  58. package/dist/src/mcpl/reference-fetcher.js +20 -9
  59. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  60. package/dist/src/mcpl/references.d.ts +29 -6
  61. package/dist/src/mcpl/references.d.ts.map +1 -1
  62. package/dist/src/mcpl/references.js +57 -16
  63. package/dist/src/mcpl/references.js.map +1 -1
  64. package/dist/src/mcpl/server-connection.d.ts +32 -1
  65. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  66. package/dist/src/mcpl/server-connection.js +76 -11
  67. package/dist/src/mcpl/server-connection.js.map +1 -1
  68. package/dist/src/mcpl/server-registry.d.ts +5 -2
  69. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/server-registry.js +25 -7
  71. package/dist/src/mcpl/server-registry.js.map +1 -1
  72. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  73. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  74. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  75. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  76. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  77. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  78. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  79. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  80. package/dist/src/mcpl/types.d.ts +15 -5
  81. package/dist/src/mcpl/types.d.ts.map +1 -1
  82. package/dist/src/mcpl/types.js.map +1 -1
  83. package/dist/src/mcpl/visible-content.d.ts +39 -0
  84. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  85. package/dist/src/mcpl/visible-content.js +54 -0
  86. package/dist/src/mcpl/visible-content.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +90 -6
  88. package/dist/src/module-registry.d.ts.map +1 -1
  89. package/dist/src/module-registry.js +260 -18
  90. package/dist/src/module-registry.js.map +1 -1
  91. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  92. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/branch-intent.js +83 -0
  94. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  95. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  96. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  97. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  98. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  99. package/dist/src/modules/workspace/effects.d.ts +84 -0
  100. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  101. package/dist/src/modules/workspace/effects.js +368 -0
  102. package/dist/src/modules/workspace/effects.js.map +1 -0
  103. package/dist/src/modules/workspace/index.d.ts +155 -17
  104. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/index.js +1101 -424
  106. package/dist/src/modules/workspace/index.js.map +1 -1
  107. package/dist/src/modules/workspace/observe.d.ts +288 -0
  108. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  109. package/dist/src/modules/workspace/observe.js +825 -0
  110. package/dist/src/modules/workspace/observe.js.map +1 -0
  111. package/dist/src/modules/workspace/reconcile.d.ts +238 -0
  112. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/reconcile.js +909 -0
  114. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +6 -59
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  117. package/dist/src/modules/workspace/sync.js +6 -257
  118. package/dist/src/modules/workspace/sync.js.map +1 -1
  119. package/dist/src/modules/workspace/types.d.ts +77 -10
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  121. package/dist/src/modules/workspace/types.js.map +1 -1
  122. package/dist/src/operator-log.d.ts +41 -0
  123. package/dist/src/operator-log.d.ts.map +1 -1
  124. package/dist/src/operator-log.js.map +1 -1
  125. package/dist/src/provider-waits.d.ts +216 -0
  126. package/dist/src/provider-waits.d.ts.map +1 -0
  127. package/dist/src/provider-waits.js +482 -0
  128. package/dist/src/provider-waits.js.map +1 -0
  129. package/dist/src/record-journal.d.ts +127 -0
  130. package/dist/src/record-journal.d.ts.map +1 -0
  131. package/dist/src/record-journal.js +202 -0
  132. package/dist/src/record-journal.js.map +1 -0
  133. package/dist/src/refusal-reactions.d.ts +14 -12
  134. package/dist/src/refusal-reactions.d.ts.map +1 -1
  135. package/dist/src/refusal-reactions.js +14 -12
  136. package/dist/src/refusal-reactions.js.map +1 -1
  137. package/dist/src/shutdown.d.ts +13 -0
  138. package/dist/src/shutdown.d.ts.map +1 -0
  139. package/dist/src/shutdown.js +46 -0
  140. package/dist/src/shutdown.js.map +1 -0
  141. package/dist/src/silent-heartbeat.d.ts +74 -0
  142. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  143. package/dist/src/silent-heartbeat.js +77 -0
  144. package/dist/src/silent-heartbeat.js.map +1 -0
  145. package/dist/src/tool-presentation.d.ts +50 -0
  146. package/dist/src/tool-presentation.d.ts.map +1 -0
  147. package/dist/src/tool-presentation.js +235 -0
  148. package/dist/src/tool-presentation.js.map +1 -0
  149. package/dist/src/tool-result-guard.d.ts +142 -6
  150. package/dist/src/tool-result-guard.d.ts.map +1 -1
  151. package/dist/src/tool-result-guard.js +357 -36
  152. package/dist/src/tool-result-guard.js.map +1 -1
  153. package/dist/src/tool-result-history.d.ts +13 -0
  154. package/dist/src/tool-result-history.d.ts.map +1 -1
  155. package/dist/src/tool-result-history.js +21 -1
  156. package/dist/src/tool-result-history.js.map +1 -1
  157. package/dist/src/types/agent.d.ts +18 -2
  158. package/dist/src/types/agent.d.ts.map +1 -1
  159. package/dist/src/types/events.d.ts +16 -0
  160. package/dist/src/types/events.d.ts.map +1 -1
  161. package/dist/src/types/framework.d.ts +12 -0
  162. package/dist/src/types/framework.d.ts.map +1 -1
  163. package/dist/src/types/module.d.ts +26 -4
  164. package/dist/src/types/module.d.ts.map +1 -1
  165. package/dist/src/types/trace.d.ts +87 -3
  166. package/dist/src/types/trace.d.ts.map +1 -1
  167. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  168. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  169. package/dist/test/batched-wake-locus.test.js +166 -0
  170. package/dist/test/batched-wake-locus.test.js.map +1 -0
  171. package/dist/test/code-execution.test.js +386 -2
  172. package/dist/test/code-execution.test.js.map +1 -1
  173. package/dist/test/component-defaults.test.d.ts +2 -0
  174. package/dist/test/component-defaults.test.d.ts.map +1 -0
  175. package/dist/test/component-defaults.test.js +54 -0
  176. package/dist/test/component-defaults.test.js.map +1 -0
  177. package/dist/test/conversation-routing.test.js +57 -1
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +3 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/ephemeral-settle.test.js +3 -0
  182. package/dist/test/ephemeral-settle.test.js.map +1 -1
  183. package/dist/test/event-gate.test.js +118 -0
  184. package/dist/test/event-gate.test.js.map +1 -1
  185. package/dist/test/explicit-prose-routing.test.js +975 -0
  186. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  187. package/dist/test/framework.test.js +160 -1
  188. package/dist/test/framework.test.js.map +1 -1
  189. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  190. package/dist/test/helpers/coalescing-fixture.js +3 -2
  191. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  192. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  193. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  194. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  195. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  196. package/dist/test/inference-exhaustion-rewind.test.js +142 -14
  197. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  198. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  199. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  200. package/dist/test/inference-failure-bounds.test.js +1201 -0
  201. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  202. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  203. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  204. package/dist/test/late-events-after-stop.test.js +122 -0
  205. package/dist/test/late-events-after-stop.test.js.map +1 -0
  206. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  207. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  208. package/dist/test/mcpl-coalescing.test.js +97 -22
  209. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  210. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  211. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  212. package/dist/test/mcpl-config-diagnostics.test.js +452 -0
  213. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  214. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  215. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  216. package/dist/test/mcpl-empty-content.test.js +368 -0
  217. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  218. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  219. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  220. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  221. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  222. package/dist/test/mcpl-reference-fields.test.js +69 -2
  223. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  224. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  225. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  226. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  227. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  228. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  229. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  230. package/dist/test/mcpl-ws-transport.test.js +103 -0
  231. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  232. package/dist/test/ops-alert.test.js +2 -1
  233. package/dist/test/ops-alert.test.js.map +1 -1
  234. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  235. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  236. package/dist/test/placed-reaction-baseline.test.js +312 -0
  237. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  238. package/dist/test/prose-stream-router.test.js +41 -0
  239. package/dist/test/prose-stream-router.test.js.map +1 -1
  240. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  241. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  242. package/dist/test/provider-waits.test.d.ts +2 -0
  243. package/dist/test/provider-waits.test.d.ts.map +1 -0
  244. package/dist/test/provider-waits.test.js +2196 -0
  245. package/dist/test/provider-waits.test.js.map +1 -0
  246. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  247. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  248. package/dist/test/reaction-baseline-platform.test.js +124 -0
  249. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  250. package/dist/test/record-journal.test.d.ts +6 -0
  251. package/dist/test/record-journal.test.d.ts.map +1 -0
  252. package/dist/test/record-journal.test.js +269 -0
  253. package/dist/test/record-journal.test.js.map +1 -0
  254. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  255. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  256. package/dist/test/save-recent-image.test.js +240 -2
  257. package/dist/test/save-recent-image.test.js.map +1 -1
  258. package/dist/test/shutdown-errors.test.d.ts +2 -0
  259. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  260. package/dist/test/shutdown-errors.test.js +662 -0
  261. package/dist/test/shutdown-errors.test.js.map +1 -0
  262. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  263. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  264. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  265. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  266. package/dist/test/silent-heartbeat.test.js +49 -38
  267. package/dist/test/silent-heartbeat.test.js.map +1 -1
  268. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  269. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  270. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  271. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  272. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  273. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  274. package/dist/test/surgery-marks-receipt.test.js +540 -0
  275. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  276. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  277. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  278. package/dist/test/tool-batch-hook.test.js +275 -0
  279. package/dist/test/tool-batch-hook.test.js.map +1 -0
  280. package/dist/test/tool-presentation.test.d.ts +2 -0
  281. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  282. package/dist/test/tool-presentation.test.js +268 -0
  283. package/dist/test/tool-presentation.test.js.map +1 -0
  284. package/dist/test/tool-result-guard.test.js +2545 -19
  285. package/dist/test/tool-result-guard.test.js.map +1 -1
  286. package/dist/test/tool-result-history.test.js +2 -1
  287. package/dist/test/tool-result-history.test.js.map +1 -1
  288. package/dist/test/turn-trigger-provenance.test.js +35 -0
  289. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  290. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  291. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  292. package/dist/test/unconvertible-tool-result.test.js +358 -0
  293. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  294. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  295. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  296. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  297. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  298. package/dist/test/workspace-three-way.test.d.ts +12 -0
  299. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  300. package/dist/test/workspace-three-way.test.js +3194 -0
  301. package/dist/test/workspace-three-way.test.js.map +1 -0
  302. package/dist/tsconfig.tsbuildinfo +1 -1
  303. package/package.json +2 -2
  304. package/src/agent.ts +124 -45
  305. package/src/api/server.ts +43 -4
  306. package/src/code-execution/py-runner.ts +66 -9
  307. package/src/code-execution/runtime-py.ts +215 -38
  308. package/src/framework.ts +3191 -544
  309. package/src/gate/event-gate.ts +59 -7
  310. package/src/index.ts +18 -2
  311. package/src/mcpl/channel-registry.ts +24 -3
  312. package/src/mcpl/conversation-router.ts +34 -1
  313. package/src/mcpl/feature-set-manager.ts +36 -11
  314. package/src/mcpl/index.ts +4 -0
  315. package/src/mcpl/prose-stream-router.ts +79 -23
  316. package/src/mcpl/push-coalescer.ts +62 -13
  317. package/src/mcpl/push-handler.ts +41 -12
  318. package/src/mcpl/reference-fetcher.ts +18 -8
  319. package/src/mcpl/references.ts +57 -13
  320. package/src/mcpl/server-connection.ts +95 -13
  321. package/src/mcpl/server-registry.ts +24 -7
  322. package/src/mcpl/tool-lifecycle.ts +77 -2
  323. package/src/mcpl/tool-pattern-check.ts +106 -0
  324. package/src/mcpl/types.ts +15 -5
  325. package/src/mcpl/visible-content.ts +57 -0
  326. package/src/module-registry.ts +269 -25
  327. package/src/modules/workspace/branch-intent.ts +123 -0
  328. package/src/modules/workspace/disk-agreement.ts +325 -0
  329. package/src/modules/workspace/effects.ts +362 -0
  330. package/src/modules/workspace/index.ts +1098 -446
  331. package/src/modules/workspace/observe.ts +865 -0
  332. package/src/modules/workspace/reconcile.ts +1157 -0
  333. package/src/modules/workspace/sync.ts +6 -336
  334. package/src/modules/workspace/types.ts +75 -10
  335. package/src/operator-log.ts +30 -0
  336. package/src/provider-waits.ts +523 -0
  337. package/src/record-journal.ts +246 -0
  338. package/src/refusal-reactions.ts +14 -12
  339. package/src/shutdown.ts +49 -0
  340. package/src/silent-heartbeat.ts +141 -0
  341. package/src/tool-presentation.ts +188 -0
  342. package/src/tool-result-guard.ts +384 -34
  343. package/src/tool-result-history.ts +25 -1
  344. package/src/types/agent.ts +18 -2
  345. package/src/types/events.ts +16 -0
  346. package/src/types/framework.ts +12 -0
  347. package/src/types/module.ts +27 -4
  348. package/src/types/trace.ts +94 -3
@@ -18,6 +18,7 @@
18
18
  * bookkeeping and is unit-tested without a framework.
19
19
  */
20
20
  import type { McplContentBlock, PushEventResult } from './types.js';
21
+ import { isVisiblyEmptyContent } from './visible-content.js';
21
22
 
22
23
  export const PUSH_COALESCING_SUPPORT = {
23
24
  pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
@@ -36,6 +37,15 @@ export class CoalesceError extends Error {
36
37
  }
37
38
  }
38
39
 
40
+ /** Content with nothing model-visible (see visible-content.ts): -32602. */
41
+ export class EmptyContentError extends CoalesceError {
42
+ readonly reason = 'empty-content';
43
+ constructor(field = 'payload.content') {
44
+ super(field, 'content has no visible content: send at least one non-text block or non-whitespace text');
45
+ this.name = 'EmptyContentError';
46
+ }
47
+ }
48
+
39
49
  /** Where an unread occurrence currently lives. */
40
50
  export interface CoalescingPlacement {
41
51
  agent: string;
@@ -100,6 +110,18 @@ interface Rendering<E> {
100
110
  batch: DeferredBatch<E>;
101
111
  cancelled: boolean;
102
112
  done: Promise<void>;
113
+ /** Set once the render (or its fallback) was delivered as content. */
114
+ materialized?: boolean;
115
+ }
116
+
117
+ /** What an assembly did, per subject, for the turn being assembled. */
118
+ export interface AssemblyResult {
119
+ /** Subjects whose render this assembly waited on to completion. */
120
+ settled: Set<string>;
121
+ /** Of those, the subjects whose render or fallback was delivered as content.
122
+ * A settled subject that is not here gave the turn nothing to read: empty
123
+ * or blank render (§5.2), cancelled, or revoked at response. */
124
+ materialized: Set<string>;
103
125
  }
104
126
 
105
127
  interface SubjectState<E> {
@@ -207,8 +229,20 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
207
229
  return out;
208
230
  }
209
231
 
210
- /** Validate wire content before it is stored, rendered or converted. */
211
- export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 1024): asserts content is McplContentBlock[] {
232
+ /**
233
+ * Validate wire content before it is stored, rendered or converted.
234
+ *
235
+ * Visibly-empty content is refused unless `allowEmpty`: an admitted
236
+ * occurrence with nothing to show would wake the model with no visible cause.
237
+ * Empty is legitimate only where the RFC gives it a meaning — a retraction
238
+ * with nothing to announce (§6) and a render result that says nothing
239
+ * happened (§5.2) — and for the silent-heartbeat marker.
240
+ */
241
+ export function validateCoalescedContent(
242
+ content: unknown,
243
+ maxBytes = 1024 * 1024,
244
+ options: { allowEmpty?: boolean } = {},
245
+ ): asserts content is McplContentBlock[] {
212
246
  if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
213
247
  for (const b of content as Array<Record<string, unknown>>) {
214
248
  if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
@@ -219,6 +253,7 @@ export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 102
219
253
  throw new CoalesceError('payload.content', 'invalid content block');
220
254
  }
221
255
  if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
256
+ if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
222
257
  }
223
258
 
224
259
  /** Validate the `coalesce` member of either lane (§13). */
@@ -486,7 +521,9 @@ export class PushCoalescer<E = unknown> {
486
521
  this.host.cancelWake(subject);
487
522
  state.consumedEventId = undefined;
488
523
  if (state.history === 'none') return 'retracted';
489
- if (!occurrence.content.length) return 'consumed';
524
+ // An empty notice — or one with only blank text, which would reach the
525
+ // model as an empty message — is pure withdrawal: append nothing.
526
+ if (isVisiblyEmptyContent(occurrence.content)) return 'consumed';
490
527
  // The notice is an ordinary occurrence: it rides the normal delivery path
491
528
  // (tags → gate policy → wake) and is consumed like any message — in the
492
529
  // context that read a version, never a new one (§3.2).
@@ -563,20 +600,29 @@ export class PushCoalescer<E = unknown> {
563
600
  * Render and materialize every pending batch whose audience includes
564
601
  * `agentName`. Called by the host at a turn's assembly boundary, before the
565
602
  * compile. Bounded by the host's render timeout; a failed or late render
566
- * materializes the admitted fallback (§5.3).
603
+ * materializes the admitted fallback (§5.3). Reports which subjects
604
+ * settled and which of them produced content, so the host can tell a turn
605
+ * whose only cause rendered nothing.
567
606
  */
568
- async assemble(agentName: string): Promise<void> {
569
- if (this.suspended) return;
607
+ async assemble(agentName: string): Promise<AssemblyResult> {
608
+ const result: AssemblyResult = { settled: new Set(), materialized: new Set() };
609
+ if (this.suspended) return result;
570
610
  const work: Promise<void>[] = [];
571
611
  for (const subject of [...this.subjects.keys()]) {
572
612
  // The handle is wrapped: a bare promise returned through `locked` would
573
613
  // be flattened by `then`, holding the lock until the render settled —
574
614
  // and the settlement itself needs the lock.
575
615
  const started = await this.locked(() => this.freeze(subject, agentName));
576
- if (started) work.push(started.done);
616
+ if (started) {
617
+ work.push(started.done.then(() => {
618
+ result.settled.add(subject);
619
+ if (started.rendering.materialized) result.materialized.add(subject);
620
+ }));
621
+ }
577
622
  }
578
623
  await Promise.all(work);
579
624
  this.persist();
625
+ return result;
580
626
  }
581
627
 
582
628
  /**
@@ -585,13 +631,13 @@ export class PushCoalescer<E = unknown> {
585
631
  * the RPC. Returns the render's completion (shared by every assembly that
586
632
  * waits on this batch, vector 25) or undefined when nothing was started.
587
633
  */
588
- private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void> } | undefined> {
634
+ private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void>; rendering: Rendering<E> } | undefined> {
589
635
  const state = this.subjects.get(subject);
590
636
  if (!state || this.suspended) return undefined;
591
637
  if (state.rendering) {
592
638
  // Another assembly froze this batch; share its outcome (vector 25).
593
639
  const rendering = state.rendering;
594
- return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
640
+ return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done, rendering } : undefined;
595
641
  }
596
642
  const batch = state.batch;
597
643
  if (!batch) return undefined;
@@ -634,7 +680,7 @@ export class PushCoalescer<E = unknown> {
634
680
  return undefined;
635
681
  }
636
682
  rendering.done = this.render(subject, state, rendering, agentName);
637
- return { done: rendering.done };
683
+ return { done: rendering.done, rendering };
638
684
  }
639
685
 
640
686
  private async render(subject: string, state: SubjectState<E>, rendering: Rendering<E>, assemblingFor: string): Promise<void> {
@@ -654,7 +700,8 @@ export class PushCoalescer<E = unknown> {
654
700
  try {
655
701
  const result = await this.host.render(occurrence, params);
656
702
  if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
657
- else validateCoalescedContent(result?.content, this.options.maxContentBytes);
703
+ // §5.2: an empty result is legitimate ("nothing happened").
704
+ else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
658
705
  if (rendering.cancelled) throw new CancelledRender();
659
706
  content = result.content;
660
707
  timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
@@ -673,9 +720,11 @@ export class PushCoalescer<E = unknown> {
673
720
  if (rendering.cancelled || state.rendering !== rendering || this.suspended) return;
674
721
  // Rule 4: authority is re-checked at response.
675
722
  if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; }
676
- this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 });
677
- if (!content.length) return; // §5.2: nothing happened
723
+ const empty = isVisiblyEmptyContent(content);
724
+ this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty });
725
+ if (empty) return; // §5.2: nothing happened (blank text included)
678
726
  const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
727
+ rendering.materialized = true;
679
728
  if (placement?.deferredId) {
680
729
  // Landed in another agent's deferred queue (its turn is alive): still
681
730
  // unread there, so it stays replaceable and withdrawable.
@@ -18,7 +18,8 @@ import type {
18
18
  import type { FeatureSetManager } from './feature-set-manager.js';
19
19
  import { McplFeatureSetError } from './feature-set-manager.js';
20
20
  import { expandCoreTags } from './tags.js';
21
- import { validateCoalescedContent } from './push-coalescer.js';
21
+ import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
22
+ import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js';
22
23
 
23
24
  // ============================================================================
24
25
  // McplPushEvent (the ProcessEvent shape pushed to the queue)
@@ -188,9 +189,9 @@ export class PushHandler {
188
189
  * Handle a push/event message from an MCPL server.
189
190
  *
190
191
  * 1. Validate feature set
191
- * 2. Deduplicate by eventId
192
- * 3. Optionally check shouldTriggerInference callback
193
- * 4. Convert content blocks
192
+ * 2. Convert content blocks; reject visibly-empty content
193
+ * 3. Deduplicate by eventId
194
+ * 4. Optionally check shouldTriggerInference callback
194
195
  * 5. Push event to queue
195
196
  * 6. Emit trace
196
197
  * 7. Respond with accepted + inferenceId
@@ -229,30 +230,46 @@ export class PushHandler {
229
230
  return;
230
231
  }
231
232
 
232
- // 2. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
233
- // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
234
- // gets its original result, which this set could not return).
235
233
  const coalesced = params.coalesce !== undefined && !!this.handleCoalesced;
234
+ // Empty content is meaningful only for a coalescing retraction (pure
235
+ // withdrawal, RFC-006 §6 — the coalescer appends nothing for it) and for
236
+ // the silent-heartbeat marker. Anything else that shows the model nothing
237
+ // would wake it with no visible cause.
238
+ const emptyAllowed = (coalesced && params.coalesce?.retract === true)
239
+ || isSilentHeartbeatMarker({ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content });
236
240
  if (coalesced) {
237
241
  // RFC-006 §13: malformed content is a -32602, checked before conversion.
238
242
  try {
239
- validateCoalescedContent(params.payload?.content);
243
+ validateCoalescedContent(params.payload?.content, undefined, { allowEmpty: emptyAllowed });
240
244
  } catch (error) {
241
245
  const err = error as Error & { code?: number; field?: string };
246
+ if (err instanceof EmptyContentError) this.traceEmptyRejection(serverId, params);
242
247
  if (responder?.respondError) responder.respondError(err.code ?? -32602, err.message, { field: err.field });
243
- else responder?.respond({ accepted: false, reason: err.message });
248
+ else responder?.respond({ accepted: false, reason: err instanceof EmptyContentError ? err.reason : err.message });
244
249
  return;
245
250
  }
246
251
  }
252
+
253
+ // 2. Convert content blocks, and refuse content with nothing visible in
254
+ // it — before dedup, so a refused eventId is not burned for a retry.
255
+ const content: ContentBlock[] = params.payload.content.map(convertBlock);
256
+ if (!emptyAllowed && isVisiblyEmptyContent(content)) {
257
+ const err = new EmptyContentError();
258
+ this.traceEmptyRejection(serverId, params);
259
+ if (responder?.respondError) responder.respondError(err.code, err.message, { field: err.field });
260
+ else responder?.respond({ accepted: false, reason: err.reason });
261
+ return;
262
+ }
263
+
264
+ // 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
265
+ // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
266
+ // gets its original result, which this set could not return).
247
267
  if (!coalesced && this.dedup.checkAndAdd(params.eventId)) {
248
268
  console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
249
269
  responder?.respond({ accepted: false, reason: 'duplicate' });
250
270
  return;
251
271
  }
252
272
 
253
- // 3. Convert content blocks
254
- const content: ContentBlock[] = params.payload.content.map(convertBlock);
255
-
256
273
  // 4. Check shouldTriggerInference callback
257
274
  let triggerInference = true;
258
275
  if (this.shouldTriggerInference) {
@@ -316,4 +333,16 @@ export class PushHandler {
316
333
  // 8. Respond
317
334
  responder?.respond({ accepted: true, inferenceId });
318
335
  }
336
+
337
+ /** Loud, like every other push rejection: the producer's wake went nowhere. */
338
+ private traceEmptyRejection(serverId: string, params: PushEventParams): void {
339
+ console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=empty-content`);
340
+ this.emitTraceFn({
341
+ type: 'mcpl:push-event-rejected',
342
+ serverId,
343
+ eventId: params.eventId,
344
+ featureSet: params.featureSet,
345
+ reason: 'empty-content',
346
+ });
347
+ }
319
348
  }
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { createHash } from 'node:crypto';
31
31
  import type { ReferenceRecord } from './references.js';
32
- import { isReferenceExpired } from './references.js';
32
+ import { EXPIRY_REASON, isReferenceExpired, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel } from './references.js';
33
33
 
34
34
  /** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
35
35
  export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
@@ -79,13 +79,14 @@ export function sniffMime(data: Buffer): string | null {
79
79
  return null;
80
80
  }
81
81
 
82
- const EXT_BY_MIME: Record<string, string> = {
82
+ /** A Map, so a server's type such as `constructor` finds no inherited key. */
83
+ const EXT_BY_MIME = new Map<string, string>(Object.entries({
83
84
  'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
84
85
  'audio/flac': 'flac', 'audio/midi': 'mid', 'audio/ogg': 'ogg',
85
86
  'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
86
87
  'video/mp4': 'mp4', 'application/json': 'json', 'text/plain': 'txt',
87
88
  'application/pdf': 'pdf', 'application/zip': 'zip',
88
- };
89
+ }));
89
90
 
90
91
  /** ws→http / wss→https with default ports elided, for origin comparison. */
91
92
  export function normalizedOrigin(url: string): string | null {
@@ -111,9 +112,11 @@ export class ReferenceFetcher {
111
112
  private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
112
113
  ) {}
113
114
 
114
- /** Would the eager (autofetch) default fetch this record? */
115
+ /** Would the eager (autofetch) default fetch this record? Not one the
116
+ * fetcher refuses as expired, which would only take an eager slot. */
115
117
  isEagerEligible(record: ReferenceRecord): boolean {
116
118
  if (record.fetchedPath) return false;
119
+ if (isReferenceExpired(record.testimony)) return false;
117
120
  if (record.serverId === undefined) return false;
118
121
  const binding = this.resolveServer(record.serverId);
119
122
  if (!binding) return false;
@@ -161,7 +164,8 @@ export class ReferenceFetcher {
161
164
 
162
165
  const refusal = this.originCheck(record, binding);
163
166
  if (refusal) return { ok: false, error: refusal };
164
- if (isReferenceExpired(t)) return { ok: false, error: 'reference expired (or unparseable expiry — fail closed)' };
167
+ const expiry = referenceExpiry(t);
168
+ if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
165
169
 
166
170
  const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
167
171
  // Vector 4: a claim already over the ceiling refuses without dialing.
@@ -215,9 +219,15 @@ export class ReferenceFetcher {
215
219
  digestVerified = true;
216
220
  }
217
221
 
218
- const headerMime = resp.headers.get('content-type')?.split(';')[0].trim() || null;
219
- const observedMime = sniffMime(data) ?? headerMime ?? t.mimeType ?? 'application/octet-stream';
220
- const ext = EXT_BY_MIME[observedMime] ?? EXT_BY_MIME[headerMime ?? ''] ?? EXT_BY_MIME[t.mimeType ?? ''] ?? 'bin';
222
+ // The response's header and the server's claimed type are both the
223
+ // server's words, and either can become fetch_reference's mime_type,
224
+ // the saved type and the record's verified type, so each is shown as a
225
+ // label is. One left empty once sanitized counts as absent.
226
+ const header = resp.headers.get('content-type');
227
+ const headerMime = header !== null ? sanitizeLabel(header.split(';')[0], REFERENCE_LIMITS.mimeType) || null : null;
228
+ const claimedMime = t.mimeType !== undefined ? sanitizeLabel(t.mimeType, REFERENCE_LIMITS.mimeType) || undefined : undefined;
229
+ const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
230
+ const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
221
231
  // §7.3: host-generated storage name; the server's `name` is display-only.
222
232
  const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
223
233
  if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
@@ -13,7 +13,8 @@
13
13
  * - `disposition:"never"` withholds payload AND uri from model context
14
14
  * unconditionally — enforced in code here, not by caller discipline (§5);
15
15
  * - stub size is independent of every server-supplied field length (§5);
16
- * - unparseable `expiresAt` fails closed (§7.4);
16
+ * - unparseable `expiresAt` fails closed (§7.4), and so does one rejected
17
+ * as a field: it's absent as testimony, never read as immortal;
17
18
  * - unknown block types never propagate `undefined` (the pre-existing
18
19
  * convertBlock switches had no default — a latent corruption this module
19
20
  * retires).
@@ -141,16 +142,42 @@ export function isInlineContradiction(block: unknown): boolean {
141
142
  export const INLINE_WITHHELD_TEXT =
142
143
  '[inline content withheld: nonconformant bulk disposition on inline data]';
143
144
 
144
- export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt'>, nowMs = Date.now()): boolean {
145
- if (t.expiresAt === undefined) return false;
145
+ /**
146
+ * Why a reference counts as expired, or null when it doesn't: its expiresAt
147
+ * has passed, doesn't parse, or was rejected as a field (§7.4, §8). A
148
+ * rejected expiry (past its schema maximum, or not a string, null included)
149
+ * is absent as testimony under the one invalid-field rule, but reading that
150
+ * absence as "never expires" would let field invalidity widen exposure:
151
+ * like an unparseable one, it fails closed. A reference that claims no
152
+ * expiry has none.
153
+ */
154
+ export function referenceExpiry(
155
+ t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now(),
156
+ ): 'rejected' | 'unparseable' | 'passed' | null {
157
+ if (t.rejectedFields.includes('expiresAt')) return 'rejected';
158
+ if (t.expiresAt === undefined) return null;
146
159
  const exp = Date.parse(t.expiresAt);
147
- return Number.isNaN(exp) ? true : exp <= nowMs;
160
+ if (Number.isNaN(exp)) return 'unparseable';
161
+ return exp <= nowMs ? 'passed' : null;
162
+ }
163
+
164
+ export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now()): boolean {
165
+ return referenceExpiry(t, nowMs) !== null;
148
166
  }
149
167
 
150
- /** Strip C0/C1 controls plus bidi marks/overrides/isolates; bound length.
168
+ /** Each expiry case as the stub and the fetcher's refusal both say it. */
169
+ export const EXPIRY_REASON = {
170
+ passed: 'expired: its expiresAt has passed',
171
+ unparseable: 'treated as expired: its expiresAt does not parse (fail closed)',
172
+ rejected: `treated as expired: its expiresAt was rejected as a field, over ${REFERENCE_LIMITS.expiresAt} characters or not a string (fail closed)`,
173
+ } as const;
174
+
175
+ /** Strip control characters (C0, DEL, C1) and every bidi control (marks,
176
+ * embeddings, overrides, isolates, and the Arabic letter mark), trim the
177
+ * whitespace (a BOM included) left at either end, and bound the length.
151
178
  * Labels are labels — storage naming is host-generated elsewhere (§7.3). */
152
179
  export function sanitizeLabel(s: string, maxChars: number): string {
153
- const cleaned = s.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
180
+ const cleaned = s.replace(/[\p{Cc}\p{Bidi_Control}]/gu, '').trim();
154
181
  if (cleaned.length <= maxChars) return cleaned;
155
182
  let cut = cleaned.slice(0, Math.max(0, maxChars - 1));
156
183
  // never split a surrogate pair at the truncation boundary
@@ -187,9 +214,10 @@ const MAX_RECORDS = 2000;
187
214
  /**
188
215
  * Host-private store of received references. Raw URIs live here and in stubs
189
216
  * never (under `never`) or by policy only (`ref`/absent). Ids are stable and
190
- * never reused within the process (vector 20); eviction is FIFO past
191
- * MAX_RECORDS and a stale lookup returns undefined — a defined miss, never a
192
- * different record.
217
+ * never reused within the process (vector 20). Past MAX_RECORDS the record
218
+ * registered least recently is evicted (registering a uri the registry holds
219
+ * makes its record the newest), and a stale lookup returns undefined — a
220
+ * defined miss, never a different record.
193
221
  *
194
222
  * Process-wide singleton by design: ids are host-local *names*, not
195
223
  * capabilities, and the record itself carries serverId for any future
@@ -222,6 +250,9 @@ export class ReferenceRegistry {
222
250
  // describes verified bytes on disk and keeps the testimony they were
223
251
  // verified against.
224
252
  if (!existing.fetchedPath) existing.testimony = testimony;
253
+ // Newest again, so a uri just mentioned is the last to be evicted.
254
+ this.records.delete(existingId);
255
+ this.records.set(existingId, existing);
225
256
  return existing;
226
257
  }
227
258
  }
@@ -257,6 +288,12 @@ export const referenceRegistry = new ReferenceRegistry();
257
288
  // Stub building (§5)
258
289
  // ============================================================================
259
290
 
291
+ /** A reference's type as its stub shows one, or '' when nothing is left of
292
+ * it once sanitized (the stub then leaves it out). */
293
+ export function referenceTypeLabel(mimeType: string | undefined): string {
294
+ return mimeType ? sanitizeLabel(mimeType, STUB_FIELD_CHARS) : '';
295
+ }
296
+
260
297
  /**
261
298
  * Build the model-visible stub for a reference. Output length is bounded by
262
299
  * host constants and independent of every server-supplied field length.
@@ -267,18 +304,25 @@ export const referenceRegistry = new ReferenceRegistry();
267
304
  export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string {
268
305
  const record = referenceRegistry.register(t, serverId);
269
306
  const parts: string[] = [];
270
- if (t.name) parts.push(sanitizeLabel(t.name, STUB_FIELD_CHARS));
307
+ // A label is shown only when something is left of it once sanitized.
308
+ const name = t.name ? sanitizeLabel(t.name, STUB_FIELD_CHARS) : '';
309
+ if (name) parts.push(name);
271
310
  const meta: string[] = [];
272
- if (t.mimeType) meta.push(sanitizeLabel(t.mimeType, STUB_FIELD_CHARS));
311
+ const mimeType = referenceTypeLabel(t.mimeType);
312
+ if (mimeType) meta.push(mimeType);
273
313
  if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`);
274
314
  if (meta.length) parts.push(meta.join(', '));
275
- if (provenance) parts.push(sanitizeLabel(provenance, STUB_FIELD_CHARS));
315
+ const origin = provenance ? sanitizeLabel(provenance, STUB_FIELD_CHARS) : '';
316
+ if (origin) parts.push(origin);
276
317
  if (record.fetchedPath) {
277
318
  // Already materialized by the host-mediated fetcher: hand the model the
278
319
  // workspace path (host-generated, §7.3) so existing tools just work.
279
320
  parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
280
321
  } else {
281
- parts.push('fetch with fetch_reference');
322
+ // An expired reference says so rather than inviting a fetch_reference
323
+ // call the fetcher can only refuse.
324
+ const expiry = referenceExpiry(record.testimony);
325
+ parts.push(expiry ? EXPIRY_REASON[expiry] : 'fetch with fetch_reference');
282
326
  }
283
327
  return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`;
284
328
  }
@@ -48,6 +48,28 @@ import type {
48
48
  import { McplMethod } from './types.js';
49
49
  import type { ToolLifecycleParams, ToolObserveRule } from './tool-lifecycle.js';
50
50
 
51
+ /**
52
+ * Payload of the connection `'close'` event (third argument; the first two
53
+ * stay `(code, signal)` for existing listeners). Lets a host see WHY the
54
+ * link went down — in particular a WebSocket close code such as 4001 from
55
+ * a server that refused or expired the credential — so it can refresh what
56
+ * `accessProvider` will hand the next dial instead of replaying it.
57
+ */
58
+ export interface McplConnectionCloseInfo {
59
+ /** Transport kind that closed; null when the connection never had one
60
+ * (a disconnected stub torn down before its first reconnect). */
61
+ transport: McplTransport['kind'] | null;
62
+ /** WebSocket close code (e.g. 4001) or stdio child exit code; null when unknown. */
63
+ code: number | null;
64
+ /** Signal that ended a stdio child; null otherwise. */
65
+ signal: NodeJS.Signals | null;
66
+ /** WebSocket close reason, or a short description of the cause. */
67
+ reason?: string;
68
+ /** True when the host closed the link (`close()` / `reconnectAfterFailure()`),
69
+ * false when the transport dropped unexpectedly. */
70
+ byHost: boolean;
71
+ }
72
+
51
73
  /** Timeout for the initialize handshake in milliseconds.
52
74
  * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
53
75
  const INITIALIZE_TIMEOUT_MS = 30_000;
@@ -93,7 +115,7 @@ interface RequestOptions {
93
115
  * - `'channels-changed'` — Server sent `channels/changed`
94
116
  * - `'channels-incoming'` — Server sent `channels/incoming`
95
117
  * - `'error'` — Connection-level error
96
- * - `'close'` — Connection closed
118
+ * - `'close'` — Connection closed: `(code, signal, info: McplConnectionCloseInfo)`
97
119
  * - `'connect-failed'` — Initial connect failed; background retry scheduled
98
120
  * - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
99
121
  * - `'reconnect'` — Background reconnect succeeded
@@ -209,6 +231,12 @@ export class McplServerConnection extends EventEmitter {
209
231
  * observable even though its original promise has already been rejected. */
210
232
  private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
211
233
  private closed = false;
234
+ /** Set by `reconnectAfterFailure()` before it closes a live transport, so
235
+ * the transport close handler reports that teardown as `byHost: true`. */
236
+ private hostCloseRequested = false;
237
+ private closePromise: Promise<void> | null = null;
238
+ private closeIncomplete = false;
239
+ private reconnectPromise: Promise<void> | null = null;
212
240
 
213
241
  /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
214
242
  private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
@@ -837,10 +865,10 @@ export class McplServerConnection extends EventEmitter {
837
865
  this.reconnectTimer = null;
838
866
  }
839
867
 
840
- if (this.closed) {
841
- return;
842
- }
868
+ if (this.closePromise) return this.closePromise;
869
+ if (this.closed && !this.closeIncomplete && !this.reconnectPromise) return;
843
870
  this.closed = true;
871
+ this.closeIncomplete = true;
844
872
  this.resetPolicyForTransportBoundary();
845
873
 
846
874
  // Reject all pending requests
@@ -854,11 +882,20 @@ export class McplServerConnection extends EventEmitter {
854
882
  // Tear down the transport (kills the child / closes the socket). The
855
883
  // transport's own 'close' event is short-circuited by `this.closed` in
856
884
  // setupLifecycle, so this is the single source of the connection 'close'.
857
- if (this.transport) {
858
- await this.transport.close();
859
- }
860
-
861
- this.emit('close');
885
+ const attempt = Promise.resolve().then(async () => {
886
+ // An already opened handshake owns a transport too. It hands that
887
+ // transport back without admission once reconnect has been disabled.
888
+ // The registry bounds this wait; retry reuses this real close attempt.
889
+ if (this.reconnectPromise) await this.reconnectPromise;
890
+ if (this.transport) await this.transport.close();
891
+ this.closeIncomplete = false;
892
+ this.emit('close', null, null, this.closeInfoByHost());
893
+ });
894
+ this.closePromise = attempt;
895
+ void attempt.catch(() => {
896
+ if (this.closePromise === attempt) this.closePromise = null;
897
+ });
898
+ return attempt;
862
899
  }
863
900
 
864
901
  /**
@@ -874,14 +911,26 @@ export class McplServerConnection extends EventEmitter {
874
911
  return;
875
912
  }
876
913
  if (this.transport) {
914
+ this.hostCloseRequested = true;
877
915
  await this.transport.close();
878
916
  return;
879
917
  }
880
918
  this.closed = true;
881
- this.emit('close');
919
+ this.emit('close', null, null, this.closeInfoByHost());
882
920
  this.scheduleReconnect();
883
921
  }
884
922
 
923
+ /** 'close' payload for a host-initiated teardown (no transport close code). */
924
+ private closeInfoByHost(): McplConnectionCloseInfo {
925
+ return {
926
+ transport: this.transport?.kind ?? null,
927
+ code: null,
928
+ signal: null,
929
+ reason: 'closed by host',
930
+ byHost: true,
931
+ };
932
+ }
933
+
885
934
  /**
886
935
  * Schedule a background reconnection attempt.
887
936
  *
@@ -912,8 +961,19 @@ export class McplServerConnection extends EventEmitter {
912
961
  * approach this builds no throwaway connection instance, so there are no
913
962
  * leaked listeners on a dead object.
914
963
  */
915
- private async attemptReconnect(): Promise<void> {
916
- if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
964
+ private attemptReconnect(): Promise<void> {
965
+ if (this.reconnectPromise) return this.reconnectPromise;
966
+ const attempt = this.reconnectOnce();
967
+ this.reconnectPromise = attempt;
968
+ const clear = () => { if (this.reconnectPromise === attempt) this.reconnectPromise = null; };
969
+ void attempt.then(clear, clear);
970
+ return attempt;
971
+ }
972
+
973
+ private async reconnectOnce(): Promise<void> {
974
+ // A queued recovery timer may outlive an earlier successful handshake.
975
+ // Recheck disconnection before opening or replacing any owned transport.
976
+ if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
917
977
 
918
978
  // Ordinal of this attempt: 0 was the initial connect, so the Nth retry
919
979
  // reports attempt N (reconnectAttempts counts failures so far).
@@ -925,6 +985,14 @@ export class McplServerConnection extends EventEmitter {
925
985
  this.hostCapabilities,
926
986
  );
927
987
 
988
+ if (!this.reconnectEnabled) {
989
+ // The prior transport was disconnected before this retry began. Keep
990
+ // the fresh, unadmitted transport owned by close(), including on a
991
+ // failed transport cleanup, instead of reviving a stopped connection.
992
+ this.transport = transport;
993
+ return;
994
+ }
995
+
928
996
  // Close the data plane before the new transport can emit. The reconnect
929
997
  // lifecycle event bypasses buffering; its framework listener installs the
930
998
  // awareness barrier synchronously, then releases control traffic needed
@@ -1254,6 +1322,7 @@ export class McplServerConnection extends EventEmitter {
1254
1322
  * and — when enabled — schedules a background reconnect.
1255
1323
  */
1256
1324
  private setupLifecycle(transport: McplTransport): void {
1325
+ this.hostCloseRequested = false;
1257
1326
  transport.on('error', (err: Error) => {
1258
1327
  this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
1259
1328
  });
@@ -1275,7 +1344,20 @@ export class McplServerConnection extends EventEmitter {
1275
1344
  this.pendingRequests.clear();
1276
1345
  this.orphanedRequests.clear();
1277
1346
 
1278
- this.emit('close', info.code ?? null, info.signal ?? null);
1347
+ // Forward the transport's close info so listeners can tell a server
1348
+ // that refused the credential (WebSocket 4001) from a crash, and act
1349
+ // before the reconnect below redials. A teardown the host asked for
1350
+ // via reconnectAfterFailure() arrives here too; report it as such.
1351
+ const byHost = this.hostCloseRequested;
1352
+ this.hostCloseRequested = false;
1353
+ const closeInfo: McplConnectionCloseInfo = {
1354
+ transport: transport.kind,
1355
+ code: info.code ?? null,
1356
+ signal: info.signal ?? null,
1357
+ reason: info.reason ?? (byHost ? 'closed by host' : undefined),
1358
+ byHost,
1359
+ };
1360
+ this.emit('close', closeInfo.code, closeInfo.signal, closeInfo);
1279
1361
 
1280
1362
  // Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
1281
1363
  if (this.reconnectEnabled) {