@animalabs/agent-framework 0.22.0 → 0.22.2

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 (418) hide show
  1. package/README.md +12 -5
  2. package/dist/src/agent.d.ts +192 -19
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +407 -66
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts +41 -0
  7. package/dist/src/api/mcp-server-core.d.ts.map +1 -0
  8. package/dist/src/api/mcp-server-core.js +593 -0
  9. package/dist/src/api/mcp-server-core.js.map +1 -0
  10. package/dist/src/api/mcp-server.d.ts +2 -36
  11. package/dist/src/api/mcp-server.d.ts.map +1 -1
  12. package/dist/src/api/mcp-server.js +15 -595
  13. package/dist/src/api/mcp-server.js.map +1 -1
  14. package/dist/src/api/server.d.ts +9 -1
  15. package/dist/src/api/server.d.ts.map +1 -1
  16. package/dist/src/api/server.js +84 -23
  17. package/dist/src/api/server.js.map +1 -1
  18. package/dist/src/api/types.d.ts +9 -1
  19. package/dist/src/api/types.d.ts.map +1 -1
  20. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  21. package/dist/src/code-execution/py-runner.js.map +1 -1
  22. package/dist/src/framework.d.ts +682 -103
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +3202 -718
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/gate-script.d.ts.map +1 -1
  28. package/dist/src/gate/gate-script.js.map +1 -1
  29. package/dist/src/index.d.ts +4 -4
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +2 -2
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  34. package/dist/src/mcpl/channel-registry.d.ts +26 -7
  35. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  36. package/dist/src/mcpl/channel-registry.js +102 -22
  37. package/dist/src/mcpl/channel-registry.js.map +1 -1
  38. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  40. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  41. package/dist/src/mcpl/errors.js.map +1 -1
  42. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  43. package/dist/src/mcpl/feature-set-manager.js +4 -3
  44. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  46. package/dist/src/mcpl/index.d.ts +2 -0
  47. package/dist/src/mcpl/index.d.ts.map +1 -1
  48. package/dist/src/mcpl/index.js.map +1 -1
  49. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  50. package/dist/src/mcpl/log-value.d.ts +54 -0
  51. package/dist/src/mcpl/log-value.d.ts.map +1 -0
  52. package/dist/src/mcpl/log-value.js +109 -0
  53. package/dist/src/mcpl/log-value.js.map +1 -0
  54. package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
  55. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  56. package/dist/src/mcpl/prose-stream-router.js +4 -4
  57. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  58. package/dist/src/mcpl/push-coalescer.d.ts +38 -3
  59. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  60. package/dist/src/mcpl/push-coalescer.js +81 -16
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/push-handler.d.ts +26 -3
  63. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  64. package/dist/src/mcpl/push-handler.js +87 -23
  65. package/dist/src/mcpl/push-handler.js.map +1 -1
  66. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  67. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  68. package/dist/src/mcpl/reference-fetcher.js +20 -9
  69. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  70. package/dist/src/mcpl/references.d.ts +29 -6
  71. package/dist/src/mcpl/references.d.ts.map +1 -1
  72. package/dist/src/mcpl/references.js +57 -16
  73. package/dist/src/mcpl/references.js.map +1 -1
  74. package/dist/src/mcpl/refusal-lines.d.ts +37 -0
  75. package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
  76. package/dist/src/mcpl/refusal-lines.js +61 -0
  77. package/dist/src/mcpl/refusal-lines.js.map +1 -0
  78. package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
  79. package/dist/src/mcpl/server-connection.d.ts +46 -3
  80. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  81. package/dist/src/mcpl/server-connection.js +98 -15
  82. package/dist/src/mcpl/server-connection.js.map +1 -1
  83. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  84. package/dist/src/mcpl/tool-classes.d.ts +1 -1
  85. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  86. package/dist/src/mcpl/tool-classes.js +3 -2
  87. package/dist/src/mcpl/tool-classes.js.map +1 -1
  88. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  89. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  90. package/dist/src/mcpl/transport.d.ts +23 -5
  91. package/dist/src/mcpl/transport.d.ts.map +1 -1
  92. package/dist/src/mcpl/transport.js +103 -10
  93. package/dist/src/mcpl/transport.js.map +1 -1
  94. package/dist/src/mcpl/types.d.ts +30 -26
  95. package/dist/src/mcpl/types.d.ts.map +1 -1
  96. package/dist/src/mcpl/types.js.map +1 -1
  97. package/dist/src/module-registry.d.ts +9 -3
  98. package/dist/src/module-registry.d.ts.map +1 -1
  99. package/dist/src/module-registry.js +3 -2
  100. package/dist/src/module-registry.js.map +1 -1
  101. package/dist/src/modules/api/index.d.ts.map +1 -1
  102. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  103. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  104. package/dist/src/modules/discord/index.d.ts.map +1 -1
  105. package/dist/src/modules/discord/index.js +4 -2
  106. package/dist/src/modules/discord/index.js.map +1 -1
  107. package/dist/src/modules/health/index.d.ts.map +1 -1
  108. package/dist/src/modules/health/index.js.map +1 -1
  109. package/dist/src/modules/history/index.d.ts.map +1 -1
  110. package/dist/src/modules/history/index.js.map +1 -1
  111. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  112. package/dist/src/modules/history/semantic.js.map +1 -1
  113. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  114. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  115. package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
  116. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  117. package/dist/src/modules/workspace/disk-agreement.js +63 -25
  118. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  119. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/index.d.ts +8 -4
  121. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  122. package/dist/src/modules/workspace/index.js +85 -16
  123. package/dist/src/modules/workspace/index.js.map +1 -1
  124. package/dist/src/modules/workspace/observe.d.ts +56 -11
  125. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/observe.js +288 -43
  127. package/dist/src/modules/workspace/observe.js.map +1 -1
  128. package/dist/src/modules/workspace/reconcile.d.ts +31 -1
  129. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/reconcile.js +63 -12
  131. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  132. package/dist/src/modules/workspace/types.d.ts +27 -2
  133. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  134. package/dist/src/modules/workspace/types.js.map +1 -1
  135. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  136. package/dist/src/modules/workspace/watcher.js.map +1 -1
  137. package/dist/src/operator-log.d.ts +41 -0
  138. package/dist/src/operator-log.d.ts.map +1 -1
  139. package/dist/src/operator-log.js.map +1 -1
  140. package/dist/src/provider-waits.d.ts +216 -0
  141. package/dist/src/provider-waits.d.ts.map +1 -0
  142. package/dist/src/provider-waits.js +482 -0
  143. package/dist/src/provider-waits.js.map +1 -0
  144. package/dist/src/queue.d.ts.map +1 -1
  145. package/dist/src/record-journal.d.ts.map +1 -1
  146. package/dist/src/record-journal.js.map +1 -1
  147. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  148. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  149. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  150. package/dist/src/recovery/offline-branch.js +29 -24
  151. package/dist/src/recovery/offline-branch.js.map +1 -1
  152. package/dist/src/recovery/recover-cli.js.map +1 -1
  153. package/dist/src/refusal-reactions.d.ts +14 -12
  154. package/dist/src/refusal-reactions.d.ts.map +1 -1
  155. package/dist/src/refusal-reactions.js +14 -12
  156. package/dist/src/refusal-reactions.js.map +1 -1
  157. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
  158. package/dist/src/runtime/liveness-watchdog.js.map +1 -1
  159. package/dist/src/shutdown.d.ts.map +1 -1
  160. package/dist/src/shutdown.js.map +1 -1
  161. package/dist/src/stored-removal.d.ts +73 -0
  162. package/dist/src/stored-removal.d.ts.map +1 -0
  163. package/dist/src/stored-removal.js +100 -0
  164. package/dist/src/stored-removal.js.map +1 -0
  165. package/dist/src/thinking-token-stamp.js.map +1 -1
  166. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  167. package/dist/src/tool-image-ledger.js.map +1 -1
  168. package/dist/src/tool-presentation.d.ts.map +1 -1
  169. package/dist/src/tool-presentation.js.map +1 -1
  170. package/dist/src/tool-result-guard.d.ts +30 -13
  171. package/dist/src/tool-result-guard.d.ts.map +1 -1
  172. package/dist/src/tool-result-guard.js +71 -25
  173. package/dist/src/tool-result-guard.js.map +1 -1
  174. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  175. package/dist/src/tune-out/coordinator.js.map +1 -1
  176. package/dist/src/tune-out/tools.d.ts +1 -1
  177. package/dist/src/tune-out/tools.d.ts.map +1 -1
  178. package/dist/src/types/agent.d.ts +42 -6
  179. package/dist/src/types/agent.d.ts.map +1 -1
  180. package/dist/src/types/events.d.ts +12 -0
  181. package/dist/src/types/events.d.ts.map +1 -1
  182. package/dist/src/types/framework.d.ts +10 -0
  183. package/dist/src/types/framework.d.ts.map +1 -1
  184. package/dist/src/types/module.d.ts +20 -8
  185. package/dist/src/types/module.d.ts.map +1 -1
  186. package/dist/src/types/trace.d.ts +75 -13
  187. package/dist/src/types/trace.d.ts.map +1 -1
  188. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  189. package/dist/test/agent-runtime-settings.test.js +1139 -3
  190. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  191. package/dist/test/batched-wake-locus.test.js.map +1 -1
  192. package/dist/test/channel-label-history.test.js.map +1 -1
  193. package/dist/test/code-execution.test.js.map +1 -1
  194. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  195. package/dist/test/conversation-router.test.js.map +1 -1
  196. package/dist/test/conversation-routing.test.js +240 -6
  197. package/dist/test/conversation-routing.test.js.map +1 -1
  198. package/dist/test/discord-awareness-outbox.test.js +107 -0
  199. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  200. package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
  201. package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
  202. package/dist/test/discord-typing-keepalive.test.js +77 -0
  203. package/dist/test/discord-typing-keepalive.test.js.map +1 -0
  204. package/dist/test/ephemeral-settle.test.js.map +1 -1
  205. package/dist/test/event-gate-passthrough.test.js.map +1 -1
  206. package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
  207. package/dist/test/event-gate-rate-limit.test.js.map +1 -1
  208. package/dist/test/event-gate-script.test.js.map +1 -1
  209. package/dist/test/event-gate-selfwake.test.js.map +1 -1
  210. package/dist/test/event-gate-tags.test.js.map +1 -1
  211. package/dist/test/event-gate.test.js.map +1 -1
  212. package/dist/test/explicit-prose-routing.test.js +363 -44
  213. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  214. package/dist/test/framework.test.js +160 -1
  215. package/dist/test/framework.test.js.map +1 -1
  216. package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
  217. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  218. package/dist/test/helpers/coalescing-fixture.js +11 -4
  219. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  220. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  221. package/dist/test/helpers/mock-membrane.js.map +1 -1
  222. package/dist/test/hide-then-turn.test.d.ts +2 -0
  223. package/dist/test/hide-then-turn.test.d.ts.map +1 -0
  224. package/dist/test/hide-then-turn.test.js +101 -0
  225. package/dist/test/hide-then-turn.test.js.map +1 -0
  226. package/dist/test/history-module-overview.test.js.map +1 -1
  227. package/dist/test/history-module-semantic.test.js.map +1 -1
  228. package/dist/test/history-module.test.js.map +1 -1
  229. package/dist/test/history-semantic-review-findings.test.js.map +1 -1
  230. package/dist/test/host-quiesce.test.js +125 -0
  231. package/dist/test/host-quiesce.test.js.map +1 -1
  232. package/dist/test/inference-exhaustion-rewind.test.js +220 -14
  233. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  234. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  235. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  236. package/dist/test/inference-failure-bounds.test.js +1202 -0
  237. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  238. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
  239. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  240. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  241. package/dist/test/late-events-after-stop.test.js +122 -0
  242. package/dist/test/late-events-after-stop.test.js.map +1 -0
  243. package/dist/test/liveness-watchdog.test.js.map +1 -1
  244. package/dist/test/maintenance-timer.test.js +1564 -2
  245. package/dist/test/maintenance-timer.test.js.map +1 -1
  246. package/dist/test/mcp-server-bin.test.d.ts +2 -0
  247. package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
  248. package/dist/test/mcp-server-bin.test.js +142 -0
  249. package/dist/test/mcp-server-bin.test.js.map +1 -0
  250. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  251. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  252. package/dist/test/mcpl-child-env.test.js +1 -1
  253. package/dist/test/mcpl-child-env.test.js.map +1 -1
  254. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
  255. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
  256. package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
  257. package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
  258. package/dist/test/mcpl-coalescing.test.js +78 -16
  259. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  260. package/dist/test/mcpl-config-diagnostics.test.js +6 -3
  261. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  262. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  263. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  264. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  265. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  266. package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
  267. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  268. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  269. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  270. package/dist/test/mcpl-reference-fields.test.js +69 -2
  271. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  272. package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
  273. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
  274. package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
  275. package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
  276. package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
  277. package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
  278. package/dist/test/mcpl-stderr-tail.test.js +210 -0
  279. package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
  280. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
  281. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  282. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
  283. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
  284. package/dist/test/mcpl-transport-close.test.d.ts +2 -0
  285. package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
  286. package/dist/test/mcpl-transport-close.test.js +373 -0
  287. package/dist/test/mcpl-transport-close.test.js.map +1 -0
  288. package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
  289. package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
  290. package/dist/test/mcpl-transport-spawn.test.js +58 -0
  291. package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
  292. package/dist/test/mcpl-ws-transport.test.js +104 -0
  293. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  294. package/dist/test/offline-recovery-branch.test.js +344 -0
  295. package/dist/test/offline-recovery-branch.test.js.map +1 -1
  296. package/dist/test/operator-surgery.test.js +197 -0
  297. package/dist/test/operator-surgery.test.js.map +1 -1
  298. package/dist/test/ops-alert.test.js +2 -1
  299. package/dist/test/ops-alert.test.js.map +1 -1
  300. package/dist/test/overbudget-drain-breaker.test.js +1 -0
  301. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  302. package/dist/test/pack-installs-alone.test.d.ts +2 -0
  303. package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
  304. package/dist/test/pack-installs-alone.test.js +284 -0
  305. package/dist/test/pack-installs-alone.test.js.map +1 -0
  306. package/dist/test/physical-window-restart.test.d.ts +6 -4
  307. package/dist/test/physical-window-restart.test.d.ts.map +1 -1
  308. package/dist/test/physical-window-restart.test.js +275 -10
  309. package/dist/test/physical-window-restart.test.js.map +1 -1
  310. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  311. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  312. package/dist/test/placed-reaction-baseline.test.js +312 -0
  313. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  314. package/dist/test/present-while-acting.test.js +949 -6
  315. package/dist/test/present-while-acting.test.js.map +1 -1
  316. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  317. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  318. package/dist/test/provider-waits.test.d.ts +2 -0
  319. package/dist/test/provider-waits.test.d.ts.map +1 -0
  320. package/dist/test/provider-waits.test.js +2259 -0
  321. package/dist/test/provider-waits.test.js.map +1 -0
  322. package/dist/test/puppet-tool-call.test.js +2 -0
  323. package/dist/test/puppet-tool-call.test.js.map +1 -1
  324. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  325. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  326. package/dist/test/reaction-baseline-platform.test.js +124 -0
  327. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  328. package/dist/test/record-journal.test.js.map +1 -1
  329. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  330. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  331. package/dist/test/refusal-rewind.test.js +9 -0
  332. package/dist/test/refusal-rewind.test.js.map +1 -1
  333. package/dist/test/refusal-single-call.test.js.map +1 -1
  334. package/dist/test/release-bump.test.d.ts +2 -0
  335. package/dist/test/release-bump.test.d.ts.map +1 -0
  336. package/dist/test/release-bump.test.js +248 -0
  337. package/dist/test/release-bump.test.js.map +1 -0
  338. package/dist/test/release-changelog.test.js.map +1 -1
  339. package/dist/test/release-holds.test.d.ts +2 -0
  340. package/dist/test/release-holds.test.d.ts.map +1 -0
  341. package/dist/test/release-holds.test.js +456 -0
  342. package/dist/test/release-holds.test.js.map +1 -0
  343. package/dist/test/same-round-think-policy.test.js.map +1 -1
  344. package/dist/test/save-recent-image.test.js +138 -2
  345. package/dist/test/save-recent-image.test.js.map +1 -1
  346. package/dist/test/shutdown-errors.test.js.map +1 -1
  347. package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
  348. package/dist/test/stop-with-host-operations.test.d.ts +20 -0
  349. package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
  350. package/dist/test/stop-with-host-operations.test.js +1174 -0
  351. package/dist/test/stop-with-host-operations.test.js.map +1 -0
  352. package/dist/test/stop-with-work-in-flight.test.js +162 -50
  353. package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
  354. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  355. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  356. package/dist/test/surgery-marks-receipt.test.js +540 -0
  357. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  358. package/dist/test/tool-batch-hook.test.js.map +1 -1
  359. package/dist/test/tool-result-guard.test.js +352 -16
  360. package/dist/test/tool-result-guard.test.js.map +1 -1
  361. package/dist/test/tool-result-spill.test.js.map +1 -1
  362. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
  363. package/dist/test/trunk-channel-routing.test.js +330 -7
  364. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  365. package/dist/test/tune-out-e2e.test.js.map +1 -1
  366. package/dist/test/unconvertible-tool-result.test.js.map +1 -1
  367. package/dist/test/unsent-compiles.test.d.ts +2 -0
  368. package/dist/test/unsent-compiles.test.d.ts.map +1 -0
  369. package/dist/test/unsent-compiles.test.js +139 -0
  370. package/dist/test/unsent-compiles.test.js.map +1 -0
  371. package/dist/test/workspace-branch-continuation.test.js.map +1 -1
  372. package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
  373. package/dist/test/workspace-read-text.test.js.map +1 -1
  374. package/dist/test/workspace-three-way.test.js +1022 -7
  375. package/dist/test/workspace-three-way.test.js.map +1 -1
  376. package/dist/test/workspace-watcher.test.js.map +1 -1
  377. package/dist/test/xml-round-persist.test.js.map +1 -1
  378. package/dist/tsconfig.tsbuildinfo +1 -1
  379. package/package.json +8 -6
  380. package/src/agent.ts +415 -67
  381. package/src/api/mcp-server-core.ts +681 -0
  382. package/src/api/mcp-server.ts +16 -685
  383. package/src/api/server.ts +87 -26
  384. package/src/api/types.ts +9 -1
  385. package/src/framework.ts +3389 -782
  386. package/src/index.ts +9 -4
  387. package/src/mcpl/channel-registry.ts +126 -31
  388. package/src/mcpl/feature-set-manager.ts +4 -3
  389. package/src/mcpl/index.ts +2 -0
  390. package/src/mcpl/log-value.ts +116 -0
  391. package/src/mcpl/prose-stream-router.ts +6 -6
  392. package/src/mcpl/push-coalescer.ts +76 -12
  393. package/src/mcpl/push-handler.ts +95 -23
  394. package/src/mcpl/reference-fetcher.ts +18 -8
  395. package/src/mcpl/references.ts +57 -13
  396. package/src/mcpl/refusal-lines.ts +59 -0
  397. package/src/mcpl/server-connection.ts +125 -15
  398. package/src/mcpl/tool-classes.ts +3 -2
  399. package/src/mcpl/transport.ts +105 -14
  400. package/src/mcpl/types.ts +5 -1
  401. package/src/module-registry.ts +17 -9
  402. package/src/modules/discord/index.ts +4 -2
  403. package/src/modules/workspace/disk-agreement.ts +74 -18
  404. package/src/modules/workspace/index.ts +85 -15
  405. package/src/modules/workspace/observe.ts +295 -43
  406. package/src/modules/workspace/reconcile.ts +90 -12
  407. package/src/modules/workspace/types.ts +26 -1
  408. package/src/operator-log.ts +30 -0
  409. package/src/provider-waits.ts +523 -0
  410. package/src/recovery/offline-branch.ts +32 -28
  411. package/src/refusal-reactions.ts +14 -12
  412. package/src/stored-removal.ts +126 -0
  413. package/src/tool-result-guard.ts +72 -24
  414. package/src/types/agent.ts +42 -6
  415. package/src/types/events.ts +12 -0
  416. package/src/types/framework.ts +10 -0
  417. package/src/types/module.ts +20 -8
  418. package/src/types/trace.ts +76 -14
