@richhardry/codor 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -0
  3. package/bin/codor.mjs +7 -0
  4. package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts +41 -0
  5. package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts.map +1 -0
  6. package/node_modules/@codor/adapter-antigravity/dist/adapter.js +275 -0
  7. package/node_modules/@codor/adapter-antigravity/dist/adapter.js.map +1 -0
  8. package/node_modules/@codor/adapter-antigravity/dist/index.d.ts +2 -0
  9. package/node_modules/@codor/adapter-antigravity/dist/index.d.ts.map +1 -0
  10. package/node_modules/@codor/adapter-antigravity/dist/index.js +2 -0
  11. package/node_modules/@codor/adapter-antigravity/dist/index.js.map +1 -0
  12. package/node_modules/@codor/adapter-antigravity/package.json +18 -0
  13. package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts +94 -0
  14. package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts.map +1 -0
  15. package/node_modules/@codor/adapter-claude-code/dist/adapter.js +785 -0
  16. package/node_modules/@codor/adapter-claude-code/dist/adapter.js.map +1 -0
  17. package/node_modules/@codor/adapter-claude-code/dist/index.d.ts +8 -0
  18. package/node_modules/@codor/adapter-claude-code/dist/index.d.ts.map +1 -0
  19. package/node_modules/@codor/adapter-claude-code/dist/index.js +5 -0
  20. package/node_modules/@codor/adapter-claude-code/dist/index.js.map +1 -0
  21. package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts +18 -0
  22. package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts.map +1 -0
  23. package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js +67 -0
  24. package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js.map +1 -0
  25. package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts +3 -0
  26. package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts.map +1 -0
  27. package/node_modules/@codor/adapter-claude-code/dist/peek.js +128 -0
  28. package/node_modules/@codor/adapter-claude-code/dist/peek.js.map +1 -0
  29. package/node_modules/@codor/adapter-claude-code/dist/query.d.ts +9 -0
  30. package/node_modules/@codor/adapter-claude-code/dist/query.d.ts.map +1 -0
  31. package/node_modules/@codor/adapter-claude-code/dist/query.js +7 -0
  32. package/node_modules/@codor/adapter-claude-code/dist/query.js.map +1 -0
  33. package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts +40 -0
  34. package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts.map +1 -0
  35. package/node_modules/@codor/adapter-claude-code/dist/translate.js +505 -0
  36. package/node_modules/@codor/adapter-claude-code/dist/translate.js.map +1 -0
  37. package/node_modules/@codor/adapter-claude-code/package.json +18 -0
  38. package/node_modules/@codor/adapter-codex/dist/adapter.d.ts +105 -0
  39. package/node_modules/@codor/adapter-codex/dist/adapter.d.ts.map +1 -0
  40. package/node_modules/@codor/adapter-codex/dist/adapter.js +625 -0
  41. package/node_modules/@codor/adapter-codex/dist/adapter.js.map +1 -0
  42. package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts +39 -0
  43. package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts.map +1 -0
  44. package/node_modules/@codor/adapter-codex/dist/app-server-transport.js +186 -0
  45. package/node_modules/@codor/adapter-codex/dist/app-server-transport.js.map +1 -0
  46. package/node_modules/@codor/adapter-codex/dist/index.d.ts +8 -0
  47. package/node_modules/@codor/adapter-codex/dist/index.d.ts.map +1 -0
  48. package/node_modules/@codor/adapter-codex/dist/index.js +5 -0
  49. package/node_modules/@codor/adapter-codex/dist/index.js.map +1 -0
  50. package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts +18 -0
  51. package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts.map +1 -0
  52. package/node_modules/@codor/adapter-codex/dist/limits-probe.js +87 -0
  53. package/node_modules/@codor/adapter-codex/dist/limits-probe.js.map +1 -0
  54. package/node_modules/@codor/adapter-codex/dist/peek.d.ts +3 -0
  55. package/node_modules/@codor/adapter-codex/dist/peek.d.ts.map +1 -0
  56. package/node_modules/@codor/adapter-codex/dist/peek.js +98 -0
  57. package/node_modules/@codor/adapter-codex/dist/peek.js.map +1 -0
  58. package/node_modules/@codor/adapter-codex/dist/translate.d.ts +17 -0
  59. package/node_modules/@codor/adapter-codex/dist/translate.d.ts.map +1 -0
  60. package/node_modules/@codor/adapter-codex/dist/translate.js +286 -0
  61. package/node_modules/@codor/adapter-codex/dist/translate.js.map +1 -0
  62. package/node_modules/@codor/adapter-codex/package.json +17 -0
  63. package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts +40 -0
  64. package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts.map +1 -0
  65. package/node_modules/@codor/adapter-copilot/dist/adapter.js +190 -0
  66. package/node_modules/@codor/adapter-copilot/dist/adapter.js.map +1 -0
  67. package/node_modules/@codor/adapter-copilot/dist/index.d.ts +4 -0
  68. package/node_modules/@codor/adapter-copilot/dist/index.d.ts.map +1 -0
  69. package/node_modules/@codor/adapter-copilot/dist/index.js +3 -0
  70. package/node_modules/@codor/adapter-copilot/dist/index.js.map +1 -0
  71. package/node_modules/@codor/adapter-copilot/dist/translate.d.ts +11 -0
  72. package/node_modules/@codor/adapter-copilot/dist/translate.d.ts.map +1 -0
  73. package/node_modules/@codor/adapter-copilot/dist/translate.js +184 -0
  74. package/node_modules/@codor/adapter-copilot/dist/translate.js.map +1 -0
  75. package/node_modules/@codor/adapter-copilot/package.json +18 -0
  76. package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts +32 -0
  77. package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts.map +1 -0
  78. package/node_modules/@codor/adapter-cursor/dist/adapter.js +179 -0
  79. package/node_modules/@codor/adapter-cursor/dist/adapter.js.map +1 -0
  80. package/node_modules/@codor/adapter-cursor/dist/index.d.ts +9 -0
  81. package/node_modules/@codor/adapter-cursor/dist/index.d.ts.map +1 -0
  82. package/node_modules/@codor/adapter-cursor/dist/index.js +8 -0
  83. package/node_modules/@codor/adapter-cursor/dist/index.js.map +1 -0
  84. package/node_modules/@codor/adapter-cursor/dist/translate.d.ts +15 -0
  85. package/node_modules/@codor/adapter-cursor/dist/translate.d.ts.map +1 -0
  86. package/node_modules/@codor/adapter-cursor/dist/translate.js +164 -0
  87. package/node_modules/@codor/adapter-cursor/dist/translate.js.map +1 -0
  88. package/node_modules/@codor/adapter-cursor/package.json +17 -0
  89. package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts +36 -0
  90. package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts.map +1 -0
  91. package/node_modules/@codor/adapter-gemini/dist/adapter.js +245 -0
  92. package/node_modules/@codor/adapter-gemini/dist/adapter.js.map +1 -0
  93. package/node_modules/@codor/adapter-gemini/dist/index.d.ts +4 -0
  94. package/node_modules/@codor/adapter-gemini/dist/index.d.ts.map +1 -0
  95. package/node_modules/@codor/adapter-gemini/dist/index.js +3 -0
  96. package/node_modules/@codor/adapter-gemini/dist/index.js.map +1 -0
  97. package/node_modules/@codor/adapter-gemini/dist/translate.d.ts +12 -0
  98. package/node_modules/@codor/adapter-gemini/dist/translate.d.ts.map +1 -0
  99. package/node_modules/@codor/adapter-gemini/dist/translate.js +123 -0
  100. package/node_modules/@codor/adapter-gemini/dist/translate.js.map +1 -0
  101. package/node_modules/@codor/adapter-gemini/package.json +18 -0
  102. package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts +42 -0
  103. package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts.map +1 -0
  104. package/node_modules/@codor/adapter-opencode/dist/adapter.js +240 -0
  105. package/node_modules/@codor/adapter-opencode/dist/adapter.js.map +1 -0
  106. package/node_modules/@codor/adapter-opencode/dist/index.d.ts +4 -0
  107. package/node_modules/@codor/adapter-opencode/dist/index.d.ts.map +1 -0
  108. package/node_modules/@codor/adapter-opencode/dist/index.js +3 -0
  109. package/node_modules/@codor/adapter-opencode/dist/index.js.map +1 -0
  110. package/node_modules/@codor/adapter-opencode/dist/translate.d.ts +12 -0
  111. package/node_modules/@codor/adapter-opencode/dist/translate.d.ts.map +1 -0
  112. package/node_modules/@codor/adapter-opencode/dist/translate.js +144 -0
  113. package/node_modules/@codor/adapter-opencode/dist/translate.js.map +1 -0
  114. package/node_modules/@codor/adapter-opencode/package.json +18 -0
  115. package/node_modules/@codor/cli/dist/artifact.d.ts +33 -0
  116. package/node_modules/@codor/cli/dist/artifact.d.ts.map +1 -0
  117. package/node_modules/@codor/cli/dist/artifact.js +183 -0
  118. package/node_modules/@codor/cli/dist/artifact.js.map +1 -0
  119. package/node_modules/@codor/cli/dist/attach.d.ts +25 -0
  120. package/node_modules/@codor/cli/dist/attach.d.ts.map +1 -0
  121. package/node_modules/@codor/cli/dist/attach.js +133 -0
  122. package/node_modules/@codor/cli/dist/attach.js.map +1 -0
  123. package/node_modules/@codor/cli/dist/connection.d.ts +21 -0
  124. package/node_modules/@codor/cli/dist/connection.d.ts.map +1 -0
  125. package/node_modules/@codor/cli/dist/connection.js +130 -0
  126. package/node_modules/@codor/cli/dist/connection.js.map +1 -0
  127. package/node_modules/@codor/cli/dist/detect.d.ts +14 -0
  128. package/node_modules/@codor/cli/dist/detect.d.ts.map +1 -0
  129. package/node_modules/@codor/cli/dist/detect.js +99 -0
  130. package/node_modules/@codor/cli/dist/detect.js.map +1 -0
  131. package/node_modules/@codor/cli/dist/index.d.ts +17 -0
  132. package/node_modules/@codor/cli/dist/index.d.ts.map +1 -0
  133. package/node_modules/@codor/cli/dist/index.js +24 -0
  134. package/node_modules/@codor/cli/dist/index.js.map +1 -0
  135. package/node_modules/@codor/cli/dist/mirror.d.ts +4 -0
  136. package/node_modules/@codor/cli/dist/mirror.d.ts.map +1 -0
  137. package/node_modules/@codor/cli/dist/mirror.js +93 -0
  138. package/node_modules/@codor/cli/dist/mirror.js.map +1 -0
  139. package/node_modules/@codor/cli/dist/program.d.ts +17 -0
  140. package/node_modules/@codor/cli/dist/program.d.ts.map +1 -0
  141. package/node_modules/@codor/cli/dist/program.js +1068 -0
  142. package/node_modules/@codor/cli/dist/program.js.map +1 -0
  143. package/node_modules/@codor/cli/dist/runtime-install.d.ts +61 -0
  144. package/node_modules/@codor/cli/dist/runtime-install.d.ts.map +1 -0
  145. package/node_modules/@codor/cli/dist/runtime-install.js +122 -0
  146. package/node_modules/@codor/cli/dist/runtime-install.js.map +1 -0
  147. package/node_modules/@codor/cli/dist/runtime-paths.d.ts +24 -0
  148. package/node_modules/@codor/cli/dist/runtime-paths.d.ts.map +1 -0
  149. package/node_modules/@codor/cli/dist/runtime-paths.js +69 -0
  150. package/node_modules/@codor/cli/dist/runtime-paths.js.map +1 -0
  151. package/node_modules/@codor/cli/dist/setup-flow.d.ts +69 -0
  152. package/node_modules/@codor/cli/dist/setup-flow.d.ts.map +1 -0
  153. package/node_modules/@codor/cli/dist/setup-flow.js +110 -0
  154. package/node_modules/@codor/cli/dist/setup-flow.js.map +1 -0
  155. package/node_modules/@codor/cli/dist/setup-session.d.ts +105 -0
  156. package/node_modules/@codor/cli/dist/setup-session.d.ts.map +1 -0
  157. package/node_modules/@codor/cli/dist/setup-session.js +346 -0
  158. package/node_modules/@codor/cli/dist/setup-session.js.map +1 -0
  159. package/node_modules/@codor/cli/dist/setup-ui.d.ts +93 -0
  160. package/node_modules/@codor/cli/dist/setup-ui.d.ts.map +1 -0
  161. package/node_modules/@codor/cli/dist/setup-ui.js +249 -0
  162. package/node_modules/@codor/cli/dist/setup-ui.js.map +1 -0
  163. package/node_modules/@codor/cli/dist/setup.d.ts +108 -0
  164. package/node_modules/@codor/cli/dist/setup.d.ts.map +1 -0
  165. package/node_modules/@codor/cli/dist/setup.js +878 -0
  166. package/node_modules/@codor/cli/dist/setup.js.map +1 -0
  167. package/node_modules/@codor/cli/dist/terminal-keys.d.ts +26 -0
  168. package/node_modules/@codor/cli/dist/terminal-keys.d.ts.map +1 -0
  169. package/node_modules/@codor/cli/dist/terminal-keys.js +70 -0
  170. package/node_modules/@codor/cli/dist/terminal-keys.js.map +1 -0
  171. package/node_modules/@codor/cli/dist/terminal-qr.d.ts +2 -0
  172. package/node_modules/@codor/cli/dist/terminal-qr.d.ts.map +1 -0
  173. package/node_modules/@codor/cli/dist/terminal-qr.js +30 -0
  174. package/node_modules/@codor/cli/dist/terminal-qr.js.map +1 -0
  175. package/node_modules/@codor/cli/dist/up.d.ts +52 -0
  176. package/node_modules/@codor/cli/dist/up.d.ts.map +1 -0
  177. package/node_modules/@codor/cli/dist/up.js +178 -0
  178. package/node_modules/@codor/cli/dist/up.js.map +1 -0
  179. package/node_modules/@codor/cli/package.json +24 -0
  180. package/node_modules/@codor/cli/packaging/systemd/codor.service +21 -0
  181. package/node_modules/@codor/cli/runtime/web/assets/LandingPage-BOmG5kSw.js +31 -0
  182. package/node_modules/@codor/cli/runtime/web/assets/LedgerPage-CecuTS8-.js +1 -0
  183. package/node_modules/@codor/cli/runtime/web/assets/NoChannels-CYiBXGQd.js +11 -0
  184. package/node_modules/@codor/cli/runtime/web/assets/PairingCodeInput-Cr6XDWx-.js +1 -0
  185. package/node_modules/@codor/cli/runtime/web/assets/PairingPage-CQBWNA2L.js +6 -0
  186. package/node_modules/@codor/cli/runtime/web/assets/SettingsPage-DGLbSXE7.js +16 -0
  187. package/node_modules/@codor/cli/runtime/web/assets/StartupUnavailable-DdQBHip_.js +1 -0
  188. package/node_modules/@codor/cli/runtime/web/assets/arrow-left-BOpjMuRi.js +6 -0
  189. package/node_modules/@codor/cli/runtime/web/assets/browser-BWQYNOsD.js +6 -0
  190. package/node_modules/@codor/cli/runtime/web/assets/graph-toBZHlC0.js +1 -0
  191. package/node_modules/@codor/cli/runtime/web/assets/index-Cxi54Xim.js +408 -0
  192. package/node_modules/@codor/cli/runtime/web/assets/index-DjwJakJC.css +1 -0
  193. package/node_modules/@codor/cli/runtime/web/assets/qr-DpynCwP9.js +8 -0
  194. package/node_modules/@codor/cli/runtime/web/assets/react-C8w-UNLI.js +32 -0
  195. package/node_modules/@codor/cli/runtime/web/assets/sodium-DtsvMzX9.js +1 -0
  196. package/node_modules/@codor/cli/runtime/web/codor-192.png +0 -0
  197. package/node_modules/@codor/cli/runtime/web/codor-512.png +0 -0
  198. package/node_modules/@codor/cli/runtime/web/codor-apple-touch-180.png +0 -0
  199. package/node_modules/@codor/cli/runtime/web/codor-favicon.svg +86 -0
  200. package/node_modules/@codor/cli/runtime/web/codor-mark.svg +86 -0
  201. package/node_modules/@codor/cli/runtime/web/codor-maskable-512.png +0 -0
  202. package/node_modules/@codor/cli/runtime/web/codor-og.png +0 -0
  203. package/node_modules/@codor/cli/runtime/web/fonts/BitcountPropSingle-latin.woff2 +0 -0
  204. package/node_modules/@codor/cli/runtime/web/fonts/Geist-Variable.woff2 +0 -0
  205. package/node_modules/@codor/cli/runtime/web/fonts/GeistMono-Variable.woff2 +0 -0
  206. package/node_modules/@codor/cli/runtime/web/fonts/OFL.txt +93 -0
  207. package/node_modules/@codor/cli/runtime/web/index.html +53 -0
  208. package/node_modules/@codor/cli/runtime/web/manifest.webmanifest +1 -0
  209. package/node_modules/@codor/cli/runtime/web/sw.js +2 -0
  210. package/node_modules/@codor/protocol/dist/adapter.d.ts +118 -0
  211. package/node_modules/@codor/protocol/dist/adapter.d.ts.map +1 -0
  212. package/node_modules/@codor/protocol/dist/adapter.js +15 -0
  213. package/node_modules/@codor/protocol/dist/adapter.js.map +1 -0
  214. package/node_modules/@codor/protocol/dist/body.d.ts +14 -0
  215. package/node_modules/@codor/protocol/dist/body.d.ts.map +1 -0
  216. package/node_modules/@codor/protocol/dist/body.js +50 -0
  217. package/node_modules/@codor/protocol/dist/body.js.map +1 -0
  218. package/node_modules/@codor/protocol/dist/changelog.d.ts +30 -0
  219. package/node_modules/@codor/protocol/dist/changelog.d.ts.map +1 -0
  220. package/node_modules/@codor/protocol/dist/changelog.js +19 -0
  221. package/node_modules/@codor/protocol/dist/changelog.js.map +1 -0
  222. package/node_modules/@codor/protocol/dist/default-recipient.d.ts +8 -0
  223. package/node_modules/@codor/protocol/dist/default-recipient.d.ts.map +1 -0
  224. package/node_modules/@codor/protocol/dist/default-recipient.js +19 -0
  225. package/node_modules/@codor/protocol/dist/default-recipient.js.map +1 -0
  226. package/node_modules/@codor/protocol/dist/delivery.d.ts +42 -0
  227. package/node_modules/@codor/protocol/dist/delivery.d.ts.map +1 -0
  228. package/node_modules/@codor/protocol/dist/delivery.js +44 -0
  229. package/node_modules/@codor/protocol/dist/delivery.js.map +1 -0
  230. package/node_modules/@codor/protocol/dist/events.d.ts +284 -0
  231. package/node_modules/@codor/protocol/dist/events.d.ts.map +1 -0
  232. package/node_modules/@codor/protocol/dist/events.js +150 -0
  233. package/node_modules/@codor/protocol/dist/events.js.map +1 -0
  234. package/node_modules/@codor/protocol/dist/ids.d.ts +19 -0
  235. package/node_modules/@codor/protocol/dist/ids.d.ts.map +1 -0
  236. package/node_modules/@codor/protocol/dist/ids.js +27 -0
  237. package/node_modules/@codor/protocol/dist/ids.js.map +1 -0
  238. package/node_modules/@codor/protocol/dist/index.d.ts +14 -0
  239. package/node_modules/@codor/protocol/dist/index.d.ts.map +1 -0
  240. package/node_modules/@codor/protocol/dist/index.js +14 -0
  241. package/node_modules/@codor/protocol/dist/index.js.map +1 -0
  242. package/node_modules/@codor/protocol/dist/interaction.d.ts +45 -0
  243. package/node_modules/@codor/protocol/dist/interaction.d.ts.map +1 -0
  244. package/node_modules/@codor/protocol/dist/interaction.js +33 -0
  245. package/node_modules/@codor/protocol/dist/interaction.js.map +1 -0
  246. package/node_modules/@codor/protocol/dist/member.d.ts +146 -0
  247. package/node_modules/@codor/protocol/dist/member.d.ts.map +1 -0
  248. package/node_modules/@codor/protocol/dist/member.js +169 -0
  249. package/node_modules/@codor/protocol/dist/member.js.map +1 -0
  250. package/node_modules/@codor/protocol/dist/message.d.ts +166 -0
  251. package/node_modules/@codor/protocol/dist/message.d.ts.map +1 -0
  252. package/node_modules/@codor/protocol/dist/message.js +95 -0
  253. package/node_modules/@codor/protocol/dist/message.js.map +1 -0
  254. package/node_modules/@codor/protocol/dist/observability.d.ts +80 -0
  255. package/node_modules/@codor/protocol/dist/observability.d.ts.map +1 -0
  256. package/node_modules/@codor/protocol/dist/observability.js +41 -0
  257. package/node_modules/@codor/protocol/dist/observability.js.map +1 -0
  258. package/node_modules/@codor/protocol/dist/room.d.ts +430 -0
  259. package/node_modules/@codor/protocol/dist/room.d.ts.map +1 -0
  260. package/node_modules/@codor/protocol/dist/room.js +142 -0
  261. package/node_modules/@codor/protocol/dist/room.js.map +1 -0
  262. package/node_modules/@codor/protocol/dist/ws.d.ts +1348 -0
  263. package/node_modules/@codor/protocol/dist/ws.d.ts.map +1 -0
  264. package/node_modules/@codor/protocol/dist/ws.js +282 -0
  265. package/node_modules/@codor/protocol/dist/ws.js.map +1 -0
  266. package/node_modules/@codor/protocol/package.json +17 -0
  267. package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts +14 -0
  268. package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts.map +1 -0
  269. package/node_modules/@codor/switchboard/dist/adapter-registry.js +175 -0
  270. package/node_modules/@codor/switchboard/dist/adapter-registry.js.map +1 -0
  271. package/node_modules/@codor/switchboard/dist/authorization.d.ts +13 -0
  272. package/node_modules/@codor/switchboard/dist/authorization.d.ts.map +1 -0
  273. package/node_modules/@codor/switchboard/dist/authorization.js +96 -0
  274. package/node_modules/@codor/switchboard/dist/authorization.js.map +1 -0
  275. package/node_modules/@codor/switchboard/dist/blobs.d.ts +17 -0
  276. package/node_modules/@codor/switchboard/dist/blobs.d.ts.map +1 -0
  277. package/node_modules/@codor/switchboard/dist/blobs.js +54 -0
  278. package/node_modules/@codor/switchboard/dist/blobs.js.map +1 -0
  279. package/node_modules/@codor/switchboard/dist/collaboration.d.ts +115 -0
  280. package/node_modules/@codor/switchboard/dist/collaboration.d.ts.map +1 -0
  281. package/node_modules/@codor/switchboard/dist/collaboration.js +123 -0
  282. package/node_modules/@codor/switchboard/dist/collaboration.js.map +1 -0
  283. package/node_modules/@codor/switchboard/dist/continuation.d.ts +33 -0
  284. package/node_modules/@codor/switchboard/dist/continuation.d.ts.map +1 -0
  285. package/node_modules/@codor/switchboard/dist/continuation.js +177 -0
  286. package/node_modules/@codor/switchboard/dist/continuation.js.map +1 -0
  287. package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts +22 -0
  288. package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts.map +1 -0
  289. package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js +92 -0
  290. package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js.map +1 -0
  291. package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts +37 -0
  292. package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts.map +1 -0
  293. package/node_modules/@codor/switchboard/dist/crypto/challenge.js +132 -0
  294. package/node_modules/@codor/switchboard/dist/crypto/challenge.js.map +1 -0
  295. package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts +41 -0
  296. package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts.map +1 -0
  297. package/node_modules/@codor/switchboard/dist/crypto/keys.js +145 -0
  298. package/node_modules/@codor/switchboard/dist/crypto/keys.js.map +1 -0
  299. package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts +53 -0
  300. package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts.map +1 -0
  301. package/node_modules/@codor/switchboard/dist/crypto/pairing.js +182 -0
  302. package/node_modules/@codor/switchboard/dist/crypto/pairing.js.map +1 -0
  303. package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts +30 -0
  304. package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts.map +1 -0
  305. package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js +101 -0
  306. package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js.map +1 -0
  307. package/node_modules/@codor/switchboard/dist/daemon.d.ts +545 -0
  308. package/node_modules/@codor/switchboard/dist/daemon.d.ts.map +1 -0
  309. package/node_modules/@codor/switchboard/dist/daemon.js +3907 -0
  310. package/node_modules/@codor/switchboard/dist/daemon.js.map +1 -0
  311. package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts +103 -0
  312. package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts.map +1 -0
  313. package/node_modules/@codor/switchboard/dist/fake-adapter.js +228 -0
  314. package/node_modules/@codor/switchboard/dist/fake-adapter.js.map +1 -0
  315. package/node_modules/@codor/switchboard/dist/index.d.ts +44 -0
  316. package/node_modules/@codor/switchboard/dist/index.d.ts.map +1 -0
  317. package/node_modules/@codor/switchboard/dist/index.js +24 -0
  318. package/node_modules/@codor/switchboard/dist/index.js.map +1 -0
  319. package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts +12 -0
  320. package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts.map +1 -0
  321. package/node_modules/@codor/switchboard/dist/ledger/resolve.js +17 -0
  322. package/node_modules/@codor/switchboard/dist/ledger/resolve.js.map +1 -0
  323. package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts +47 -0
  324. package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts.map +1 -0
  325. package/node_modules/@codor/switchboard/dist/ledger/vault.js +175 -0
  326. package/node_modules/@codor/switchboard/dist/ledger/vault.js.map +1 -0
  327. package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts +73 -0
  328. package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts.map +1 -0
  329. package/node_modules/@codor/switchboard/dist/ledger/watch.js +232 -0
  330. package/node_modules/@codor/switchboard/dist/ledger/watch.js.map +1 -0
  331. package/node_modules/@codor/switchboard/dist/local-dirs.d.ts +14 -0
  332. package/node_modules/@codor/switchboard/dist/local-dirs.d.ts.map +1 -0
  333. package/node_modules/@codor/switchboard/dist/local-dirs.js +40 -0
  334. package/node_modules/@codor/switchboard/dist/local-dirs.js.map +1 -0
  335. package/node_modules/@codor/switchboard/dist/local-socket.d.ts +3 -0
  336. package/node_modules/@codor/switchboard/dist/local-socket.d.ts.map +1 -0
  337. package/node_modules/@codor/switchboard/dist/local-socket.js +16 -0
  338. package/node_modules/@codor/switchboard/dist/local-socket.js.map +1 -0
  339. package/node_modules/@codor/switchboard/dist/pricing.d.ts +32 -0
  340. package/node_modules/@codor/switchboard/dist/pricing.d.ts.map +1 -0
  341. package/node_modules/@codor/switchboard/dist/pricing.js +64 -0
  342. package/node_modules/@codor/switchboard/dist/pricing.js.map +1 -0
  343. package/node_modules/@codor/switchboard/dist/process-liveness.d.ts +2 -0
  344. package/node_modules/@codor/switchboard/dist/process-liveness.d.ts.map +1 -0
  345. package/node_modules/@codor/switchboard/dist/process-liveness.js +9 -0
  346. package/node_modules/@codor/switchboard/dist/process-liveness.js.map +1 -0
  347. package/node_modules/@codor/switchboard/dist/push/producer.d.ts +54 -0
  348. package/node_modules/@codor/switchboard/dist/push/producer.d.ts.map +1 -0
  349. package/node_modules/@codor/switchboard/dist/push/producer.js +168 -0
  350. package/node_modules/@codor/switchboard/dist/push/producer.js.map +1 -0
  351. package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts +29 -0
  352. package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts.map +1 -0
  353. package/node_modules/@codor/switchboard/dist/push/subscriptions.js +83 -0
  354. package/node_modules/@codor/switchboard/dist/push/subscriptions.js.map +1 -0
  355. package/node_modules/@codor/switchboard/dist/redact.d.ts +11 -0
  356. package/node_modules/@codor/switchboard/dist/redact.d.ts.map +1 -0
  357. package/node_modules/@codor/switchboard/dist/redact.js +65 -0
  358. package/node_modules/@codor/switchboard/dist/redact.js.map +1 -0
  359. package/node_modules/@codor/switchboard/dist/residency.d.ts +110 -0
  360. package/node_modules/@codor/switchboard/dist/residency.d.ts.map +1 -0
  361. package/node_modules/@codor/switchboard/dist/residency.js +587 -0
  362. package/node_modules/@codor/switchboard/dist/residency.js.map +1 -0
  363. package/node_modules/@codor/switchboard/dist/router.d.ts +120 -0
  364. package/node_modules/@codor/switchboard/dist/router.d.ts.map +1 -0
  365. package/node_modules/@codor/switchboard/dist/router.js +198 -0
  366. package/node_modules/@codor/switchboard/dist/router.js.map +1 -0
  367. package/node_modules/@codor/switchboard/dist/server.d.ts +50 -0
  368. package/node_modules/@codor/switchboard/dist/server.d.ts.map +1 -0
  369. package/node_modules/@codor/switchboard/dist/server.js +1425 -0
  370. package/node_modules/@codor/switchboard/dist/server.js.map +1 -0
  371. package/node_modules/@codor/switchboard/dist/store.d.ts +534 -0
  372. package/node_modules/@codor/switchboard/dist/store.d.ts.map +1 -0
  373. package/node_modules/@codor/switchboard/dist/store.js +2625 -0
  374. package/node_modules/@codor/switchboard/dist/store.js.map +1 -0
  375. package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts +59 -0
  376. package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts.map +1 -0
  377. package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js +183 -0
  378. package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js.map +1 -0
  379. package/node_modules/@codor/switchboard/dist/transport/peer.d.ts +87 -0
  380. package/node_modules/@codor/switchboard/dist/transport/peer.d.ts.map +1 -0
  381. package/node_modules/@codor/switchboard/dist/transport/peer.js +316 -0
  382. package/node_modules/@codor/switchboard/dist/transport/peer.js.map +1 -0
  383. package/node_modules/@codor/switchboard/dist/working-directory.d.ts +2 -0
  384. package/node_modules/@codor/switchboard/dist/working-directory.d.ts.map +1 -0
  385. package/node_modules/@codor/switchboard/dist/working-directory.js +26 -0
  386. package/node_modules/@codor/switchboard/dist/working-directory.js.map +1 -0
  387. package/node_modules/@codor/switchboard/package.json +33 -0
  388. package/package.json +58 -0