@@ -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 { classifyBlock, isInlineContradiction } from './references.js';
21
22
  import { isVisiblyEmptyContent } from './visible-content.js';
22
23
 
23
24
  export const PUSH_COALESCING_SUPPORT = {
@@ -37,6 +38,18 @@ export class CoalesceError extends Error {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * How the host names a coalesce rejection, from the code the wire answered
43
+ * with. `coalesce-failed` is the host's own failure: -32000 (coalescing
44
+ * unavailable, the host stopping, or an acceptance it couldn't make durable)
45
+ * and -32603 (an internal error). `coalesce-invalid` is every other answer,
46
+ * the sender's: -32602 for a malformed item, -32017 or -32023 for a channel
47
+ * that isn't permitted or isn't registered.
48
+ */
49
+ export function coalesceRejectionReason(answered: number): 'coalesce-failed' | 'coalesce-invalid' {
50
+ return answered === -32000 || answered === -32603 ? 'coalesce-failed' : 'coalesce-invalid';
51
+ }
52
+
40
53
  /** Content with nothing model-visible (see visible-content.ts): -32602. */
41
54
  export class EmptyContentError extends CoalesceError {
42
55
  readonly reason = 'empty-content';
@@ -212,7 +225,6 @@ export interface CoalescerOptions {
212
225
  recoveryBackoffMs?: number;
213
226
  maxSubjects?: number;
214
227
  maxNotices?: number;
215
- maxContentBytes?: number;
216
228
  maxDataBytes?: number;
217
229
  }
218
230
 
@@ -232,6 +244,32 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
232
244
  /**
233
245
  * Validate wire content before it is stored, rendered or converted.
234
246
  *
247
+ * Its shape and its visible emptiness, nothing else. An incoming coalesced
248
+ * occurrence reaches context through the converters an uncoalesced message
249
+ * goes through: a plain occurrence the moment it is admitted (the coalescer
250
+ * only remembers where it landed), a deferred one when its render settles or
251
+ * falls back to it. Its content must be an array of objects. A block the
252
+ * converters can't render (a video, an unknown type, an image with neither
253
+ * uri nor data) becomes a placeholder, as it does uncoalesced, and so does a
254
+ * malformed one, since the converters take any object without throwing: a
255
+ * type, text, data, mimeType or uri that isn't a string counts as absent, a
256
+ * text block's as no text. The uncoalesced lane doesn't check the array:
257
+ * there a member that isn't an object becomes the untyped placeholder, and a
258
+ * null member or content that isn't an array throws in conversion
259
+ * (agent-framework#297 brings that lane to this rule).
260
+ * Nor does the uncoalesced path set a byte budget on content:
261
+ * an inline image is admitted here as it is there (agent-framework#266: a
262
+ * 1 MiB serialized-content budget here dropped every coalesced message
263
+ * carrying a screenshot). A deferred occurrence's content is held, until then,
264
+ * in the coalescer's state and its persisted snapshot and receipts.
265
+ *
266
+ * A render result, which the host delivers in the occurrence's place, gets
267
+ * these checks, as RFC-006 §5.2 gives it every check a pushed payload gets,
268
+ * and one more (`checkBlockShapes`): each block must be one the converters
269
+ * show as itself or as a reference stub, not as a placeholder. A malformed
270
+ * render falls back to the occurrence's own content; a pushed payload has
271
+ * nothing to fall back to, and gets its placeholders.
272
+ *
235
273
  * Visibly-empty content is refused unless `allowEmpty`: an admitted
236
274
  * occurrence with nothing to show would wake the model with no visible cause.
237
275
  * Empty is legitimate only where the RFC gives it a meaning — a retraction
@@ -240,22 +278,38 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
240
278
  */
241
279
  export function validateCoalescedContent(
242
280
  content: unknown,
243
- maxBytes = 1024 * 1024,
244
- options: { allowEmpty?: boolean } = {},
281
+ options: { allowEmpty?: boolean; checkBlockShapes?: boolean } = {},
245
282
  ): asserts content is McplContentBlock[] {
246
283
  if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
247
284
  for (const b of content as Array<Record<string, unknown>>) {
248
285
  if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
249
- if (b.type === 'text' && typeof b.text === 'string') continue;
250
- if (b.type === 'resource' && typeof b.uri === 'string') continue;
251
- if ((b.type === 'image' || b.type === 'audio')
252
- && (typeof b.uri === 'string' || (typeof b.data === 'string' && typeof b.mimeType === 'string'))) continue;
253
- throw new CoalesceError('payload.content', 'invalid content block');
286
+ if (options.checkBlockShapes && !showsAsItself(b)) throw new CoalesceError('payload.content', 'invalid content block');
254
287
  }
255
- if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
256
288
  if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
257
289
  }
258
290
 
291
+ /**
292
+ * Whether the converters (`convertBlock` in push-handler.ts and
293
+ * channel-registry.ts) show a block as itself, its text or its image or
294
+ * audio source, or as a reference stub, and not as a placeholder: an
295
+ * unrecognized block, a no-data or no-media-type one, or withheld inline
296
+ * data. A text block whose text isn't a string, which they show as no text,
297
+ * is refused too. This follows their branches in their order.
298
+ */
299
+ function showsAsItself(b: Record<string, unknown>): boolean {
300
+ if (b.type === 'text') return typeof b.text === 'string';
301
+ if (b.type === 'resource') return classifyBlock(b).kind === 'reference';
302
+ if (b.type !== 'image' && b.type !== 'audio') return false;
303
+ if (isInlineContradiction(b)) return false;
304
+ if (isFilledString(b.uri) && b.disposition) return classifyBlock(b).kind === 'reference';
305
+ if (isFilledString(b.data) && isFilledString(b.mimeType)) return true;
306
+ return b.type === 'image' && isFilledString(b.uri);
307
+ }
308
+
309
+ function isFilledString(value: unknown): value is string {
310
+ return typeof value === 'string' && value !== '';
311
+ }
312
+
259
313
  /** Validate the `coalesce` member of either lane (§13). */
260
314
  export function validateCoalesceMember(
261
315
  c: unknown,
@@ -700,14 +754,24 @@ export class PushCoalescer<E = unknown> {
700
754
  try {
701
755
  const result = await this.host.render(occurrence, params);
702
756
  if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
703
- // §5.2: an empty result is legitimate ("nothing happened").
704
- else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
757
+ // §5.2: an empty result is legitimate ("nothing happened"); each
758
+ // block must be well formed.
759
+ else validateCoalescedContent(result?.content, { allowEmpty: true, checkBlockShapes: true });
705
760
  if (rendering.cancelled) throw new CancelledRender();
706
761
  content = result.content;
707
762
  timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
708
763
  source = 'render';
709
764
  } catch (error) {
710
- if (!(error instanceof CancelledRender)) this.host.audit({ kind: 'render-failed', subject, eventId: occurrence.eventId, error: String(error) });
765
+ if (!(error instanceof CancelledRender)) {
766
+ // The occurrence's own content is delivered instead; the host names
767
+ // the failure where operators look (a console line, not only this
768
+ // audit record).
769
+ this.host.audit({
770
+ kind: 'render-failed', subject, serverId: occurrence.serverId, eventId: occurrence.eventId,
771
+ ...(error instanceof CoalesceError ? { field: error.field } : {}),
772
+ error: String(error),
773
+ });
774
+ }
711
775
  } finally {
712
776
  const left = (this.rendersInFlight.get(occurrence.serverId) ?? 1) - 1;
713
777
  if (left > 0) this.rendersInFlight.set(occurrence.serverId, left); else this.rendersInFlight.delete(occurrence.serverId);
@@ -18,8 +18,10 @@ 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 { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
21
+ import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
22
22
  import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js';
23
+ import { logDetail, logValue } from './log-value.js';
24
+ import { RefusalLines } from './refusal-lines.js';
23
25
 
24
26
  // ============================================================================
25
27
  // McplPushEvent (the ProcessEvent shape pushed to the queue)
@@ -55,13 +57,17 @@ export interface McplPushEvent {
55
57
  // ============================================================================
56
58
 
57
59
  /**
58
- * Convert a single MCPL wire-format content block to a membrane ContentBlock.
59
- * Same logic as hook-orchestrator.ts.
60
+ * Convert a single MCPL wire-format content block to a membrane ContentBlock,
61
+ * as the channel lane's convertBlock (channel-registry.ts) does, apart from
62
+ * the provenance its reference stubs name. Hook context injections convert
63
+ * through hook-orchestrator.ts's own.
60
64
  */
61
65
  export function convertBlock(block: McplContentBlock): ContentBlock {
62
66
  switch (block.type) {
63
67
  case 'text':
64
- return { type: 'text', text: block.text };
68
+ // Text that isn't a string is no text, as the request sanitizer and
69
+ // the visible-content check already read it.
70
+ return { type: 'text', text: typeof block.text === 'string' ? block.text : '' };
65
71
 
66
72
  case 'image':
67
73
  if (isInlineContradiction(block)) {
@@ -69,24 +75,24 @@ export function convertBlock(block: McplContentBlock): ContentBlock {
69
75
  // closed, withhold the data (checked BEFORE the data branch).
70
76
  return { type: 'text', text: INLINE_WITHHELD_TEXT };
71
77
  }
72
- if (block.uri && block.disposition) {
78
+ if (isFilledString(block.uri) && block.disposition) {
73
79
  // RFC-005 uri-form media with a disposition claim: stub, do not
74
80
  // hand the URI to the provider or inline it.
75
81
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
76
82
  }
77
- if (block.data && block.mimeType) {
83
+ if (isFilledString(block.data) && isFilledString(block.mimeType)) {
78
84
  return {
79
85
  type: 'image',
80
86
  source: { type: 'base64', data: block.data, mediaType: block.mimeType },
81
87
  } as ContentBlock;
82
88
  }
83
- if (block.uri) {
89
+ if (isFilledString(block.uri)) {
84
90
  return {
85
91
  type: 'image',
86
92
  source: { type: 'url', url: block.uri },
87
93
  } as ContentBlock;
88
94
  }
89
- return { type: 'text', text: '[Image: no data]' };
95
+ return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };
90
96
 
91
97
  case 'audio':
92
98
  if (isInlineContradiction(block)) {
@@ -94,31 +100,43 @@ export function convertBlock(block: McplContentBlock): ContentBlock {
94
100
  // closed, withhold the data (checked BEFORE the data branch).
95
101
  return { type: 'text', text: INLINE_WITHHELD_TEXT };
96
102
  }
97
- if (block.uri && block.disposition) {
103
+ if (isFilledString(block.uri) && block.disposition) {
98
104
  // RFC-005 uri-form media with a disposition claim: stub, do not
99
105
  // hand the URI to the provider or inline it.
100
106
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
101
107
  }
102
- if (block.data && block.mimeType) {
108
+ if (isFilledString(block.data) && isFilledString(block.mimeType)) {
103
109
  return {
104
110
  type: 'audio',
105
111
  source: { type: 'base64', data: block.data, mediaType: block.mimeType },
106
112
  } as ContentBlock;
107
113
  }
108
- return { type: 'text', text: '[Audio: no data]' };
114
+ return { type: 'text', text: isFilledString(block.data) ? '[Audio: no media type]' : '[Audio: no data]' };
109
115
 
110
116
  case 'resource':
111
117
  // RFC-005: reference blocks become bounded stubs — never raw URIs
112
118
  // (a signed URL is a bearer credential that looks like a location).
113
119
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
114
120
 
115
- default:
121
+ default: {
116
122
  // Unknown wire block types previously fell off the exhaustive switch
117
- // and propagated `undefined` into ContentBlock[]. Fail visibly.
118
- return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` };
123
+ // and propagated `undefined` into ContentBlock[]. Fail visibly. A type
124
+ // that isn't a string is no type, and is never converted to one: an
125
+ // object can throw when it is.
126
+ const type: unknown = (block as { type?: unknown }).type;
127
+ return { type: 'text', text: `[unrecognized content block: ${typeof type === 'string' ? type : 'untyped'}]` };
128
+ }
119
129
  }
120
130
  }
121
131
 
132
+ /** A string with something in it: the only data, mimeType or uri a block's
133
+ * source is built from, and the only uri that makes it a reference. Anything
134
+ * else a sender puts there leaves the block without it, so conversion never
135
+ * throws and never copies a non-string. */
136
+ function isFilledString(value: unknown): value is string {
137
+ return typeof value === 'string' && value !== '';
138
+ }
139
+
122
140
  // ============================================================================
123
141
  // LRU Dedup Set
124
142
  // ============================================================================
@@ -171,6 +189,9 @@ export class PushHandler {
171
189
  private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
172
190
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
173
191
  private dedup = new DedupSet(1000);
192
+ /** Holds back repeats of the line for a channel-scoped push refused because
193
+ * its channel isn't declared (-32023) or may not be used (-32017). */
194
+ private channelRefusalLines = new RefusalLines();
174
195
 
175
196
  constructor(
176
197
  featureSetManager: FeatureSetManager,
@@ -221,7 +242,7 @@ export class PushHandler {
221
242
  : 'Feature set validation failed';
222
243
  // Loud rejection — a rejected push event is an agent that silently
223
244
  // never hears the message. (2026-07-09 diagnosability pass.)
224
- console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=${reason}`);
245
+ console.error(`[push-event-rejected] server=${serverId} eventId=${logValue(params.eventId ?? '')} reason=${logValue(reason)}`);
225
246
  if (err instanceof McplFeatureSetError && responder?.respondError) {
226
247
  responder.respondError(err.code, reason, { featureSet: err.featureSet });
227
248
  } else {
@@ -240,11 +261,13 @@ export class PushHandler {
240
261
  if (coalesced) {
241
262
  // RFC-006 §13: malformed content is a -32602, checked before conversion.
242
263
  try {
243
- validateCoalescedContent(params.payload?.content, undefined, { allowEmpty: emptyAllowed });
264
+ validateCoalescedContent(params.payload?.content, { allowEmpty: emptyAllowed });
244
265
  } catch (error) {
245
266
  const err = error as Error & { code?: number; field?: string };
267
+ const code = err.code ?? -32602;
246
268
  if (err instanceof EmptyContentError) this.traceEmptyRejection(serverId, params);
247
- if (responder?.respondError) responder.respondError(err.code ?? -32602, err.message, { field: err.field });
269
+ else this.traceCoalesceRejection(serverId, params, err, code);
270
+ if (responder?.respondError) responder.respondError(code, err.message, { field: err.field });
248
271
  else responder?.respond({ accepted: false, reason: err instanceof EmptyContentError ? err.reason : err.message });
249
272
  return;
250
273
  }
@@ -265,7 +288,7 @@ export class PushHandler {
265
288
  // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
266
289
  // gets its original result, which this set could not return).
267
290
  if (!coalesced && this.dedup.checkAndAdd(params.eventId)) {
268
- console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
291
+ console.error(`[push-event-rejected] server=${serverId} eventId=${logValue(params.eventId ?? '')} reason=duplicate`);
269
292
  responder?.respond({ accepted: false, reason: 'duplicate' });
270
293
  return;
271
294
  }
@@ -314,8 +337,9 @@ export class PushHandler {
314
337
  responder?.respond(result);
315
338
  } catch (error) {
316
339
  const err = error as Error & { code?: number; field?: string };
317
- console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=${err.message}`);
318
- if (responder?.respondError) responder.respondError(typeof err.code === 'number' ? err.code : -32603, err.message, { field: err.field });
340
+ const code = typeof err.code === 'number' ? err.code : -32603;
341
+ this.traceCoalesceRejection(serverId, params, err, code);
342
+ if (responder?.respondError) responder.respondError(code, err.message, { field: err.field });
319
343
  else responder?.respond({ accepted: false, reason: err.message });
320
344
  }
321
345
  return;
@@ -334,13 +358,61 @@ export class PushHandler {
334
358
  responder?.respond({ accepted: true, inferenceId });
335
359
  }
336
360
 
337
- /** Loud, like every other push rejection: the producer's wake went nowhere. */
361
+ /**
362
+ * A coalesced occurrence the host refused, as loud as every other push
363
+ * rejection: its producer is told on the wire, and until now the host said
364
+ * nothing, so a dropped message left no trace where operators look
365
+ * (agent-framework#266). The producer gets the wire answer this lane gives
366
+ * (`answered`): the thrown error's code and message, or -32602 for a
367
+ * malformed occurrence without one. The reason follows that answer
368
+ * (coalesceRejectionReason): `coalesce-failed` when the host itself failed,
369
+ * `coalesce-invalid` when the producer's occurrence was refused, with the
370
+ * failing `field` and the error's message as `detail`. A missing eventId
371
+ * prints as `""` and traces as `''`, as the channel lane's missing id does.
372
+ * A channel-scoped push refused because its channel isn't declared or may
373
+ * not be used names the channel on its line and its trace, and is refused
374
+ * for as long as that lasts, so its line prints at most once a minute per
375
+ * channel (RefusalLines); every refusal is traced.
376
+ */
377
+ private traceCoalesceRejection(
378
+ serverId: string,
379
+ params: PushEventParams,
380
+ error: { field?: unknown; message: string },
381
+ answered: number,
382
+ ): void {
383
+ const reason = coalesceRejectionReason(answered);
384
+ const field = typeof error.field === 'string' && error.field ? error.field : undefined;
385
+ const scoped = params.coalesce?.channelId;
386
+ const channelId = (answered === -32023 || answered === -32017) && typeof scoped === 'string' ? scoped : undefined;
387
+ const held = channelId === undefined ? 0 : this.channelRefusalLines.admit(serverId, channelId);
388
+ if (held !== null) {
389
+ console.error(
390
+ `[push-event-rejected] server=${serverId} featureSet=${logValue(params.featureSet ?? '')} eventId=${logValue(params.eventId ?? '')} ` +
391
+ (channelId === undefined ? '' : `channel=${logValue(channelId)} `) +
392
+ `reason=${reason}${field ? ` field=${logValue(field)}` : ''}: ${logDetail(error.message)}` +
393
+ (held ? ` (${held} more since the last line)` : ''),
394
+ );
395
+ }
396
+ this.emitTraceFn({
397
+ type: 'mcpl:push-event-rejected',
398
+ serverId,
399
+ eventId: typeof params.eventId === 'string' ? params.eventId : '',
400
+ featureSet: params.featureSet,
401
+ reason,
402
+ ...(field ? { field } : {}),
403
+ ...(channelId === undefined ? {} : { channelId }),
404
+ detail: error.message,
405
+ });
406
+ }
407
+
408
+ /** Loud, like every other push rejection: the producer's wake went nowhere.
409
+ * A missing eventId prints as `""` and traces as `''`, as on every push line. */
338
410
  private traceEmptyRejection(serverId: string, params: PushEventParams): void {
339
- console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=empty-content`);
411
+ console.error(`[push-event-rejected] server=${serverId} eventId=${logValue(params.eventId ?? '')} reason=empty-content`);
340
412
  this.emitTraceFn({
341
413
  type: 'mcpl:push-event-rejected',
342
414
  serverId,
343
- eventId: params.eventId,
415
+ eventId: typeof params.eventId === 'string' ? params.eventId : '',
344
416
  featureSet: params.featureSet,
345
417
  reason: 'empty-content',
346
418
  });
@@ -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
  }
@@ -0,0 +1,59 @@
1
+ import { logValue } from './log-value.js';
2
+
3
+ /**
4
+ * At most one host line a minute for a refusal a server keeps repeating for
5
+ * as long as a standing condition lasts: messages for a channel it never
6
+ * registered, or channel-scoped pushes to a channel it hasn't declared or may
7
+ * not use. A server that never registered a busy channel would otherwise
8
+ * print a line for every message in it. Only the line is held back: every
9
+ * refusal is still answered and traced. Each lane keeps its own records, as
10
+ * its lines are its own.
11
+ *
12
+ * Per server and channel, the first refusal prints. After that a refusal
13
+ * prints only once a minute has passed since that channel's last line, and
14
+ * its line names how many were held back since. The minute is timed with a
15
+ * monotonic clock, so a step of the wall clock can't silence a channel. A
16
+ * refusal within a minute of the channel's last line is counted into its
17
+ * next line, whatever happens to the channel in between (it may be
18
+ * registered, unregistered or reconnected).
19
+ *
20
+ * A record is keyed by the channel as its line prints it (logValue), so two
21
+ * ids share one exactly when their lines name them alike, which only ids
22
+ * over 256 units can. The records cover the 1,000 most recently refused
23
+ * channels: a new channel evicts the least recently refused one, with its
24
+ * minute and any count it held, and that channel's next refusal prints as a
25
+ * first.
26
+ */
27
+ export class RefusalLines {
28
+ /** By key, from the least recently refused channel to the most. */
29
+ private readonly records = new Map<string, { at: number; since: number }>();
30
+
31
+ constructor(private readonly now: () => number = () => performance.now()) {}
32
+
33
+ /**
34
+ * Record one refusal for a server's channel. Returns null when its line is
35
+ * held back, or else how many of the channel's refusals were held back
36
+ * since its last line (0 for its first).
37
+ */
38
+ admit(serverId: string, channelId: string): number | null {
39
+ // logValue never prints a NUL, so the key's last NUL keeps the two apart
40
+ // whatever a server id holds.
41
+ const key = `${serverId}\u0000${logValue(channelId)}`;
42
+ const now = this.now();
43
+ const record = this.records.get(key);
44
+ if (record) this.records.delete(key);
45
+ else if (this.records.size >= MAX_CHANNELS) this.records.delete(this.records.keys().next().value!);
46
+ if (!record || now - record.at >= EVERY_MS) {
47
+ this.records.set(key, { at: now, since: 0 });
48
+ return record?.since ?? 0;
49
+ }
50
+ record.since++;
51
+ this.records.set(key, record);
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** A channel's line prints at most this often. */
57
+ const EVERY_MS = 60_000;
58
+ /** The channels the records cover at once. */
59
+ const MAX_CHANNELS = 1_000;