@@ -0,0 +1,2625 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import Database from 'better-sqlite3';
3
+ import { AttachLeaseSchema, BridgeOriginSchema, ChangeLogEntrySchema, DeliverySchema, MemberSchema, MessageSchema, PendingInteractionSchema, RoomConfigSchema, RoomMeterSchema, RoomSchema, RoomSupportSchema, deriveRoomColor, } from '@codor/protocol';
4
+ import { redactText } from './redact.js';
5
+ import { CollaborationGroupSchema, CollaborationParticipantSchema, CollaborationRoundSchema, } from './collaboration.js';
6
+ // harn:assume run-journals-own-evidence-across-output-messages ref=store-schema-no-blobs
7
+ // The DB persists pointers (RunSummary.events_ref) — never run event payloads.
8
+ // One lifecycle root points at the JSONL journal for all of its lightweight
9
+ // continuation rows; the store has no table or column for event payloads.
10
+ // harn:assume attach-custody-lease-tracks-child-pid ref=attach-lease-store
11
+ // harn:assume collaboration-groups-are-durable-state ref=collaboration-store-schema
12
+ // harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
13
+ const SCHEMA = `
14
+ CREATE TABLE IF NOT EXISTS rooms (
15
+ id TEXT PRIMARY KEY,
16
+ name TEXT NOT NULL,
17
+ created_ts TEXT NOT NULL,
18
+ config TEXT NOT NULL, -- RoomConfig JSON
19
+ seq INTEGER NOT NULL DEFAULT 0
20
+ );
21
+ CREATE TABLE IF NOT EXISTS members (
22
+ id TEXT PRIMARY KEY,
23
+ room TEXT NOT NULL REFERENCES rooms(id),
24
+ kind TEXT NOT NULL,
25
+ handle TEXT NOT NULL,
26
+ display_name TEXT NOT NULL,
27
+ purpose TEXT,
28
+ harness TEXT,
29
+ session_ref TEXT,
30
+ cwd TEXT,
31
+ policy TEXT,
32
+ model TEXT,
33
+ thinking TEXT,
34
+ credential_hash TEXT,
35
+ host TEXT,
36
+ state TEXT,
37
+ custody TEXT,
38
+ parent TEXT,
39
+ role TEXT,
40
+ conventions_sent INTEGER NOT NULL DEFAULT 0,
41
+ misaddressed INTEGER NOT NULL DEFAULT 0,
42
+ roster_stale INTEGER NOT NULL DEFAULT 1,
43
+ removed_ts TEXT
44
+ );
45
+ CREATE TABLE IF NOT EXISTS messages (
46
+ room TEXT NOT NULL REFERENCES rooms(id),
47
+ id INTEGER NOT NULL,
48
+ author TEXT NOT NULL,
49
+ kind TEXT NOT NULL,
50
+ body TEXT NOT NULL,
51
+ mentions TEXT NOT NULL, -- MentionSpan[] JSON (member ids, never handles)
52
+ refs TEXT NOT NULL, -- number[] JSON
53
+ ledger_refs TEXT NOT NULL, -- string[] JSON
54
+ reply_to INTEGER,
55
+ run TEXT, -- RunSummary JSON: events_ref pointer only, no events
56
+ -- harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
57
+ run_parent_id INTEGER, -- lifecycle root for a permanent continuation row
58
+ -- harn:end continuation-writer-follows-journaled-output-ownership
59
+ ask TEXT, -- AskCard JSON
60
+ origin TEXT, -- BridgeOrigin JSON
61
+ attachments TEXT, -- Attachment[] JSON (metadata; files live under the data dir)
62
+ ack INTEGER NOT NULL DEFAULT 0,
63
+ pinned INTEGER NOT NULL DEFAULT 0,
64
+ deleted INTEGER NOT NULL DEFAULT 0,
65
+ ts TEXT NOT NULL,
66
+ seq INTEGER NOT NULL,
67
+ activity_seq INTEGER,
68
+ PRIMARY KEY (room, id)
69
+ );
70
+ CREATE TABLE IF NOT EXISTS deliveries (
71
+ id TEXT PRIMARY KEY,
72
+ room TEXT NOT NULL REFERENCES rooms(id),
73
+ message_id INTEGER NOT NULL,
74
+ recipient TEXT NOT NULL,
75
+ state TEXT NOT NULL,
76
+ attempt_count INTEGER NOT NULL DEFAULT 0,
77
+ batch_id TEXT,
78
+ run_msg_id INTEGER,
79
+ read_ts TEXT,
80
+ interaction_resolved_ts TEXT,
81
+ payload_snapshot TEXT, -- immutable routed prompt context; never run events
82
+ process_id INTEGER, -- bounded attempt evidence, never run event payloads
83
+ process_group_id INTEGER,
84
+ queue_seq INTEGER NOT NULL, -- durable FIFO order; timestamps can tie
85
+ group_id TEXT,
86
+ group_round INTEGER,
87
+ ts TEXT NOT NULL
88
+ );
89
+ CREATE TABLE IF NOT EXISTS collaboration_groups (
90
+ id TEXT PRIMARY KEY,
91
+ room TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
92
+ root_message_id INTEGER NOT NULL,
93
+ state TEXT NOT NULL CHECK (state IN ('open', 'completed', 'cancelled')),
94
+ created_ts TEXT NOT NULL,
95
+ completed_ts TEXT,
96
+ UNIQUE (room, root_message_id),
97
+ FOREIGN KEY (room, root_message_id) REFERENCES messages(room, id) ON DELETE CASCADE
98
+ );
99
+ CREATE TABLE IF NOT EXISTS collaboration_rounds (
100
+ group_id TEXT NOT NULL REFERENCES collaboration_groups(id) ON DELETE CASCADE,
101
+ round_number INTEGER NOT NULL CHECK (round_number > 0),
102
+ state TEXT NOT NULL CHECK (state IN ('collecting', 'released', 'closed')),
103
+ created_ts TEXT NOT NULL,
104
+ released_ts TEXT,
105
+ PRIMARY KEY (group_id, round_number)
106
+ );
107
+ CREATE TABLE IF NOT EXISTS collaboration_participants (
108
+ group_id TEXT NOT NULL,
109
+ round_number INTEGER NOT NULL,
110
+ ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
111
+ member_id TEXT NOT NULL REFERENCES members(id),
112
+ delivery_id TEXT NOT NULL UNIQUE REFERENCES deliveries(id),
113
+ terminal_status TEXT CHECK (
114
+ terminal_status IS NULL OR terminal_status IN ('completed', 'failed', 'interrupted', 'skipped')
115
+ ),
116
+ result_message_id INTEGER,
117
+ completed_ts TEXT,
118
+ PRIMARY KEY (group_id, round_number, member_id),
119
+ UNIQUE (group_id, round_number, ordinal),
120
+ FOREIGN KEY (group_id, round_number)
121
+ REFERENCES collaboration_rounds(group_id, round_number) ON DELETE CASCADE
122
+ );
123
+ CREATE TABLE IF NOT EXISTS pending_interactions (
124
+ id TEXT PRIMARY KEY,
125
+ room TEXT NOT NULL REFERENCES rooms(id),
126
+ member_id TEXT NOT NULL,
127
+ message_id INTEGER NOT NULL,
128
+ native_id TEXT NOT NULL,
129
+ kind TEXT NOT NULL,
130
+ targets TEXT NOT NULL, -- MemberId[] JSON
131
+ state TEXT NOT NULL,
132
+ answer TEXT, -- JSON
133
+ answered_by TEXT,
134
+ answered_ts TEXT
135
+ );
136
+ CREATE TABLE IF NOT EXISTS meters (
137
+ room TEXT NOT NULL REFERENCES rooms(id),
138
+ day TEXT NOT NULL,
139
+ turns INTEGER NOT NULL DEFAULT 0,
140
+ cost_usd REAL NOT NULL DEFAULT 0,
141
+ estimated_cost_usd REAL NOT NULL DEFAULT 0,
142
+ input_tokens INTEGER NOT NULL DEFAULT 0,
143
+ output_tokens INTEGER NOT NULL DEFAULT 0,
144
+ PRIMARY KEY (room, day)
145
+ );
146
+ CREATE TABLE IF NOT EXISTS mirrored_turns (
147
+ room TEXT NOT NULL REFERENCES rooms(id),
148
+ member_id TEXT NOT NULL,
149
+ native_turn_id TEXT NOT NULL,
150
+ message_id INTEGER NOT NULL,
151
+ PRIMARY KEY (room, member_id, native_turn_id)
152
+ );
153
+ CREATE TABLE IF NOT EXISTS attach_leases (
154
+ id TEXT PRIMARY KEY,
155
+ room TEXT NOT NULL REFERENCES rooms(id),
156
+ member_id TEXT NOT NULL UNIQUE,
157
+ cli_pid INTEGER NOT NULL,
158
+ child_pid INTEGER,
159
+ process_group_id INTEGER,
160
+ heartbeat_ts INTEGER NOT NULL
161
+ );
162
+ CREATE TABLE IF NOT EXISTS changes (
163
+ room_id TEXT NOT NULL REFERENCES rooms(id),
164
+ seq INTEGER NOT NULL,
165
+ entity TEXT NOT NULL, -- message|member|inbox|meter|room
166
+ entity_id TEXT NOT NULL,
167
+ PRIMARY KEY (room_id, seq)
168
+ );
169
+ `;
170
+ // harn:end substantive-output-messages-drive-unread
171
+ // harn:end collaboration-groups-are-durable-state
172
+ // harn:end attach-custody-lease-tracks-child-pid
173
+ // harn:end run-journals-own-evidence-across-output-messages
174
+ // harn:assume delivery-payload-snapshotted ref=delivery-payload-storage
175
+ function migrateDeliveryPayloadSnapshot(db) {
176
+ const columns = db.pragma('table_info(deliveries)');
177
+ if (!columns.some((column) => column.name === 'payload_snapshot')) {
178
+ db.exec('ALTER TABLE deliveries ADD COLUMN payload_snapshot TEXT');
179
+ }
180
+ if (!columns.some((column) => column.name === 'process_id')) {
181
+ db.exec('ALTER TABLE deliveries ADD COLUMN process_id INTEGER');
182
+ }
183
+ if (!columns.some((column) => column.name === 'process_group_id')) {
184
+ db.exec('ALTER TABLE deliveries ADD COLUMN process_group_id INTEGER');
185
+ }
186
+ if (!columns.some((column) => column.name === 'queue_seq')) {
187
+ db.exec('ALTER TABLE deliveries ADD COLUMN queue_seq INTEGER');
188
+ }
189
+ db.exec('UPDATE deliveries SET queue_seq = rowid WHERE queue_seq IS NULL');
190
+ // harn:assume collaboration-groups-are-durable-state ref=collaboration-store-migration
191
+ if (!columns.some((column) => column.name === 'group_id')) {
192
+ db.exec('ALTER TABLE deliveries ADD COLUMN group_id TEXT');
193
+ }
194
+ if (!columns.some((column) => column.name === 'group_round')) {
195
+ db.exec('ALTER TABLE deliveries ADD COLUMN group_round INTEGER');
196
+ }
197
+ db.exec(`
198
+ CREATE INDEX IF NOT EXISTS collaboration_groups_room_state
199
+ ON collaboration_groups (room, state, created_ts);
200
+ CREATE INDEX IF NOT EXISTS collaboration_rounds_state
201
+ ON collaboration_rounds (group_id, state, round_number);
202
+ CREATE INDEX IF NOT EXISTS collaboration_participants_terminal
203
+ ON collaboration_participants (group_id, round_number, terminal_status, ordinal);
204
+ CREATE INDEX IF NOT EXISTS delivery_group_round_lookup
205
+ ON deliveries (room, group_id, group_round, state, queue_seq);
206
+ CREATE UNIQUE INDEX IF NOT EXISTS delivery_group_round_recipient_unique
207
+ ON deliveries (group_id, group_round, recipient)
208
+ WHERE group_id IS NOT NULL;
209
+ `);
210
+ // harn:end collaboration-groups-are-durable-state
211
+ }
212
+ // harn:end delivery-payload-snapshotted
213
+ function migrateMemberCustody(db) {
214
+ const columns = db.pragma('table_info(members)');
215
+ if (!columns.some((column) => column.name === 'custody')) {
216
+ db.exec('ALTER TABLE members ADD COLUMN custody TEXT');
217
+ }
218
+ }
219
+ // harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-storage
220
+ // An existing database has members whose model and thinking were only ever held in
221
+ // memory, and are already gone. Null is the honest value for them: it means the
222
+ // harness default, which is exactly what they have been silently getting.
223
+ function migrateMemberAgentConfig(db) {
224
+ const columns = db.pragma('table_info(members)');
225
+ if (!columns.some((column) => column.name === 'model')) {
226
+ db.exec('ALTER TABLE members ADD COLUMN model TEXT');
227
+ }
228
+ if (!columns.some((column) => column.name === 'thinking')) {
229
+ db.exec('ALTER TABLE members ADD COLUMN thinking TEXT');
230
+ }
231
+ }
232
+ // harn:end agent-model-and-thinking-are-durable
233
+ function migrateMemberLimits(db) {
234
+ const columns = db.pragma('table_info(members)');
235
+ if (!columns.some((column) => column.name === 'limits')) {
236
+ db.exec('ALTER TABLE members ADD COLUMN limits TEXT');
237
+ }
238
+ }
239
+ function migrateMemberContextWindow(db) {
240
+ const columns = db.pragma('table_info(members)');
241
+ if (!columns.some((column) => column.name === 'context_window')) {
242
+ db.exec('ALTER TABLE members ADD COLUMN context_window INTEGER');
243
+ }
244
+ }
245
+ // harn:assume agent-member-credentials-stay-secret ref=member-credential-storage
246
+ function migrateMemberCredential(db) {
247
+ const columns = db.pragma('table_info(members)');
248
+ if (!columns.some((column) => column.name === 'credential_hash')) {
249
+ db.exec('ALTER TABLE members ADD COLUMN credential_hash TEXT');
250
+ }
251
+ db.exec(`
252
+ CREATE UNIQUE INDEX IF NOT EXISTS member_credential_hash_unique
253
+ ON members (credential_hash) WHERE credential_hash IS NOT NULL
254
+ `);
255
+ }
256
+ // harn:end agent-member-credentials-stay-secret
257
+ // harn:assume roster-briefing-refreshes-on-membership ref=active-roster-storage
258
+ function migrateMemberLifecycle(db) {
259
+ const columns = db.pragma('table_info(members)');
260
+ if (!columns.some((column) => column.name === 'purpose')) {
261
+ db.exec('ALTER TABLE members ADD COLUMN purpose TEXT');
262
+ }
263
+ if (!columns.some((column) => column.name === 'roster_stale')) {
264
+ db.exec('ALTER TABLE members ADD COLUMN roster_stale INTEGER NOT NULL DEFAULT 1');
265
+ }
266
+ if (!columns.some((column) => column.name === 'removed_ts')) {
267
+ db.exec('ALTER TABLE members ADD COLUMN removed_ts TEXT');
268
+ }
269
+ const table = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'members'").get();
270
+ if (/UNIQUE\s*\(\s*room\s*,\s*handle\s*\)/i.test(table.sql)) {
271
+ db.pragma('foreign_keys = OFF');
272
+ db.exec(`
273
+ ALTER TABLE members RENAME TO members_with_global_handle_unique;
274
+ CREATE TABLE members (
275
+ id TEXT PRIMARY KEY,
276
+ room TEXT NOT NULL REFERENCES rooms(id),
277
+ kind TEXT NOT NULL,
278
+ handle TEXT NOT NULL,
279
+ display_name TEXT NOT NULL,
280
+ purpose TEXT,
281
+ harness TEXT,
282
+ session_ref TEXT,
283
+ cwd TEXT,
284
+ policy TEXT,
285
+ host TEXT,
286
+ state TEXT,
287
+ custody TEXT,
288
+ parent TEXT,
289
+ role TEXT,
290
+ conventions_sent INTEGER NOT NULL DEFAULT 0,
291
+ misaddressed INTEGER NOT NULL DEFAULT 0,
292
+ roster_stale INTEGER NOT NULL DEFAULT 1,
293
+ removed_ts TEXT
294
+ );
295
+ INSERT INTO members SELECT id, room, kind, handle, display_name, purpose, harness,
296
+ session_ref, cwd, policy, host, state, custody, parent, role, conventions_sent,
297
+ misaddressed, roster_stale, removed_ts
298
+ FROM members_with_global_handle_unique;
299
+ DROP TABLE members_with_global_handle_unique;
300
+ `);
301
+ db.pragma('foreign_keys = ON');
302
+ }
303
+ db.exec(`
304
+ CREATE UNIQUE INDEX IF NOT EXISTS member_active_handle_unique
305
+ ON members (room, handle) WHERE removed_ts IS NULL
306
+ `);
307
+ }
308
+ // harn:end roster-briefing-refreshes-on-membership
309
+ function migrateMessageAck(db) {
310
+ const columns = db.pragma('table_info(messages)');
311
+ if (!columns.some((column) => column.name === 'ack')) {
312
+ db.exec('ALTER TABLE messages ADD COLUMN ack INTEGER NOT NULL DEFAULT 0');
313
+ }
314
+ }
315
+ function migrateMessagePinned(db) {
316
+ const columns = db.pragma('table_info(messages)');
317
+ if (!columns.some((column) => column.name === 'pinned')) {
318
+ db.exec('ALTER TABLE messages ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
319
+ }
320
+ }
321
+ function migrateMessageDeleted(db) {
322
+ const columns = db.pragma('table_info(messages)');
323
+ if (!columns.some((column) => column.name === 'deleted')) {
324
+ db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0');
325
+ }
326
+ }
327
+ function migrateMessageAttachments(db) {
328
+ const columns = db.pragma('table_info(messages)');
329
+ if (!columns.some((column) => column.name === 'attachments')) {
330
+ db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
331
+ }
332
+ }
333
+ // harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
334
+ function migrateMessageContinuations(db) {
335
+ const columns = db.pragma('table_info(messages)');
336
+ if (!columns.some((column) => column.name === 'run_parent_id')) {
337
+ db.exec('ALTER TABLE messages ADD COLUMN run_parent_id INTEGER');
338
+ }
339
+ db.exec(`
340
+ CREATE INDEX IF NOT EXISTS message_run_continuations
341
+ ON messages (room, run_parent_id, id) WHERE run_parent_id IS NOT NULL
342
+ `);
343
+ }
344
+ // harn:end continuation-writer-follows-journaled-output-ownership
345
+ // harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
346
+ function migrateMessageActivity(db) {
347
+ const columns = db.pragma('table_info(messages)');
348
+ if (!columns.some((column) => column.name === 'activity_seq')) {
349
+ db.exec('ALTER TABLE messages ADD COLUMN activity_seq INTEGER');
350
+ }
351
+ db.exec(`
352
+ UPDATE messages
353
+ SET activity_seq = seq
354
+ WHERE activity_seq IS NULL
355
+ AND deleted = 0
356
+ AND ack = 0
357
+ AND (
358
+ kind = 'chat'
359
+ OR (
360
+ kind = 'run'
361
+ AND run IS NOT NULL
362
+ AND json_extract(run, '$.status') <> 'running'
363
+ )
364
+ );
365
+ CREATE INDEX IF NOT EXISTS message_unread_activity
366
+ ON messages (room, activity_seq) WHERE activity_seq IS NOT NULL;
367
+ `);
368
+ }
369
+ // harn:end substantive-output-messages-drive-unread
370
+ // harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
371
+ function migrateRoomReadCursors(db) {
372
+ db.exec(`
373
+ CREATE TABLE IF NOT EXISTS room_read_cursors (
374
+ room TEXT NOT NULL REFERENCES rooms(id),
375
+ member_id TEXT NOT NULL REFERENCES members(id),
376
+ read_seq INTEGER NOT NULL,
377
+ updated_ts TEXT NOT NULL,
378
+ PRIMARY KEY (room, member_id)
379
+ );
380
+ INSERT OR IGNORE INTO room_read_cursors (room, member_id, read_seq, updated_ts)
381
+ SELECT members.room, members.id, rooms.seq, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
382
+ FROM members
383
+ JOIN rooms ON rooms.id = members.room
384
+ WHERE members.kind = 'human';
385
+ `);
386
+ }
387
+ // harn:end human-room-read-cursors-are-durable-and-monotonic
388
+ // harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-migration
389
+ function migrateApprovalDeliveryResolution(db) {
390
+ const columns = db.pragma('table_info(deliveries)');
391
+ if (!columns.some((column) => column.name === 'interaction_resolved_ts')) {
392
+ db.exec('ALTER TABLE deliveries ADD COLUMN interaction_resolved_ts TEXT');
393
+ }
394
+ db.exec(`
395
+ UPDATE deliveries
396
+ SET interaction_resolved_ts = COALESCE(
397
+ interaction_resolved_ts,
398
+ (
399
+ SELECT interaction.answered_ts
400
+ FROM pending_interactions AS interaction
401
+ WHERE interaction.room = deliveries.room
402
+ AND interaction.message_id = deliveries.message_id
403
+ AND interaction.kind = 'approval'
404
+ AND interaction.state <> 'pending'
405
+ LIMIT 1
406
+ ),
407
+ read_ts,
408
+ ts
409
+ ),
410
+ read_ts = COALESCE(
411
+ read_ts,
412
+ (
413
+ SELECT interaction.answered_ts
414
+ FROM pending_interactions AS interaction
415
+ WHERE interaction.room = deliveries.room
416
+ AND interaction.message_id = deliveries.message_id
417
+ AND interaction.kind = 'approval'
418
+ AND interaction.state <> 'pending'
419
+ LIMIT 1
420
+ ),
421
+ ts
422
+ )
423
+ WHERE (interaction_resolved_ts IS NULL OR read_ts IS NULL)
424
+ AND EXISTS (
425
+ SELECT 1
426
+ FROM pending_interactions AS interaction
427
+ JOIN members AS recipient
428
+ ON recipient.room = interaction.room
429
+ AND recipient.id = deliveries.recipient
430
+ AND recipient.kind = 'human'
431
+ WHERE interaction.room = deliveries.room
432
+ AND interaction.message_id = deliveries.message_id
433
+ AND interaction.kind = 'approval'
434
+ AND interaction.state <> 'pending'
435
+ AND EXISTS (
436
+ SELECT 1 FROM json_each(interaction.targets) AS target
437
+ WHERE target.value = deliveries.recipient
438
+ )
439
+ )
440
+ `);
441
+ }
442
+ // harn:end approval-deliveries-project-resolution-separately
443
+ function migrateDeliveryHopCount(db) {
444
+ const columns = db.pragma('table_info(deliveries)');
445
+ if (!columns.some((column) => column.name === 'hop_count')) {
446
+ db.exec('ALTER TABLE deliveries ADD COLUMN hop_count INTEGER NOT NULL DEFAULT 0');
447
+ }
448
+ }
449
+ function migrateMeterUncostedTokens(db) {
450
+ const columns = db.pragma('table_info(meters)');
451
+ if (!columns.some((column) => column.name === 'uncosted_tokens')) {
452
+ db.exec('ALTER TABLE meters ADD COLUMN uncosted_tokens INTEGER NOT NULL DEFAULT 0');
453
+ }
454
+ }
455
+ function migrateMeterEstimatedCost(db) {
456
+ const columns = db.pragma('table_info(meters)');
457
+ if (!columns.some((column) => column.name === 'estimated_cost_usd')) {
458
+ db.exec('ALTER TABLE meters ADD COLUMN estimated_cost_usd REAL NOT NULL DEFAULT 0');
459
+ }
460
+ }
461
+ // harn:assume bridge-enable-admin-or-owner ref=bridge-origin-uniqueness
462
+ function migrateBridgeOriginUniqueness(db) {
463
+ db.exec(`
464
+ CREATE UNIQUE INDEX IF NOT EXISTS message_bridge_origin_unique
465
+ ON messages (
466
+ author,
467
+ json_extract(origin, '$.platform'),
468
+ json_extract(origin, '$.external_id')
469
+ )
470
+ WHERE origin IS NOT NULL
471
+ `);
472
+ }
473
+ // harn:end bridge-enable-admin-or-owner
474
+ const toBool = (n) => n !== 0;
475
+ const fromBool = (b) => (b ? 1 : 0);
476
+ const orNull = (v) => (v === undefined ? null : v);
477
+ const jsonOrNull = (v) => (v === undefined ? null : JSON.stringify(v));
478
+ const messageDrivesUnread = (message) => message.deleted !== true
479
+ && message.ack !== true
480
+ && (message.kind === 'chat'
481
+ || (message.kind === 'run' && message.run !== undefined && message.run.status !== 'running'));
482
+ function nextActivitySeq(message, mode, seq, stored = null) {
483
+ if (message.deleted === true || message.ack === true || mode === 'defer')
484
+ return null;
485
+ if (mode === 'force')
486
+ return seq;
487
+ if (!messageDrivesUnread(message))
488
+ return null;
489
+ return message.kind === 'chat' ? (stored ?? seq) : seq;
490
+ }
491
+ function memberFromRow(row) {
492
+ return MemberSchema.parse({
493
+ id: row.id,
494
+ kind: row.kind,
495
+ handle: row.handle,
496
+ display_name: row.display_name,
497
+ purpose: row.purpose ?? undefined,
498
+ harness: row.harness ?? undefined,
499
+ session_ref: row.session_ref ?? undefined,
500
+ cwd: row.cwd ?? undefined,
501
+ policy: row.policy ?? undefined,
502
+ model: row.model ?? undefined,
503
+ thinking: row.thinking ?? undefined,
504
+ host: row.host ?? undefined,
505
+ state: row.state ?? undefined,
506
+ custody: row.custody ?? undefined,
507
+ parent: row.parent ?? undefined,
508
+ role: row.role ?? undefined,
509
+ conventions_sent: toBool(row.conventions_sent),
510
+ misaddressed: toBool(row.misaddressed),
511
+ roster_stale: toBool(row.roster_stale),
512
+ removed_ts: row.removed_ts ?? undefined,
513
+ limits: row.limits ? JSON.parse(row.limits) : undefined,
514
+ });
515
+ }
516
+ function messageFromRow(row) {
517
+ return MessageSchema.parse({
518
+ id: row.id,
519
+ room: row.room,
520
+ author: row.author,
521
+ kind: row.kind,
522
+ body: row.body,
523
+ mentions: JSON.parse(row.mentions),
524
+ refs: JSON.parse(row.refs),
525
+ ledger_refs: JSON.parse(row.ledger_refs),
526
+ reply_to: row.reply_to ?? undefined,
527
+ run: row.run ? JSON.parse(row.run) : undefined,
528
+ run_parent_id: row.run_parent_id ?? undefined,
529
+ ask: row.ask ? JSON.parse(row.ask) : undefined,
530
+ origin: row.origin ? JSON.parse(row.origin) : undefined,
531
+ ack: toBool(row.ack) ? true : undefined,
532
+ pinned: toBool(row.pinned) ? true : undefined,
533
+ deleted: toBool(row.deleted) ? true : undefined,
534
+ attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
535
+ ts: row.ts,
536
+ seq: row.seq,
537
+ });
538
+ }
539
+ function deliveryFromRow(row) {
540
+ return DeliverySchema.parse({
541
+ id: row.id,
542
+ room: row.room,
543
+ message_id: row.message_id,
544
+ recipient: row.recipient,
545
+ state: row.state,
546
+ hop_count: row.hop_count,
547
+ attempt_count: row.attempt_count,
548
+ batch_id: row.batch_id ?? undefined,
549
+ run_msg_id: row.run_msg_id ?? undefined,
550
+ read_ts: row.read_ts ?? undefined,
551
+ interaction_resolved_ts: row.interaction_resolved_ts ?? undefined,
552
+ group_id: row.group_id ?? undefined,
553
+ group_round: row.group_round ?? undefined,
554
+ ts: row.ts,
555
+ });
556
+ }
557
+ function collaborationGroupFromRow(row) {
558
+ return CollaborationGroupSchema.parse({
559
+ ...row,
560
+ completed_ts: row.completed_ts ?? undefined,
561
+ });
562
+ }
563
+ function collaborationRoundFromRow(row) {
564
+ return CollaborationRoundSchema.parse({
565
+ ...row,
566
+ released_ts: row.released_ts ?? undefined,
567
+ });
568
+ }
569
+ function collaborationParticipantFromRow(row) {
570
+ return CollaborationParticipantSchema.parse({
571
+ ...row,
572
+ terminal_status: row.terminal_status ?? undefined,
573
+ result_message_id: row.result_message_id ?? undefined,
574
+ completed_ts: row.completed_ts ?? undefined,
575
+ });
576
+ }
577
+ function interactionFromRow(row) {
578
+ return PendingInteractionSchema.parse({
579
+ id: row.id,
580
+ room: row.room,
581
+ member_id: row.member_id,
582
+ message_id: row.message_id,
583
+ native_id: row.native_id,
584
+ kind: row.kind,
585
+ targets: JSON.parse(row.targets),
586
+ state: row.state,
587
+ answer: row.answer === null ? undefined : JSON.parse(row.answer),
588
+ answered_by: row.answered_by ?? undefined,
589
+ answered_ts: row.answered_ts ?? undefined,
590
+ });
591
+ }
592
+ // harn:assume every-channel-has-a-visible-accent ref=channel-accent-persistence
593
+ function roomFromRow(row) {
594
+ const config = JSON.parse(row.config);
595
+ return RoomSchema.parse({
596
+ id: row.id,
597
+ name: row.name,
598
+ created_ts: row.created_ts,
599
+ // Channels the CLI made (the boot-seeded unit among them) carry no colour.
600
+ // Deriving on read gives every existing channel an accent without a migration.
601
+ config: { ...config, color: config.color ?? deriveRoomColor(row.id) },
602
+ });
603
+ }
604
+ // harn:end every-channel-has-a-visible-accent
605
+ function meterFromRow(row) {
606
+ return RoomMeterSchema.parse(row);
607
+ }
608
+ /**
609
+ * The room store: better-sqlite3, synchronous, one file per switchboard.
610
+ * Every mutation of a client-visible entity appends to the change log inside
611
+ * the same transaction — sync hydrates exclusively from that log.
612
+ */
613
+ export class Store {
614
+ db;
615
+ constructor(path) {
616
+ this.db = new Database(path);
617
+ this.db.pragma('journal_mode = WAL');
618
+ this.db.pragma('foreign_keys = ON');
619
+ this.db.exec(SCHEMA);
620
+ migrateDeliveryPayloadSnapshot(this.db);
621
+ migrateMemberCustody(this.db);
622
+ migrateMemberLifecycle(this.db);
623
+ // MUST run after migrateMemberLifecycle: on a legacy database that one REBUILDS the
624
+ // members table from an explicit column list, which would silently drop these two
625
+ // again — and then every insert would fail on a column that no longer exists.
626
+ migrateMemberAgentConfig(this.db);
627
+ migrateMemberLimits(this.db);
628
+ migrateMemberContextWindow(this.db);
629
+ migrateMemberCredential(this.db);
630
+ migrateMessageAck(this.db);
631
+ migrateMessagePinned(this.db);
632
+ migrateMessageDeleted(this.db);
633
+ migrateMessageAttachments(this.db);
634
+ migrateMessageContinuations(this.db);
635
+ migrateMessageActivity(this.db);
636
+ migrateRoomReadCursors(this.db);
637
+ migrateApprovalDeliveryResolution(this.db);
638
+ migrateDeliveryHopCount(this.db);
639
+ migrateMeterUncostedTokens(this.db);
640
+ migrateMeterEstimatedCost(this.db);
641
+ migrateBridgeOriginUniqueness(this.db);
642
+ }
643
+ close() {
644
+ this.db.close();
645
+ }
646
+ // harn:assume changelog-covers-every-visible-entity ref=changelog-append
647
+ /**
648
+ * Allocates the room's next seq and appends one change row — called by
649
+ * EVERY mutating method below, inside its transaction. Returns the seq so
650
+ * the mutation can stamp it on the entity (messages carry their seq).
651
+ */
652
+ appendChange(room, entity, entityId) {
653
+ const bumped = this.db
654
+ .prepare('UPDATE rooms SET seq = seq + 1 WHERE id = ? RETURNING seq')
655
+ .get(room);
656
+ if (!bumped)
657
+ throw new Error(`no such room: ${room}`);
658
+ this.db
659
+ .prepare('INSERT INTO changes (room_id, seq, entity, entity_id) VALUES (?, ?, ?, ?)')
660
+ .run(room, bumped.seq, entity, entityId);
661
+ return bumped.seq;
662
+ }
663
+ // harn:end changelog-covers-every-visible-entity
664
+ // ── rooms ─────────────────────────────────────────────────────────────
665
+ // harn:assume owner-and-system-members-seeded ref=room-seeding
666
+ /**
667
+ * Creates a room and atomically seeds its two structural members: the
668
+ * owner human (the authenticated principal's author identity) and the
669
+ * non-addressable system member holding the reserved 'switchboard' handle.
670
+ */
671
+ createRoom(opts) {
672
+ const config = RoomConfigSchema.parse({
673
+ ...opts.config,
674
+ color: opts.config?.color ?? deriveRoomColor(opts.id),
675
+ });
676
+ const ts = new Date().toISOString();
677
+ const result = this.db.transaction(() => {
678
+ this.db
679
+ .prepare('INSERT INTO rooms (id, name, created_ts, config, seq) VALUES (?, ?, ?, ?, 0)')
680
+ .run(opts.id, opts.name, ts, JSON.stringify(config));
681
+ this.appendChange(opts.id, 'room', opts.id);
682
+ const owner = this.insertMember(opts.id, {
683
+ kind: 'human',
684
+ handle: opts.owner.handle,
685
+ display_name: opts.owner.display_name,
686
+ role: 'owner',
687
+ });
688
+ const system = this.insertMember(opts.id, {
689
+ kind: 'system',
690
+ handle: 'switchboard',
691
+ display_name: 'Switchboard',
692
+ });
693
+ return { owner, system };
694
+ })();
695
+ return { room: this.getRoom(opts.id), ...result };
696
+ }
697
+ // harn:end owner-and-system-members-seeded
698
+ getRoom(id) {
699
+ const row = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(id);
700
+ return row ? roomFromRow(row) : undefined;
701
+ }
702
+ listRooms() {
703
+ const rows = this.db.prepare('SELECT * FROM rooms ORDER BY id').all();
704
+ return rows.map(roomFromRow);
705
+ }
706
+ updateRoomConfig(room, patch) {
707
+ return this.db.transaction(() => {
708
+ const current = this.getRoom(room);
709
+ if (!current)
710
+ throw new Error(`no such room: ${room}`);
711
+ const config = RoomConfigSchema.parse({ ...current.config, ...patch });
712
+ this.db
713
+ .prepare('UPDATE rooms SET config = ? WHERE id = ?')
714
+ .run(JSON.stringify(config), room);
715
+ this.appendChange(room, 'room', room);
716
+ return this.getRoom(room);
717
+ })();
718
+ }
719
+ // ── members ───────────────────────────────────────────────────────────
720
+ insertMember(room, member) {
721
+ const validated = MemberSchema.parse({ id: this.newUlid(), ...member });
722
+ this.db
723
+ .prepare(`INSERT INTO members (id, room, kind, handle, display_name, purpose, harness, session_ref,
724
+ cwd, policy, model, thinking, host, state, custody, parent, role, conventions_sent,
725
+ misaddressed, roster_stale, removed_ts)
726
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
727
+ .run(validated.id, room, validated.kind, validated.handle, validated.display_name, orNull(validated.purpose), orNull(validated.harness), orNull(validated.session_ref), orNull(validated.cwd), orNull(validated.policy), orNull(validated.model), orNull(validated.thinking), orNull(validated.host), orNull(validated.state), orNull(validated.custody), orNull(validated.parent), orNull(validated.role), fromBool(validated.conventions_sent), fromBool(validated.misaddressed), fromBool(validated.roster_stale), orNull(validated.removed_ts));
728
+ const seq = this.appendChange(room, 'member', validated.id);
729
+ // harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
730
+ if (validated.kind === 'human') {
731
+ this.db.prepare(`INSERT INTO room_read_cursors (room, member_id, read_seq, updated_ts)
732
+ VALUES (?, ?, ?, ?)
733
+ ON CONFLICT (room, member_id) DO NOTHING`).run(room, validated.id, seq, new Date().toISOString());
734
+ }
735
+ // harn:end human-room-read-cursors-are-durable-and-monotonic
736
+ return validated;
737
+ }
738
+ addMember(room, member) {
739
+ return this.db.transaction(() => this.insertMember(room, member))();
740
+ }
741
+ updateMember(room, memberId, patch) {
742
+ return this.db.transaction(() => {
743
+ const existing = this.getMember(room, memberId);
744
+ if (!existing)
745
+ throw new Error(`no such member: ${memberId}`);
746
+ const merged = MemberSchema.parse({ ...existing, ...patch });
747
+ this.db
748
+ .prepare(
749
+ // harn:assume member-config-is-changed-not-respawned ref=member-config-storage
750
+ // A2 gave members a model and a thinking level and taught the INSERT and the
751
+ // READ about them — but not this. Nothing changed them after spawn, so nothing
752
+ // noticed. A configure that called this would have reported success and
753
+ // persisted nothing at all.
754
+ `UPDATE members SET handle = ?, display_name = ?, purpose = ?, harness = ?, session_ref = ?,
755
+ cwd = ?, policy = ?, model = ?, thinking = ?, host = ?, state = ?, custody = ?,
756
+ parent = ?, role = ?, conventions_sent = ?, misaddressed = ?, roster_stale = ?,
757
+ removed_ts = ?, limits = ?
758
+ WHERE room = ? AND id = ?`)
759
+ .run(merged.handle, merged.display_name, orNull(merged.purpose), orNull(merged.harness), orNull(merged.session_ref), orNull(merged.cwd), orNull(merged.policy), orNull(merged.model), orNull(merged.thinking), orNull(merged.host), orNull(merged.state), orNull(merged.custody), orNull(merged.parent), orNull(merged.role), fromBool(merged.conventions_sent), fromBool(merged.misaddressed), fromBool(merged.roster_stale), orNull(merged.removed_ts), merged.limits === undefined ? null : JSON.stringify(merged.limits), room, memberId);
760
+ // harn:end member-config-is-changed-not-respawned
761
+ this.appendChange(room, 'member', memberId);
762
+ return merged;
763
+ })();
764
+ }
765
+ // harn:assume agent-member-credentials-stay-secret ref=member-credential-storage
766
+ setAgentCredentialHash(room, memberId, credentialHash) {
767
+ if (!/^[a-f0-9]{64}$/.test(credentialHash)) {
768
+ throw new Error('member credential hash must be a SHA-256 digest');
769
+ }
770
+ const member = this.getMember(room, memberId);
771
+ if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
772
+ throw new Error(`no active agent member: ${memberId}`);
773
+ }
774
+ this.db
775
+ .prepare('UPDATE members SET credential_hash = ? WHERE room = ? AND id = ?')
776
+ .run(credentialHash, room, memberId);
777
+ }
778
+ findAgentByCredentialHash(credentialHash) {
779
+ const row = this.db
780
+ .prepare(`SELECT * FROM members
781
+ WHERE credential_hash = ? AND kind = 'agent' AND removed_ts IS NULL
782
+ AND state <> 'dead'`)
783
+ .get(credentialHash);
784
+ return row ? { room: row.room, member: memberFromRow(row) } : undefined;
785
+ }
786
+ // harn:end agent-member-credentials-stay-secret
787
+ getMember(room, memberId) {
788
+ const row = this.db
789
+ .prepare('SELECT * FROM members WHERE room = ? AND id = ?')
790
+ .get(room, memberId);
791
+ return row ? memberFromRow(row) : undefined;
792
+ }
793
+ getMemberByHandle(room, handle) {
794
+ const row = this.db
795
+ .prepare('SELECT * FROM members WHERE room = ? AND handle = ? AND removed_ts IS NULL')
796
+ .get(room, handle);
797
+ return row ? memberFromRow(row) : undefined;
798
+ }
799
+ findMemberBySessionRef(harness, sessionRef) {
800
+ const row = this.db
801
+ .prepare('SELECT * FROM members WHERE harness = ? AND session_ref = ? AND removed_ts IS NULL ORDER BY room LIMIT 1')
802
+ .get(harness, sessionRef);
803
+ return row ? { room: row.room, member: memberFromRow(row) } : undefined;
804
+ }
805
+ getExtensionByNativeId(room, parentId, nativeId) {
806
+ const row = this.db
807
+ .prepare(`SELECT * FROM members
808
+ WHERE room = ? AND kind = 'extension' AND parent = ? AND session_ref = ?
809
+ ORDER BY id LIMIT 1`)
810
+ .get(room, parentId, nativeId);
811
+ return row ? memberFromRow(row) : undefined;
812
+ }
813
+ listMembers(room, options = {}) {
814
+ const rows = this.db
815
+ .prepare(`SELECT * FROM members WHERE room = ?
816
+ AND (? = 1 OR removed_ts IS NULL) ORDER BY id`)
817
+ .all(room, options.includeRemoved ? 1 : 0);
818
+ return rows.map(memberFromRow);
819
+ }
820
+ markAgentRostersStale(room) {
821
+ this.db.prepare(`UPDATE members SET roster_stale = 1
822
+ WHERE room = ? AND kind = 'agent' AND removed_ts IS NULL`).run(room);
823
+ }
824
+ clearAgentRosterStale(room, memberId) {
825
+ this.db.prepare(`UPDATE members SET roster_stale = 0
826
+ WHERE room = ? AND id = ? AND kind = 'agent' AND removed_ts IS NULL`).run(room, memberId);
827
+ }
828
+ getMirroredMessageId(room, memberId, nativeTurnId) {
829
+ const row = this.db
830
+ .prepare(`SELECT message_id FROM mirrored_turns
831
+ WHERE room = ? AND member_id = ? AND native_turn_id = ?`)
832
+ .get(room, memberId, nativeTurnId);
833
+ return row?.message_id;
834
+ }
835
+ recordMirroredTurn(room, memberId, nativeTurnId, messageId) {
836
+ this.db
837
+ .prepare(`INSERT INTO mirrored_turns (room, member_id, native_turn_id, message_id)
838
+ VALUES (?, ?, ?, ?)`)
839
+ .run(room, memberId, nativeTurnId, messageId);
840
+ }
841
+ // harn:assume mirrored-turn-commit-transactional ref=atomic-mirrored-turn
842
+ commitMirroredTurn(room, opts) {
843
+ return this.db.transaction(() => {
844
+ const existingId = this.getMirroredMessageId(room, opts.memberId, opts.nativeTurnId);
845
+ if (existingId !== undefined) {
846
+ const message = this.getMessage(room, existingId);
847
+ if (!message)
848
+ throw new Error(`mirrored turn points to missing message #${existingId}`);
849
+ return { message, deliveries: [], deduped: true };
850
+ }
851
+ const placeholder = this.postMessage(room, {
852
+ author: opts.memberId,
853
+ kind: 'run',
854
+ body: '',
855
+ });
856
+ const finalized = opts.finalize(placeholder);
857
+ const message = this.updateMessage(room, placeholder.id, finalized.message);
858
+ const member = finalized.markMisaddressed
859
+ ? this.updateMember(room, opts.memberId, { misaddressed: true })
860
+ : undefined;
861
+ const deliveries = finalized.fanout.map((delivery) => this.createDelivery(room, {
862
+ message_id: message.id,
863
+ recipient: delivery.recipient,
864
+ state: delivery.state,
865
+ payload_snapshot: delivery.payload_snapshot,
866
+ hop_count: delivery.hop_count,
867
+ }));
868
+ const collaboration = finalized.collaboration === undefined
869
+ ? undefined
870
+ : this.createCollaborationGroup(room, {
871
+ groupId: finalized.collaboration.groupId,
872
+ rootMessageId: message.id,
873
+ participants: finalized.collaboration.participants,
874
+ });
875
+ if (collaboration)
876
+ deliveries.push(...collaboration.deliveries);
877
+ this.recordMirroredTurn(room, opts.memberId, opts.nativeTurnId, message.id);
878
+ return { message, deliveries, member, collaboration, deduped: false };
879
+ })();
880
+ }
881
+ // harn:end mirrored-turn-commit-transactional
882
+ createAttachLease(input) {
883
+ const lease = AttachLeaseSchema.parse({ id: this.newUlid(), ...input });
884
+ this.db
885
+ .prepare(`INSERT INTO attach_leases
886
+ (id, room, member_id, cli_pid, child_pid, process_group_id, heartbeat_ts)
887
+ VALUES (?, ?, ?, ?, NULL, NULL, ?)`)
888
+ .run(lease.id, lease.room, lease.member_id, lease.cli_pid, lease.heartbeat_ts);
889
+ return lease;
890
+ }
891
+ getAttachLease(id) {
892
+ const row = this.db.prepare('SELECT * FROM attach_leases WHERE id = ?').get(id);
893
+ return row ? this.attachLeaseFromRow(row) : undefined;
894
+ }
895
+ getAttachLeaseForMember(memberId) {
896
+ const row = this.db.prepare('SELECT * FROM attach_leases WHERE member_id = ?').get(memberId);
897
+ return row ? this.attachLeaseFromRow(row) : undefined;
898
+ }
899
+ listAttachLeases() {
900
+ return this.db.prepare('SELECT * FROM attach_leases ORDER BY id').all()
901
+ .map((row) => this.attachLeaseFromRow(row));
902
+ }
903
+ setAttachLeaseChild(id, childPid, processGroupId, heartbeatTs) {
904
+ const result = this.db
905
+ .prepare(`UPDATE attach_leases
906
+ SET child_pid = ?, process_group_id = ?, heartbeat_ts = ?
907
+ WHERE id = ?`)
908
+ .run(childPid, processGroupId, heartbeatTs, id);
909
+ if (result.changes !== 1)
910
+ throw new Error(`no such attach lease ${id}`);
911
+ return this.getAttachLease(id);
912
+ }
913
+ heartbeatAttachLease(id, heartbeatTs) {
914
+ const result = this.db
915
+ .prepare('UPDATE attach_leases SET heartbeat_ts = ? WHERE id = ?')
916
+ .run(heartbeatTs, id);
917
+ if (result.changes !== 1)
918
+ throw new Error(`no such attach lease ${id}`);
919
+ return this.getAttachLease(id);
920
+ }
921
+ deleteAttachLease(id) {
922
+ this.db.prepare('DELETE FROM attach_leases WHERE id = ?').run(id);
923
+ }
924
+ attachLeaseFromRow(row) {
925
+ return AttachLeaseSchema.parse({
926
+ id: row.id,
927
+ room: row.room,
928
+ member_id: row.member_id,
929
+ cli_pid: row.cli_pid,
930
+ child_pid: row.child_pid ?? undefined,
931
+ process_group_id: row.process_group_id ?? undefined,
932
+ heartbeat_ts: row.heartbeat_ts,
933
+ });
934
+ }
935
+ // ── messages ──────────────────────────────────────────────────────────
936
+ // harn:assume message-id-txn-allocation ref=message-id-allocation
937
+ /**
938
+ * Message ids are per-room dense monotonic ints allocated as MAX(id)+1
939
+ * INSIDE the same synchronous transaction as the insert — ids are permanent
940
+ * (#N refs), so allocation can never race or leave gaps.
941
+ */
942
+ postMessage(room, message, options = {}) {
943
+ return this.db.transaction(() => {
944
+ const next = this.db
945
+ .prepare('SELECT COALESCE(MAX(id), 0) + 1 AS id FROM messages WHERE room = ?')
946
+ .get(room);
947
+ const seq = this.appendChange(room, 'message', String(next.id));
948
+ const validated = MessageSchema.parse({
949
+ id: next.id,
950
+ room,
951
+ author: message.author,
952
+ kind: message.kind,
953
+ body: message.body,
954
+ mentions: message.mentions ?? [],
955
+ refs: message.refs ?? [],
956
+ ledger_refs: message.ledger_refs ?? [],
957
+ reply_to: message.reply_to,
958
+ run: message.run,
959
+ run_parent_id: message.run_parent_id,
960
+ ask: message.ask,
961
+ origin: message.origin,
962
+ ack: message.ack,
963
+ attachments: message.attachments,
964
+ ts: new Date().toISOString(),
965
+ seq,
966
+ });
967
+ // harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
968
+ const activitySeq = nextActivitySeq(validated, options.activity ?? 'auto', seq);
969
+ this.db
970
+ .prepare(`INSERT INTO messages (room, id, author, kind, body, mentions, refs, ledger_refs,
971
+ reply_to, run, run_parent_id, ask, origin, attachments, ack, pinned, deleted, ts, seq, activity_seq)
972
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
973
+ .run(room, validated.id, validated.author, validated.kind, validated.body, JSON.stringify(validated.mentions), JSON.stringify(validated.refs), JSON.stringify(validated.ledger_refs), orNull(validated.reply_to), jsonOrNull(validated.run), orNull(validated.run_parent_id), jsonOrNull(validated.ask), jsonOrNull(validated.origin), jsonOrNull(validated.attachments), fromBool(validated.ack === true), fromBool(validated.pinned === true), fromBool(validated.deleted === true), validated.ts, validated.seq, activitySeq);
974
+ // harn:end substantive-output-messages-drive-unread
975
+ return validated;
976
+ })();
977
+ }
978
+ // harn:end message-id-txn-allocation
979
+ // harn:assume eligible-multi-agent-routing-starts-one-group ref=atomic-routed-message-commit
980
+ commitRoutedMessage(room, opts) {
981
+ return this.db.transaction(() => {
982
+ const message = this.postMessage(room, opts.message);
983
+ const plan = opts.plan(message);
984
+ const member = plan.markMisaddressed
985
+ ? this.updateMember(room, message.author, { misaddressed: true })
986
+ : undefined;
987
+ const deliveries = plan.fanout.map((delivery) => this.createDelivery(room, {
988
+ message_id: message.id,
989
+ recipient: delivery.recipient,
990
+ state: delivery.state,
991
+ payload_snapshot: delivery.payload_snapshot,
992
+ hop_count: delivery.hop_count,
993
+ }));
994
+ const collaboration = plan.collaboration === undefined
995
+ ? undefined
996
+ : this.createCollaborationGroup(room, {
997
+ groupId: plan.collaboration.groupId,
998
+ rootMessageId: message.id,
999
+ participants: plan.collaboration.participants,
1000
+ });
1001
+ if (collaboration)
1002
+ deliveries.push(...collaboration.deliveries);
1003
+ return { message, deliveries, member, collaboration };
1004
+ })();
1005
+ }
1006
+ // harn:end eligible-multi-agent-routing-starts-one-group
1007
+ postBridgeMessage(room, bridgeMemberId, body, origin, parsed, plan) {
1008
+ const member = this.getMember(room, bridgeMemberId);
1009
+ if (member?.kind !== 'bridge')
1010
+ throw new Error(`no such bridge member: ${bridgeMemberId}`);
1011
+ const validOrigin = BridgeOriginSchema.parse(origin);
1012
+ return this.db.transaction(() => {
1013
+ const existing = this.db.prepare(`SELECT * FROM messages
1014
+ WHERE room = ? AND author = ?
1015
+ AND json_extract(origin, '$.platform') = ?
1016
+ AND json_extract(origin, '$.external_id') = ?
1017
+ LIMIT 1`).get(room, bridgeMemberId, validOrigin.platform, validOrigin.external_id);
1018
+ if (existing) {
1019
+ return { message: messageFromRow(existing), deliveries: [], deduped: true };
1020
+ }
1021
+ const message = this.postMessage(room, {
1022
+ author: bridgeMemberId,
1023
+ kind: 'chat',
1024
+ body,
1025
+ ...parsed,
1026
+ origin: validOrigin,
1027
+ });
1028
+ const routed = plan?.(message);
1029
+ const memberPatch = routed?.markMisaddressed
1030
+ ? this.updateMember(room, bridgeMemberId, { misaddressed: true })
1031
+ : undefined;
1032
+ const deliveries = (routed?.fanout ?? []).map((delivery) => this.createDelivery(room, {
1033
+ message_id: message.id,
1034
+ recipient: delivery.recipient,
1035
+ state: delivery.state,
1036
+ payload_snapshot: delivery.payload_snapshot,
1037
+ hop_count: delivery.hop_count,
1038
+ }));
1039
+ const collaboration = routed?.collaboration === undefined
1040
+ ? undefined
1041
+ : this.createCollaborationGroup(room, {
1042
+ groupId: routed.collaboration.groupId,
1043
+ rootMessageId: message.id,
1044
+ participants: routed.collaboration.participants,
1045
+ });
1046
+ if (collaboration)
1047
+ deliveries.push(...collaboration.deliveries);
1048
+ return {
1049
+ message,
1050
+ deliveries,
1051
+ member: memberPatch,
1052
+ collaboration,
1053
+ deduped: false,
1054
+ };
1055
+ })();
1056
+ }
1057
+ latestMessageId(room) {
1058
+ const row = this.db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages WHERE room = ?').get(room);
1059
+ return row.id;
1060
+ }
1061
+ // harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
1062
+ /** Allocate and insert a permanent empty continuation before its id is journaled. */
1063
+ createRunContinuation(room, rootMessageId) {
1064
+ const root = this.getMessage(room, rootMessageId);
1065
+ if (root?.kind !== 'run' || root.run === undefined || root.run_parent_id !== undefined) {
1066
+ throw new Error(`#${rootMessageId} is not a lifecycle run root`);
1067
+ }
1068
+ if (root.run.status !== 'running' || root.run.output_mode !== 'messages') {
1069
+ throw new Error(`#${rootMessageId} is not accepting continuation output`);
1070
+ }
1071
+ return this.postMessage(room, {
1072
+ author: root.author,
1073
+ kind: 'run',
1074
+ body: '',
1075
+ run_parent_id: root.id,
1076
+ }, { activity: 'defer' });
1077
+ }
1078
+ listRunContinuations(room, rootMessageId) {
1079
+ const rows = this.db.prepare('SELECT * FROM messages WHERE room = ? AND run_parent_id = ? ORDER BY id').all(room, rootMessageId);
1080
+ return rows.map(messageFromRow);
1081
+ }
1082
+ /** Resolve either a root or one of its result fragments to lifecycle truth. */
1083
+ getRunRoot(room, message) {
1084
+ return message.run_parent_id === undefined
1085
+ ? (message.run === undefined ? undefined : message)
1086
+ : this.getMessage(room, message.run_parent_id);
1087
+ }
1088
+ // harn:end continuation-writer-follows-journaled-output-ownership
1089
+ // harn:assume default-recipient-fallback-chain ref=substantive-default-recipient
1090
+ latestFinalizedAgentAuthor(room) {
1091
+ const row = this.db.prepare(`SELECT messages.author
1092
+ FROM messages
1093
+ JOIN members ON members.room = messages.room AND members.id = messages.author
1094
+ WHERE messages.room = ?
1095
+ AND messages.kind = 'run'
1096
+ AND members.kind = 'agent'
1097
+ AND members.removed_ts IS NULL
1098
+ AND messages.ack = 0
1099
+ AND json_extract(messages.run, '$.status') <> 'running'
1100
+ ORDER BY messages.id DESC
1101
+ LIMIT 1`).get(room);
1102
+ return row?.author;
1103
+ }
1104
+ // harn:end default-recipient-fallback-chain
1105
+ listMessagesAfter(room, after, limit = 100) {
1106
+ const rows = this.db.prepare(`SELECT * FROM messages WHERE room = ? AND id > ? ORDER BY id ASC LIMIT ?`).all(room, after, limit);
1107
+ return rows.map(messageFromRow);
1108
+ }
1109
+ /**
1110
+ * In-place update of a message (run finalization: body becomes final_text,
1111
+ * mentions/refs re-parsed, run summary updated). Same id, new seq.
1112
+ */
1113
+ updateMessage(room, id, patch, options = {}) {
1114
+ return this.db.transaction(() => {
1115
+ const existing = this.getMessage(room, id);
1116
+ if (!existing)
1117
+ throw new Error(`no such message: #${id}`);
1118
+ const seq = this.appendChange(room, 'message', String(id));
1119
+ const merged = MessageSchema.parse({ ...existing, ...patch, seq });
1120
+ // harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
1121
+ const storedActivity = this.db.prepare('SELECT activity_seq FROM messages WHERE room = ? AND id = ?').get(room, id);
1122
+ const activitySeq = nextActivitySeq(merged, options.activity ?? 'auto', seq, storedActivity.activity_seq);
1123
+ this.db
1124
+ .prepare(`UPDATE messages SET body = ?, mentions = ?, refs = ?, ledger_refs = ?,
1125
+ run = ?, ask = ?, ack = ?, seq = ?, activity_seq = ?
1126
+ WHERE room = ? AND id = ?`)
1127
+ .run(merged.body, JSON.stringify(merged.mentions), JSON.stringify(merged.refs), JSON.stringify(merged.ledger_refs), jsonOrNull(merged.run), jsonOrNull(merged.ask), fromBool(merged.ack === true), seq, activitySeq, room, id);
1128
+ // harn:end substantive-output-messages-drive-unread
1129
+ return merged;
1130
+ })();
1131
+ }
1132
+ /**
1133
+ * Flip a message's pinned flag through the change log, so live frames and
1134
+ * reconnect sync both carry it. Same id, new seq; nothing else on the row
1135
+ * moves.
1136
+ */
1137
+ setMessagePinned(room, id, pinned) {
1138
+ return this.db.transaction(() => {
1139
+ const existing = this.getMessage(room, id);
1140
+ if (!existing)
1141
+ throw new Error(`no such message: #${id}`);
1142
+ const seq = this.appendChange(room, 'message', String(id));
1143
+ const merged = MessageSchema.parse({ ...existing, pinned: pinned || undefined, seq });
1144
+ this.db
1145
+ .prepare('UPDATE messages SET pinned = ?, seq = ? WHERE room = ? AND id = ?')
1146
+ .run(fromBool(pinned), seq, room, id);
1147
+ return merged;
1148
+ })();
1149
+ }
1150
+ /**
1151
+ * Purge a message in place through the change log: body emptied, payload
1152
+ * columns nulled, pin cleared (a tombstone cannot stay pinned), the deleted
1153
+ * flag set. Same id and new seq keep ordering, attribution, and permalinks
1154
+ * coherent; the purge is irreversible.
1155
+ */
1156
+ deleteMessage(room, id) {
1157
+ return this.db.transaction(() => {
1158
+ const existing = this.getMessage(room, id);
1159
+ if (!existing)
1160
+ throw new Error(`no such message: #${id}`);
1161
+ const seq = this.appendChange(room, 'message', String(id));
1162
+ const merged = MessageSchema.parse({
1163
+ ...existing,
1164
+ body: '',
1165
+ mentions: [],
1166
+ refs: [],
1167
+ ledger_refs: [],
1168
+ ask: undefined,
1169
+ origin: undefined,
1170
+ attachments: undefined,
1171
+ pinned: undefined,
1172
+ deleted: true,
1173
+ seq,
1174
+ });
1175
+ this.db
1176
+ .prepare(`UPDATE messages SET body = '', mentions = '[]', refs = '[]', ledger_refs = '[]',
1177
+ ask = NULL, origin = NULL, attachments = NULL, pinned = 0, deleted = 1, seq = ?
1178
+ WHERE room = ? AND id = ?`)
1179
+ .run(seq, room, id);
1180
+ return merged;
1181
+ })();
1182
+ }
1183
+ getMessage(room, id) {
1184
+ const row = this.db
1185
+ .prepare('SELECT * FROM messages WHERE room = ? AND id = ?')
1186
+ .get(room, id);
1187
+ return row ? messageFromRow(row) : undefined;
1188
+ }
1189
+ /** Every pinned message, id-ascending — the strip hydrates from this whole
1190
+ * set (pins are few) so a pin older than the loaded page still shows. */
1191
+ listPinnedMessages(room) {
1192
+ const rows = this.db
1193
+ .prepare('SELECT * FROM messages WHERE room = ? AND pinned = 1 ORDER BY id')
1194
+ .all(room);
1195
+ return rows.map(messageFromRow);
1196
+ }
1197
+ /** Engine-reported context window, persisted so gauge estimates outlive
1198
+ * restarts (daemon-internal; not part of the protocol Member shape). */
1199
+ getMemberContextWindow(room, id) {
1200
+ const row = this.db
1201
+ .prepare('SELECT context_window FROM members WHERE room = ? AND id = ?')
1202
+ .get(room, id);
1203
+ return row?.context_window ?? undefined;
1204
+ }
1205
+ setMemberContextWindow(room, id, contextWindow) {
1206
+ this.db
1207
+ .prepare('UPDATE members SET context_window = ? WHERE room = ? AND id = ?')
1208
+ .run(contextWindow, room, id);
1209
+ }
1210
+ // harn:assume durable-room-summaries-stream-and-fallback ref=durable-room-summary
1211
+ /** Newest message in a room — the rail preview's single source. */
1212
+ latestMessage(room, options = {}) {
1213
+ const row = this.db
1214
+ .prepare(`SELECT * FROM messages
1215
+ WHERE room = ? AND (? = 0 OR ack = 0)
1216
+ ORDER BY id DESC LIMIT 1`)
1217
+ .get(room, options.ignoreAcks ? 1 : 0);
1218
+ return row ? messageFromRow(row) : undefined;
1219
+ }
1220
+ /** Legacy caller-cursor arithmetic retained until every client opts into
1221
+ * durable room support. */
1222
+ countMessagesAfter(room, afterId) {
1223
+ const row = this.db
1224
+ .prepare('SELECT COUNT(*) AS n FROM messages WHERE room = ? AND id > ?')
1225
+ .get(room, afterId);
1226
+ return row.n;
1227
+ }
1228
+ // harn:end durable-room-summaries-stream-and-fallback
1229
+ // harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
1230
+ getRoomReadSeq(room, memberId) {
1231
+ const member = this.getMember(room, memberId);
1232
+ if (member?.kind !== 'human')
1233
+ throw new Error(`no human member ${memberId} in ${room}`);
1234
+ const row = this.db.prepare('SELECT read_seq FROM room_read_cursors WHERE room = ? AND member_id = ?').get(room, memberId);
1235
+ if (!row)
1236
+ throw new Error(`human member ${memberId} has no room read cursor`);
1237
+ return row.read_seq;
1238
+ }
1239
+ markRoomRead(room, memberId, throughSeq) {
1240
+ return this.db.transaction(() => {
1241
+ if (!Number.isInteger(throughSeq) || throughSeq < 0) {
1242
+ throw new Error('room read seq must be a non-negative integer');
1243
+ }
1244
+ const roomSeq = this.currentSeq(room);
1245
+ if (throughSeq > roomSeq) {
1246
+ throw new Error(`room read seq ${throughSeq} is ahead of current seq ${roomSeq}`);
1247
+ }
1248
+ const previous = this.getRoomReadSeq(room, memberId);
1249
+ const readSeq = Math.max(previous, throughSeq);
1250
+ if (readSeq > previous) {
1251
+ this.db.prepare(`UPDATE room_read_cursors SET read_seq = ?, updated_ts = ?
1252
+ WHERE room = ? AND member_id = ?`).run(readSeq, new Date().toISOString(), room, memberId);
1253
+ }
1254
+ const candidates = this.db.prepare(`SELECT deliveries.id
1255
+ FROM deliveries
1256
+ JOIN messages ON messages.room = deliveries.room
1257
+ AND messages.id = deliveries.message_id
1258
+ WHERE deliveries.room = ?
1259
+ AND deliveries.recipient = ?
1260
+ AND deliveries.state = 'consumed'
1261
+ AND deliveries.read_ts IS NULL
1262
+ AND messages.seq <= ?
1263
+ ORDER BY deliveries.queue_seq, deliveries.id`).all(room, memberId, readSeq);
1264
+ const readTs = new Date().toISOString();
1265
+ const deliveries = candidates.map(({ id }) => this.updateDelivery(room, id, { read_ts: readTs }));
1266
+ return { read_seq: readSeq, deliveries };
1267
+ })();
1268
+ }
1269
+ // harn:end human-room-read-cursors-are-durable-and-monotonic
1270
+ // harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
1271
+ countUnreadMessages(room, memberId) {
1272
+ const readSeq = this.getRoomReadSeq(room, memberId);
1273
+ const row = this.db.prepare(`SELECT COUNT(*) AS n
1274
+ FROM messages
1275
+ WHERE room = ?
1276
+ AND deleted = 0
1277
+ AND activity_seq IS NOT NULL
1278
+ AND activity_seq > ?
1279
+ AND author <> ?`).get(room, readSeq, memberId);
1280
+ return row.n;
1281
+ }
1282
+ // harn:end substantive-output-messages-drive-unread
1283
+ // harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-projection
1284
+ // harn:assume actionable-inbox-clears-on-read-or-reply ref=actionable-inbox-projection
1285
+ actionableInbox(room, memberId) {
1286
+ const rows = this.db.prepare(`SELECT deliveries.*
1287
+ FROM deliveries
1288
+ JOIN messages ON messages.room = deliveries.room
1289
+ AND messages.id = deliveries.message_id
1290
+ WHERE deliveries.room = ?
1291
+ AND deliveries.recipient = ?
1292
+ AND deliveries.state = 'consumed'
1293
+ AND deliveries.read_ts IS NULL
1294
+ AND messages.deleted = 0
1295
+ AND (
1296
+ messages.kind IN ('ask', 'approval')
1297
+ OR EXISTS (
1298
+ SELECT 1 FROM json_each(messages.mentions) AS mention
1299
+ WHERE json_extract(mention.value, '$.member_id') = ?
1300
+ )
1301
+ )
1302
+ AND NOT EXISTS (
1303
+ SELECT 1 FROM messages AS reply
1304
+ WHERE reply.room = messages.room
1305
+ AND reply.reply_to = messages.id
1306
+ AND reply.author = ?
1307
+ AND reply.deleted = 0
1308
+ )
1309
+ ORDER BY deliveries.queue_seq DESC, deliveries.id DESC`).all(room, memberId, memberId, memberId);
1310
+ return rows.flatMap((row) => {
1311
+ const delivery = deliveryFromRow(row);
1312
+ const message = this.getMessage(room, delivery.message_id);
1313
+ if (!message)
1314
+ return [];
1315
+ const author = this.getMember(room, message.author);
1316
+ if (!author)
1317
+ return [];
1318
+ return [{
1319
+ delivery,
1320
+ author_id: author.id,
1321
+ author_handle: author.handle,
1322
+ author_kind: author.kind,
1323
+ message_kind: message.kind,
1324
+ preview: (message.body.split('\n', 1)[0] ?? '').slice(0, 140),
1325
+ ts: message.ts,
1326
+ }];
1327
+ });
1328
+ }
1329
+ // harn:end actionable-inbox-clears-on-read-or-reply
1330
+ roomSupport(room, memberId) {
1331
+ const viewer = this.getMember(room, memberId);
1332
+ if (viewer?.kind !== 'human')
1333
+ throw new Error(`no human member ${memberId} in ${room}`);
1334
+ const roomRow = this.getRoom(room);
1335
+ if (!roomRow)
1336
+ throw new Error(`no such room: ${room}`);
1337
+ const members = this.listMembers(room);
1338
+ const latest = this.latestMessage(room, { ignoreAcks: true });
1339
+ const latestRun = this.listRunMessages(room, { limit: 1 })[0];
1340
+ const latestAuthor = latest ? this.getMember(room, latest.author) : undefined;
1341
+ const latestRunAuthor = latestRun
1342
+ ? this.getMember(room, latestRun.author)
1343
+ : undefined;
1344
+ const working = members.some((member) => member.kind === 'agent'
1345
+ && (member.state === 'running' || member.state === 'queued'));
1346
+ const activeRows = this.db.prepare(`SELECT * FROM messages
1347
+ WHERE room = ? AND deleted = 0 AND kind = 'run'
1348
+ AND json_extract(run, '$.status') = 'running'
1349
+ ORDER BY id`).all(room);
1350
+ const interactionRows = this.db.prepare(`SELECT messages.*
1351
+ FROM pending_interactions
1352
+ JOIN messages ON messages.room = pending_interactions.room
1353
+ AND messages.id = pending_interactions.message_id
1354
+ WHERE pending_interactions.room = ?
1355
+ AND pending_interactions.state = 'pending'
1356
+ AND messages.deleted = 0
1357
+ AND EXISTS (
1358
+ SELECT 1 FROM json_each(pending_interactions.targets) AS target
1359
+ WHERE target.value = ?
1360
+ )
1361
+ ORDER BY messages.id`).all(room, memberId);
1362
+ return RoomSupportSchema.parse({
1363
+ room,
1364
+ summary: {
1365
+ id: roomRow.id,
1366
+ name: roomRow.name,
1367
+ created_ts: roomRow.created_ts,
1368
+ color: roomRow.config.color,
1369
+ working,
1370
+ attention: !working
1371
+ && latestRun?.run?.status === 'failed'
1372
+ && latestRunAuthor?.kind === 'agent'
1373
+ && latestRunAuthor.state === 'dead',
1374
+ ...(latest !== undefined && {
1375
+ latest: {
1376
+ id: latest.id,
1377
+ ts: latest.ts,
1378
+ kind: latest.kind,
1379
+ author_handle: latestAuthor?.handle ?? '',
1380
+ author_kind: latestAuthor?.kind ?? 'human',
1381
+ preview: (latest.body.split('\n', 1)[0] ?? '').slice(0, 140),
1382
+ },
1383
+ }),
1384
+ unread: this.countUnreadMessages(room, memberId),
1385
+ },
1386
+ latest_finalized_agent_id: this.latestFinalizedAgentAuthor(room),
1387
+ active_runs: activeRows.map(messageFromRow),
1388
+ interactions: interactionRows.map(messageFromRow),
1389
+ inbox: this.actionableInbox(room, memberId),
1390
+ });
1391
+ }
1392
+ // harn:end room-support-is-bounded-recipient-scoped-state
1393
+ // harn:assume permalink-ids-stable ref=message-history-search
1394
+ listMessages(room, opts = {}) {
1395
+ const rows = this.db
1396
+ .prepare(`SELECT * FROM messages WHERE room = ? AND id < ?
1397
+ ORDER BY id DESC LIMIT ?`)
1398
+ .all(room, opts.before ?? Number.MAX_SAFE_INTEGER, opts.limit ?? 100);
1399
+ return rows.reverse().map(messageFromRow);
1400
+ }
1401
+ // harn:assume run-evidence-search-is-bounded-and-redacted ref=bounded-run-message-listing
1402
+ listRunMessages(room, opts = {}) {
1403
+ const rows = this.db
1404
+ .prepare(`SELECT * FROM messages
1405
+ WHERE room = ? AND kind = 'run' AND run IS NOT NULL
1406
+ AND (? IS NULL OR author = ?)
1407
+ ORDER BY id DESC LIMIT ?`)
1408
+ .all(room, opts.author ?? null, opts.author ?? null, opts.limit ?? 50);
1409
+ return rows.map(messageFromRow);
1410
+ }
1411
+ // harn:end run-evidence-search-is-bounded-and-redacted
1412
+ // harn:assume member-status-is-bounded-and-identity-safe ref=bounded-live-post-listing
1413
+ listChatMessagesByAuthorWithin(room, author, startedTs, endedTs, limit = 5) {
1414
+ const rows = this.db
1415
+ .prepare(`SELECT * FROM messages
1416
+ WHERE room = ? AND author = ? AND kind = 'chat' AND ts >= ?
1417
+ AND (? IS NULL OR ts <= ?)
1418
+ ORDER BY id DESC LIMIT ?`)
1419
+ .all(room, author, startedTs, endedTs ?? null, endedTs ?? null, limit);
1420
+ return rows.map(messageFromRow);
1421
+ }
1422
+ // harn:end member-status-is-bounded-and-identity-safe
1423
+ // harn:assume search-does-not-reveal-redacted-text ref=redacted-message-search-match
1424
+ searchMessages(room, query, opts = {}) {
1425
+ const limit = opts.limit ?? 50;
1426
+ if (this.getRoom(room)?.config.redaction_enabled !== false) {
1427
+ const needle = query.toLowerCase();
1428
+ const rows = this.db
1429
+ .prepare('SELECT * FROM messages WHERE room = ? ORDER BY id DESC')
1430
+ .all(room);
1431
+ return rows
1432
+ .map(messageFromRow)
1433
+ .filter((message) => redactText(message.body).toLowerCase().includes(needle))
1434
+ .slice(0, limit);
1435
+ }
1436
+ const literal = query.replace(/[\\%_]/g, '\\$&');
1437
+ const rows = this.db
1438
+ .prepare(`SELECT * FROM messages
1439
+ WHERE room = ? AND body LIKE ? ESCAPE '\\'
1440
+ ORDER BY id DESC LIMIT ?`)
1441
+ .all(room, `%${literal}%`, limit);
1442
+ return rows.map(messageFromRow);
1443
+ }
1444
+ // harn:end search-does-not-reveal-redacted-text
1445
+ // harn:end permalink-ids-stable
1446
+ // ── deliveries ────────────────────────────────────────────────────────
1447
+ // harn:assume collaboration-groups-are-durable-state ref=collaboration-store-projection
1448
+ createDelivery(room, delivery) {
1449
+ return this.db.transaction(() => {
1450
+ const nextQueueSeq = this.db
1451
+ .prepare('SELECT COALESCE(MAX(queue_seq), 0) + 1 AS seq FROM deliveries WHERE room = ?')
1452
+ .get(room);
1453
+ const validated = DeliverySchema.parse({
1454
+ id: randomUUID(),
1455
+ room,
1456
+ message_id: delivery.message_id,
1457
+ recipient: delivery.recipient,
1458
+ state: delivery.state ?? 'queued',
1459
+ hop_count: delivery.hop_count ?? 0,
1460
+ group_id: delivery.group_id,
1461
+ group_round: delivery.group_round,
1462
+ ts: new Date().toISOString(),
1463
+ });
1464
+ this.db
1465
+ .prepare(`INSERT INTO deliveries (id, room, message_id, recipient, state, attempt_count,
1466
+ batch_id, run_msg_id, read_ts, interaction_resolved_ts, payload_snapshot,
1467
+ process_id, process_group_id, hop_count, queue_seq, group_id, group_round, ts)
1468
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1469
+ .run(validated.id, room, validated.message_id, validated.recipient, validated.state, validated.attempt_count, orNull(validated.batch_id), orNull(validated.run_msg_id), orNull(validated.read_ts), orNull(validated.interaction_resolved_ts), orNull(delivery.payload_snapshot), null, null, validated.hop_count ?? 0, nextQueueSeq.seq, orNull(validated.group_id), orNull(validated.group_round), validated.ts);
1470
+ // Human inbox records are client-visible; recipient kind decides.
1471
+ const recipient = this.getMember(room, validated.recipient);
1472
+ if (recipient?.kind === 'human' || validated.state === 'held') {
1473
+ this.appendChange(room, 'inbox', validated.id);
1474
+ }
1475
+ return validated;
1476
+ })();
1477
+ }
1478
+ updateDelivery(room, deliveryId, patch) {
1479
+ return this.db.transaction(() => {
1480
+ const existing = this.getDelivery(room, deliveryId);
1481
+ if (!existing)
1482
+ throw new Error(`no such delivery: ${deliveryId}`);
1483
+ const merged = DeliverySchema.parse({ ...existing, ...patch });
1484
+ this.db
1485
+ .prepare(`UPDATE deliveries SET state = ?, attempt_count = ?, batch_id = ?,
1486
+ run_msg_id = ?, read_ts = ?, interaction_resolved_ts = ?
1487
+ WHERE room = ? AND id = ?`)
1488
+ .run(merged.state, merged.attempt_count, orNull(merged.batch_id), orNull(merged.run_msg_id), orNull(merged.read_ts), orNull(merged.interaction_resolved_ts), room, deliveryId);
1489
+ const recipient = this.getMember(room, merged.recipient);
1490
+ // Client-visible inbox records: human deliveries always; agent
1491
+ // deliveries once they need operator attention (held ⇄ released).
1492
+ if (recipient?.kind === 'human' || merged.state === 'held' || existing.state === 'held') {
1493
+ this.appendChange(room, 'inbox', deliveryId);
1494
+ }
1495
+ return merged;
1496
+ })();
1497
+ }
1498
+ getDelivery(room, deliveryId) {
1499
+ const row = this.db
1500
+ .prepare('SELECT * FROM deliveries WHERE room = ? AND id = ?')
1501
+ .get(room, deliveryId);
1502
+ return row ? deliveryFromRow(row) : undefined;
1503
+ }
1504
+ // harn:assume live-delivery-consumption-is-idempotent ref=consume-queued-transaction
1505
+ consumeQueuedDelivery(room, deliveryId, recipientId) {
1506
+ return this.db.transaction(() => {
1507
+ const existing = this.getDelivery(room, deliveryId);
1508
+ if (!existing)
1509
+ throw new Error(`no such delivery: ${deliveryId}`);
1510
+ if (existing.recipient !== recipientId) {
1511
+ throw new Error(`delivery ${deliveryId} is not addressed to member ${recipientId}`);
1512
+ }
1513
+ this.db
1514
+ .prepare(`UPDATE deliveries SET state = 'consumed'
1515
+ WHERE room = ? AND id = ? AND recipient = ? AND state = 'queued'`)
1516
+ .run(room, deliveryId, recipientId);
1517
+ const delivery = this.getDelivery(room, deliveryId);
1518
+ const message = this.getMessage(room, delivery.message_id);
1519
+ if (!message)
1520
+ throw new Error(`delivery ${deliveryId} has no source message`);
1521
+ return { delivery, message };
1522
+ })();
1523
+ }
1524
+ // harn:end live-delivery-consumption-is-idempotent
1525
+ getDeliveryPayloadSnapshot(room, deliveryId) {
1526
+ const row = this.db
1527
+ .prepare('SELECT payload_snapshot FROM deliveries WHERE room = ? AND id = ?')
1528
+ .get(room, deliveryId);
1529
+ return row?.payload_snapshot ?? undefined;
1530
+ }
1531
+ // harn:assume attempt-start-evidence-persisted ref=attempt-process-evidence
1532
+ /** Internal process evidence for one delivery attempt; not client-visible. */
1533
+ getDeliveryAttemptProcess(room, deliveryId) {
1534
+ const row = this.db
1535
+ .prepare('SELECT process_id, process_group_id FROM deliveries WHERE room = ? AND id = ?')
1536
+ .get(room, deliveryId);
1537
+ if (!row || (row.process_id === null && row.process_group_id === null))
1538
+ return undefined;
1539
+ return {
1540
+ ...(row.process_id !== null && { pid: row.process_id }),
1541
+ ...(row.process_group_id !== null && { process_group_id: row.process_group_id }),
1542
+ };
1543
+ }
1544
+ setDeliveryAttemptProcess(room, deliveryIds, process) {
1545
+ this.db.transaction(() => {
1546
+ const update = this.db.prepare(`UPDATE deliveries SET process_id = ?, process_group_id = ?
1547
+ WHERE room = ? AND id = ?`);
1548
+ for (const deliveryId of deliveryIds) {
1549
+ const result = update.run(process?.pid ?? null, process?.process_group_id ?? null, room, deliveryId);
1550
+ if (result.changes !== 1)
1551
+ throw new Error(`no such delivery: ${deliveryId}`);
1552
+ }
1553
+ })();
1554
+ }
1555
+ // harn:end attempt-start-evidence-persisted
1556
+ // harn:assume delivery-fifo-has-durable-sequence ref=delivery-queue-sequence
1557
+ listDeliveries(room, filter = {}) {
1558
+ const rows = this.db
1559
+ .prepare(`SELECT * FROM deliveries WHERE room = ?
1560
+ AND (? IS NULL OR recipient = ?)
1561
+ AND (? IS NULL OR state = ?)
1562
+ ORDER BY queue_seq, id`)
1563
+ .all(room, filter.recipient ?? null, filter.recipient ?? null, filter.state ?? null, filter.state ?? null);
1564
+ return rows.map(deliveryFromRow);
1565
+ }
1566
+ // harn:end delivery-fifo-has-durable-sequence
1567
+ // harn:assume turn-start-transactional ref=atomic-turn-start
1568
+ /** Creates/reuses the run and binds the complete batch in one transaction. */
1569
+ beginTurn(room, opts) {
1570
+ return this.db.transaction(() => {
1571
+ // harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-admission-guard
1572
+ // Admission is decided HERE, inside the transaction that binds them — not by a
1573
+ // filter that ran in an earlier statement. Between selecting a delivery and
1574
+ // admitting it, anything may have consumed it: the end of a turn, the removal of
1575
+ // the member, or (in live-collab) the agent itself. Trusting the earlier filter is
1576
+ // how a consumed delivery gets resurrected and handed to an agent as work.
1577
+ //
1578
+ // A FRESH turn admits only what is `queued`. Nothing else may be swept into it: a
1579
+ // held delivery stays held, and a consumed one is gone.
1580
+ //
1581
+ // A REUSED run — a reconciled retry, or an ambiguous turn the operator has just
1582
+ // released — re-admits the deliveries ALREADY BOUND TO THAT RUN, whatever state the
1583
+ // interruption left them in: `delivering` after a crash, `held` after an ambiguous
1584
+ // turn was parked. They are not being swept in; this run already claimed them, and
1585
+ // the operator asked for the retry. Restricting admission to `queued` alone would
1586
+ // silently kill both crash recovery and release_hold.
1587
+ //
1588
+ // `consumed` is admissible in NO case. That is the whole point: it is the state a
1589
+ // delivery reaches when its work is done, or when the member it was addressed to no
1590
+ // longer exists, and resurrecting it hands an agent work that was already taken.
1591
+ const boundToReusedRun = (delivery) => opts.reuseRunMsgId !== undefined && delivery.run_msg_id === opts.reuseRunMsgId;
1592
+ const admissible = opts.deliveryIds
1593
+ .map((deliveryId) => {
1594
+ const delivery = this.getDelivery(room, deliveryId);
1595
+ if (!delivery)
1596
+ throw new Error(`no such delivery: ${deliveryId}`);
1597
+ if (delivery.recipient !== opts.memberId) {
1598
+ throw new Error(`delivery ${deliveryId} does not belong to member ${opts.memberId}`);
1599
+ }
1600
+ return delivery;
1601
+ })
1602
+ .filter((delivery) => delivery.state !== 'consumed' &&
1603
+ (delivery.state === 'queued' || boundToReusedRun(delivery)));
1604
+ // harn:assume unresolved-delivery-fences-fresh-member-turns ref=durable-delivery-turn-fence
1605
+ // The in-memory inflight guard cannot survive a process boundary. Refuse a
1606
+ // FRESH run while this member still owns another durably active attempt;
1607
+ // runtime/boot reconciliation moves that attempt to held or consumed, and
1608
+ // an explicit reused-run recovery is allowed to reclaim its own binding.
1609
+ const ownsUnresolvedAttempt = opts.reuseRunMsgId === undefined && this
1610
+ .listDeliveries(room, { recipient: opts.memberId, state: 'delivering' })
1611
+ .some((delivery) => {
1612
+ if (delivery.run_msg_id === undefined)
1613
+ return false;
1614
+ const run = this.getMessage(room, delivery.run_msg_id);
1615
+ return run?.kind === 'run' && run.run?.status === 'running';
1616
+ });
1617
+ if (ownsUnresolvedAttempt)
1618
+ return undefined;
1619
+ // harn:end unresolved-delivery-fences-fresh-member-turns
1620
+ // harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-start-with-nothing-admissible
1621
+ // Nothing left to say. An empty run message would be a defect of its own, so the
1622
+ // turn does not begin at all — no message, no attempt, and the caller idles the
1623
+ // member.
1624
+ if (admissible.length === 0)
1625
+ return undefined;
1626
+ // harn:end only-an-admissible-delivery-becomes-delivering
1627
+ let runMessage;
1628
+ if (opts.reuseRunMsgId !== undefined) {
1629
+ const existing = this.getMessage(room, opts.reuseRunMsgId);
1630
+ if (!existing?.run ||
1631
+ existing.kind !== 'run' ||
1632
+ existing.author !== opts.memberId ||
1633
+ existing.run.status !== 'running') {
1634
+ throw new Error(`run #${opts.reuseRunMsgId} is not reusable`);
1635
+ }
1636
+ runMessage = existing.run.output_mode === 'messages'
1637
+ ? existing
1638
+ : this.updateMessage(room, existing.id, {
1639
+ run: { ...existing.run, output_mode: 'messages' },
1640
+ }, { activity: 'defer' });
1641
+ }
1642
+ else {
1643
+ const posted = this.postMessage(room, { author: opts.memberId, kind: 'run', body: '' });
1644
+ // harn:assume run-cost-estimates-are-finalization-snapshots ref=run-model-snapshot
1645
+ runMessage = this.updateMessage(room, posted.id, {
1646
+ run: {
1647
+ status: 'running',
1648
+ started_ts: opts.startedTs,
1649
+ ...(opts.model !== undefined && { model: opts.model }),
1650
+ tool_calls: 0,
1651
+ events_ref: opts.eventsRef(posted.id),
1652
+ output_mode: 'messages',
1653
+ },
1654
+ });
1655
+ // harn:end run-cost-estimates-are-finalization-snapshots
1656
+ }
1657
+ const deliveries = admissible.map((delivery) => {
1658
+ const updated = this.updateDelivery(room, delivery.id, {
1659
+ state: 'delivering',
1660
+ attempt_count: delivery.attempt_count + 1,
1661
+ run_msg_id: runMessage.id,
1662
+ batch_id: `batch-${runMessage.id}`,
1663
+ });
1664
+ this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
1665
+ return updated;
1666
+ });
1667
+ // harn:end only-an-admissible-delivery-becomes-delivering
1668
+ return { runMessage, deliveries };
1669
+ })();
1670
+ }
1671
+ // harn:end turn-start-transactional
1672
+ // harn:assume turn-output-finalization-is-atomic ref=atomic-turn-finalization
1673
+ /** Commits lifecycle truth, every output row, custody, accounting, and fanout together. */
1674
+ completeTurn(room, opts) {
1675
+ return this.db.transaction(() => {
1676
+ const rootOutput = opts.outputs.find((output) => output.id === opts.runMsgId);
1677
+ if (!rootOutput)
1678
+ throw new Error(`turn #${opts.runMsgId} has no root output patch`);
1679
+ const message = this.updateMessage(room, opts.runMsgId, {
1680
+ ...opts.message,
1681
+ body: rootOutput.body,
1682
+ mentions: rootOutput.mentions,
1683
+ refs: rootOutput.refs,
1684
+ ledger_refs: rootOutput.ledger_refs,
1685
+ }, { activity: rootOutput.substantive && rootOutput.ack !== true ? 'force' : 'defer' });
1686
+ const outputMessages = [message];
1687
+ for (const output of opts.outputs) {
1688
+ if (output.id === opts.runMsgId)
1689
+ continue;
1690
+ const current = this.getMessage(room, output.id);
1691
+ if (current?.run_parent_id !== opts.runMsgId) {
1692
+ throw new Error(`#${output.id} is not a continuation of turn #${opts.runMsgId}`);
1693
+ }
1694
+ outputMessages.push(this.updateMessage(room, output.id, {
1695
+ body: output.body,
1696
+ mentions: output.mentions,
1697
+ refs: output.refs,
1698
+ ledger_refs: output.ledger_refs,
1699
+ ack: output.ack,
1700
+ }, { activity: output.substantive && output.ack !== true ? 'force' : 'defer' }));
1701
+ }
1702
+ const referenced = new Set(opts.outputs.map((output) => output.id));
1703
+ for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
1704
+ if (referenced.has(continuation.id) || continuation.deleted === true)
1705
+ continue;
1706
+ outputMessages.push(this.deleteMessage(room, continuation.id));
1707
+ }
1708
+ if (!referenced.has(opts.resultMessageId)) {
1709
+ throw new Error(`turn #${opts.runMsgId} result #${opts.resultMessageId} is not journal-owned`);
1710
+ }
1711
+ for (const deliveryId of opts.inputDeliveryIds) {
1712
+ this.updateDelivery(room, deliveryId, { state: 'consumed' });
1713
+ }
1714
+ const member = this.updateMember(room, opts.memberId, opts.memberPatch);
1715
+ const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
1716
+ const deliveries = opts.fanout.map((delivery) => this.createDelivery(room, {
1717
+ message_id: opts.resultMessageId,
1718
+ recipient: delivery.recipient,
1719
+ state: delivery.state,
1720
+ payload_snapshot: delivery.payload_snapshot,
1721
+ hop_count: delivery.hop_count,
1722
+ }));
1723
+ if (opts.participantTerminal !== undefined) {
1724
+ this.recordCollaborationParticipantTerminal(room, {
1725
+ deliveryId: opts.participantTerminal.deliveryId,
1726
+ status: opts.participantTerminal.status,
1727
+ resultMessageId: opts.resultMessageId,
1728
+ completedTs: opts.participantTerminal.completedTs,
1729
+ });
1730
+ }
1731
+ const collaboration = opts.collaboration === undefined
1732
+ ? undefined
1733
+ : this.createCollaborationGroup(room, {
1734
+ groupId: opts.collaboration.groupId,
1735
+ rootMessageId: opts.resultMessageId,
1736
+ participants: opts.collaboration.participants,
1737
+ });
1738
+ if (collaboration)
1739
+ deliveries.push(...collaboration.deliveries);
1740
+ return {
1741
+ message,
1742
+ outputMessages: outputMessages.sort((left, right) => left.id - right.id),
1743
+ member,
1744
+ meter,
1745
+ deliveries,
1746
+ collaboration,
1747
+ };
1748
+ })();
1749
+ }
1750
+ // harn:end turn-output-finalization-is-atomic
1751
+ getCollaborationGroup(room, groupId) {
1752
+ const row = this.db.prepare('SELECT * FROM collaboration_groups WHERE room = ? AND id = ?').get(room, groupId);
1753
+ return row ? collaborationGroupFromRow(row) : undefined;
1754
+ }
1755
+ getCollaborationGroupByRoot(room, rootMessageId) {
1756
+ const row = this.db.prepare('SELECT * FROM collaboration_groups WHERE room = ? AND root_message_id = ?').get(room, rootMessageId);
1757
+ return row ? collaborationGroupFromRow(row) : undefined;
1758
+ }
1759
+ listCollaborationGroups(room, state) {
1760
+ const rows = this.db.prepare(`SELECT * FROM collaboration_groups
1761
+ WHERE room = ? AND (? IS NULL OR state = ?)
1762
+ ORDER BY created_ts, id`).all(room, state ?? null, state ?? null);
1763
+ return rows.map(collaborationGroupFromRow);
1764
+ }
1765
+ getCollaborationRound(room, groupId, roundNumber) {
1766
+ const row = this.db.prepare(`SELECT collaboration_rounds.* FROM collaboration_rounds
1767
+ JOIN collaboration_groups ON collaboration_groups.id = collaboration_rounds.group_id
1768
+ WHERE collaboration_groups.room = ?
1769
+ AND collaboration_rounds.group_id = ?
1770
+ AND collaboration_rounds.round_number = ?`).get(room, groupId, roundNumber);
1771
+ return row ? collaborationRoundFromRow(row) : undefined;
1772
+ }
1773
+ listCollaborationRounds(room, groupId) {
1774
+ const rows = this.db.prepare(`SELECT collaboration_rounds.* FROM collaboration_rounds
1775
+ JOIN collaboration_groups ON collaboration_groups.id = collaboration_rounds.group_id
1776
+ WHERE collaboration_groups.room = ? AND collaboration_rounds.group_id = ?
1777
+ ORDER BY collaboration_rounds.round_number`).all(room, groupId);
1778
+ return rows.map(collaborationRoundFromRow);
1779
+ }
1780
+ listCollaborationParticipants(room, groupId, roundNumber) {
1781
+ const rows = this.db.prepare(`SELECT collaboration_participants.* FROM collaboration_participants
1782
+ JOIN collaboration_groups ON collaboration_groups.id = collaboration_participants.group_id
1783
+ WHERE collaboration_groups.room = ?
1784
+ AND collaboration_participants.group_id = ?
1785
+ AND collaboration_participants.round_number = ?
1786
+ ORDER BY collaboration_participants.ordinal`).all(room, groupId, roundNumber);
1787
+ return rows.map(collaborationParticipantFromRow);
1788
+ }
1789
+ findCollaborationParticipantByDelivery(room, deliveryId) {
1790
+ const row = this.db.prepare(`SELECT collaboration_participants.* FROM collaboration_participants
1791
+ JOIN collaboration_groups ON collaboration_groups.id = collaboration_participants.group_id
1792
+ WHERE collaboration_groups.room = ? AND collaboration_participants.delivery_id = ?`).get(room, deliveryId);
1793
+ return row ? collaborationParticipantFromRow(row) : undefined;
1794
+ }
1795
+ getCollaborationRoundProjection(room, groupId, roundNumber) {
1796
+ const group = this.getCollaborationGroup(room, groupId);
1797
+ const round = this.getCollaborationRound(room, groupId, roundNumber);
1798
+ if (!group || !round)
1799
+ return undefined;
1800
+ const participants = this.listCollaborationParticipants(room, groupId, roundNumber);
1801
+ const deliveries = participants.map((participant) => {
1802
+ const delivery = this.getDelivery(room, participant.delivery_id);
1803
+ if (!delivery ||
1804
+ delivery.group_id !== groupId ||
1805
+ delivery.group_round !== roundNumber ||
1806
+ delivery.recipient !== participant.member_id) {
1807
+ throw new Error(`invalid collaboration delivery association: ${participant.delivery_id}`);
1808
+ }
1809
+ return delivery;
1810
+ });
1811
+ return { group, round, participants, deliveries };
1812
+ }
1813
+ updateCollaborationGroup(room, groupId, patch) {
1814
+ const existing = this.getCollaborationGroup(room, groupId);
1815
+ if (!existing)
1816
+ throw new Error(`no such collaboration group: ${groupId}`);
1817
+ const merged = CollaborationGroupSchema.parse({ ...existing, ...patch });
1818
+ this.db.prepare('UPDATE collaboration_groups SET state = ?, completed_ts = ? WHERE room = ? AND id = ?').run(merged.state, orNull(merged.completed_ts), room, groupId);
1819
+ return merged;
1820
+ }
1821
+ updateCollaborationRound(room, groupId, roundNumber, patch) {
1822
+ const existing = this.getCollaborationRound(room, groupId, roundNumber);
1823
+ if (!existing)
1824
+ throw new Error(`no such collaboration round: ${groupId}/${roundNumber}`);
1825
+ const merged = CollaborationRoundSchema.parse({ ...existing, ...patch });
1826
+ this.db.prepare(`UPDATE collaboration_rounds SET state = ?, released_ts = ?
1827
+ WHERE group_id = ? AND round_number = ?`).run(merged.state, orNull(merged.released_ts), groupId, roundNumber);
1828
+ return merged;
1829
+ }
1830
+ updateCollaborationParticipant(room, groupId, roundNumber, memberId, patch) {
1831
+ const existing = this.listCollaborationParticipants(room, groupId, roundNumber)
1832
+ .find((participant) => participant.member_id === memberId);
1833
+ if (!existing) {
1834
+ throw new Error(`no such collaboration participant: ${groupId}/${roundNumber}/${memberId}`);
1835
+ }
1836
+ const merged = CollaborationParticipantSchema.parse({ ...existing, ...patch });
1837
+ this.db.prepare(`UPDATE collaboration_participants
1838
+ SET terminal_status = ?, result_message_id = ?, completed_ts = ?
1839
+ WHERE group_id = ? AND round_number = ? AND member_id = ?`).run(orNull(merged.terminal_status), orNull(merged.result_message_id), orNull(merged.completed_ts), groupId, roundNumber, memberId);
1840
+ return merged;
1841
+ }
1842
+ // harn:assume group-participant-terminality-commits-with-the-turn ref=collaboration-turn-finalization
1843
+ recordCollaborationParticipantTerminal(room, opts) {
1844
+ const participant = this.findCollaborationParticipantByDelivery(room, opts.deliveryId);
1845
+ if (!participant)
1846
+ throw new Error(`delivery ${opts.deliveryId} is not a collaboration participant`);
1847
+ if (participant.terminal_status !== undefined) {
1848
+ if (participant.terminal_status !== opts.status ||
1849
+ participant.result_message_id !== opts.resultMessageId) {
1850
+ throw new Error(`collaboration participant ${opts.deliveryId} already has a different result`);
1851
+ }
1852
+ return participant;
1853
+ }
1854
+ return this.updateCollaborationParticipant(room, participant.group_id, participant.round_number, participant.member_id, {
1855
+ terminal_status: opts.status,
1856
+ result_message_id: opts.resultMessageId,
1857
+ completed_ts: opts.completedTs,
1858
+ });
1859
+ }
1860
+ recoverCollaborationParticipantTerminal(room, opts) {
1861
+ return this.db.transaction(() => {
1862
+ const delivery = this.getDelivery(room, opts.deliveryId);
1863
+ if (!delivery)
1864
+ throw new Error(`no such delivery: ${opts.deliveryId}`);
1865
+ const consumed = delivery.state === 'consumed'
1866
+ ? delivery
1867
+ : this.updateDelivery(room, delivery.id, { state: 'consumed' });
1868
+ const participant = this.recordCollaborationParticipantTerminal(room, opts);
1869
+ return { delivery: consumed, participant };
1870
+ })();
1871
+ }
1872
+ // harn:end group-participant-terminality-commits-with-the-turn
1873
+ // harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-member-skip-transaction
1874
+ skipCollaborationParticipant(room, deliveryId, completedTs) {
1875
+ return this.db.transaction(() => {
1876
+ const participant = this.findCollaborationParticipantByDelivery(room, deliveryId);
1877
+ if (!participant)
1878
+ throw new Error(`delivery ${deliveryId} is not a collaboration participant`);
1879
+ const delivery = this.getDelivery(room, deliveryId);
1880
+ if (!delivery)
1881
+ throw new Error(`no such delivery: ${deliveryId}`);
1882
+ if (participant.terminal_status !== undefined)
1883
+ return { delivery, participant };
1884
+ if ((delivery.state !== 'queued' && delivery.state !== 'held' && delivery.state !== 'consumed') ||
1885
+ delivery.run_msg_id !== undefined) {
1886
+ throw new Error(`collaboration delivery ${deliveryId} already started`);
1887
+ }
1888
+ const consumed = delivery.state === 'consumed'
1889
+ ? delivery
1890
+ : this.updateDelivery(room, deliveryId, { state: 'consumed' });
1891
+ const skipped = this.updateCollaborationParticipant(room, participant.group_id, participant.round_number, participant.member_id, { terminal_status: 'skipped', completed_ts: completedTs });
1892
+ return { delivery: consumed, participant: skipped };
1893
+ })();
1894
+ }
1895
+ // harn:end open-collaboration-groups-reconcile-without-resurrection
1896
+ // harn:assume collaboration-round-release-is-one-barrier ref=collaboration-round-release-transaction
1897
+ releaseCollaborationRound(room, opts) {
1898
+ return this.db.transaction(() => {
1899
+ const projection = this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber);
1900
+ if (!projection) {
1901
+ throw new Error(`no such collaboration round: ${opts.groupId}/${opts.roundNumber}`);
1902
+ }
1903
+ if (projection.round.state !== 'collecting') {
1904
+ return { status: 'already_released', deliveries: [], projection };
1905
+ }
1906
+ if (projection.participants.some((participant) => participant.terminal_status === undefined)) {
1907
+ return { status: 'pending', deliveries: [], projection };
1908
+ }
1909
+ if (opts.nextParticipants.length === 0) {
1910
+ this.updateCollaborationRound(room, opts.groupId, opts.roundNumber, {
1911
+ state: 'closed',
1912
+ released_ts: opts.releasedTs,
1913
+ });
1914
+ this.updateCollaborationGroup(room, opts.groupId, {
1915
+ state: 'completed',
1916
+ completed_ts: opts.releasedTs,
1917
+ });
1918
+ return {
1919
+ status: 'closed',
1920
+ deliveries: [],
1921
+ projection: this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber),
1922
+ };
1923
+ }
1924
+ this.assertCollaborationParticipantInputShape(opts.nextParticipants, 1);
1925
+ this.assertActiveAgentParticipants(room, opts.nextParticipants);
1926
+ const next = this.materializeCollaborationRound(room, projection.group, opts.roundNumber + 1, opts.releasedTs, opts.nextParticipants);
1927
+ this.updateCollaborationRound(room, opts.groupId, opts.roundNumber, {
1928
+ state: 'released',
1929
+ released_ts: opts.releasedTs,
1930
+ });
1931
+ return { status: 'released', deliveries: next.deliveries, projection: next };
1932
+ })();
1933
+ }
1934
+ // harn:end collaboration-round-release-is-one-barrier
1935
+ // harn:assume group-round-creation-is-atomic-and-idempotent ref=collaboration-round-materialization
1936
+ assertCollaborationParticipantInputShape(participants, minimum) {
1937
+ if (participants.length < minimum) {
1938
+ throw new Error(`collaboration round requires at least ${minimum} participant(s)`);
1939
+ }
1940
+ const seen = new Set();
1941
+ for (const participant of participants) {
1942
+ if (seen.has(participant.memberId)) {
1943
+ throw new Error(`duplicate collaboration participant: ${participant.memberId}`);
1944
+ }
1945
+ seen.add(participant.memberId);
1946
+ if (participant.state !== undefined && participant.state !== 'queued' && participant.state !== 'held') {
1947
+ throw new Error(`invalid initial collaboration delivery state: ${participant.state}`);
1948
+ }
1949
+ }
1950
+ }
1951
+ assertActiveAgentParticipants(room, participants) {
1952
+ for (const participant of participants) {
1953
+ const member = this.getMember(room, participant.memberId);
1954
+ if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
1955
+ throw new Error(`no active agent member: ${participant.memberId}`);
1956
+ }
1957
+ }
1958
+ }
1959
+ assertExistingCollaborationRound(projection, requested) {
1960
+ const sameMembers = projection.participants.length === requested.length &&
1961
+ projection.participants.every((participant, index) => participant.member_id === requested[index].memberId);
1962
+ const sameSnapshots = sameMembers && projection.deliveries.every((delivery, index) => this.getDeliveryPayloadSnapshot(delivery.room, delivery.id) === requested[index].payloadSnapshot);
1963
+ if (!sameSnapshots) {
1964
+ throw new Error(`collaboration round ${projection.group.id}/${projection.round.round_number}` +
1965
+ ' already exists with different participants or payloads');
1966
+ }
1967
+ return projection;
1968
+ }
1969
+ materializeCollaborationRound(room, group, roundNumber, createdTs, participants) {
1970
+ const round = CollaborationRoundSchema.parse({
1971
+ group_id: group.id,
1972
+ round_number: roundNumber,
1973
+ state: 'collecting',
1974
+ created_ts: createdTs,
1975
+ });
1976
+ this.db.prepare(`INSERT INTO collaboration_rounds
1977
+ (group_id, round_number, state, created_ts, released_ts)
1978
+ VALUES (?, ?, ?, ?, ?)`).run(round.group_id, round.round_number, round.state, round.created_ts, null);
1979
+ for (const [ordinal, input] of participants.entries()) {
1980
+ const delivery = this.createDelivery(room, {
1981
+ message_id: group.root_message_id,
1982
+ recipient: input.memberId,
1983
+ state: input.state,
1984
+ payload_snapshot: input.payloadSnapshot,
1985
+ hop_count: input.hopCount,
1986
+ group_id: group.id,
1987
+ group_round: roundNumber,
1988
+ });
1989
+ const participant = CollaborationParticipantSchema.parse({
1990
+ group_id: group.id,
1991
+ round_number: roundNumber,
1992
+ ordinal,
1993
+ member_id: input.memberId,
1994
+ delivery_id: delivery.id,
1995
+ });
1996
+ this.db.prepare(`INSERT INTO collaboration_participants
1997
+ (group_id, round_number, ordinal, member_id, delivery_id,
1998
+ terminal_status, result_message_id, completed_ts)
1999
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(participant.group_id, participant.round_number, participant.ordinal, participant.member_id, participant.delivery_id, null, null, null);
2000
+ }
2001
+ return this.getCollaborationRoundProjection(room, group.id, roundNumber);
2002
+ }
2003
+ createCollaborationGroup(room, opts) {
2004
+ return this.db.transaction(() => {
2005
+ this.assertCollaborationParticipantInputShape(opts.participants, 2);
2006
+ const existing = this.getCollaborationGroupByRoot(room, opts.rootMessageId);
2007
+ if (existing) {
2008
+ const projection = this.getCollaborationRoundProjection(room, existing.id, 1);
2009
+ return this.assertExistingCollaborationRound(projection, opts.participants);
2010
+ }
2011
+ if (!this.getMessage(room, opts.rootMessageId)) {
2012
+ throw new Error(`no such collaboration root message: #${opts.rootMessageId}`);
2013
+ }
2014
+ this.assertActiveAgentParticipants(room, opts.participants);
2015
+ const createdTs = opts.createdTs ?? new Date().toISOString();
2016
+ const group = CollaborationGroupSchema.parse({
2017
+ id: opts.groupId ?? randomUUID(),
2018
+ room,
2019
+ root_message_id: opts.rootMessageId,
2020
+ state: 'open',
2021
+ created_ts: createdTs,
2022
+ });
2023
+ this.db.prepare(`INSERT INTO collaboration_groups
2024
+ (id, room, root_message_id, state, created_ts, completed_ts)
2025
+ VALUES (?, ?, ?, ?, ?, ?)`).run(group.id, group.room, group.root_message_id, group.state, group.created_ts, null);
2026
+ return this.materializeCollaborationRound(room, group, 1, createdTs, opts.participants);
2027
+ })();
2028
+ }
2029
+ createCollaborationRound(room, opts) {
2030
+ return this.db.transaction(() => {
2031
+ if (!Number.isInteger(opts.roundNumber) || opts.roundNumber < 2) {
2032
+ throw new Error('later collaboration round number must be an integer of at least 2');
2033
+ }
2034
+ this.assertCollaborationParticipantInputShape(opts.participants, 1);
2035
+ const existing = this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber);
2036
+ if (existing)
2037
+ return this.assertExistingCollaborationRound(existing, opts.participants);
2038
+ const group = this.getCollaborationGroup(room, opts.groupId);
2039
+ if (!group)
2040
+ throw new Error(`no such collaboration group: ${opts.groupId}`);
2041
+ if (group.state !== 'open')
2042
+ throw new Error(`collaboration group ${opts.groupId} is ${group.state}`);
2043
+ if (!this.getCollaborationRound(room, opts.groupId, opts.roundNumber - 1)) {
2044
+ throw new Error(`collaboration round ${opts.groupId}/${opts.roundNumber - 1} does not exist`);
2045
+ }
2046
+ this.assertActiveAgentParticipants(room, opts.participants);
2047
+ return this.materializeCollaborationRound(room, group, opts.roundNumber, opts.createdTs ?? new Date().toISOString(), opts.participants);
2048
+ })();
2049
+ }
2050
+ // harn:end group-round-creation-is-atomic-and-idempotent
2051
+ // harn:end collaboration-groups-are-durable-state
2052
+ // harn:assume failed-finalization-reconciles-at-runtime ref=finalization-repair-transaction
2053
+ /**
2054
+ * Settle a turn whose journal is terminal but whose NORMAL finalization could
2055
+ * not commit — the #599 shape: a lifecycle retry rebound to a delivery whose
2056
+ * participant already holds a terminal result, so completeTurn's participant
2057
+ * write correctly refused and took the whole transaction down with it, leaving
2058
+ * the row `running` and the delivery `delivering` after the in-memory guard
2059
+ * had already cleared.
2060
+ *
2061
+ * This is deliberately NOT the normal path: it routes nothing, replaces no
2062
+ * participant result, and keeps the original journal so the evidence of what
2063
+ * the engine actually did survives. It only makes the durable state honest —
2064
+ * the attempt failed, its input is settled, the member is not running.
2065
+ *
2066
+ * Idempotent by construction: a run that is no longer `running` has already
2067
+ * been repaired (or finalized normally), so a second runtime or boot pass
2068
+ * returns `repaired: false` and writes nothing — including the meter, which
2069
+ * is why the rolled-back attempt is counted exactly once rather than never.
2070
+ */
2071
+ repairFailedFinalization(room, opts) {
2072
+ return this.db.transaction(() => {
2073
+ const runMsg = this.getMessage(room, opts.runMsgId);
2074
+ if (!runMsg?.run || runMsg.run.status !== 'running') {
2075
+ return { repaired: false, deliveries: [], held: [] };
2076
+ }
2077
+ const outputPatches = opts.outputs ?? [];
2078
+ const rootOutput = outputPatches.find((output) => output.id === opts.runMsgId) ?? {
2079
+ id: opts.runMsgId,
2080
+ body: '',
2081
+ mentions: [],
2082
+ refs: [],
2083
+ ledger_refs: [],
2084
+ substantive: false,
2085
+ };
2086
+ const message = this.updateMessage(room, opts.runMsgId, {
2087
+ body: rootOutput.body,
2088
+ mentions: rootOutput.mentions,
2089
+ refs: rootOutput.refs,
2090
+ ledger_refs: rootOutput.ledger_refs,
2091
+ run: {
2092
+ ...runMsg.run,
2093
+ status: 'failed',
2094
+ ended_ts: opts.endedTs,
2095
+ stalled_since: undefined,
2096
+ final_text: undefined,
2097
+ usage: opts.usage,
2098
+ estimated_cost_usd: opts.estimatedCostUsd,
2099
+ error: opts.error,
2100
+ },
2101
+ }, { activity: rootOutput.substantive ? 'force' : 'defer' });
2102
+ const outputMessages = [message];
2103
+ const referenced = new Set(outputPatches.map((output) => output.id));
2104
+ for (const output of outputPatches) {
2105
+ if (output.id === opts.runMsgId)
2106
+ continue;
2107
+ const current = this.getMessage(room, output.id);
2108
+ if (current?.run_parent_id !== opts.runMsgId)
2109
+ continue;
2110
+ outputMessages.push(this.updateMessage(room, output.id, {
2111
+ body: output.body,
2112
+ mentions: output.mentions,
2113
+ refs: output.refs,
2114
+ ledger_refs: output.ledger_refs,
2115
+ }, { activity: output.substantive ? 'force' : 'defer' }));
2116
+ }
2117
+ for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
2118
+ if (referenced.has(continuation.id) || continuation.deleted === true)
2119
+ continue;
2120
+ outputMessages.push(this.deleteMessage(room, continuation.id));
2121
+ }
2122
+ const deliveries = [];
2123
+ const held = [];
2124
+ for (const deliveryId of opts.deliveryIds) {
2125
+ const delivery = this.getDelivery(room, deliveryId);
2126
+ if (!delivery || delivery.state !== 'delivering')
2127
+ continue;
2128
+ // Consume only what is PROVABLY obsolete: this delivery's collaboration
2129
+ // work already has a terminal result, or its round/group is closed, so
2130
+ // re-running it could only duplicate an answer that already exists.
2131
+ // Anything else is ambiguous and becomes the operator's call.
2132
+ const obsolete = this.collaborationWorkIsSettled(room, delivery.id);
2133
+ deliveries.push(this.updateDelivery(room, delivery.id, {
2134
+ state: obsolete ? 'consumed' : 'held',
2135
+ batch_id: undefined,
2136
+ }));
2137
+ this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
2138
+ if (!obsolete)
2139
+ held.push(delivery.id);
2140
+ }
2141
+ const current = this.getMember(room, opts.memberId);
2142
+ const member = current === undefined
2143
+ ? undefined
2144
+ : this.updateMember(room, opts.memberId, {
2145
+ // A repaired attempt leaves the member reachable, not dead: nothing
2146
+ // about a finalization rollback says the agent itself is gone.
2147
+ state: current.state === 'dead' || current.state === 'paused' ? current.state : 'idle',
2148
+ });
2149
+ // completeTurn's rollback took its meter write with it, so this attempt's
2150
+ // spend is currently recorded NOWHERE. Count it here, exactly once — and
2151
+ // return it, so a connected UI sees the same total the database now holds.
2152
+ const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
2153
+ const system = this.listMembers(room).find((candidate) => candidate.kind === 'system');
2154
+ if (!system)
2155
+ throw new Error(`room ${room} has no system member`);
2156
+ const handle = `@${member?.handle ?? opts.memberId}`;
2157
+ const detail = opts.error.replace(/^finalization could not commit:\s*/, '');
2158
+ // The notice commits with the repair. A crash after this transaction can
2159
+ // neither lose the explanation nor create a duplicate on the next pass.
2160
+ const notice = this.postMessage(room, {
2161
+ author: system.id,
2162
+ kind: 'system',
2163
+ body: held.length > 0
2164
+ ? `turn #${String(opts.runMsgId)} for ${handle} could not finalize (${detail}) — its instruction is held; release_hold or redeliver to retry`
2165
+ : `turn #${String(opts.runMsgId)} for ${handle} could not finalize (${detail}) — its work already had a result, so the duplicate instruction was consumed`,
2166
+ });
2167
+ return {
2168
+ repaired: true,
2169
+ message,
2170
+ outputMessages: outputMessages.sort((left, right) => left.id - right.id),
2171
+ member,
2172
+ meter,
2173
+ notice,
2174
+ deliveries,
2175
+ held,
2176
+ };
2177
+ })();
2178
+ }
2179
+ /**
2180
+ * True when this delivery's collaboration work is already settled — a terminal
2181
+ * participant, or a round/group that is no longer accepting work. A delivery
2182
+ * with no collaboration context at all is NOT settled: an ordinary turn that
2183
+ * failed to finalize is exactly the ambiguous case an operator should see.
2184
+ *
2185
+ * Public because it is the SAME predicate on both sides of the seam: the
2186
+ * daemon asks it before attempting normal finalization, and repair asks it
2187
+ * when deciding consume-vs-hold. A second copy of this rule that drifted
2188
+ * would let a closed group accept a new result — `recordCollaboration-
2189
+ * ParticipantTerminal` guards only the participant, never its round or group.
2190
+ */
2191
+ collaborationWorkIsSettled(room, deliveryId) {
2192
+ const participant = this.findCollaborationParticipantByDelivery(room, deliveryId);
2193
+ if (!participant)
2194
+ return false;
2195
+ if (participant.terminal_status !== undefined)
2196
+ return true;
2197
+ const group = this.getCollaborationGroup(room, participant.group_id);
2198
+ if (group !== undefined && group.state !== 'open')
2199
+ return true;
2200
+ const round = this.getCollaborationRound(room, participant.group_id, participant.round_number);
2201
+ return round !== undefined && round.state !== 'collecting';
2202
+ }
2203
+ // harn:end failed-finalization-reconciles-at-runtime
2204
+ // harn:assume lifecycle-retries-only-live-collaboration-work ref=lifecycle-interruption-settlement
2205
+ /**
2206
+ * Settle a turn the daemon's OWN lifecycle interrupted, deciding retry
2207
+ * eligibility and terminality as ONE fact.
2208
+ *
2209
+ * The #598/#599 bug was these being two facts: the attempt finalized as a
2210
+ * terminal participant result, and only afterwards was its delivery re-queued
2211
+ * — so the retry came back to a participant that already had an answer. Here
2212
+ * a delivery is either durably re-queued (and its participant deliberately
2213
+ * left nonterminal, its round still open, so the eventual retry produces the
2214
+ * one result) or it is settled and its participant recorded `interrupted` so
2215
+ * the barrier releases. Never both, never neither: a nonterminal participant
2216
+ * may exist only with a queued retry behind it.
2217
+ *
2218
+ * Retry is admissible only while the work is genuinely still live — below the
2219
+ * attempt ceiling, trigger not deleted, and collaboration work not settled.
2220
+ * Operator Stop never reaches here; it is terminal by construction.
2221
+ */
2222
+ settleLifecycleInterruption(room, opts) {
2223
+ return this.db.transaction(() => {
2224
+ const rootOutput = opts.outputs.find((output) => output.id === opts.runMsgId) ?? {
2225
+ id: opts.runMsgId,
2226
+ body: '',
2227
+ mentions: [],
2228
+ refs: [],
2229
+ ledger_refs: [],
2230
+ substantive: false,
2231
+ };
2232
+ const message = this.updateMessage(room, opts.runMsgId, {
2233
+ ...opts.message,
2234
+ body: rootOutput.body,
2235
+ mentions: rootOutput.mentions,
2236
+ refs: rootOutput.refs,
2237
+ ledger_refs: rootOutput.ledger_refs,
2238
+ }, { activity: rootOutput.substantive ? 'force' : 'defer' });
2239
+ const outputMessages = [message];
2240
+ const referenced = new Set(opts.outputs.map((output) => output.id));
2241
+ for (const output of opts.outputs) {
2242
+ if (output.id === opts.runMsgId)
2243
+ continue;
2244
+ const current = this.getMessage(room, output.id);
2245
+ if (current?.run_parent_id !== opts.runMsgId)
2246
+ continue;
2247
+ outputMessages.push(this.updateMessage(room, output.id, {
2248
+ body: output.body,
2249
+ mentions: output.mentions,
2250
+ refs: output.refs,
2251
+ ledger_refs: output.ledger_refs,
2252
+ }, { activity: output.substantive ? 'force' : 'defer' }));
2253
+ }
2254
+ for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
2255
+ if (referenced.has(continuation.id) || continuation.deleted === true)
2256
+ continue;
2257
+ outputMessages.push(this.deleteMessage(room, continuation.id));
2258
+ }
2259
+ const member = this.updateMember(room, opts.memberId, opts.memberPatch);
2260
+ const requeued = [];
2261
+ const settled = [];
2262
+ const refusals = [];
2263
+ for (const deliveryId of opts.deliveryIds) {
2264
+ const delivery = this.getDelivery(room, deliveryId);
2265
+ if (!delivery)
2266
+ continue;
2267
+ if (delivery.state !== 'consumed' && delivery.state !== 'delivering')
2268
+ continue;
2269
+ const atCeiling = delivery.attempt_count >= opts.attemptCeiling;
2270
+ const triggerPurged = this.getMessage(room, delivery.message_id)?.deleted === true;
2271
+ const workSettled = this.collaborationWorkIsSettled(room, delivery.id);
2272
+ if (!atCeiling && !triggerPurged && !workSettled) {
2273
+ // Live work: re-queue WITHOUT touching the participant, so the round
2274
+ // stays open for the retry that will produce its one result.
2275
+ requeued.push(this.updateDelivery(room, delivery.id, {
2276
+ state: 'queued',
2277
+ run_msg_id: undefined,
2278
+ batch_id: undefined,
2279
+ }));
2280
+ this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
2281
+ continue;
2282
+ }
2283
+ // Refused: settle the input and release the barrier in the same commit.
2284
+ // A participant left nonterminal here would block its round forever,
2285
+ // because nothing is coming back to answer it.
2286
+ const consumed = this.updateDelivery(room, delivery.id, {
2287
+ state: 'consumed',
2288
+ batch_id: undefined,
2289
+ });
2290
+ settled.push(consumed);
2291
+ this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
2292
+ refusals.push({
2293
+ delivery: consumed,
2294
+ reason: atCeiling
2295
+ ? 'attempt_ceiling'
2296
+ : triggerPurged
2297
+ ? 'deleted_trigger'
2298
+ : 'settled_collaboration',
2299
+ });
2300
+ const participant = this.findCollaborationParticipantByDelivery(room, delivery.id);
2301
+ if (participant !== undefined && participant.terminal_status === undefined) {
2302
+ this.recordCollaborationParticipantTerminal(room, {
2303
+ deliveryId: delivery.id,
2304
+ status: 'interrupted',
2305
+ resultMessageId: opts.runMsgId,
2306
+ completedTs: opts.endedTs,
2307
+ });
2308
+ }
2309
+ }
2310
+ const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
2311
+ let notice;
2312
+ if (refusals.length > 0) {
2313
+ const system = this.listMembers(room).find((candidate) => candidate.kind === 'system');
2314
+ if (!system)
2315
+ throw new Error(`room ${room} has no system member`);
2316
+ const recipients = [...new Set(refusals.map(({ delivery }) => delivery.recipient))]
2317
+ .map((recipient) => `@${this.getMember(room, recipient)?.handle ?? recipient}`)
2318
+ .join(', ');
2319
+ const reasons = [...new Set(refusals.map(({ reason }) => reason))]
2320
+ .map((reason) => reason === 'attempt_ceiling'
2321
+ ? 'the retry ceiling was reached'
2322
+ : reason === 'deleted_trigger'
2323
+ ? 'the instruction was deleted'
2324
+ : 'the collaboration work was already settled')
2325
+ .join('; ');
2326
+ const mayForce = refusals.some(({ reason }) => reason === 'attempt_ceiling');
2327
+ notice = this.postMessage(room, {
2328
+ author: system.id,
2329
+ kind: 'system',
2330
+ body: `delivery to ${recipients} not re-queued after lifecycle interruption (turn #${String(opts.runMsgId)}: ${reasons}) — ${mayForce ? 'retry_run can force a fresh attempt' : 'the instruction was consumed'}`,
2331
+ });
2332
+ }
2333
+ return {
2334
+ message,
2335
+ outputMessages: outputMessages.sort((left, right) => left.id - right.id),
2336
+ member,
2337
+ meter,
2338
+ notice,
2339
+ requeued,
2340
+ settled,
2341
+ refusals,
2342
+ };
2343
+ })();
2344
+ }
2345
+ // harn:end lifecycle-retries-only-live-collaboration-work
2346
+ // ── pending interactions ──────────────────────────────────────────────
2347
+ upsertInteraction(interaction) {
2348
+ return this.db.transaction(() => {
2349
+ const validated = PendingInteractionSchema.parse(interaction);
2350
+ this.db
2351
+ .prepare(`INSERT INTO pending_interactions (id, room, member_id, message_id, native_id,
2352
+ kind, targets, state, answer, answered_by, answered_ts)
2353
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2354
+ ON CONFLICT (id) DO UPDATE SET native_id = excluded.native_id,
2355
+ state = excluded.state, answer = excluded.answer,
2356
+ answered_by = excluded.answered_by, answered_ts = excluded.answered_ts`)
2357
+ .run(validated.id, validated.room, validated.member_id, validated.message_id, validated.native_id, validated.kind, JSON.stringify(validated.targets), validated.state, jsonOrNull(validated.answer), orNull(validated.answered_by), orNull(validated.answered_ts));
2358
+ return validated;
2359
+ })();
2360
+ }
2361
+ getInteraction(id) {
2362
+ const row = this.db
2363
+ .prepare('SELECT * FROM pending_interactions WHERE id = ?')
2364
+ .get(id);
2365
+ return row ? interactionFromRow(row) : undefined;
2366
+ }
2367
+ listInteractions(room, state) {
2368
+ const rows = this.db
2369
+ .prepare(`SELECT * FROM pending_interactions WHERE room = ?
2370
+ AND (? IS NULL OR state = ?) ORDER BY id`)
2371
+ .all(room, state ?? null, state ?? null);
2372
+ return rows.map(interactionFromRow);
2373
+ }
2374
+ // harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-store
2375
+ resolveApprovalDeliveries(interaction, resolvedTs) {
2376
+ const targets = new Set(interaction.targets);
2377
+ const rows = this.db.prepare(`SELECT deliveries.* FROM deliveries
2378
+ JOIN members
2379
+ ON members.room = deliveries.room
2380
+ AND members.id = deliveries.recipient
2381
+ AND members.kind = 'human'
2382
+ WHERE deliveries.room = ? AND deliveries.message_id = ?
2383
+ ORDER BY deliveries.queue_seq, deliveries.id`).all(interaction.room, interaction.message_id);
2384
+ return rows
2385
+ .map(deliveryFromRow)
2386
+ .filter((delivery) => targets.has(delivery.recipient))
2387
+ .map((delivery) => (delivery.read_ts !== undefined && delivery.interaction_resolved_ts !== undefined
2388
+ ? delivery
2389
+ : this.updateDelivery(interaction.room, delivery.id, {
2390
+ read_ts: delivery.read_ts ?? resolvedTs,
2391
+ interaction_resolved_ts: delivery.interaction_resolved_ts ?? resolvedTs,
2392
+ })));
2393
+ }
2394
+ orphanInteraction(room, interactionId, orphanedTs) {
2395
+ return this.db.transaction(() => {
2396
+ const existing = this.getInteraction(interactionId);
2397
+ if (!existing || existing.room !== room)
2398
+ throw new Error(`no such interaction ${interactionId}`);
2399
+ if (existing.state !== 'pending' && existing.state !== 'answered') {
2400
+ throw new Error(`interaction ${interactionId} is ${existing.state}`);
2401
+ }
2402
+ const interaction = this.upsertInteraction({ ...existing, state: 'orphaned' });
2403
+ const deliveries = interaction.kind === 'approval'
2404
+ ? this.resolveApprovalDeliveries(interaction, orphanedTs)
2405
+ : [];
2406
+ return { interaction, deliveries };
2407
+ })();
2408
+ }
2409
+ // harn:end approval-deliveries-project-resolution-separately
2410
+ // harn:assume approval-answer-is-atomic-and-chatless ref=approval-answer-transaction
2411
+ answerApproval(room, interactionId, answer, answeredBy, answeredTs) {
2412
+ return this.db.transaction(() => {
2413
+ const existing = this.getInteraction(interactionId);
2414
+ if (!existing || existing.room !== room)
2415
+ throw new Error(`no such interaction ${interactionId}`);
2416
+ if (existing.kind !== 'approval')
2417
+ throw new Error(`interaction ${interactionId} is not an approval`);
2418
+ if (existing.state !== 'pending')
2419
+ throw new Error(`interaction ${interactionId} is ${existing.state}`);
2420
+ if (!existing.targets.includes(answeredBy)) {
2421
+ throw new Error(`interaction ${interactionId} is not addressed to member ${answeredBy}`);
2422
+ }
2423
+ const interaction = this.upsertInteraction({
2424
+ ...existing,
2425
+ state: 'answered',
2426
+ answer,
2427
+ answered_by: answeredBy,
2428
+ answered_ts: answeredTs,
2429
+ });
2430
+ const deliveries = this.resolveApprovalDeliveries(interaction, answeredTs);
2431
+ return { interaction, deliveries };
2432
+ })();
2433
+ }
2434
+ // harn:end approval-answer-is-atomic-and-chatless
2435
+ // ── meters ────────────────────────────────────────────────────────────
2436
+ // harn:assume spend-meter-always-on ref=meter-cost-and-token-accounting
2437
+ bumpMeter(room, day, delta) {
2438
+ // harn:assume estimated-cost-is-advisory-not-spend-brake-input ref=separated-meter-accounting
2439
+ return this.db.transaction(() => {
2440
+ this.db
2441
+ .prepare(`INSERT INTO meters
2442
+ (room, day, turns, cost_usd, estimated_cost_usd, input_tokens, output_tokens, uncosted_tokens)
2443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2444
+ ON CONFLICT (room, day) DO UPDATE SET
2445
+ turns = turns + excluded.turns,
2446
+ cost_usd = cost_usd + excluded.cost_usd,
2447
+ estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,
2448
+ input_tokens = input_tokens + excluded.input_tokens,
2449
+ output_tokens = output_tokens + excluded.output_tokens,
2450
+ uncosted_tokens = uncosted_tokens + excluded.uncosted_tokens`)
2451
+ .run(room, day, delta.turns ?? 0, delta.cost_usd ?? 0, delta.estimated_cost_usd ?? 0, delta.input_tokens ?? 0, delta.output_tokens ?? 0, delta.uncosted_tokens ?? 0);
2452
+ this.appendChange(room, 'meter', day);
2453
+ const row = this.db
2454
+ .prepare('SELECT * FROM meters WHERE room = ? AND day = ?')
2455
+ .get(room, day);
2456
+ return meterFromRow(row);
2457
+ })();
2458
+ // harn:end estimated-cost-is-advisory-not-spend-brake-input
2459
+ }
2460
+ getMeter(room, day) {
2461
+ const row = this.db
2462
+ .prepare('SELECT * FROM meters WHERE room = ? AND day = ?')
2463
+ .get(room, day);
2464
+ return row ? meterFromRow(row) : undefined;
2465
+ }
2466
+ // harn:end spend-meter-always-on
2467
+ // ── sync ──────────────────────────────────────────────────────────────
2468
+ currentSeq(room) {
2469
+ const row = this.db.prepare('SELECT seq FROM rooms WHERE id = ?').get(room);
2470
+ if (!row)
2471
+ throw new Error(`no such room: ${room}`);
2472
+ return row.seq;
2473
+ }
2474
+ getChangesSince(room, sinceSeq) {
2475
+ const rows = this.db
2476
+ .prepare('SELECT room_id, seq, entity, entity_id FROM changes WHERE room_id = ? AND seq > ? ORDER BY seq')
2477
+ .all(room, sinceSeq);
2478
+ return rows.map((row) => ChangeLogEntrySchema.parse({
2479
+ room: row.room_id,
2480
+ seq: row.seq,
2481
+ entity: row.entity,
2482
+ entity_id: row.entity_id,
2483
+ }));
2484
+ }
2485
+ // harn:assume sync-cursor-commits-after-hydration ref=consistent-sync-snapshot
2486
+ /** Delta-sync: hydrate rows and its final cursor from one SQLite snapshot. */
2487
+ sync(room, sinceSeq, opts = {}) {
2488
+ return this.db.transaction(() => {
2489
+ const seq = this.currentSeq(room);
2490
+ // A bound is honoured ONLY on a cold subscribe that asked for one. Every
2491
+ // warm sync replays every change as before, or an in-place finalization
2492
+ // could be missed; a subscriber that sends no bound is untouched too.
2493
+ if (sinceSeq === 0 && opts.hydrateLimit !== undefined) {
2494
+ const roomRow = this.getRoom(room);
2495
+ if (!roomRow)
2496
+ throw new Error(`no such room: ${room}`);
2497
+ return this.boundedColdSnapshot(room, roomRow, seq, opts.hydrateLimit, opts.subscriber, opts.strictTail === true, opts.supportFor);
2498
+ }
2499
+ const changes = this.getChangesSince(room, sinceSeq);
2500
+ const wanted = new Map();
2501
+ for (const change of changes) {
2502
+ let ids = wanted.get(change.entity);
2503
+ if (!ids)
2504
+ wanted.set(change.entity, (ids = new Set()));
2505
+ ids.add(change.entity_id);
2506
+ }
2507
+ const roomRow = this.getRoom(room);
2508
+ if (!roomRow)
2509
+ throw new Error(`no such room: ${room}`);
2510
+ return {
2511
+ seq,
2512
+ room: roomRow,
2513
+ messages: [...(wanted.get('message') ?? [])]
2514
+ .map((id) => this.getMessage(room, Number(id)))
2515
+ .filter((message) => message !== undefined),
2516
+ members: [...(wanted.get('member') ?? [])]
2517
+ .map((id) => this.getMember(room, id))
2518
+ .filter((member) => member !== undefined),
2519
+ inbox: [...(wanted.get('inbox') ?? [])]
2520
+ .map((id) => this.getDelivery(room, id))
2521
+ .filter((delivery) => delivery !== undefined),
2522
+ meters: [...(wanted.get('meter') ?? [])]
2523
+ .map((day) => this.getMeter(room, day))
2524
+ .filter((meter) => meter !== undefined),
2525
+ ...(opts.supportFor !== undefined && {
2526
+ support: this.roomSupport(room, opts.supportFor),
2527
+ }),
2528
+ };
2529
+ })();
2530
+ }
2531
+ // harn:end sync-cursor-commits-after-hydration
2532
+ // harn:assume addressed-cold-hydration-is-strict-and-legacy-safe ref=addressed-hydration-contract
2533
+ /**
2534
+ * A cold viewer's bounded snapshot, built INSIDE sync()'s transaction. An
2535
+ * addressed subscriber gets exactly the contiguous tail and a separate
2536
+ * support projection. A legacy subscriber keeps the prior outlier-bearing
2537
+ * shape until the client cutover:
2538
+ * - every running run, so a live turn renders however old it is;
2539
+ * - the cards of unresolved interactions, so an ask that scrolled past the
2540
+ * tail is still answerable;
2541
+ * - the messages this subscriber's unread deliveries point at, because the
2542
+ * inbox filter drops deliveries whose message is absent and the badge
2543
+ * would undercount;
2544
+ * - the latest finalized non-ack agent run, the default-recipient seed.
2545
+ * `history_floor` is the tail's floor ALONE — an outlier must never drag the
2546
+ * client's cursor backwards past a gap it did not receive.
2547
+ */
2548
+ boundedColdSnapshot(room, roomRow, seq, limit, subscriber, strictTail = false, supportFor) {
2549
+ const tail = this.db
2550
+ .prepare('SELECT * FROM messages WHERE room = ? ORDER BY id DESC LIMIT ?')
2551
+ .all(room, limit)
2552
+ .map(messageFromRow)
2553
+ .reverse();
2554
+ const byId = new Map(tail.map((message) => [message.id, message]));
2555
+ const includeOutliers = (rows) => {
2556
+ for (const row of rows) {
2557
+ const message = messageFromRow(row);
2558
+ if (!byId.has(message.id))
2559
+ byId.set(message.id, message);
2560
+ }
2561
+ };
2562
+ if (!strictTail) {
2563
+ includeOutliers(this.db
2564
+ .prepare(`SELECT * FROM messages
2565
+ WHERE room = ? AND kind = 'run' AND json_extract(run, '$.status') = 'running'`)
2566
+ .all(room));
2567
+ includeOutliers(this.db
2568
+ .prepare(`SELECT messages.* FROM messages
2569
+ JOIN pending_interactions ON pending_interactions.room = messages.room
2570
+ AND pending_interactions.message_id = messages.id
2571
+ WHERE messages.room = ? AND pending_interactions.state IN ('pending', 'answered')`)
2572
+ .all(room));
2573
+ if (subscriber !== undefined) {
2574
+ includeOutliers(this.db
2575
+ .prepare(`SELECT messages.* FROM messages
2576
+ JOIN deliveries ON deliveries.room = messages.room
2577
+ AND deliveries.message_id = messages.id
2578
+ WHERE messages.room = ? AND deliveries.recipient = ?
2579
+ AND deliveries.read_ts IS NULL AND deliveries.state = 'consumed'`)
2580
+ .all(room, subscriber));
2581
+ }
2582
+ includeOutliers(this.db
2583
+ .prepare(`SELECT messages.* FROM messages
2584
+ JOIN members ON members.room = messages.room AND members.id = messages.author
2585
+ WHERE messages.room = ? AND messages.kind = 'run' AND members.kind = 'agent'
2586
+ AND members.removed_ts IS NULL AND messages.ack = 0
2587
+ AND json_extract(messages.run, '$.status') <> 'running'
2588
+ ORDER BY messages.id DESC LIMIT 1`)
2589
+ .all(room));
2590
+ }
2591
+ const latestMeter = this.db
2592
+ .prepare('SELECT * FROM meters WHERE room = ? ORDER BY day DESC LIMIT 1')
2593
+ .get(room);
2594
+ return {
2595
+ seq,
2596
+ room: roomRow,
2597
+ history_floor: tail[0]?.id,
2598
+ messages: [...byId.values()].sort((left, right) => left.id - right.id),
2599
+ // The full roster and the subscriber's inbox stay whole: bounding history
2600
+ // must not shrink who is in the room or what is waiting for them.
2601
+ members: this.listMembers(room, { includeRemoved: true }),
2602
+ inbox: this.listDeliveries(room),
2603
+ meters: latestMeter ? [meterFromRow(latestMeter)] : [],
2604
+ ...(supportFor !== undefined && { support: this.roomSupport(room, supportFor) }),
2605
+ };
2606
+ }
2607
+ // harn:end addressed-cold-hydration-is-strict-and-legacy-safe
2608
+ // ── helpers ───────────────────────────────────────────────────────────
2609
+ /** Crockford-base32 ULID (timestamp + randomness), no external dep. */
2610
+ newUlid() {
2611
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
2612
+ let ts = Date.now();
2613
+ let time = '';
2614
+ for (let i = 0; i < 10; i++) {
2615
+ time = ALPHABET[ts % 32] + time;
2616
+ ts = Math.floor(ts / 32);
2617
+ }
2618
+ let random = '';
2619
+ for (let i = 0; i < 16; i++) {
2620
+ random += ALPHABET[Math.floor(Math.random() * 32)];
2621
+ }
2622
+ return time + random;
2623
+ }
2624
+ }
2625
+ //# sourceMappingURL=store.js.map