@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,3907 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { isDeepStrictEqual, promisify } from 'node:util';
7
+ import { AttachmentSchema, MemberStatusResponseSchema, deriveRoomId, parseRunItemPayload, } from '@codor/protocol';
8
+ import { BlobStore } from './blobs.js';
9
+ import { validateSpawnOptions } from './adapter-registry.js';
10
+ import { roleAllows } from './authorization.js';
11
+ import { composeGroupRoundPayload, selectDeliveryBatchPrefix, } from './collaboration.js';
12
+ import { ContinuationWriter, projectContinuationOutputs } from './continuation.js';
13
+ import { localSocketPath } from './local-socket.js';
14
+ import { processProbeTarget } from './process-liveness.js';
15
+ import { estimateCostUsd } from './pricing.js';
16
+ import { redactValue } from './redact.js';
17
+ import { RemoteAttemptAmbiguousError, remoteMemberSpec, } from './residency.js';
18
+ import { composeDeliveryBriefing, composePayload, evaluateBrakes, parseBody, resolveRecipients, } from './router.js';
19
+ import { Store, } from './store.js';
20
+ import { normalizeWorkingDirectory } from './working-directory.js';
21
+ const execFileAsync = promisify(execFile);
22
+ // ── Room git working-state (diff explorer) ─────────────────────────────────
23
+ // Every git call is read-only and runs through execFileAsync (no shell, no
24
+ // interpolation). The directory is always one the room already recorded — never
25
+ // a free path from the client. Per-file diffs are capped so a large working tree
26
+ // cannot balloon the response. See gitWorkingState / readGitWorkingFiles below.
27
+ // ── Message attachments ────────────────────────────────────────────────────
28
+ export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
29
+ const MAX_ATTACHMENTS_PER_MESSAGE = 8;
30
+ const ORPHAN_ATTACHMENT_MS = 24 * 60 * 60 * 1000;
31
+ const ATTACHMENT_ID = /^[0-9a-f]{32}$/;
32
+ function formatBytes(size) {
33
+ if (size < 1024)
34
+ return `${String(size)} B`;
35
+ if (size < 1024 * 1024)
36
+ return `${(size / 1024).toFixed(1)} KB`;
37
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
38
+ }
39
+ // Boot-recovery requeue fence: a delivery whose turn has been started this many
40
+ // times without completing is left consumed instead of re-queued, so a poisonous
41
+ // instruction that kills the daemon on every start cannot ping-pong the service.
42
+ // Matches the existing give-up bound (residency retries stop at attempt_count 2).
43
+ export const RECOVERY_ATTEMPT_CEILING = 2;
44
+ const GIT_TIMEOUT_MS = 5_000;
45
+ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
46
+ const MAX_FILE_DIFF_CHARS = 40_000;
47
+ const DIFF_TRUNCATED_MARKER = '\n… diff truncated …\n';
48
+ function porcelainStatus(x, y) {
49
+ if (x === '?' && y === '?')
50
+ return 'untracked';
51
+ if (x === 'R' || y === 'R' || x === 'C' || y === 'C')
52
+ return 'renamed';
53
+ if (x === 'D' || y === 'D')
54
+ return 'deleted';
55
+ if (x === 'A' || y === 'A')
56
+ return 'added';
57
+ return 'modified';
58
+ }
59
+ /** Parse `git status --porcelain=v1 -z` into per-file entries. A rename/copy
60
+ * record carries a trailing original-path field, which is consumed (skipped). */
61
+ function parsePorcelainStatus(output) {
62
+ const tokens = output.split('\0');
63
+ const entries = [];
64
+ for (let i = 0; i < tokens.length; i++) {
65
+ const token = tokens[i];
66
+ if (token === undefined || token.length < 4)
67
+ continue;
68
+ const x = token[0];
69
+ const y = token[1];
70
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C')
71
+ i++; // skip original path
72
+ entries.push({ path: token.slice(3), status: porcelainStatus(x, y) });
73
+ }
74
+ return entries;
75
+ }
76
+ /** Additions/deletions counted from a unified diff — the fallback for sources
77
+ * `--numstat` does not cover (untracked files diffed via --no-index). */
78
+ function countDiffLines(diff) {
79
+ let additions = 0;
80
+ let deletions = 0;
81
+ for (const line of diff.split('\n')) {
82
+ if (line.startsWith('+') && !line.startsWith('+++'))
83
+ additions++;
84
+ else if (line.startsWith('-') && !line.startsWith('---'))
85
+ deletions++;
86
+ }
87
+ return { additions, deletions };
88
+ }
89
+ /**
90
+ * Untrusted CLI stdout: only these shapes become buttons, and never many.
91
+ * Multi-segment ids are real (opencode reports `openrouter/anthropic/claude-…`),
92
+ * but a leading dash is not a model — it is a flag smuggled into an argv slot.
93
+ */
94
+ const MODEL_ID = /^\w[\w.:-]*(?:\/[\w.:-]+)*$/;
95
+ const MAX_MODELS = 200;
96
+ // harn:assume run-cost-estimates-are-finalization-snapshots ref=run-estimate-finalization
97
+ function accountRunUsage(run, usage) {
98
+ if (usage === undefined)
99
+ return { costUsd: 0, uncostedTokens: 0 };
100
+ if (usage.cost_usd !== undefined) {
101
+ return { costUsd: usage.cost_usd, uncostedTokens: 0 };
102
+ }
103
+ const estimatedCostUsd = run.estimated_cost_usd ?? estimateCostUsd(run.model, usage);
104
+ return {
105
+ costUsd: 0,
106
+ ...(estimatedCostUsd !== undefined && { estimatedCostUsd }),
107
+ uncostedTokens: estimatedCostUsd === undefined
108
+ ? usage.input_tokens + usage.output_tokens
109
+ : 0,
110
+ };
111
+ }
112
+ const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
113
+ function ulid() {
114
+ let ts = Date.now();
115
+ let time = '';
116
+ for (let i = 0; i < 10; i++) {
117
+ time = ULID_ALPHABET[ts % 32] + time;
118
+ ts = Math.floor(ts / 32);
119
+ }
120
+ let random = '';
121
+ for (let i = 0; i < 16; i++)
122
+ random += ULID_ALPHABET[Math.floor(Math.random() * 32)];
123
+ return time + random;
124
+ }
125
+ function extensionSuffix(nativeId) {
126
+ const clean = nativeId.toLowerCase().replace(/[^a-z0-9]/g, '');
127
+ if (clean.length >= 6)
128
+ return clean.slice(0, 6);
129
+ let hash = 2166136261;
130
+ for (const char of nativeId)
131
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
132
+ return `${clean}${(hash >>> 0).toString(36)}`.slice(0, 6).padEnd(6, '0');
133
+ }
134
+ function extensionDescription(event) {
135
+ if (event.item_type !== 'tool_call' || typeof event.payload !== 'object' || event.payload === null) {
136
+ return undefined;
137
+ }
138
+ const payload = event.payload;
139
+ if (payload.tool !== 'Agent' && payload.tool !== 'Task')
140
+ return undefined;
141
+ if (typeof payload.input !== 'object' || payload.input === null)
142
+ return undefined;
143
+ const input = payload.input;
144
+ for (const value of [input.description, input.name, input.prompt]) {
145
+ if (typeof value === 'string' && value.trim() !== '')
146
+ return value.trim().slice(0, 160);
147
+ }
148
+ return undefined;
149
+ }
150
+ /** Semantic identity of an interaction — native ids are process-lifetime only. */
151
+ function interactionKey(kind, card) {
152
+ const labels = (card.options ?? []).map((o) => o.label).join('|');
153
+ return `${kind}\0${card.prompt}\0${labels}\0${card.tool ?? ''}`;
154
+ }
155
+ /**
156
+ * The switchboard daemon core: turn pump, interaction state machine,
157
+ * attempt-WAL reconcile, change-log fanout. Transport-free — server.ts puts
158
+ * WS/REST in front of it; tests drive it with a FakeAdapter.
159
+ */
160
+ export class Daemon {
161
+ store;
162
+ blobs;
163
+ pushLog = [];
164
+ adapters = new Map();
165
+ modelCatalogs = new Map();
166
+ pendingDiscoveries = 0;
167
+ sessions = new Map();
168
+ /**
169
+ * Members whose settings changed and whose cached session is therefore out of date.
170
+ *
171
+ * The session is NOT dropped when it is marked: a turn in flight raised its own ask
172
+ * cards against that very session object, and answering one looks it up here — pull it
173
+ * out from under a running turn and the operator's answer lands on nothing. It is
174
+ * rebuilt at the START of the next turn, which is the only moment at which the old
175
+ * settings stop being the ones actually in use.
176
+ */
177
+ staleSessions = new Set();
178
+ inflight = new Set();
179
+ active = new Set();
180
+ listeners = [];
181
+ pendingAttach = new Set();
182
+ releasedDeliveries = new Set();
183
+ operatorInterrupts = new Set();
184
+ /** Members whose in-flight turn THIS daemon's lifecycle is interrupting, so
185
+ * finalization can tell retryable collateral from a deliberate Stop. */
186
+ lifecycleInterrupts = new Set();
187
+ memberWaits = new Map();
188
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
189
+ lastUsage = new Map();
190
+ // harn:end last-agent-usage-is-transient-and-seeded
191
+ groupWaits = new Map();
192
+ attachLeaseTimeoutMs;
193
+ processProbe;
194
+ attachLeaseTimer;
195
+ stallTimer;
196
+ limitsProbeTimer;
197
+ probingLimits = false;
198
+ runActivity = new Map();
199
+ hostId;
200
+ residency;
201
+ ledger;
202
+ pushProducer;
203
+ onBackgroundError;
204
+ homeDir;
205
+ socketPath;
206
+ attachmentsRoot;
207
+ stopResidencyReachability;
208
+ closing = false;
209
+ closed = false;
210
+ constructor(options) {
211
+ this.store = new Store(options.dbPath);
212
+ this.blobs = new BlobStore(options.blobRoot);
213
+ this.hostId = options.hostId;
214
+ this.residency = options.residency;
215
+ this.ledger = options.ledger;
216
+ this.pushProducer = options.pushProducer;
217
+ this.onBackgroundError = options.onBackgroundError ?? (() => undefined);
218
+ this.homeDir = options.homeDir ?? homedir();
219
+ this.socketPath = options.socketPath ?? localSocketPath(dirname(options.dbPath));
220
+ this.attachmentsRoot = join(dirname(options.dbPath), 'attachments');
221
+ this.sweepOrphanAttachments(); // boot-time: drop uploads no message ever claimed
222
+ this.ledger?.setRoomValidator((room) => this.store.getRoom(room) !== undefined);
223
+ this.ledger?.setRemoteWriteAuthorizer((peerId, room, author) => {
224
+ const members = this.store.listMembers(room);
225
+ const peerBelongsToRoom = members.some((member) => member.kind === 'agent' && member.host === peerId);
226
+ if (!peerBelongsToRoom)
227
+ return false;
228
+ const attributed = members.find((member) => member.handle === author);
229
+ if (attributed?.kind === 'agent')
230
+ return attributed.host === peerId;
231
+ return attributed?.kind === 'human' && attributed.role !== undefined &&
232
+ roleAllows(attributed.role, 'manage_ledger');
233
+ });
234
+ this.ledger?.setChangeHandler(({ room, name, author }) => {
235
+ if (this.store.getRoom(room))
236
+ this.postSystemMessage(room, `@${author} updated [[${name}]]`);
237
+ });
238
+ for (const adapter of options.adapters)
239
+ this.adapters.set(adapter.id, adapter);
240
+ if (options.discoverModels ?? true)
241
+ this.discoverModels();
242
+ this.attachLeaseTimeoutMs = options.attachLeaseTimeoutMs ?? 5_000;
243
+ this.processProbe = options.processProbe ?? ((target) => {
244
+ try {
245
+ process.kill(target, 0);
246
+ return true;
247
+ }
248
+ catch (error) {
249
+ return error.code === 'EPERM';
250
+ }
251
+ });
252
+ this.attachLeaseTimer = setInterval(() => this.reconcileAttachLeases(), options.attachLeasePollMs ?? 1_000);
253
+ this.attachLeaseTimer.unref();
254
+ this.stallTimer = setInterval(() => this.checkStalls(), options.stallPollMs ?? 60_000);
255
+ this.stallTimer.unref();
256
+ this.track(this.probeAdapterLimits());
257
+ this.limitsProbeTimer = setInterval(() => this.track(this.probeAdapterLimits()), options.limitsProbeMs ?? 15 * 60_000);
258
+ this.limitsProbeTimer.unref();
259
+ this.stopResidencyReachability = this.residency?.onReachability((peerId, connected) => this.handleResidentReachability(peerId, connected));
260
+ }
261
+ // harn:assume agent-member-credentials-stay-secret ref=member-session-environment
262
+ issueMemberCredential(room, member, session) {
263
+ const token = randomBytes(32).toString('base64url');
264
+ const credentialHash = createHash('sha256').update(token).digest('hex');
265
+ this.store.setAgentCredentialHash(room, member.id, credentialHash);
266
+ session.env = {
267
+ ...session.env,
268
+ CODOR_SOCKET: this.socketPath,
269
+ CODOR_CHANNEL: room,
270
+ CODOR_MEMBER_ID: member.id,
271
+ CODOR_MEMBER_TOKEN: token,
272
+ // harn:assume member-session-masks-operator-token ref=member-token-environment-mask
273
+ CODOR_TOKEN: token,
274
+ // harn:end member-session-masks-operator-token
275
+ };
276
+ }
277
+ authenticateAgentToken(token) {
278
+ if (token === '')
279
+ return undefined;
280
+ const credentialHash = createHash('sha256').update(token).digest('hex');
281
+ return this.store.findAgentByCredentialHash(credentialHash);
282
+ }
283
+ // harn:end agent-member-credentials-stay-secret
284
+ async close(options = {}) {
285
+ if (this.closed)
286
+ return;
287
+ this.closing = true;
288
+ clearInterval(this.attachLeaseTimer);
289
+ clearInterval(this.stallTimer);
290
+ clearInterval(this.limitsProbeTimer);
291
+ this.stopResidencyReachability?.();
292
+ if (options.force !== true) {
293
+ // harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
294
+ // A graceful stop interrupts live turns through the same adapter path the
295
+ // Stop button uses, so those runs finalize `interrupted` BEFORE the store
296
+ // closes — at boot they are indistinguishable from a deliberate Stop (#492).
297
+ // Mark them HERE, where the cause is provably lifecycle, so finalization
298
+ // takes the atomic retry-or-terminal settlement instead of committing a
299
+ // participant result and re-queueing behind it.
300
+ for (const turn of this.snapshotInFlightTurns()) {
301
+ this.lifecycleInterrupts.add(this.store.getMessage(turn.room, turn.runMsgId).author);
302
+ }
303
+ // harn:end lifecycle-retries-only-live-collaboration-work
304
+ for (const [memberId, session] of this.sessions) {
305
+ const member = this.store.listRooms().find((room) => this.store.getMember(room.id, memberId));
306
+ const persisted = member ? this.store.getMember(member.id, memberId) : undefined;
307
+ if (persisted?.harness)
308
+ this.requireAdapter(persisted.harness).interrupt(session);
309
+ }
310
+ // Each turn settled itself atomically as it finalized; there is no second
311
+ // healing pass to run, and nothing left half-committed if we stop here.
312
+ await this.settle();
313
+ }
314
+ await this.ledger?.close();
315
+ this.store.close();
316
+ this.closed = true;
317
+ }
318
+ // harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
319
+ /** Deliveries bound to a run the daemon's lifecycle is about to interrupt (or
320
+ * already has) — the set the atomic settlement decides over. */
321
+ boundLifecycleDeliveries(room, runMsgId) {
322
+ return this.store
323
+ .listDeliveries(room)
324
+ .filter((delivery) => delivery.run_msg_id === runMsgId
325
+ && (delivery.state === 'consumed' || delivery.state === 'delivering'));
326
+ }
327
+ /** The in-flight turns a graceful close is about to interrupt, captured BEFORE
328
+ * the interrupt so the cause is provably lifecycle. Scoped to turns THIS daemon
329
+ * is actually running (`inflight`) — a run left `running` by an earlier crash is
330
+ * not ours to heal here; boot recovery owns that seam. A turn an operator stopped
331
+ * is excluded: its run already finalized, or its member is still marked as an
332
+ * operator interrupt — either way a deliberate Stop must stay stopped. */
333
+ snapshotInFlightTurns() {
334
+ const snapshot = [];
335
+ for (const room of this.store.listRooms()) {
336
+ for (const message of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
337
+ if (message.kind !== 'run' || message.run?.status !== 'running')
338
+ continue;
339
+ if (!this.inflight.has(message.author))
340
+ continue; // not a turn this daemon is running
341
+ if (this.operatorInterrupts.has(message.author))
342
+ continue; // a Stop stays stopped
343
+ const bound = this.boundLifecycleDeliveries(room.id, message.id);
344
+ if (bound.length === 0)
345
+ continue;
346
+ snapshot.push({
347
+ room: room.id,
348
+ runMsgId: message.id,
349
+ deliveryIds: bound.map((delivery) => delivery.id),
350
+ });
351
+ }
352
+ }
353
+ return snapshot;
354
+ }
355
+ // harn:end lifecycle-retries-only-live-collaboration-work
356
+ /** Tracks a fire-and-forget turn chain so settle() can await quiescence. */
357
+ track(promise) {
358
+ const wrapped = promise.catch((error) => {
359
+ const failure = error instanceof Error ? error : new Error(String(error));
360
+ try {
361
+ this.onBackgroundError(failure);
362
+ }
363
+ catch {
364
+ // A diagnostic sink must never break daemon settlement.
365
+ }
366
+ }).finally(() => this.active.delete(wrapped));
367
+ this.active.add(wrapped);
368
+ }
369
+ /** Resolves when no turn chains are running (blocked asks keep it waiting). */
370
+ async settle() {
371
+ while (this.active.size > 0) {
372
+ await Promise.all([...this.active]);
373
+ }
374
+ }
375
+ // harn:assume stall-flags-never-kills ref=run-stall-watchdog
376
+ checkStalls(now = new Date()) {
377
+ for (const room of this.store.listRooms()) {
378
+ const timeoutMs = room.config.stall_minutes * 60_000;
379
+ for (const message of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
380
+ if (message.kind !== 'run' || message.run?.status !== 'running')
381
+ continue;
382
+ // harn:assume live-agent-waits-are-transient ref=wait-stall-exemption
383
+ const wait = this.memberWaits.get(message.author);
384
+ if (wait && Date.parse(wait.until_ts) > now.getTime())
385
+ continue;
386
+ // harn:end live-agent-waits-are-transient
387
+ const key = `${room.id}:${message.id}`;
388
+ const lastActivity = this.runActivity.get(key) ?? Date.parse(message.run.started_ts);
389
+ if (now.getTime() - lastActivity < timeoutMs || message.run.stalled_since !== undefined) {
390
+ continue;
391
+ }
392
+ const stalled = this.store.updateMessage(room.id, message.id, {
393
+ run: { ...message.run, stalled_since: now.toISOString() },
394
+ });
395
+ this.emitMessage(room.id, stalled);
396
+ const member = this.store.getMember(room.id, message.author);
397
+ const body = `@${member?.handle ?? message.author} run #${message.id} has stalled with no events`;
398
+ this.pushLog.push({ room: room.id, body, ts: now.toISOString() });
399
+ this.queueHumanPush(room.id, message.id, 'stall', body, [this.ownerOf(room.id).id]);
400
+ }
401
+ }
402
+ }
403
+ noteRunActivity(room, messageId) {
404
+ this.runActivity.set(`${room}:${messageId}`, Date.now());
405
+ const message = this.store.getMessage(room, messageId);
406
+ if (message?.run?.status !== 'running' || message.run.stalled_since === undefined)
407
+ return;
408
+ const progressed = this.store.updateMessage(room, messageId, {
409
+ run: { ...message.run, stalled_since: undefined },
410
+ });
411
+ this.emitMessage(room, progressed);
412
+ }
413
+ // harn:end stall-flags-never-kills
414
+ onFrame(listener) {
415
+ this.listeners.push(listener);
416
+ return () => {
417
+ const i = this.listeners.indexOf(listener);
418
+ if (i >= 0)
419
+ this.listeners.splice(i, 1);
420
+ };
421
+ }
422
+ // harn:assume redaction-before-fanout ref=redacted-frame-emit
423
+ /**
424
+ * ALL fanout funnels through here: frames are deep-redacted before any
425
+ * listener (WS/REST serializer) sees them, unless the room opted out.
426
+ * The store and blobs keep raw content.
427
+ */
428
+ emit(room, frame) {
429
+ const projected = this.projectFrame(room, frame);
430
+ for (const listener of this.listeners)
431
+ listener(room, projected);
432
+ }
433
+ projectFrame(room, frame) {
434
+ // harn:assume run-item-raw-journal-only ref=live-run-item-raw-projection
435
+ let liveFrame = frame;
436
+ if (frame.type === 'run_event' &&
437
+ frame.event.type === 'run.item' &&
438
+ typeof frame.event.payload === 'object' &&
439
+ frame.event.payload !== null &&
440
+ !Array.isArray(frame.event.payload)) {
441
+ const { raw: _raw, ...payload } = frame.event.payload;
442
+ liveFrame = { ...frame, event: { ...frame.event, payload } };
443
+ }
444
+ // harn:end run-item-raw-journal-only
445
+ const config = this.store.getRoom(room)?.config;
446
+ if (config?.redaction_enabled === false)
447
+ return liveFrame;
448
+ return redactValue(liveFrame);
449
+ }
450
+ /** Redacted view of arbitrary sync/REST payloads. */
451
+ project(room, value) {
452
+ const config = this.store.getRoom(room)?.config;
453
+ if (config?.redaction_enabled === false)
454
+ return value;
455
+ return redactValue(value);
456
+ }
457
+ // harn:end redaction-before-fanout
458
+ emitMessage(room, message) {
459
+ this.emit(room, { type: 'message', seq: message.seq, message });
460
+ }
461
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
462
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
463
+ /**
464
+ * Pre-turn gauge seeding: estimate context from the harness's on-disk
465
+ * session artifact so operators see pressure BEFORE spending a turn.
466
+ * Fire-and-forget; a seed never overwrites a live (non-estimated) value.
467
+ */
468
+ seedContextUsage(room, member) {
469
+ if (member.kind !== 'agent' || member.harness === undefined || member.session_ref === undefined)
470
+ return;
471
+ const adapter = this.adapters.get(member.harness);
472
+ if (adapter?.peekContextUsage === undefined)
473
+ return;
474
+ const existing = this.lastUsage.get(member.id);
475
+ if (existing !== undefined && existing.estimated !== true)
476
+ return;
477
+ const ref = member.session_ref;
478
+ this.track((async () => {
479
+ const peeked = await adapter.peekContextUsage(ref);
480
+ if (peeked === undefined)
481
+ return;
482
+ // The artifact scan cannot see settings-applied windows (e.g. the 1m
483
+ // beta), so an engine-reported window persisted on the member outranks
484
+ // the peek's curated guess; the used-tokens estimate stays the peek's.
485
+ const persisted = this.store.getMemberContextWindow(room, member.id);
486
+ const seeded = persisted === undefined ? peeked : { ...peeked, contextWindowMaxTokens: persisted };
487
+ const current = this.lastUsage.get(member.id);
488
+ if (current !== undefined && current.estimated !== true)
489
+ return; // live won meanwhile
490
+ if (isDeepStrictEqual(current, seeded))
491
+ return;
492
+ this.lastUsage.set(member.id, { ...seeded });
493
+ const fresh = this.store.getMember(room, member.id);
494
+ if (fresh !== undefined && fresh.removed_ts === undefined)
495
+ this.emitMember(room, fresh);
496
+ })().catch(() => undefined));
497
+ }
498
+ // harn:end last-agent-usage-is-transient-and-seeded
499
+ memberWithLastUsage(room, member) {
500
+ const lastUsage = this.lastUsage.get(member.id);
501
+ return lastUsage === undefined ? member : { ...member, lastUsage: { ...lastUsage } };
502
+ }
503
+ // harn:end last-agent-usage-is-transient-and-seeded
504
+ // harn:assume engine-reported-window-outlives-restarts ref=persisted-window-seed
505
+ /** Persist the engine's reported window (a stable engine fact, unlike usage)
506
+ * so estimated seeds after a restart present the true ceiling. */
507
+ landContextWindow(room, memberId, usage) {
508
+ const window = usage?.contextWindowMaxTokens;
509
+ if (usage?.estimated === true || typeof window !== 'number' || window <= 0)
510
+ return;
511
+ if (this.store.getMemberContextWindow(room, memberId) === window)
512
+ return;
513
+ this.store.setMemberContextWindow(room, memberId, window);
514
+ }
515
+ // harn:end engine-reported-window-outlives-restarts
516
+ emitMember(room, member) {
517
+ // harn:assume live-agent-waits-are-transient ref=wait-member-projection
518
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
519
+ const waiting = this.memberWaits.get(member.id);
520
+ this.emit(room, {
521
+ type: 'member',
522
+ seq: this.store.currentSeq(room),
523
+ member: { ...this.memberWithLastUsage(room, member), ...(waiting && { waiting }) },
524
+ });
525
+ // harn:end last-agent-usage-is-transient-and-seeded
526
+ // harn:end live-agent-waits-are-transient
527
+ }
528
+ landMemberLimits(room, memberId, limits) {
529
+ const member = this.store.getMember(room, memberId);
530
+ if (!member || member.removed_ts !== undefined)
531
+ return;
532
+ if (isDeepStrictEqual(member.limits, limits))
533
+ return;
534
+ this.emitMember(room, this.store.updateMember(room, member.id, { limits }));
535
+ }
536
+ // harn:assume push-only-for-human-targeted-events ref=push-target-dispatch
537
+ emitInbox(room, delivery) {
538
+ this.emit(room, { type: 'inbox', seq: this.store.currentSeq(room), delivery });
539
+ const recipient = this.store.getMember(room, delivery.recipient);
540
+ if (recipient?.kind !== 'human' || delivery.state !== 'consumed' || delivery.read_ts !== undefined) {
541
+ return;
542
+ }
543
+ const message = this.store.getMessage(room, delivery.message_id);
544
+ if (!message || !['chat', 'run', 'ask', 'approval'].includes(message.kind))
545
+ return;
546
+ const kind = message.kind === 'ask' || message.kind === 'approval'
547
+ ? message.kind
548
+ : 'inbox';
549
+ this.queueHumanPush(room, message.id, kind, message.body, [recipient.id], delivery.id);
550
+ }
551
+ queueHumanPush(room, messageId, kind, preview, targetHumanIds, deliveryId) {
552
+ if (!this.pushProducer || targetHumanIds.length === 0)
553
+ return;
554
+ this.track(this.pushProducer.notify({
555
+ room,
556
+ msg_id: messageId,
557
+ kind,
558
+ preview,
559
+ target_human_ids: targetHumanIds,
560
+ ...(deliveryId && { delivery_id: deliveryId }),
561
+ }).then((results) => {
562
+ const failures = results.filter((result) => result.status === 'failed');
563
+ if (failures.length > 0) {
564
+ const statuses = failures.map((result) => result.http_status ?? result.error ?? 'unknown').join(',');
565
+ throw new Error(`push delivery failed for ${String(failures.length)} device(s): ${statuses}`);
566
+ }
567
+ }));
568
+ }
569
+ // harn:end push-only-for-human-targeted-events
570
+ // ── room / member management ──────────────────────────────────────────
571
+ // harn:assume channel-creation-derived-and-seeded ref=derived-channel-creation
572
+ createRoom(opts) {
573
+ // harn:assume starting-agent-name-derives-one-valid-identity-v6 ref=starting-agent-create-validation
574
+ if (opts.starting_agent?.handle === opts.owner.handle) {
575
+ throw new Error(`starting agent handle @${opts.starting_agent.handle} is already in use by the channel owner`);
576
+ }
577
+ // harn:end starting-agent-name-derives-one-valid-identity-v6
578
+ const baseId = opts.id ?? deriveRoomId(opts.name);
579
+ let id = baseId;
580
+ if (opts.id === undefined) {
581
+ for (let suffix = 2; this.store.getRoom(id); suffix++)
582
+ id = `${baseId}-${String(suffix)}`;
583
+ }
584
+ // harn:assume spawn-default-cwd-is-absolute-or-empty ref=implicit-starting-agent-cwd
585
+ const cwd = opts.cwd !== undefined
586
+ ? normalizeWorkingDirectory(opts.cwd, this.homeDir)
587
+ : opts.starting_agent !== undefined
588
+ ? normalizeWorkingDirectory(process.cwd(), this.homeDir)
589
+ : undefined;
590
+ // harn:end spawn-default-cwd-is-absolute-or-empty
591
+ const created = this.store.createRoom({
592
+ id,
593
+ name: opts.name,
594
+ owner: opts.owner,
595
+ config: {
596
+ ...(opts.color !== undefined && { color: opts.color }),
597
+ ...(cwd !== undefined && { cwd }),
598
+ // harn:assume channel-starting-agent-handle-persisted ref=starting-agent-creation-record
599
+ ...(opts.starting_agent !== undefined && {
600
+ starting_agent_handle: opts.starting_agent.handle,
601
+ }),
602
+ // harn:end channel-starting-agent-handle-persisted
603
+ },
604
+ });
605
+ if (opts.starting_agent) {
606
+ try {
607
+ this.spawnMember(id, {
608
+ ...opts.starting_agent,
609
+ cwd: cwd,
610
+ });
611
+ }
612
+ catch (error) {
613
+ this.postSystemMessage(id, `could not spawn @${opts.starting_agent.handle}: ${error instanceof Error ? error.message : String(error)}`);
614
+ }
615
+ }
616
+ return created;
617
+ }
618
+ // harn:end channel-creation-derived-and-seeded
619
+ configureRoom(room, patch) {
620
+ const updated = this.store.updateRoomConfig(room, patch);
621
+ this.emit(room, { type: 'room', seq: this.store.currentSeq(room), room: updated });
622
+ return updated;
623
+ }
624
+ enableLedger(room) {
625
+ if (!this.store.getRoom(room))
626
+ throw new Error(`no such room ${room}`);
627
+ if (!this.ledger)
628
+ throw new Error('ledger is not configured');
629
+ this.ledger.enable(room);
630
+ }
631
+ addLedgerNote(room, write) {
632
+ if (!this.store.getRoom(room))
633
+ throw new Error(`no such room ${room}`);
634
+ if (!this.ledger)
635
+ throw new Error('ledger is not configured');
636
+ return this.ledger.add(room, write);
637
+ }
638
+ getLedgerNote(room, name) {
639
+ if (!this.store.getRoom(room))
640
+ throw new Error(`no such room ${room}`);
641
+ return this.ledger?.note(room, name);
642
+ }
643
+ ledgerSnapshot(room) {
644
+ if (!this.store.getRoom(room))
645
+ throw new Error(`no such room ${room}`);
646
+ return this.ledger?.snapshot(room) ?? {};
647
+ }
648
+ ledgerGraph(room) {
649
+ if (!this.store.getRoom(room))
650
+ throw new Error(`no such room ${room}`);
651
+ return this.ledger?.graph(room) ?? { nodes: [], edges: [] };
652
+ }
653
+ // harn:assume bridge-enable-admin-or-owner ref=bridge-daemon-ingress
654
+ enableBridge(room, platform, channel) {
655
+ if (!this.store.getRoom(room))
656
+ throw new Error(`no such room ${room}`);
657
+ const normalizedChannel = channel.trim();
658
+ if (normalizedChannel === '')
659
+ throw new Error('bridge channel is required');
660
+ const handle = `${platform}-bridge`;
661
+ const existing = this.store.getMemberByHandle(room, handle);
662
+ if (existing) {
663
+ if (existing.kind !== 'bridge')
664
+ throw new Error(`handle @${handle} is already in use`);
665
+ const expectedName = `${platform === 'slack' ? 'Slack' : 'Telegram'} · ${normalizedChannel}`;
666
+ if (existing.display_name !== expectedName) {
667
+ throw new Error(`${platform} bridge is already paired to another channel`);
668
+ }
669
+ if (this.store.getRoom(room)?.config.bridged !== true) {
670
+ this.configureRoom(room, { bridged: true });
671
+ }
672
+ return { member: existing, after: this.store.latestMessageId(room) };
673
+ }
674
+ const member = this.store.addMember(room, {
675
+ kind: 'bridge',
676
+ handle,
677
+ display_name: `${platform === 'slack' ? 'Slack' : 'Telegram'} · ${normalizedChannel}`,
678
+ });
679
+ this.emitMember(room, member);
680
+ this.configureRoom(room, { bridged: true });
681
+ return { member, after: this.store.latestMessageId(room) };
682
+ }
683
+ postBridgeMessage(room, bridgeMemberId, body, origin) {
684
+ const bridge = this.store.getMember(room, bridgeMemberId);
685
+ if (bridge?.kind !== 'bridge')
686
+ throw new Error(`no such bridge member: ${bridgeMemberId}`);
687
+ const platform = bridge.handle.endsWith('-bridge')
688
+ ? bridge.handle.slice(0, -'-bridge'.length)
689
+ : '';
690
+ if (origin.platform !== platform)
691
+ throw new Error('bridge origin platform does not match member');
692
+ const normalizedBody = body.trim();
693
+ if (normalizedBody === '')
694
+ throw new Error('bridge message body is required');
695
+ const parsed = parseBody(normalizedBody, this.store.listMembers(room));
696
+ const result = this.store.postBridgeMessage(room, bridgeMemberId, normalizedBody, origin, parsed, (message) => {
697
+ const planned = this.planRoutedMessage(room, message, undefined, undefined, false, true);
698
+ return planned.plan;
699
+ });
700
+ if (!result.deduped) {
701
+ this.emitMessage(room, result.message);
702
+ if (result.member)
703
+ this.emitMember(room, result.member);
704
+ this.dispatchCreatedDeliveries(room, result.deliveries);
705
+ }
706
+ return result;
707
+ }
708
+ bridgeMessagesAfter(room, after, limit = 100) {
709
+ if (!this.store.getRoom(room))
710
+ throw new Error(`no such room ${room}`);
711
+ return this.store.listMessagesAfter(room, after, limit);
712
+ }
713
+ // harn:end bridge-enable-admin-or-owner
714
+ ownerOf(room) {
715
+ const owner = this.store.listMembers(room).find((m) => m.kind === 'human' && m.role === 'owner');
716
+ if (!owner)
717
+ throw new Error(`room ${room} has no owner`);
718
+ return owner;
719
+ }
720
+ // harn:assume adapters-own-their-model-catalog ref=adapter-model-discovery
721
+ /**
722
+ * Ask every adapter that can answer what models its harness takes. Runs once,
723
+ * in the background, at registration — never on a request path, because a hung
724
+ * CLI must not be able to wedge /api/adapters, which gates both dialogs.
725
+ * Any failure leaves the harness without a list: the dialog then offers the
726
+ * custom escape, which is a worse UI, not a broken one.
727
+ */
728
+ discoverModels() {
729
+ for (const adapter of this.adapters.values()) {
730
+ if (!adapter.listModels)
731
+ continue;
732
+ this.pendingDiscoveries += 1;
733
+ void adapter.listModels().finally(() => {
734
+ this.pendingDiscoveries -= 1;
735
+ }).then((catalog) => {
736
+ const models = catalog.models.filter((model) => MODEL_ID.test(model)).slice(0, MAX_MODELS);
737
+ if (models.length > 0)
738
+ this.modelCatalogs.set(adapter.id, { ...catalog, models });
739
+ }, (error) => this.onBackgroundError(error instanceof Error ? error : new Error(`${adapter.id} model discovery failed`)));
740
+ }
741
+ }
742
+ // harn:assume model-catalogs-reach-a-browser-that-arrives-early ref=adapter-discovery-pending-signal
743
+ /**
744
+ * True while a harness that can answer still hasn't. It lets a browser tell an
745
+ * empty catalog apart from an unfinished one, so a page loaded during discovery
746
+ * asks again instead of stranding the operator with no models until reload.
747
+ */
748
+ modelDiscoveryPending() {
749
+ return this.pendingDiscoveries > 0;
750
+ }
751
+ // harn:end model-catalogs-reach-a-browser-that-arrives-early
752
+ registeredAdapters() {
753
+ return [...this.adapters.values()]
754
+ .map((adapter) => {
755
+ const catalog = this.modelCatalogs.get(adapter.id);
756
+ return {
757
+ id: adapter.id,
758
+ capabilities: adapter.capabilities,
759
+ ...(catalog && { models: catalog.models, models_source: catalog.source }),
760
+ };
761
+ })
762
+ .sort((a, b) => a.id.localeCompare(b.id));
763
+ }
764
+ // harn:end adapters-own-their-model-catalog
765
+ /** Provider limits are account-level: one probe fans out to every active
766
+ * agent using that harness. Missing credentials and failures preserve the
767
+ * last stream-reported value. */
768
+ async probeAdapterLimits() {
769
+ if (this.probingLimits || this.closing)
770
+ return;
771
+ this.probingLimits = true;
772
+ try {
773
+ const membersByHarness = new Map();
774
+ for (const room of this.store.listRooms()) {
775
+ for (const member of this.store.listMembers(room.id)) {
776
+ if (member.kind !== 'agent' || member.harness === undefined)
777
+ continue;
778
+ const members = membersByHarness.get(member.harness) ?? [];
779
+ members.push({ room: room.id, member });
780
+ membersByHarness.set(member.harness, members);
781
+ }
782
+ }
783
+ for (const adapter of this.adapters.values()) {
784
+ const targets = membersByHarness.get(adapter.id);
785
+ if (!adapter.probeLimits || targets === undefined || targets.length === 0)
786
+ continue;
787
+ let limits;
788
+ try {
789
+ limits = await adapter.probeLimits();
790
+ }
791
+ catch {
792
+ continue;
793
+ }
794
+ if (this.closing || limits === undefined || limits.length === 0)
795
+ continue;
796
+ for (const target of targets) {
797
+ const current = this.store.getMember(target.room, target.member.id);
798
+ if (current?.kind !== 'agent'
799
+ || current.harness !== adapter.id
800
+ || current.removed_ts !== undefined)
801
+ continue;
802
+ this.landMemberLimits(target.room, current.id, limits);
803
+ }
804
+ }
805
+ }
806
+ finally {
807
+ this.probingLimits = false;
808
+ }
809
+ }
810
+ setHumanRole(room, memberId, role) {
811
+ const member = this.store.getMember(room, memberId);
812
+ if (member?.kind !== 'human')
813
+ throw new Error(`no such human member: ${memberId}`);
814
+ if (member.role === 'owner' && role !== 'owner') {
815
+ const owners = this.store.listMembers(room).filter((candidate) => candidate.kind === 'human' && candidate.role === 'owner');
816
+ if (owners.length === 1)
817
+ throw new Error('a room must retain at least one owner');
818
+ }
819
+ const updated = this.store.updateMember(room, memberId, { role });
820
+ this.emitMember(room, updated);
821
+ this.postSystemMessage(room, `@${updated.handle} is now ${role}`);
822
+ return updated;
823
+ }
824
+ // harn:assume pins-are-durable-owner-admin-markers ref=pin-message-contract
825
+ /**
826
+ * Pin or unpin a message. Only human owners/admins may flip it — the server's
827
+ * capability gate enforces the role, and this refuses non-humans/underprivileged
828
+ * callers defensively so a direct daemon call cannot bypass the contract.
829
+ * Idempotent: re-flipping to the same value changes nothing and emits nothing.
830
+ * The flip rides the change log like any message edit; it never re-routes
831
+ * deliveries or touches run journals.
832
+ */
833
+ pinMessage(room, messageId, pinned, byMemberId) {
834
+ const actor = this.store.getMember(room, byMemberId);
835
+ if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
836
+ throw new Error('forbidden: only owners and admins can pin messages');
837
+ }
838
+ const message = this.store.getMessage(room, messageId);
839
+ if (!message)
840
+ throw new Error(`no such message: #${messageId}`);
841
+ if (pinned && message.deleted === true) {
842
+ throw new Error('cannot pin a deleted message'); // a tombstone is not a pin target
843
+ }
844
+ if ((message.pinned === true) === pinned)
845
+ return message; // idempotent — emit nothing
846
+ const updated = this.store.setMessagePinned(room, messageId, pinned);
847
+ this.emitMessage(room, updated);
848
+ return updated;
849
+ }
850
+ // harn:end pins-are-durable-owner-admin-markers
851
+ // harn:assume deleted-messages-purge-rows-and-files ref=delete-message-contract
852
+ /**
853
+ * Purge a chat message, leaving a durable [deleted] tombstone. Only human
854
+ * owners/admins may delete (the server gate enforces the role; this refuses
855
+ * non-humans/underprivileged callers defensively). Only chat messages qualify
856
+ * — run rows are journal evidence and system rows are daemon speech, both
857
+ * refused. Idempotent when already deleted (emits nothing). Still-pending
858
+ * deliveries (queued or held) of the message are cancelled so purged content
859
+ * never delivers late; already-consumed deliveries keep their snapshots. Any
860
+ * attachment files are unlinked from disk (the store nulls their metadata).
861
+ * Deletion never renumbers messages or touches run journals.
862
+ */
863
+ deleteMessage(room, messageId, byMemberId) {
864
+ const actor = this.store.getMember(room, byMemberId);
865
+ if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
866
+ throw new Error('forbidden: only owners and admins can delete messages');
867
+ }
868
+ const message = this.store.getMessage(room, messageId);
869
+ if (!message)
870
+ throw new Error(`no such message: #${messageId}`);
871
+ if (message.kind !== 'chat') {
872
+ throw new Error(`only chat messages can be deleted, not ${message.kind}`);
873
+ }
874
+ if (message.deleted === true)
875
+ return message; // idempotent — emit nothing
876
+ const tombstone = this.store.deleteMessage(room, messageId);
877
+ // The store nulled the metadata column; remove the bytes from disk too.
878
+ this.unlinkAttachments(room, message.attachments);
879
+ // Cancel still-pending deliveries so the purged body is never delivered.
880
+ for (const delivery of this.store.listDeliveries(room)) {
881
+ if (delivery.message_id !== messageId)
882
+ continue;
883
+ if (delivery.state !== 'queued' && delivery.state !== 'held')
884
+ continue;
885
+ this.emitInbox(room, this.store.updateDelivery(room, delivery.id, { state: 'consumed' }));
886
+ }
887
+ this.emitMessage(room, tombstone);
888
+ return tombstone;
889
+ }
890
+ // harn:end deleted-messages-purge-rows-and-files
891
+ memberDetails(room) {
892
+ const messages = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER });
893
+ return this.store.listMembers(room).map((member) => {
894
+ const runs = messages.filter((message) => message.kind === 'run' &&
895
+ message.author === member.id &&
896
+ message.run?.status !== 'running');
897
+ return {
898
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
899
+ member: this.memberWithLastUsage(room, member),
900
+ // harn:end last-agent-usage-is-transient-and-seeded
901
+ queued_count: this.store.listDeliveries(room, {
902
+ recipient: member.id,
903
+ state: 'queued',
904
+ }).length,
905
+ // harn:assume estimated-cost-is-advisory-not-spend-brake-input ref=member-advisory-cost-projection
906
+ spend: runs.reduce((total, message) => {
907
+ const usage = message.run?.usage;
908
+ const accounting = accountRunUsage({
909
+ // Completed history is read only from stored run snapshots. The
910
+ // current member model and today's rate table never reprice it.
911
+ model: undefined,
912
+ estimated_cost_usd: usage?.cost_usd === undefined
913
+ ? message.run?.estimated_cost_usd
914
+ : undefined,
915
+ }, usage);
916
+ return {
917
+ turns: total.turns + 1,
918
+ input_tokens: total.input_tokens + (usage?.input_tokens ?? 0),
919
+ output_tokens: total.output_tokens + (usage?.output_tokens ?? 0),
920
+ cost_usd: total.cost_usd + accounting.costUsd,
921
+ estimated_cost_usd: total.estimated_cost_usd + (accounting.estimatedCostUsd ?? 0),
922
+ uncosted_tokens: total.uncosted_tokens + accounting.uncostedTokens,
923
+ };
924
+ }, {
925
+ turns: 0,
926
+ input_tokens: 0,
927
+ output_tokens: 0,
928
+ cost_usd: 0,
929
+ estimated_cost_usd: 0,
930
+ uncosted_tokens: 0,
931
+ }),
932
+ // harn:end estimated-cost-is-advisory-not-spend-brake-input
933
+ };
934
+ });
935
+ }
936
+ // harn:assume roster-briefing-refreshes-on-membership ref=roster-membership-transitions
937
+ markRostersStale(room) {
938
+ this.store.markAgentRostersStale(room);
939
+ }
940
+ // harn:end roster-briefing-refreshes-on-membership
941
+ // harn:assume working-directories-validated-before-spawn ref=daemon-cwd-enforcement
942
+ spawnMember(room, opts) {
943
+ const cwd = normalizeWorkingDirectory(opts.cwd, this.homeDir);
944
+ const adapter = this.requireAdapter(opts.harness);
945
+ const spawnOpts = {
946
+ cwd,
947
+ policy: opts.policy,
948
+ model: opts.model,
949
+ thinking: opts.thinking,
950
+ };
951
+ // harn:assume canonical-spawn-controls-enforced ref=daemon-initial-spawn-validation
952
+ validateSpawnOptions(adapter, spawnOpts);
953
+ const session = adapter.spawn(spawnOpts);
954
+ // harn:end canonical-spawn-controls-enforced
955
+ const member = this.store.addMember(room, {
956
+ kind: 'agent',
957
+ handle: opts.handle,
958
+ display_name: opts.display_name ?? opts.handle,
959
+ purpose: opts.purpose,
960
+ harness: opts.harness,
961
+ cwd,
962
+ policy: opts.policy,
963
+ // harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-rebuild
964
+ // These are turn arguments, re-derived from the session on every turn. Held only
965
+ // in memory, they vanish on restart and the agent quietly becomes a different one.
966
+ model: opts.model,
967
+ thinking: opts.thinking,
968
+ // harn:end agent-model-and-thinking-are-durable
969
+ host: this.hostId,
970
+ state: 'idle',
971
+ custody: 'owned',
972
+ });
973
+ this.issueMemberCredential(room, member, session);
974
+ this.sessions.set(member.id, session);
975
+ this.markRostersStale(room);
976
+ this.emitMember(room, member);
977
+ return member;
978
+ }
979
+ // harn:end working-directories-validated-before-spawn
980
+ // harn:assume room-home-single-authority ref=remote-run-home-finalization
981
+ spawnRemoteMember(room, opts) {
982
+ if (!this.residency || !this.hostId)
983
+ throw new Error('remote residency is not configured');
984
+ if (opts.host === this.hostId)
985
+ throw new Error('remote member host must differ from the room home');
986
+ const member = this.store.addMember(room, {
987
+ kind: 'agent',
988
+ handle: opts.handle,
989
+ display_name: opts.display_name ?? opts.handle,
990
+ harness: opts.harness,
991
+ session_ref: opts.session_ref,
992
+ cwd: opts.cwd,
993
+ policy: opts.policy,
994
+ host: opts.host,
995
+ state: this.residency.isReachable(opts.host) ? 'idle' : 'unreachable',
996
+ custody: 'owned',
997
+ });
998
+ this.emitMember(room, member);
999
+ return member;
1000
+ }
1001
+ isRemoteMember(member) {
1002
+ return member.kind === 'agent' && member.host !== undefined && member.host !== this.hostId;
1003
+ }
1004
+ remoteRpcId(room, runMessageId) {
1005
+ if (!this.hostId)
1006
+ throw new Error('remote residency requires a home host id');
1007
+ return `${this.hostId}:${room}:${String(runMessageId)}`;
1008
+ }
1009
+ // harn:end room-home-single-authority
1010
+ // harn:assume remote-deliveries-queue-when-unreachable ref=remote-member-reachability
1011
+ handleResidentReachability(peerId, connected) {
1012
+ for (const room of this.store.listRooms()) {
1013
+ for (const member of this.store.listMembers(room.id)) {
1014
+ if (!this.isRemoteMember(member) || member.host !== peerId)
1015
+ continue;
1016
+ if (member.state === 'dead' || member.state === 'paused' || member.state === 'custody_uncertain') {
1017
+ continue;
1018
+ }
1019
+ if (!connected) {
1020
+ if (member.state !== 'unreachable') {
1021
+ this.emitMember(room.id, this.store.updateMember(room.id, member.id, { state: 'unreachable' }));
1022
+ }
1023
+ continue;
1024
+ }
1025
+ const queued = this.store.listDeliveries(room.id, {
1026
+ recipient: member.id,
1027
+ state: 'queued',
1028
+ });
1029
+ const restored = this.store.updateMember(room.id, member.id, {
1030
+ state: queued.length > 0 ? 'queued' : 'idle',
1031
+ });
1032
+ this.emitMember(room.id, restored);
1033
+ this.track(this.maybeStartTurn(room.id, member.id));
1034
+ }
1035
+ }
1036
+ if (connected)
1037
+ this.track(this.reconcile());
1038
+ }
1039
+ // harn:end remote-deliveries-queue-when-unreachable
1040
+ attachedSession(member) {
1041
+ if (!member.harness || !member.session_ref) {
1042
+ throw new Error(`member @${member.handle} has no resumable session`);
1043
+ }
1044
+ const adapter = this.requireAdapter(member.harness);
1045
+ if (!adapter.capabilities.resume) {
1046
+ throw new Error(`adapter '${adapter.id}' does not support resume`);
1047
+ }
1048
+ const session = adapter.attach(member.session_ref);
1049
+ session.cwd = member.cwd ?? session.cwd;
1050
+ session.policy = member.policy;
1051
+ // A revived agent must be the SAME agent: same model, same thinking level.
1052
+ session.model = member.model;
1053
+ session.thinking = member.thinking;
1054
+ const located = this.store.listRooms().find((room) => this.store.getMember(room.id, member.id) !== undefined);
1055
+ if (!located)
1056
+ throw new Error(`no room for agent member: ${member.id}`);
1057
+ this.issueMemberCredential(located.id, member, session);
1058
+ return session;
1059
+ }
1060
+ // harn:assume revive-via-session-ref ref=revive-native-session
1061
+ reviveMember(room, memberId) {
1062
+ const existing = this.store.getMember(room, memberId);
1063
+ if (!existing || existing.kind !== 'agent')
1064
+ throw new Error(`no such agent member: ${memberId}`);
1065
+ if (existing.state !== 'dead')
1066
+ throw new Error(`member @${existing.handle} is not dead`);
1067
+ if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
1068
+ throw new Error(`member @${existing.handle} has an active interactive attach lease`);
1069
+ }
1070
+ const session = this.attachedSession(existing);
1071
+ this.sessions.set(memberId, session);
1072
+ const member = this.store.updateMember(room, memberId, { state: 'idle', custody: 'owned' });
1073
+ this.emitMember(room, member);
1074
+ this.track(this.maybeStartTurn(room, memberId));
1075
+ return member;
1076
+ }
1077
+ // harn:end revive-via-session-ref
1078
+ // harn:assume adoption-explicit-or-sessionend ref=mirrored-adoption-transition
1079
+ joinMember(room, opts) {
1080
+ const cwd = normalizeWorkingDirectory(opts.cwd, this.homeDir);
1081
+ const adapter = this.requireAdapter(opts.harness);
1082
+ if (!adapter.capabilities.resume) {
1083
+ throw new Error(`adapter '${adapter.id}' cannot back a persistent mirrored member`);
1084
+ }
1085
+ const joined = this.store.findMemberBySessionRef(opts.harness, opts.session_ref);
1086
+ if (joined) {
1087
+ throw new Error(`session ${opts.session_ref} is already @${joined.member.handle} in room ${joined.room}`);
1088
+ }
1089
+ const member = this.store.addMember(room, {
1090
+ kind: 'agent',
1091
+ handle: opts.handle,
1092
+ display_name: opts.handle,
1093
+ purpose: opts.purpose,
1094
+ harness: opts.harness,
1095
+ session_ref: opts.session_ref,
1096
+ cwd,
1097
+ policy: opts.policy,
1098
+ state: 'idle',
1099
+ custody: 'mirrored',
1100
+ });
1101
+ this.markRostersStale(room);
1102
+ this.emitMember(room, member);
1103
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
1104
+ this.seedContextUsage(room, member);
1105
+ // harn:end last-agent-usage-is-transient-and-seeded
1106
+ this.postSystemMessage(room, `@${member.handle} joined from a live ${opts.harness} terminal`);
1107
+ return member;
1108
+ }
1109
+ adoptMember(room, memberId) {
1110
+ const existing = this.store.getMember(room, memberId);
1111
+ if (!existing || existing.kind !== 'agent')
1112
+ throw new Error(`no such agent member: ${memberId}`);
1113
+ if (existing.custody !== 'mirrored')
1114
+ throw new Error(`member @${existing.handle} is not mirrored`);
1115
+ const lease = this.store.getAttachLeaseForMember(memberId);
1116
+ if (lease) {
1117
+ if (existing.state !== 'custody_uncertain' || this.attachChildRecorded(lease)) {
1118
+ throw new Error(`member @${existing.handle} has an active interactive attach lease`);
1119
+ }
1120
+ this.store.deleteAttachLease(lease.id);
1121
+ }
1122
+ return this.adoptMirroredMember(room, existing, `@${existing.handle} was adopted by the switchboard`);
1123
+ }
1124
+ adoptMirroredMember(room, existing, systemMessage) {
1125
+ const session = this.attachedSession(existing);
1126
+ this.sessions.set(existing.id, session);
1127
+ const member = this.store.updateMember(room, existing.id, {
1128
+ custody: 'owned',
1129
+ state: 'idle',
1130
+ });
1131
+ this.markRostersStale(room);
1132
+ this.emitMember(room, member);
1133
+ this.postSystemMessage(room, systemMessage);
1134
+ this.track(this.maybeStartTurn(room, member.id));
1135
+ return member;
1136
+ }
1137
+ mirrorSessionEnd(harness, sessionRef) {
1138
+ if (harness !== 'claude-code')
1139
+ return false;
1140
+ const joined = this.store.findMemberBySessionRef(harness, sessionRef);
1141
+ if (!joined || joined.member.custody !== 'mirrored')
1142
+ return false;
1143
+ if (this.store.getAttachLeaseForMember(joined.member.id))
1144
+ return false;
1145
+ this.adoptMember(joined.room, joined.member.id);
1146
+ return true;
1147
+ }
1148
+ // harn:end adoption-explicit-or-sessionend
1149
+ // harn:assume attach-custody-lease-tracks-child-pid ref=attach-release-handshake
1150
+ async acquireAttachLease(room, memberId, cliPid) {
1151
+ const existing = this.store.getMember(room, memberId);
1152
+ if (!existing || existing.kind !== 'agent')
1153
+ throw new Error(`no such agent member: ${memberId}`);
1154
+ if (existing.custody !== 'owned')
1155
+ throw new Error(`member @${existing.handle} is not switchboard-owned`);
1156
+ // harn:assume cli-member-recovery-is-actionable ref=attach-error-remediation
1157
+ if (existing.state === 'dead') {
1158
+ throw new Error(existing.session_ref
1159
+ ? `member @${existing.handle} is dead; revive it to retry`
1160
+ : `member @${existing.handle} is dead; remove it and spawn a replacement`);
1161
+ }
1162
+ // harn:end cli-member-recovery-is-actionable
1163
+ if (existing.state === 'awaiting_input') {
1164
+ throw new Error(`member @${existing.handle} is awaiting input; answer or interrupt it before attach`);
1165
+ }
1166
+ if (!existing.session_ref)
1167
+ throw new Error(`member @${existing.handle} has no resumable session yet`);
1168
+ const adapter = this.requireAdapter(existing.harness);
1169
+ if (!adapter.capabilities.interactiveAttach) {
1170
+ throw new Error(`adapter '${adapter.id}' does not support interactive attach`);
1171
+ }
1172
+ if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
1173
+ throw new Error(`member @${existing.handle} already has an attach lease`);
1174
+ }
1175
+ this.pendingAttach.add(memberId);
1176
+ try {
1177
+ while (this.inflight.has(memberId)) {
1178
+ await new Promise((resolve) => setTimeout(resolve, 5));
1179
+ }
1180
+ const current = this.store.getMember(room, memberId);
1181
+ if (!current || current.custody !== 'owned') {
1182
+ throw new Error(`member @${existing.handle} changed custody while attach was waiting`);
1183
+ }
1184
+ if (current.state === 'awaiting_input') {
1185
+ throw new Error(`member @${existing.handle} is awaiting input; answer or interrupt it before attach`);
1186
+ }
1187
+ const lease = this.store.createAttachLease({
1188
+ room,
1189
+ member_id: memberId,
1190
+ cli_pid: cliPid,
1191
+ heartbeat_ts: Date.now(),
1192
+ });
1193
+ try {
1194
+ const queued = this.store.listDeliveries(room, {
1195
+ recipient: memberId,
1196
+ state: 'queued',
1197
+ }).length;
1198
+ const member = this.store.updateMember(room, memberId, {
1199
+ custody: 'mirrored',
1200
+ state: queued > 0 ? 'queued' : 'idle',
1201
+ });
1202
+ this.sessions.delete(memberId);
1203
+ this.emitMember(room, member);
1204
+ this.postSystemMessage(room, `@${member.handle} released to an interactive terminal`);
1205
+ return { lease, member };
1206
+ }
1207
+ catch (error) {
1208
+ this.store.deleteAttachLease(lease.id);
1209
+ throw error;
1210
+ }
1211
+ }
1212
+ finally {
1213
+ this.pendingAttach.delete(memberId);
1214
+ }
1215
+ }
1216
+ reportAttachChild(leaseId, childPid, processGroupId) {
1217
+ const lease = this.store.setAttachLeaseChild(leaseId, childPid, processGroupId, Date.now());
1218
+ const member = this.store.getMember(lease.room, lease.member_id);
1219
+ if (!member)
1220
+ throw new Error(`attach lease ${leaseId} has no member`);
1221
+ return { lease, member };
1222
+ }
1223
+ heartbeatAttachLease(leaseId) {
1224
+ this.store.heartbeatAttachLease(leaseId, Date.now());
1225
+ }
1226
+ completeAttachLease(leaseId) {
1227
+ const lease = this.store.getAttachLease(leaseId);
1228
+ if (!lease)
1229
+ throw new Error(`no such attach lease ${leaseId}`);
1230
+ if (!this.attachChildRecorded(lease)) {
1231
+ const member = this.markCustodyUncertain(lease, 'attach completed before its native child identity was recorded; custody is uncertain');
1232
+ return { status: 'uncertain', lease, member };
1233
+ }
1234
+ if (this.attachChildAlive(lease)) {
1235
+ const member = this.markCustodyUncertain(lease);
1236
+ return { status: 'uncertain', lease, member };
1237
+ }
1238
+ return { status: 'completed', member: this.finishAttachLease(lease) };
1239
+ }
1240
+ // harn:end attach-custody-lease-tracks-child-pid
1241
+ // harn:assume custody-uncertain-never-double-writes ref=attach-lease-loss-reconcile
1242
+ reconcileAttachLeases(now = Date.now()) {
1243
+ for (const lease of this.store.listAttachLeases()) {
1244
+ if (now - lease.heartbeat_ts <= this.attachLeaseTimeoutMs)
1245
+ continue;
1246
+ if (!this.attachChildRecorded(lease)) {
1247
+ this.markCustodyUncertain(lease, 'attach heartbeat expired before its native child identity was recorded; custody is uncertain');
1248
+ }
1249
+ else if (this.attachChildAlive(lease))
1250
+ this.markCustodyUncertain(lease);
1251
+ else
1252
+ this.finishAttachLease(lease);
1253
+ }
1254
+ }
1255
+ attachChildRecorded(lease) {
1256
+ return lease.process_group_id !== undefined || lease.child_pid !== undefined;
1257
+ }
1258
+ attachChildAlive(lease) {
1259
+ const target = processProbeTarget(process.platform, lease.child_pid, lease.process_group_id);
1260
+ return target === undefined ? false : this.processProbe(target);
1261
+ }
1262
+ markCustodyUncertain(lease, reason = 'attach heartbeat was lost while its terminal may still be alive; custody is uncertain') {
1263
+ const existing = this.store.getMember(lease.room, lease.member_id);
1264
+ if (!existing)
1265
+ throw new Error(`attach lease ${lease.id} has no member`);
1266
+ if (existing.state === 'custody_uncertain')
1267
+ return existing;
1268
+ const member = this.store.updateMember(lease.room, lease.member_id, {
1269
+ custody: 'mirrored',
1270
+ state: 'custody_uncertain',
1271
+ });
1272
+ this.emitMember(lease.room, member);
1273
+ this.postSystemMessage(lease.room, `@${member.handle} ${reason}`);
1274
+ return member;
1275
+ }
1276
+ finishAttachLease(lease) {
1277
+ const existing = this.store.getMember(lease.room, lease.member_id);
1278
+ if (!existing || existing.kind !== 'agent')
1279
+ throw new Error(`attach lease ${lease.id} has no agent member`);
1280
+ const session = this.attachedSession(existing);
1281
+ this.sessions.set(existing.id, session);
1282
+ this.store.deleteAttachLease(lease.id);
1283
+ const member = this.store.updateMember(lease.room, existing.id, {
1284
+ custody: 'owned',
1285
+ state: 'idle',
1286
+ });
1287
+ this.emitMember(lease.room, member);
1288
+ this.postSystemMessage(lease.room, `@${existing.handle} interactive terminal exited; the switchboard re-adopted its session`);
1289
+ this.track(this.maybeStartTurn(lease.room, member.id));
1290
+ return member;
1291
+ }
1292
+ // harn:end custody-uncertain-never-double-writes
1293
+ // harn:assume member-config-is-changed-not-respawned ref=configure-member-daemon
1294
+ /**
1295
+ * Give a live agent new settings without losing it.
1296
+ *
1297
+ * The harness holds nothing: every turn is a fresh subprocess whose arguments are
1298
+ * re-derived from the session, and the conversation lives in the resume token on the
1299
+ * member row. So a change writes the row and DISCARDS the cached session — the next
1300
+ * turn rebuilds from that row and runs entirely on the new settings, while a turn
1301
+ * already in flight keeps the session object it started with and completes entirely
1302
+ * on the old ones. A turn can therefore never be assembled out of a mixture of the
1303
+ * two: not because we were careful, but because there is only ever one row to read.
1304
+ *
1305
+ * `undefined` leaves a setting alone; `null` clears it back to the harness default.
1306
+ */
1307
+ configureMember(room, memberId, changes, opts = {}) {
1308
+ const member = this.store.getMember(room, memberId);
1309
+ if (!member || member.kind !== 'agent')
1310
+ throw new Error(`no such agent member: ${memberId}`);
1311
+ if (member.removed_ts !== undefined)
1312
+ throw new Error(`member @${member.handle} was removed`);
1313
+ // harn:assume a-permission-change-is-never-silent ref=configure-custody-and-capability-guards
1314
+ // A mirrored member's session lives on another switchboard. A half-applied remote
1315
+ // change is worse than a refused one, so refuse it here and say where to go.
1316
+ if (member.custody === 'mirrored') {
1317
+ throw new Error(`member @${member.handle} is mirrored from another switchboard; configure it there`);
1318
+ }
1319
+ const settled = (next, current) => next === undefined ? current : (next ?? undefined);
1320
+ const next = {
1321
+ cwd: member.cwd ?? process.cwd(),
1322
+ model: settled(changes.model, member.model),
1323
+ thinking: settled(changes.thinking, member.thinking),
1324
+ policy: settled(changes.policy, member.policy),
1325
+ };
1326
+ // The same single gate the spawn path uses: an unknown policy, or a thinking level
1327
+ // this harness cannot honour, is refused rather than recorded as a preference it
1328
+ // would silently ignore.
1329
+ validateSpawnOptions(this.requireAdapter(member.harness), next);
1330
+ // harn:end a-permission-change-is-never-silent
1331
+ const updated = this.store.updateMember(room, memberId, {
1332
+ model: next.model,
1333
+ thinking: next.thinking,
1334
+ policy: next.policy,
1335
+ });
1336
+ // The next turn rebuilds from the row we just wrote. A turn already in flight keeps
1337
+ // the session it started with — including for the ask cards it has already raised.
1338
+ this.staleSessions.add(memberId);
1339
+ // harn:assume a-permission-change-is-never-silent ref=configure-audit-message
1340
+ // Raising what an agent may do to the operator's machine is a consequential act. A
1341
+ // capability change visible only as a flicker in a member frame is one nobody saw.
1342
+ const changed = [
1343
+ ['policy', member.policy, updated.policy],
1344
+ ['model', member.model, updated.model],
1345
+ ['thinking', member.thinking, updated.thinking],
1346
+ ]
1347
+ .filter(([, before, after]) => before !== after)
1348
+ .map(([field, before, after]) => `${field}: ${before ?? 'default'} → ${after ?? 'default'}`);
1349
+ if (changed.length > 0) {
1350
+ const actor = opts.actor === undefined ? undefined : this.store.getMember(room, opts.actor);
1351
+ this.postSystemMessage(room, `@${actor?.handle ?? 'someone'} changed @${updated.handle} — ${changed.join(', ')}`);
1352
+ }
1353
+ // harn:end a-permission-change-is-never-silent
1354
+ this.emitMember(room, updated);
1355
+ return updated;
1356
+ }
1357
+ // harn:end member-config-is-changed-not-respawned
1358
+ killMember(room, memberId) {
1359
+ const existing = this.store.getMember(room, memberId);
1360
+ if (!existing || existing.kind !== 'agent')
1361
+ throw new Error(`no such agent member: ${memberId}`);
1362
+ if (this.store.getAttachLeaseForMember(memberId) || this.pendingAttach.has(memberId)) {
1363
+ throw new Error(`member @${existing.handle} has an active interactive attach lease`);
1364
+ }
1365
+ const session = this.sessions.get(memberId);
1366
+ if (existing.harness && session)
1367
+ this.requireAdapter(existing.harness).interrupt(session);
1368
+ for (const interaction of this.store.listInteractions(room)) {
1369
+ if (interaction.member_id === memberId &&
1370
+ (interaction.state === 'pending' || interaction.state === 'answered')) {
1371
+ this.orphanInteraction(room, interaction);
1372
+ }
1373
+ }
1374
+ this.memberWaits.delete(memberId);
1375
+ this.groupWaits.delete(memberId);
1376
+ const member = this.store.updateMember(room, memberId, { state: 'dead' });
1377
+ this.emitMember(room, member);
1378
+ for (const delivery of this.store.listDeliveries(room, { recipient: memberId })) {
1379
+ if (delivery.group_id !== undefined &&
1380
+ delivery.run_msg_id === undefined &&
1381
+ (delivery.state === 'queued' || delivery.state === 'held')) {
1382
+ this.skipUnavailableGroupDelivery(room, delivery);
1383
+ }
1384
+ }
1385
+ this.postSystemMessage(room, member.session_ref
1386
+ ? `@${member.handle} was killed; revive to retry`
1387
+ : `@${member.handle} was killed; remove it and spawn a replacement`);
1388
+ return member;
1389
+ }
1390
+ pauseMember(room, memberId) {
1391
+ const existing = this.store.getMember(room, memberId);
1392
+ if (!existing || existing.kind !== 'agent')
1393
+ throw new Error(`no such agent member: ${memberId}`);
1394
+ if (existing.state === 'dead')
1395
+ throw new Error(`member @${existing.handle} is dead; revive it instead`);
1396
+ const member = this.store.updateMember(room, memberId, { state: 'paused' });
1397
+ this.emitMember(room, member);
1398
+ return member;
1399
+ }
1400
+ unpauseMember(room, memberId) {
1401
+ const existing = this.store.getMember(room, memberId);
1402
+ if (!existing || existing.kind !== 'agent')
1403
+ throw new Error(`no such agent member: ${memberId}`);
1404
+ if (existing.state !== 'paused')
1405
+ throw new Error(`member @${existing.handle} is not paused`);
1406
+ const member = this.store.updateMember(room, memberId, { state: 'idle' });
1407
+ this.emitMember(room, member);
1408
+ this.track(this.maybeStartTurn(room, memberId));
1409
+ return member;
1410
+ }
1411
+ interruptMember(room, memberId) {
1412
+ const member = this.store.getMember(room, memberId);
1413
+ const session = this.sessions.get(memberId);
1414
+ if (member?.harness && session) {
1415
+ if (this.inflight.has(memberId))
1416
+ this.operatorInterrupts.add(memberId);
1417
+ this.requireAdapter(member.harness).interrupt(session);
1418
+ }
1419
+ }
1420
+ // harn:assume manual-compaction-is-an-operator-act ref=compact-member-contract
1421
+ /**
1422
+ * Compact an agent's engine session on an operator's explicit request. Codor
1423
+ * never triggers compaction itself; this is the on-demand lever for a human
1424
+ * watching the context ring climb. Owner/admin humans only (the server gate
1425
+ * enforces the role, this refuses others defensively), and only for an IDLE
1426
+ * agent with a live session: compacting mid-turn races the engine rewriting
1427
+ * the same history, so an in-flight member is refused and told to stop first.
1428
+ * The harness does the compaction natively — a harness that cannot say so
1429
+ * clearly rather than silently doing nothing. When the engine reports its
1430
+ * re-baselined context, it lands as transient member usage exactly like live
1431
+ * turn telemetry, so the ring reflects the compaction immediately.
1432
+ */
1433
+ async compactMember(room, memberId, byMemberId) {
1434
+ const actor = this.store.getMember(room, byMemberId);
1435
+ if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
1436
+ throw new Error('forbidden: only owners and admins can compact an agent session');
1437
+ }
1438
+ const member = this.store.getMember(room, memberId);
1439
+ if (member?.kind !== 'agent' || member.removed_ts !== undefined) {
1440
+ throw new Error(`no such agent member: ${memberId}`);
1441
+ }
1442
+ if (member.harness === undefined)
1443
+ throw new Error(`@${member.handle} has no harness to compact`);
1444
+ // Idle-only, and the two refusals are distinct on purpose: a turn in flight
1445
+ // is a "stop it first" the operator can act on, while paused/queued/dead is
1446
+ // a different problem entirely. A retained session is NOT evidence of idle —
1447
+ // a dead or paused member can still hold one.
1448
+ if (this.inflight.has(memberId) || member.state === 'running') {
1449
+ throw new Error(`@${member.handle} is running — stop the turn before compacting`);
1450
+ }
1451
+ if (member.state !== 'idle') {
1452
+ throw new Error(`@${member.handle} is ${member.state} — only an idle agent can be compacted`);
1453
+ }
1454
+ const session = this.sessions.get(memberId);
1455
+ if (session === undefined)
1456
+ throw new Error(`@${member.handle} has no live session to compact`);
1457
+ const adapter = this.requireAdapter(member.harness);
1458
+ if (adapter.compactSession === undefined) {
1459
+ throw new Error(`harness '${member.harness}' does not support compaction`);
1460
+ }
1461
+ const usage = await adapter.compactSession(session);
1462
+ this.landCompactedUsage(room, memberId, usage);
1463
+ }
1464
+ /**
1465
+ * The re-baseline takes the same transient path live turn telemetry does.
1466
+ * A member frame goes out on EVERY successful compaction, even when the
1467
+ * engine reported no usage: the UI needs a completion edge to stop showing
1468
+ * the operator a spinner, and silence is indistinguishable from still-working.
1469
+ */
1470
+ landCompactedUsage(room, memberId, usage) {
1471
+ if (usage !== undefined) {
1472
+ this.lastUsage.set(memberId, { ...usage });
1473
+ this.landContextWindow(room, memberId, usage);
1474
+ }
1475
+ const current = this.store.getMember(room, memberId);
1476
+ if (current !== undefined)
1477
+ this.emitMember(room, current);
1478
+ }
1479
+ // harn:end manual-compaction-is-an-operator-act
1480
+ // harn:assume rename-preserves-mention-resolution ref=member-rename-stable-mentions
1481
+ renameMember(room, memberId, handle, displayName) {
1482
+ const before = this.store.getMember(room, memberId);
1483
+ if (!before || before.kind !== 'agent')
1484
+ throw new Error(`no such agent member: ${memberId}`);
1485
+ const collision = this.store.getMemberByHandle(room, handle);
1486
+ if (collision && collision.id !== memberId) {
1487
+ throw new Error(`handle '@${handle}' is already in use`);
1488
+ }
1489
+ const member = this.store.updateMember(room, memberId, {
1490
+ handle,
1491
+ ...(displayName !== undefined && { display_name: displayName }),
1492
+ });
1493
+ this.markRostersStale(room);
1494
+ this.emitMember(room, member);
1495
+ const body = `@${before.handle} is now @${handle}`;
1496
+ const secondStart = body.lastIndexOf('@');
1497
+ this.postSystemMessage(room, body, {
1498
+ mentions: [
1499
+ { member_id: member.id, start: 0, end: before.handle.length + 1 },
1500
+ { member_id: member.id, start: secondStart, end: secondStart + handle.length + 1 },
1501
+ ],
1502
+ });
1503
+ return member;
1504
+ }
1505
+ // harn:end rename-preserves-mention-resolution
1506
+ // harn:assume removed-members-remain-attribution-tombstones ref=member-removal-daemon
1507
+ removeMember(room, memberId) {
1508
+ const existing = this.store.getMember(room, memberId);
1509
+ if (!existing || existing.kind !== 'agent')
1510
+ throw new Error(`no such agent member: ${memberId}`);
1511
+ // harn:assume removing-an-agent-is-one-deliberate-step ref=remove-live-member
1512
+ // Removing an agent is ONE act, not a ritual of kill-then-find-the-other-button. The
1513
+ // invariant is preserved rather than bypassed: the member is still dead before it is
1514
+ // tombstoned — killMember interrupts the running turn, orphans its cards, and refuses
1515
+ // outright if an interactive attach lease is held, so nothing is ever half-removed.
1516
+ if (existing.state !== 'dead')
1517
+ this.killMember(room, memberId);
1518
+ // harn:end removing-an-agent-is-one-deliberate-step
1519
+ const member = this.store.updateMember(room, memberId, {
1520
+ removed_ts: new Date().toISOString(),
1521
+ });
1522
+ this.sessions.delete(memberId);
1523
+ this.staleSessions.delete(memberId);
1524
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
1525
+ this.lastUsage.delete(memberId);
1526
+ // harn:end last-agent-usage-is-transient-and-seeded
1527
+ // harn:assume removing-an-agent-is-one-deliberate-step ref=remove-drains-queued-work
1528
+ // Work addressed to a member that no longer exists has nowhere to go. Left queued it
1529
+ // would wait in the pump forever for an agent that is never coming back, and count
1530
+ // against a member the roster no longer shows.
1531
+ const abandoned = this.store.listDeliveries(room, { recipient: memberId, state: 'queued' });
1532
+ for (const delivery of abandoned) {
1533
+ if (delivery.group_id !== undefined)
1534
+ this.skipUnavailableGroupDelivery(room, delivery);
1535
+ else
1536
+ this.store.updateDelivery(room, delivery.id, { state: 'consumed' });
1537
+ }
1538
+ // harn:end removing-an-agent-is-one-deliberate-step
1539
+ this.markRostersStale(room);
1540
+ this.emitMember(room, member);
1541
+ this.postSystemMessage(room, abandoned.length > 0
1542
+ ? `@${member.handle} was removed; ${String(abandoned.length)} queued message${abandoned.length === 1 ? '' : 's'} dropped; its history remains attributed`
1543
+ : `@${member.handle} was removed; its history remains attributed`);
1544
+ return member;
1545
+ }
1546
+ // harn:end removed-members-remain-attribution-tombstones
1547
+ requireAdapter(id) {
1548
+ const adapter = this.adapters.get(id);
1549
+ if (!adapter)
1550
+ throw new Error(`no adapter registered for harness '${id}'`);
1551
+ return adapter;
1552
+ }
1553
+ /** Sessions are rebuilt from the persisted member row after a restart. */
1554
+ sessionFor(room, member) {
1555
+ // A configure since the last turn: discard the cached session so this turn is built
1556
+ // wholly from the row, and therefore wholly from the new settings.
1557
+ if (this.staleSessions.delete(member.id))
1558
+ this.sessions.delete(member.id);
1559
+ let session = this.sessions.get(member.id);
1560
+ if (!session) {
1561
+ const adapter = this.requireAdapter(member.harness);
1562
+ session =
1563
+ member.session_ref !== undefined
1564
+ ? adapter.attach(member.session_ref)
1565
+ : (() => {
1566
+ const spawnOpts = {
1567
+ cwd: member.cwd ?? process.cwd(),
1568
+ policy: member.policy,
1569
+ model: member.model,
1570
+ thinking: member.thinking,
1571
+ };
1572
+ // harn:assume canonical-spawn-controls-enforced ref=daemon-session-rebuild-validation
1573
+ validateSpawnOptions(adapter, spawnOpts);
1574
+ const rebuilt = adapter.spawn(spawnOpts);
1575
+ // harn:end canonical-spawn-controls-enforced
1576
+ return rebuilt;
1577
+ })();
1578
+ session.cwd = member.cwd ?? session.cwd; // revive MUST reuse the persisted cwd
1579
+ session.policy = member.policy;
1580
+ // harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-rebuild
1581
+ // This is the path a restart takes. Restoring cwd and policy but not these two is
1582
+ // how an agent silently reverted to its harness default model, mid-conversation.
1583
+ session.model = member.model;
1584
+ session.thinking = member.thinking;
1585
+ // harn:end agent-model-and-thinking-are-durable
1586
+ this.issueMemberCredential(room, member, session);
1587
+ this.sessions.set(member.id, session);
1588
+ }
1589
+ return session;
1590
+ }
1591
+ // ── posting ───────────────────────────────────────────────────────────
1592
+ postChatMessage(room, body, authorId, replyTo, awaitingReply = false, interim = false, attachments) {
1593
+ const parsed = parseBody(body, this.store.listMembers(room));
1594
+ // harn:assume eligible-multi-agent-routing-starts-one-group ref=multi-agent-group-ingress
1595
+ const committed = this.store.commitRoutedMessage(room, {
1596
+ message: {
1597
+ author: authorId,
1598
+ kind: 'chat',
1599
+ body,
1600
+ mentions: parsed.mentions,
1601
+ refs: parsed.refs,
1602
+ ledger_refs: parsed.ledger_refs,
1603
+ reply_to: replyTo,
1604
+ ...(attachments !== undefined && attachments.length > 0 && { attachments }),
1605
+ },
1606
+ plan: (message) => this.planRoutedMessage(room, message, undefined, undefined, awaitingReply, !interim).plan,
1607
+ });
1608
+ this.emitMessage(room, committed.message);
1609
+ if (committed.member)
1610
+ this.emitMember(room, committed.member);
1611
+ this.dispatchCreatedDeliveries(room, committed.deliveries);
1612
+ return committed.message;
1613
+ }
1614
+ postHumanMessage(room, body, opts = {}) {
1615
+ const authorId = opts.author ?? this.ownerOf(room).id;
1616
+ const author = this.store.getMember(room, authorId);
1617
+ if (author?.kind !== 'human')
1618
+ throw new Error(`no such human author: ${authorId}`);
1619
+ return this.postChatMessage(room, body, authorId, opts.reply_to, false, false, opts.attachments);
1620
+ }
1621
+ // harn:assume agent-network-authority-is-narrow ref=agent-interim-post-ingress
1622
+ postAgentMessage(room, memberId, body, replyTo, awaitingReply = false) {
1623
+ const author = this.store.getMember(room, memberId);
1624
+ if (!author || author.kind !== 'agent' || author.removed_ts !== undefined) {
1625
+ throw new Error(`no active agent author: ${memberId}`);
1626
+ }
1627
+ // harn:assume interim-agent-posts-are-nonfinal-routing ref=interim-post-classification
1628
+ // A latest running row makes this an interim post. It remains ordinary chat; status
1629
+ // derives the live-turn window from timestamps instead of changing Message kind.
1630
+ const currentRun = this.store.listRunMessages(room, { author: memberId, limit: 1 })[0];
1631
+ if (currentRun?.run?.status === 'running')
1632
+ this.noteRunActivity(room, currentRun.id);
1633
+ // harn:end interim-agent-posts-are-nonfinal-routing
1634
+ return this.postChatMessage(room, body, memberId, replyTo, awaitingReply, true);
1635
+ }
1636
+ // harn:end agent-network-authority-is-narrow
1637
+ // harn:assume live-agent-waits-are-transient ref=transient-wait-registry
1638
+ // harn:assume answered-approval-tools-can-register-live-waits ref=approved-tool-wait-eligibility
1639
+ canUseLiveWait(room, member) {
1640
+ if (member.state === 'running')
1641
+ return true;
1642
+ if (member.state !== 'awaiting_input')
1643
+ return false;
1644
+ const openInteractions = this.store.listInteractions(room).filter((interaction) => interaction.member_id === member.id
1645
+ && (interaction.state === 'pending' || interaction.state === 'answered'));
1646
+ return openInteractions.length > 0
1647
+ && openInteractions.every((interaction) => interaction.state === 'answered');
1648
+ }
1649
+ // harn:end answered-approval-tools-can-register-live-waits
1650
+ beginWait(room, memberId, input, now = new Date()) {
1651
+ const member = this.store.getMember(room, memberId);
1652
+ if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
1653
+ throw new Error(`no active agent member: ${memberId}`);
1654
+ }
1655
+ if (!this.canUseLiveWait(room, member)) {
1656
+ throw new Error(`member @${member.handle} cannot wait while ${member.state ?? 'inactive'}`);
1657
+ }
1658
+ const until = Date.parse(input.until_ts);
1659
+ if (!Number.isFinite(until) || until <= now.getTime()) {
1660
+ throw new Error('wait deadline must be in the future');
1661
+ }
1662
+ const run = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })
1663
+ .reverse()
1664
+ .find((message) => message.kind === 'run' && message.author === memberId && message.run?.status === 'running');
1665
+ if (!run)
1666
+ throw new Error(`member @${member.handle} has no running turn to wait in`);
1667
+ const peers = [...new Set(input.peers)];
1668
+ if (peers.length === 0 || peers.includes(memberId)) {
1669
+ throw new Error('wait peers must name at least one other member');
1670
+ }
1671
+ for (const peerId of peers) {
1672
+ const peer = this.store.getMember(room, peerId);
1673
+ if (!peer || peer.removed_ts !== undefined)
1674
+ throw new Error(`no active wait peer: ${peerId}`);
1675
+ }
1676
+ const waiting = {
1677
+ peers,
1678
+ reason: input.reason,
1679
+ since_ts: now.toISOString(),
1680
+ until_ts: input.until_ts,
1681
+ };
1682
+ this.memberWaits.set(memberId, waiting);
1683
+ // harn:assume same-round-terminal-peers-end-live-waits ref=collaboration-wait-context
1684
+ const groupedDelivery = this.store.listDeliveries(room, { recipient: memberId })
1685
+ .find((delivery) => delivery.run_msg_id === run.id && delivery.group_id !== undefined);
1686
+ if (groupedDelivery?.group_id !== undefined && groupedDelivery.group_round !== undefined) {
1687
+ this.groupWaits.set(memberId, {
1688
+ room,
1689
+ groupId: groupedDelivery.group_id,
1690
+ roundNumber: groupedDelivery.group_round,
1691
+ });
1692
+ }
1693
+ else {
1694
+ this.groupWaits.delete(memberId);
1695
+ }
1696
+ // harn:end same-round-terminal-peers-end-live-waits
1697
+ this.noteRunActivity(room, run.id);
1698
+ this.emitMember(room, member);
1699
+ const groupContext = this.groupWaits.get(memberId);
1700
+ if (groupContext) {
1701
+ this.clearSatisfiedGroupWaits(room, groupContext.groupId, groupContext.roundNumber);
1702
+ }
1703
+ return this.memberWaits.has(memberId) ? { ...member, waiting } : member;
1704
+ }
1705
+ endWait(room, memberId) {
1706
+ const member = this.store.getMember(room, memberId);
1707
+ if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
1708
+ throw new Error(`no active agent member: ${memberId}`);
1709
+ }
1710
+ if (!this.canUseLiveWait(room, member)) {
1711
+ throw new Error(`member @${member.handle} cannot end a wait while ${member.state ?? 'inactive'}`);
1712
+ }
1713
+ const run = this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })
1714
+ .reverse()
1715
+ .find((message) => message.kind === 'run' && message.author === memberId && message.run?.status === 'running');
1716
+ if (!run)
1717
+ throw new Error(`member @${member.handle} has no running turn to end a wait in`);
1718
+ const changed = this.memberWaits.delete(memberId);
1719
+ this.groupWaits.delete(memberId);
1720
+ if (changed)
1721
+ this.emitMember(room, member);
1722
+ return member;
1723
+ }
1724
+ // harn:end live-agent-waits-are-transient
1725
+ postSystemMessage(room, body, opts = {}) {
1726
+ const system = this.store.listMembers(room).find((m) => m.kind === 'system');
1727
+ const message = this.store.postMessage(room, {
1728
+ author: system.id,
1729
+ kind: 'system',
1730
+ body,
1731
+ mentions: opts.mentions,
1732
+ });
1733
+ this.emitMessage(room, message);
1734
+ return message; // system messages NEVER route (eligibility gate)
1735
+ }
1736
+ // harn:assume mirror-one-message-per-native-turn ref=mirrored-turn-dedupe-route
1737
+ mirrorTurn(input) {
1738
+ const joined = this.store.findMemberBySessionRef(input.harness, input.session_ref);
1739
+ if (!joined)
1740
+ throw new Error(`no mirrored member for ${input.harness} session ${input.session_ref}`);
1741
+ if (joined.member.custody !== 'mirrored') {
1742
+ throw new Error(`member @${joined.member.handle} is not mirrored; native turn was dropped`);
1743
+ }
1744
+ const parsed = parseBody(input.body, this.store.listMembers(joined.room));
1745
+ const startedTs = new Date().toISOString();
1746
+ const committed = this.store.commitMirroredTurn(joined.room, {
1747
+ memberId: joined.member.id,
1748
+ nativeTurnId: input.native_turn_id,
1749
+ finalize: (placeholder) => {
1750
+ const eventsRef = this.blobs.ref(placeholder.id);
1751
+ const patch = {
1752
+ body: input.body,
1753
+ mentions: parsed.mentions,
1754
+ refs: parsed.refs,
1755
+ ledger_refs: parsed.ledger_refs,
1756
+ run: {
1757
+ status: 'completed',
1758
+ started_ts: startedTs,
1759
+ ended_ts: startedTs,
1760
+ tool_calls: 0,
1761
+ events_ref: eventsRef,
1762
+ final_text: input.body,
1763
+ output_mode: 'messages',
1764
+ result_message_id: placeholder.id,
1765
+ },
1766
+ };
1767
+ const draft = { ...placeholder, ...patch };
1768
+ const planned = this.planRoutedMessage(joined.room, draft, this.ownerOf(joined.room).id, undefined, false, true);
1769
+ return {
1770
+ message: patch,
1771
+ fanout: planned.plan.fanout,
1772
+ collaboration: planned.plan.collaboration,
1773
+ markMisaddressed: planned.result.misaddressed,
1774
+ };
1775
+ },
1776
+ });
1777
+ if (committed.deduped)
1778
+ return { message: committed.message, deduped: true };
1779
+ const eventsRef = committed.message.run.events_ref;
1780
+ this.blobs.append(joined.room, eventsRef, {
1781
+ type: 'run.item',
1782
+ item_type: 'reasoning_summary',
1783
+ payload: {
1784
+ source: 'mirrored-turn',
1785
+ native_turn_id: input.native_turn_id,
1786
+ transcript_path: input.transcript_path,
1787
+ },
1788
+ output_message_id: committed.message.id,
1789
+ });
1790
+ this.blobs.append(joined.room, eventsRef, {
1791
+ type: 'run.completed',
1792
+ status: 'completed',
1793
+ final_text: input.body,
1794
+ output_message_id: committed.message.id,
1795
+ });
1796
+ this.emitMessage(joined.room, committed.message);
1797
+ if (committed.member)
1798
+ this.emitMember(joined.room, committed.member);
1799
+ this.dispatchCreatedDeliveries(joined.room, committed.deliveries);
1800
+ return { message: committed.message, deduped: false };
1801
+ }
1802
+ // harn:end mirror-one-message-per-native-turn
1803
+ // harn:end eligible-multi-agent-routing-starts-one-group
1804
+ // harn:assume extension-lifecycle-from-hooks ref=switchboard-extension-lifecycle
1805
+ startExtension(room, parent, event, streamDescription) {
1806
+ const description = streamDescription ?? event.description;
1807
+ let extension = this.store.getExtensionByNativeId(room, parent.id, event.ext_member);
1808
+ if (extension) {
1809
+ extension = this.store.updateMember(room, extension.id, {
1810
+ state: 'running',
1811
+ ...(description !== undefined && { display_name: description }),
1812
+ });
1813
+ }
1814
+ else {
1815
+ const suffix = extensionSuffix(event.ext_member);
1816
+ const handle = `${parent.handle.slice(0, 20)}-ext-${suffix}`;
1817
+ extension = this.store.addMember(room, {
1818
+ kind: 'extension',
1819
+ handle,
1820
+ display_name: description ?? `${parent.display_name} extension ${suffix}`,
1821
+ harness: parent.harness,
1822
+ session_ref: event.ext_member,
1823
+ cwd: parent.cwd,
1824
+ state: 'running',
1825
+ parent: parent.id,
1826
+ });
1827
+ }
1828
+ this.emitMember(room, extension);
1829
+ return {
1830
+ ...event,
1831
+ parent: parent.id,
1832
+ ext_member: extension.id,
1833
+ ...(description !== undefined && { description }),
1834
+ };
1835
+ }
1836
+ endExtension(room, parent, event) {
1837
+ const existing = this.store.getExtensionByNativeId(room, parent.id, event.ext_member);
1838
+ if (!existing)
1839
+ return event;
1840
+ const extension = existing.state === 'dead'
1841
+ ? existing
1842
+ : this.store.updateMember(room, existing.id, { state: 'dead' });
1843
+ if (existing.state !== 'dead')
1844
+ this.emitMember(room, extension);
1845
+ return { ...event, ext_member: extension.id };
1846
+ }
1847
+ // harn:end extension-lifecycle-from-hooks
1848
+ // ── routing / fanout ──────────────────────────────────────────────────
1849
+ latestFinalizedAgentAuthor(room) {
1850
+ return this.store.latestFinalizedAgentAuthor(room);
1851
+ }
1852
+ dispatchCreatedDeliveries(room, created) {
1853
+ for (const delivery of created) {
1854
+ const recipient = this.store.getMember(room, delivery.recipient);
1855
+ // harn:assume agent-delivery-lifecycle-streams ref=delivery-created-emit
1856
+ // Agent recipients stream their queued frame too — a connected client's
1857
+ // seen tick starts honest instead of waiting for a reconnect snapshot.
1858
+ if (recipient !== undefined)
1859
+ this.emitInbox(room, delivery);
1860
+ // harn:end agent-delivery-lifecycle-streams
1861
+ if (recipient?.kind === 'agent') {
1862
+ if (delivery.group_id !== undefined &&
1863
+ (recipient.state === 'dead' || recipient.removed_ts !== undefined)) {
1864
+ this.skipUnavailableGroupDelivery(room, delivery);
1865
+ }
1866
+ else {
1867
+ this.dispatchAgentDelivery(room, delivery, recipient);
1868
+ }
1869
+ }
1870
+ }
1871
+ }
1872
+ // harn:assume agent-chains-uninterrupted-by-default ref=delivery-hop-brake-dispatch
1873
+ deliveryBrakeReason(room, delivery) {
1874
+ const config = this.store.getRoom(room).config;
1875
+ const meter = this.store.getMeter(room, new Date().toISOString().slice(0, 10));
1876
+ const verdict = evaluateBrakes(config, {
1877
+ consecutiveAgentDeliveries: Math.max(0, (delivery.hop_count ?? 0) - 1),
1878
+ spendTodayUsd: meter?.cost_usd ?? 0,
1879
+ });
1880
+ if (!verdict.hold)
1881
+ return undefined;
1882
+ return verdict.reason === 'turn_brake'
1883
+ ? `turn brake before hop ${delivery.hop_count ?? 0}`
1884
+ : `spend brake at $${(meter?.cost_usd ?? 0).toFixed(2)}`;
1885
+ }
1886
+ dispatchAgentDelivery(room, delivery, recipient) {
1887
+ const reason = this.deliveryBrakeReason(room, delivery);
1888
+ if (reason) {
1889
+ this.holdDelivery(room, delivery.id, reason);
1890
+ return;
1891
+ }
1892
+ this.queueAgentDelivery(room, recipient);
1893
+ }
1894
+ // harn:end agent-chains-uninterrupted-by-default
1895
+ // harn:assume mirrored-deliveries-queue ref=mirrored-custody-hold
1896
+ queueAgentDelivery(room, recipient) {
1897
+ const mirrored = recipient.custody === 'mirrored';
1898
+ const remoteUnreachable = this.isRemoteMember(recipient) &&
1899
+ !this.residency?.isReachable(recipient.host);
1900
+ // harn:assume inflight-member-state-survives-new-delivery ref=preserve-live-state-on-queue
1901
+ const hasLiveTurn = this.inflight.has(recipient.id) &&
1902
+ (recipient.state === 'running' || recipient.state === 'awaiting_input');
1903
+ const preservesState = hasLiveTurn ||
1904
+ recipient.state === 'paused' ||
1905
+ recipient.state === 'dead' ||
1906
+ recipient.state === 'custody_uncertain';
1907
+ // harn:end inflight-member-state-survives-new-delivery
1908
+ const member = this.store.updateMember(room, recipient.id, {
1909
+ state: preservesState ? recipient.state : remoteUnreachable ? 'unreachable' : 'queued',
1910
+ });
1911
+ this.emitMember(room, member);
1912
+ if (mirrored) {
1913
+ const queued = this.store.listDeliveries(room, {
1914
+ recipient: recipient.id,
1915
+ state: 'queued',
1916
+ }).length;
1917
+ if (queued === 1) {
1918
+ this.postSystemMessage(room, `@${recipient.handle} is mirrored in an operator terminal; 1 delivery is queued`);
1919
+ }
1920
+ return;
1921
+ }
1922
+ if (remoteUnreachable)
1923
+ return;
1924
+ this.track(this.maybeStartTurn(room, recipient.id));
1925
+ }
1926
+ // harn:end mirrored-deliveries-queue
1927
+ planFanout(room, message, triggerAuthor, agentHop, awaitingReply = false) {
1928
+ const members = this.store.listMembers(room);
1929
+ const author = members.find((m) => m.id === message.author);
1930
+ const result = resolveRecipients(message, {
1931
+ members,
1932
+ author,
1933
+ repliedTo: message.reply_to !== undefined ? this.store.getMessage(room, message.reply_to) : undefined,
1934
+ latestFinalizedAgentAuthor: this.latestFinalizedAgentAuthor(room),
1935
+ roomConfig: this.store.getRoom(room).config,
1936
+ triggerAuthor,
1937
+ });
1938
+ const recipients = [...result.agents, ...result.humans];
1939
+ const fanout = [
1940
+ ...result.humans.map((human) => ({ recipient: human.id, state: 'consumed' })),
1941
+ ...result.agents.map((agent) => ({
1942
+ recipient: agent.id,
1943
+ state: 'queued',
1944
+ payload_snapshot: this.snapshotPayload(room, message, agent, recipients, awaitingReply),
1945
+ hop_count: agentHop ?? (author?.kind === 'agent' ? 1 : 0),
1946
+ })),
1947
+ ];
1948
+ return { result, fanout };
1949
+ }
1950
+ planRoutedMessage(room, message, triggerAuthor, agentHop, awaitingReply = false, allowGroup = true) {
1951
+ const planned = this.planFanout(room, message, triggerAuthor, agentHop, awaitingReply);
1952
+ const base = {
1953
+ fanout: planned.fanout,
1954
+ ...(planned.result.misaddressed && { markMisaddressed: true }),
1955
+ };
1956
+ if (!allowGroup || planned.result.agents.length < 2) {
1957
+ return { result: planned.result, plan: base };
1958
+ }
1959
+ const groupId = ulid();
1960
+ const context = this.groupPayloadContext(room, message, groupId, 1);
1961
+ const humanIds = new Set(planned.result.humans.map((member) => member.id));
1962
+ const agentFanout = new Map(planned.fanout
1963
+ .filter((delivery) => !humanIds.has(delivery.recipient))
1964
+ .map((delivery) => [delivery.recipient, delivery]));
1965
+ return {
1966
+ result: planned.result,
1967
+ plan: {
1968
+ ...base,
1969
+ fanout: planned.fanout.filter((delivery) => humanIds.has(delivery.recipient)),
1970
+ collaboration: {
1971
+ groupId,
1972
+ participants: planned.result.agents.map((agent) => ({
1973
+ memberId: agent.id,
1974
+ payloadSnapshot: this.groupPayloadSnapshot(composeGroupRoundPayload(context, agent.handle)),
1975
+ state: 'queued',
1976
+ hopCount: agentFanout.get(agent.id)?.hop_count,
1977
+ })),
1978
+ },
1979
+ },
1980
+ };
1981
+ }
1982
+ groupPayloadContext(room, root, groupId, roundNumber) {
1983
+ const author = this.store.getMember(room, root.author);
1984
+ if (!author)
1985
+ throw new Error(`group root #${root.id} has no author`);
1986
+ return {
1987
+ groupId,
1988
+ roundNumber,
1989
+ room,
1990
+ root: {
1991
+ messageId: root.id,
1992
+ authorHandle: author.handle,
1993
+ body: root.body,
1994
+ },
1995
+ refs: root.refs
1996
+ .map((id) => this.store.getMessage(room, id))
1997
+ .filter((ref) => ref !== undefined)
1998
+ .map((ref) => ({
1999
+ id: ref.id,
2000
+ authorHandle: this.store.getMember(room, ref.author)?.handle ?? 'unknown',
2001
+ ts: ref.ts,
2002
+ body: this.runRefBody(ref),
2003
+ })),
2004
+ ledgerRefs: this.ledger?.resolve(room, root.ledger_refs),
2005
+ };
2006
+ }
2007
+ // harn:assume run-failure-evidence-is-surfaced ref=run-ref-error-evidence
2008
+ /**
2009
+ * A referenced run's quotable content: its reply text, else its failure
2010
+ * evidence (labeled so consumers know it is evidence, not a reply), else
2011
+ * its body. Keeps refs to failed/interrupted runs from resolving empty.
2012
+ */
2013
+ runRefBody(ref) {
2014
+ if (ref.kind !== 'run')
2015
+ return ref.body;
2016
+ const finalText = ref.run?.final_text;
2017
+ if (finalText !== undefined && finalText !== '')
2018
+ return finalText;
2019
+ const error = ref.run?.error;
2020
+ if (error !== undefined && error !== '') {
2021
+ return `[run ${ref.run?.status ?? 'failed'}] ${error}`;
2022
+ }
2023
+ return ref.body;
2024
+ }
2025
+ // harn:end run-failure-evidence-is-surfaced
2026
+ // harn:assume collaboration-round-release-is-one-barrier ref=group-payload-snapshot-integration
2027
+ groupPayloadSnapshot(payload) {
2028
+ return JSON.stringify({ kind: 'group', payload });
2029
+ }
2030
+ // harn:end collaboration-round-release-is-one-barrier
2031
+ // harn:assume delivery-payload-snapshotted ref=daemon-payload-snapshot
2032
+ snapshotPayload(room, message, recipient, recipients, awaitingReply = false) {
2033
+ const author = this.store.getMember(room, message.author);
2034
+ const recipientIds = new Set(recipients.map((member) => member.id));
2035
+ const toHandles = [
2036
+ ...new Set(message.mentions
2037
+ .filter((span) => recipientIds.has(span.member_id))
2038
+ .map((span) => this.store.getMember(room, span.member_id)?.handle)
2039
+ .filter((handle) => handle !== undefined)),
2040
+ ];
2041
+ if (toHandles.length === 0)
2042
+ toHandles.push(...recipients.map((member) => member.handle));
2043
+ const refs = message.refs
2044
+ .map((id) => this.store.getMessage(room, id))
2045
+ .filter((ref) => ref !== undefined)
2046
+ .map((ref) => ({
2047
+ id: ref.id,
2048
+ author_handle: this.store.getMember(room, ref.author)?.handle ?? 'unknown',
2049
+ ts: ref.ts,
2050
+ body: this.runRefBody(ref),
2051
+ }));
2052
+ const ledgerRefs = this.ledger?.resolve(room, message.ledger_refs) ?? [];
2053
+ const snapshot = {
2054
+ context: {
2055
+ room,
2056
+ message,
2057
+ authorHandle: author.handle,
2058
+ authorKind: author.kind,
2059
+ toHandles,
2060
+ refs,
2061
+ ledgerRefs,
2062
+ // harn:assume awaiting-reply-marker-is-delivery-context ref=awaiting-reply-snapshot
2063
+ ...(awaitingReply && { awaitingReply: true }),
2064
+ // harn:end awaiting-reply-marker-is-delivery-context
2065
+ },
2066
+ you: recipient.handle,
2067
+ };
2068
+ return JSON.stringify(snapshot);
2069
+ }
2070
+ // harn:end delivery-payload-snapshotted
2071
+ // ── the turn pump ─────────────────────────────────────────────────────
2072
+ // harn:assume one-inflight-turn-per-member ref=inflight-guard
2073
+ /**
2074
+ * At most ONE turn in flight per member: deliveries landing while a
2075
+ * member runs stay queued and are drained as one batched turn when the
2076
+ * member goes idle again (the pump re-runs itself after finalize).
2077
+ */
2078
+ // harn:assume turn-start-requires-owned-custody ref=shared-turn-start-custody-gate
2079
+ turnStartEligibility(room, memberId) {
2080
+ const member = this.store.getMember(room, memberId);
2081
+ if (!member || member.kind !== 'agent')
2082
+ return { refusal: `no such agent member: ${memberId}` };
2083
+ if (this.closing)
2084
+ return { refusal: 'the daemon is closing' };
2085
+ if (this.inflight.has(memberId))
2086
+ return { refusal: `member @${member.handle} already has a turn in flight` };
2087
+ if (this.pendingAttach.has(memberId))
2088
+ return { refusal: `member @${member.handle} has an attach acquisition pending` };
2089
+ if (member.custody !== 'owned')
2090
+ return { refusal: `member @${member.handle} is not switchboard-owned` };
2091
+ if (this.isRemoteMember(member) && !this.residency?.isReachable(member.host)) {
2092
+ return { refusal: `member @${member.handle} resident switchboard is unreachable` };
2093
+ }
2094
+ if (member.state === 'paused' || member.state === 'dead' || member.state === 'custody_uncertain') {
2095
+ return { refusal: `member @${member.handle} is ${member.state}` };
2096
+ }
2097
+ return { member };
2098
+ }
2099
+ // harn:end turn-start-requires-owned-custody
2100
+ // harn:assume brakes-rechecked-at-turn-start ref=turn-start-brake-recheck
2101
+ applyTurnStartBrakes(room, batch, atomic) {
2102
+ const braked = batch
2103
+ .filter((delivery) => (delivery.hop_count ?? 0) > 0 && !this.releasedDeliveries.has(delivery.id))
2104
+ .map((delivery) => ({ delivery, reason: this.deliveryBrakeReason(room, delivery) }))
2105
+ .filter((item) => item.reason !== undefined);
2106
+ if (braked.length === 0) {
2107
+ for (const delivery of batch)
2108
+ this.releasedDeliveries.delete(delivery.id);
2109
+ return batch;
2110
+ }
2111
+ if (atomic) {
2112
+ const reason = braked[0].reason;
2113
+ for (const delivery of batch)
2114
+ this.holdDelivery(room, delivery.id, reason);
2115
+ return [];
2116
+ }
2117
+ const heldIds = new Set(braked.map(({ delivery }) => delivery.id));
2118
+ for (const { delivery, reason } of braked)
2119
+ this.holdDelivery(room, delivery.id, reason);
2120
+ const runnable = batch.filter((delivery) => !heldIds.has(delivery.id));
2121
+ for (const delivery of runnable)
2122
+ this.releasedDeliveries.delete(delivery.id);
2123
+ return runnable;
2124
+ }
2125
+ async maybeStartTurn(room, memberId) {
2126
+ const eligible = this.turnStartEligibility(room, memberId);
2127
+ if (!eligible.member)
2128
+ return; // holds its queue; the room shows the backlog
2129
+ const member = eligible.member;
2130
+ const queued = this.store.listDeliveries(room, { recipient: memberId, state: 'queued' });
2131
+ if (queued.length === 0)
2132
+ return;
2133
+ // harn:assume grouped-deliveries-have-an-isolated-batch-class ref=group-batch-pump-integration
2134
+ const selected = selectDeliveryBatchPrefix(queued);
2135
+ const batch = this.applyTurnStartBrakes(room, selected, selected[0]?.group_id !== undefined);
2136
+ // harn:end grouped-deliveries-have-an-isolated-batch-class
2137
+ if (batch.length === 0) {
2138
+ const current = this.store.getMember(room, memberId);
2139
+ if (current?.state === 'queued')
2140
+ this.emitMember(room, this.store.updateMember(room, memberId, { state: 'idle' }));
2141
+ return;
2142
+ }
2143
+ this.inflight.add(memberId);
2144
+ try {
2145
+ await this.runTurn(room, member, batch);
2146
+ }
2147
+ finally {
2148
+ this.inflight.delete(memberId);
2149
+ }
2150
+ await this.maybeStartTurn(room, memberId); // drain anything queued meanwhile
2151
+ }
2152
+ // harn:end brakes-rechecked-at-turn-start
2153
+ // harn:end one-inflight-turn-per-member
2154
+ async runTurn(room, member, batch, reuseRunMsg) {
2155
+ // harn:assume turns-reuse-one-root-and-append-output-messages ref=run-message-lifecycle
2156
+ // Exactly one lifecycle root owns the turn, its input custody, and journal.
2157
+ // A crash retry reuses it; only later visible stretches may append rows.
2158
+ const originalStates = new Map(batch.map((delivery) => [delivery.id, delivery.state]));
2159
+ const started = this.store.beginTurn(room, {
2160
+ memberId: member.id,
2161
+ deliveryIds: batch.map((delivery) => delivery.id),
2162
+ startedTs: new Date().toISOString(),
2163
+ model: member.model,
2164
+ eventsRef: (messageId) => this.blobs.ref(messageId),
2165
+ reuseRunMsgId: reuseRunMsg?.id,
2166
+ });
2167
+ // harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-start-with-nothing-admissible
2168
+ // Everything in the batch was consumed between selection and admission — the member
2169
+ // was removed, or the work was taken by something else. There is nothing to say, and
2170
+ // an empty run message would be a defect of its own. Idle the member and stop.
2171
+ if (!started) {
2172
+ const current = this.store.getMember(room, member.id);
2173
+ if (current !== undefined && current.state === 'queued') {
2174
+ this.emitMember(room, this.store.updateMember(room, member.id, { state: 'idle' }));
2175
+ }
2176
+ return;
2177
+ }
2178
+ // harn:end only-an-admissible-delivery-becomes-delivering
2179
+ const runMsg = started.runMessage;
2180
+ this.emitMessage(room, runMsg);
2181
+ this.noteRunActivity(room, runMsg.id);
2182
+ // harn:end turns-reuse-one-root-and-append-output-messages
2183
+ // harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
2184
+ // The journal position of the NEXT appended event. A reconciled retry
2185
+ // reuses the run message, so its blob may already carry lines.
2186
+ const existingJournal = reuseRunMsg !== undefined
2187
+ ? this.blobs.read(room, runMsg.run.events_ref)
2188
+ : [];
2189
+ let journalIndex = existingJournal.length;
2190
+ const continuation = new ContinuationWriter(runMsg.id, existingJournal);
2191
+ // harn:end run-events-merge-by-journal-index
2192
+ // harn:assume delivery-attempt-wal-reconcile ref=wal-bind-before-spawn
2193
+ // Attempt WAL: bind every batched delivery to the run message and count
2194
+ // the attempt BEFORE the adapter spawns — consumption happens only when
2195
+ // run.completed lands, so a crash leaves reconcilable evidence.
2196
+ const bound = started.deliveries;
2197
+ // harn:end delivery-attempt-wal-reconcile
2198
+ // harn:assume agent-delivery-lifecycle-streams ref=delivery-bound-emit
2199
+ // Every bound delivery whose state moved (queued/held -> delivering)
2200
+ // streams its transition — formerly only releases out of held did.
2201
+ for (const delivery of bound) {
2202
+ if (originalStates.get(delivery.id) !== delivery.state)
2203
+ this.emitInbox(room, delivery);
2204
+ }
2205
+ // harn:end agent-delivery-lifecycle-streams
2206
+ const payload = this.composeBatchPayload(room, member, bound);
2207
+ this.emitMember(room, this.store.updateMember(room, member.id, { state: 'running' }));
2208
+ const remote = this.isRemoteMember(member);
2209
+ const adapter = remote ? undefined : this.requireAdapter(member.harness);
2210
+ const session = remote ? undefined : this.sessionFor(room, member);
2211
+ let completion;
2212
+ let toolCalls = 0;
2213
+ const pendingExtensionDescriptions = [];
2214
+ try {
2215
+ const triggerMsg = bound.at(-1)?.message_id ?? runMsg.id;
2216
+ const events = remote
2217
+ ? this.residency.deliver(member.host, {
2218
+ rpc_id: this.remoteRpcId(room, runMsg.id),
2219
+ room,
2220
+ member: remoteMemberSpec(member),
2221
+ payload,
2222
+ trigger_msg: triggerMsg,
2223
+ }, {
2224
+ lastEventIndex: this.blobs.read(room, runMsg.run.events_ref).length - 1,
2225
+ onSessionRef: (sessionRef) => {
2226
+ const persisted = this.store.getMember(room, member.id);
2227
+ if (persisted?.session_ref === sessionRef)
2228
+ return;
2229
+ this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: sessionRef }));
2230
+ },
2231
+ })
2232
+ : adapter.deliver(session, payload, {
2233
+ // harn:assume attempt-start-evidence-persisted ref=attempt-start-evidence
2234
+ onStarted: (process) => {
2235
+ this.noteRunActivity(room, runMsg.id);
2236
+ this.store.setDeliveryAttemptProcess(room, bound.map((delivery) => delivery.id), process);
2237
+ const startedEvent = {
2238
+ type: 'run.started',
2239
+ member: member.id,
2240
+ trigger_msg: triggerMsg,
2241
+ };
2242
+ this.blobs.append(room, runMsg.run.events_ref, startedEvent);
2243
+ // harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
2244
+ this.emit(room, {
2245
+ type: 'run_event',
2246
+ room,
2247
+ message_id: runMsg.id,
2248
+ event: startedEvent,
2249
+ index: journalIndex++,
2250
+ });
2251
+ // harn:end run-events-merge-by-journal-index
2252
+ },
2253
+ onSessionRef: (sessionRef) => {
2254
+ const persisted = this.store.getMember(room, member.id);
2255
+ if (persisted?.session_ref === sessionRef)
2256
+ return;
2257
+ this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: sessionRef }));
2258
+ },
2259
+ // harn:end attempt-start-evidence-persisted
2260
+ });
2261
+ for await (const event of events) {
2262
+ this.noteRunActivity(room, runMsg.id);
2263
+ let journalEvent = event;
2264
+ if (event.type === 'run.item') {
2265
+ // harn:assume member-status-is-bounded-and-identity-safe ref=run-item-journal-timestamp
2266
+ // Every run item is journaled with a wall-clock ts — prose (text_delta,
2267
+ // reasoning) included — so transcript blocks can be ordered by true time,
2268
+ // not only tool events. One path, no per-type branching.
2269
+ journalEvent = { ...event, ts: new Date().toISOString() };
2270
+ // harn:end member-status-is-bounded-and-identity-safe
2271
+ if (event.item_type === 'tool_call')
2272
+ toolCalls++;
2273
+ const description = extensionDescription(event);
2274
+ if (description !== undefined)
2275
+ pendingExtensionDescriptions.push(description);
2276
+ }
2277
+ else if (event.type === 'extension.started') {
2278
+ journalEvent = this.startExtension(room, member, event, pendingExtensionDescriptions.shift());
2279
+ }
2280
+ else if (event.type === 'extension.ended') {
2281
+ journalEvent = this.endExtension(room, member, event);
2282
+ }
2283
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
2284
+ // Live usage is member runtime state: broadcast it, but do not append it
2285
+ // to the durable run journal or change log.
2286
+ if (event.type === 'usage_updated') {
2287
+ this.landContextWindow(room, member.id, event.usage);
2288
+ // Keyed by bare member id like every sibling per-member map (ULIDs
2289
+ // never repeat, so no cross-room collision). Skip the re-broadcast
2290
+ // when the snapshot is unchanged — a full member frame per identical
2291
+ // usage report is pure fanout waste.
2292
+ if (!isDeepStrictEqual(this.lastUsage.get(member.id), event.usage)) {
2293
+ this.lastUsage.set(member.id, { ...event.usage });
2294
+ const current = this.store.getMember(room, member.id);
2295
+ if (current !== undefined)
2296
+ this.emitMember(room, current);
2297
+ }
2298
+ continue;
2299
+ }
2300
+ // harn:end last-agent-usage-is-transient-and-seeded
2301
+ // harn:assume agent-usage-limits-reported-not-guessed ref=member-limits-persisted
2302
+ // Limits are member status, not run content: land the harness's report
2303
+ // on the member row and stream the member frame — nothing is journaled.
2304
+ if (event.type === 'run.limits') {
2305
+ this.landMemberLimits(room, member.id, event.limits);
2306
+ continue;
2307
+ }
2308
+ // harn:end agent-usage-limits-reported-not-guessed
2309
+ // harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-writer-engine
2310
+ // Allocate+insert before the id becomes journal truth. The empty row is
2311
+ // streamed before its first targeted event, so readers never observe an
2312
+ // event whose permanent message does not exist yet.
2313
+ let allocated;
2314
+ const assigned = continuation.assign(journalEvent, this.store.latestMessageId(room), () => {
2315
+ allocated = this.store.createRunContinuation(room, runMsg.id);
2316
+ return allocated.id;
2317
+ });
2318
+ journalEvent = assigned.event;
2319
+ if (allocated !== undefined)
2320
+ this.emitMessage(room, allocated);
2321
+ this.blobs.append(room, runMsg.run.events_ref, journalEvent);
2322
+ // harn:end continuation-writer-follows-journaled-output-ownership
2323
+ // harn:assume run-events-merge-by-journal-index ref=daemon-journal-index-stamp
2324
+ // Stamp the frame with the position this event just took in the
2325
+ // journal, so a viewer who joined mid-run merges exactly.
2326
+ const stampedIndex = journalIndex++;
2327
+ // harn:assume compaction-timeline-items-are-durable-run-evidence ref=compaction-journal-fanout
2328
+ if (journalEvent.type === 'run.started' ||
2329
+ journalEvent.type === 'run.item' ||
2330
+ journalEvent.type === 'timeline' ||
2331
+ journalEvent.type === 'extension.started' ||
2332
+ journalEvent.type === 'extension.ended') {
2333
+ this.emit(room, {
2334
+ type: 'run_event',
2335
+ room,
2336
+ message_id: runMsg.id,
2337
+ event: journalEvent,
2338
+ index: stampedIndex,
2339
+ });
2340
+ }
2341
+ // harn:end compaction-timeline-items-are-durable-run-evidence
2342
+ // harn:end run-events-merge-by-journal-index
2343
+ if (event.type === 'ask.raised' || event.type === 'approval.raised') {
2344
+ this.handleInteractionRaised(room, member, event.card, event.type === 'ask.raised' ? 'ask' : 'approval');
2345
+ }
2346
+ else if (journalEvent.type === 'run.completed') {
2347
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-runtime-registry
2348
+ if (journalEvent.agent_usage !== undefined) {
2349
+ this.lastUsage.set(member.id, { ...journalEvent.agent_usage });
2350
+ this.landContextWindow(room, member.id, journalEvent.agent_usage);
2351
+ }
2352
+ // harn:end last-agent-usage-is-transient-and-seeded
2353
+ // harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
2354
+ completion = {
2355
+ status: journalEvent.status,
2356
+ final_text: journalEvent.final_text,
2357
+ error: journalEvent.error,
2358
+ usage: journalEvent.usage,
2359
+ };
2360
+ // harn:end failed-run-details-never-route-as-replies
2361
+ }
2362
+ }
2363
+ }
2364
+ catch (error) {
2365
+ if (error instanceof RemoteAttemptAmbiguousError) {
2366
+ this.holdAmbiguousTurn(room, member, bound, runMsg.id, 'resident reported ambiguous');
2367
+ return;
2368
+ }
2369
+ // harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
2370
+ completion = completion ?? {
2371
+ status: 'failed',
2372
+ error: error instanceof Error ? error.message : String(error),
2373
+ };
2374
+ // harn:end failed-run-details-never-route-as-replies
2375
+ }
2376
+ if (session?.session_ref !== undefined &&
2377
+ session.session_ref !== this.store.getMember(room, member.id)?.session_ref) {
2378
+ this.emitMember(room, this.store.updateMember(room, member.id, { session_ref: session.session_ref }));
2379
+ }
2380
+ // harn:assume operator-interrupt-not-failure ref=interrupt-failure-classification
2381
+ const operatorInterrupted = this.operatorInterrupts.delete(member.id);
2382
+ if (operatorInterrupted && completion?.status === 'failed') {
2383
+ completion = { ...completion, status: 'interrupted' };
2384
+ }
2385
+ // harn:end operator-interrupt-not-failure
2386
+ // harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
2387
+ // Some harnesses report a SIGINT as a generic failure. Once close() has
2388
+ // captured lifecycle cause, classify that native exit as interrupted so it
2389
+ // reaches the atomic retry-or-terminal settlement instead of killing the
2390
+ // member and consuming the instruction as an operator result.
2391
+ if (this.lifecycleInterrupts.has(member.id) && completion?.status === 'failed') {
2392
+ completion = { ...completion, status: 'interrupted' };
2393
+ }
2394
+ // harn:end lifecycle-retries-only-live-collaboration-work
2395
+ // harn:assume failed-finalization-reconciles-at-runtime ref=runtime-finalization-reconcile
2396
+ // The engine's journal is already terminal here. If normal finalization
2397
+ // cannot commit — a lifecycle retry rebound to a delivery whose participant
2398
+ // already holds a result — the rollback used to leave the row running and
2399
+ // the delivery delivering while the in-memory guard cleared, so nothing
2400
+ // noticed until a human went looking. Reconcile immediately instead.
2401
+ const finalCompletion = completion ?? { status: 'interrupted' };
2402
+ const lifecycleSettlement = finalCompletion.status === 'interrupted'
2403
+ && this.lifecycleInterrupts.has(member.id);
2404
+ if (!lifecycleSettlement && this.normalFinalizationIsIllegal(room, bound)) {
2405
+ // Known-settled work never attempts normal completion: completeTurn would
2406
+ // roll back anyway, and for a closed group it might not even refuse.
2407
+ this.reconcileFailedFinalization(room, member.id, runMsg.id, bound, finalCompletion, new Error('its collaboration work was already settled'));
2408
+ return;
2409
+ }
2410
+ try {
2411
+ this.finalizeTurn(room, member.id, runMsg.id, finalCompletion, bound, toolCalls);
2412
+ }
2413
+ catch (error) {
2414
+ this.reconcileFailedFinalization(room, member.id, runMsg.id, bound, finalCompletion, error);
2415
+ }
2416
+ // harn:end failed-finalization-reconciles-at-runtime
2417
+ }
2418
+ // harn:assume failed-finalization-reconciles-at-runtime ref=runtime-finalization-reconcile
2419
+ /**
2420
+ * The one repair both runtime and boot use. It never routes and never
2421
+ * replaces a participant result: it makes the durable state honest, streams
2422
+ * the repaired rows, and says so once. A second pass finds nothing to do.
2423
+ */
2424
+ reconcileFailedFinalization(room, memberId, runMsgId, batch, completion, error) {
2425
+ const detail = error instanceof Error ? error.message : String(error);
2426
+ const runMsg = this.store.getMessage(room, runMsgId);
2427
+ if (runMsg?.run === undefined)
2428
+ throw new Error(`turn #${runMsgId} has no lifecycle root`);
2429
+ const projection = projectContinuationOutputs(runMsgId, this.blobs.read(room, runMsg.run.events_ref));
2430
+ const accounting = accountRunUsage(runMsg.run, completion?.usage);
2431
+ const repaired = this.store.repairFailedFinalization(room, {
2432
+ runMsgId,
2433
+ memberId,
2434
+ deliveryIds: batch.map((delivery) => delivery.id),
2435
+ outputs: this.outputPatches(room, runMsg, projection, false, false),
2436
+ error: `finalization could not commit: ${detail}`,
2437
+ endedTs: new Date().toISOString(),
2438
+ usage: completion?.usage,
2439
+ estimatedCostUsd: accounting.estimatedCostUsd,
2440
+ meterDay: new Date().toISOString().slice(0, 10),
2441
+ meterDelta: {
2442
+ turns: 1,
2443
+ cost_usd: accounting.costUsd,
2444
+ estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
2445
+ input_tokens: completion?.usage?.input_tokens ?? 0,
2446
+ output_tokens: completion?.usage?.output_tokens ?? 0,
2447
+ uncosted_tokens: accounting.uncostedTokens,
2448
+ },
2449
+ });
2450
+ if (!repaired.repaired) {
2451
+ // The run is already terminal, so the Store transaction DID commit and
2452
+ // something after it threw — an emit or barrier step. That is a real
2453
+ // failure with nothing to repair, and swallowing it here would hide it
2454
+ // from background error reporting and from later reconciliation.
2455
+ throw error instanceof Error ? error : new Error(detail);
2456
+ }
2457
+ for (const output of repaired.outputMessages ?? [])
2458
+ this.emitMessage(room, output);
2459
+ if (repaired.member !== undefined)
2460
+ this.emitMember(room, repaired.member);
2461
+ if (repaired.meter !== undefined) {
2462
+ this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: repaired.meter });
2463
+ }
2464
+ for (const delivery of repaired.deliveries)
2465
+ this.emitInbox(room, delivery);
2466
+ if (repaired.notice !== undefined)
2467
+ this.emitMessage(room, repaired.notice);
2468
+ this.memberWaits.delete(memberId);
2469
+ this.groupWaits.delete(memberId);
2470
+ this.retireTerminalRunRuntime(room, memberId, runMsgId);
2471
+ }
2472
+ // harn:assume collaboration-lifecycle-interruption-is-nonterminal ref=lifecycle-collaboration-finalization
2473
+ /**
2474
+ * Commit the one lifecycle settlement and stream what it changed. Nothing is
2475
+ * routed: an interrupted attempt produced no answer to deliver, and its retry
2476
+ * (if admitted) will produce the one result the round is still waiting for.
2477
+ */
2478
+ settleLifecycleInterruptedTurn(room, memberId, runMsgId, batch, messagePatch, endedTs, preserveOtherActiveTurn = false) {
2479
+ const current = this.store.getMember(room, memberId);
2480
+ const usage = messagePatch.run?.usage;
2481
+ const accounting = accountRunUsage(messagePatch.run ?? {}, usage);
2482
+ const runMsg = this.store.getMessage(room, runMsgId);
2483
+ if (runMsg?.run === undefined)
2484
+ throw new Error(`turn #${runMsgId} has no lifecycle root`);
2485
+ const projection = projectContinuationOutputs(runMsgId, this.blobs.read(room, runMsg.run.events_ref));
2486
+ const outputs = this.outputPatches(room, runMsg, projection, false, false);
2487
+ const settlement = this.store.settleLifecycleInterruption(room, {
2488
+ runMsgId,
2489
+ memberId,
2490
+ deliveryIds: batch.map((delivery) => delivery.id),
2491
+ message: messagePatch,
2492
+ outputs,
2493
+ memberPatch: {
2494
+ state: preserveOtherActiveTurn
2495
+ ? (current?.state ?? 'running')
2496
+ : current?.state === 'dead' || current?.state === 'paused'
2497
+ ? current.state
2498
+ : 'idle',
2499
+ },
2500
+ endedTs,
2501
+ attemptCeiling: RECOVERY_ATTEMPT_CEILING,
2502
+ meterDay: endedTs.slice(0, 10),
2503
+ meterDelta: {
2504
+ turns: 1,
2505
+ cost_usd: accounting.costUsd,
2506
+ estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
2507
+ input_tokens: usage?.input_tokens ?? 0,
2508
+ output_tokens: usage?.output_tokens ?? 0,
2509
+ uncosted_tokens: accounting.uncostedTokens,
2510
+ },
2511
+ });
2512
+ if (!preserveOtherActiveTurn) {
2513
+ this.lifecycleInterrupts.delete(memberId);
2514
+ this.memberWaits.delete(memberId);
2515
+ this.groupWaits.delete(memberId);
2516
+ }
2517
+ for (const output of settlement.outputMessages)
2518
+ this.emitMessage(room, output);
2519
+ this.emitMember(room, settlement.member);
2520
+ this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: settlement.meter });
2521
+ for (const delivery of [...settlement.requeued, ...settlement.settled]) {
2522
+ this.emitInbox(room, delivery);
2523
+ }
2524
+ if (settlement.notice !== undefined)
2525
+ this.emitMessage(room, settlement.notice);
2526
+ const affectedRounds = new Map();
2527
+ for (const delivery of settlement.settled) {
2528
+ if (delivery.group_id === undefined || delivery.group_round === undefined)
2529
+ continue;
2530
+ affectedRounds.set(`${delivery.group_id}:${String(delivery.group_round)}`, {
2531
+ groupId: delivery.group_id,
2532
+ roundNumber: delivery.group_round,
2533
+ });
2534
+ }
2535
+ for (const { groupId, roundNumber } of affectedRounds.values()) {
2536
+ this.clearSatisfiedGroupWaits(room, groupId, roundNumber);
2537
+ this.advanceCollaborationRound(room, groupId, roundNumber);
2538
+ }
2539
+ if (preserveOtherActiveTurn) {
2540
+ this.runActivity.delete(`${room}:${runMsgId}`);
2541
+ }
2542
+ else {
2543
+ this.retireTerminalRunRuntime(room, memberId, runMsgId);
2544
+ }
2545
+ }
2546
+ // harn:end collaboration-lifecycle-interruption-is-nonterminal
2547
+ /**
2548
+ * True when this batch cannot take the normal finalization path, because its
2549
+ * collaboration work is already settled: a terminal participant, a closed
2550
+ * group, or a closed round. It asks the Store the SAME question repair asks —
2551
+ * a narrower check here would let a closed group accept a fresh result, since
2552
+ * `recordCollaborationParticipantTerminal` guards the participant alone.
2553
+ *
2554
+ * Checked proactively before normal completion at both boot and runtime; the
2555
+ * runtime catch stays for transaction failures this cannot foresee.
2556
+ */
2557
+ normalFinalizationIsIllegal(room, batch) {
2558
+ return batch.some((delivery) => this.store.collaborationWorkIsSettled(room, delivery.id));
2559
+ }
2560
+ // harn:end failed-finalization-reconciles-at-runtime
2561
+ /** Clear transient evidence that must not outlive any terminalized attempt. */
2562
+ retireTerminalRunRuntime(room, memberId, runMsgId) {
2563
+ this.runActivity.delete(`${room}:${runMsgId}`);
2564
+ for (const extension of this.store.listMembers(room)) {
2565
+ if (extension.kind !== 'extension' || extension.parent !== memberId || extension.state !== 'running')
2566
+ continue;
2567
+ this.emitMember(room, this.store.updateMember(room, extension.id, { state: 'dead' }));
2568
+ }
2569
+ }
2570
+ composeBatchPayload(room, recipient, batch) {
2571
+ const payloads = [];
2572
+ for (const delivery of batch) {
2573
+ const encoded = this.store.getDeliveryPayloadSnapshot(room, delivery.id);
2574
+ const fresh = this.store.getMember(room, recipient.id);
2575
+ const needsConventions = !fresh.conventions_sent || fresh.misaddressed;
2576
+ const needsRoster = fresh.roster_stale;
2577
+ // harn:assume grouped-deliveries-retain-agent-briefings ref=grouped-delivery-briefing
2578
+ const roster = needsRoster
2579
+ ? this.store.listMembers(room).map((member) => ({
2580
+ handle: member.handle,
2581
+ kind: member.kind,
2582
+ ...(member.purpose !== undefined && { purpose: member.purpose }),
2583
+ }))
2584
+ : undefined;
2585
+ const conventions = needsConventions
2586
+ ? {
2587
+ ledger: this.ledger?.isEnabled(room) ?? false,
2588
+ // harn:assume collaboration-briefing-is-capability-aware ref=collaboration-capability-context
2589
+ liveInbox: fresh.harness !== undefined &&
2590
+ this.adapters.get(fresh.harness)?.capabilities.live_inbox === true,
2591
+ // harn:end collaboration-briefing-is-capability-aware
2592
+ }
2593
+ : undefined;
2594
+ if (encoded !== undefined) {
2595
+ const candidate = JSON.parse(encoded);
2596
+ if ('kind' in candidate && candidate.kind === 'group') {
2597
+ payloads.push(candidate.payload + composeDeliveryBriefing({ roster, conventions }));
2598
+ if (needsRoster)
2599
+ this.store.clearAgentRosterStale(room, recipient.id);
2600
+ if (needsConventions) {
2601
+ this.emitMember(room, this.store.updateMember(room, recipient.id, {
2602
+ conventions_sent: true,
2603
+ misaddressed: false,
2604
+ }));
2605
+ }
2606
+ // harn:end grouped-deliveries-retain-agent-briefings
2607
+ continue;
2608
+ }
2609
+ }
2610
+ const snapshot = encoded
2611
+ ? JSON.parse(encoded)
2612
+ : JSON.parse(this.snapshotPayload(room, this.store.getMessage(room, delivery.message_id), recipient, [recipient]));
2613
+ const ctx = {
2614
+ ...snapshot.context,
2615
+ roster,
2616
+ conventions: needsConventions
2617
+ ? {
2618
+ ...conventions,
2619
+ untaggedGoesTo: snapshot.context.authorHandle,
2620
+ }
2621
+ : undefined,
2622
+ };
2623
+ payloads.push(composePayload(ctx, snapshot.you));
2624
+ if (needsRoster)
2625
+ this.store.clearAgentRosterStale(room, recipient.id);
2626
+ if (needsConventions) {
2627
+ this.emitMember(room, this.store.updateMember(room, recipient.id, { conventions_sent: true, misaddressed: false }));
2628
+ }
2629
+ }
2630
+ // Attachment path lines ride each delivery's text (outside the briefing
2631
+ // anchors), recomputed from the live message so they vanish on delete.
2632
+ return payloads
2633
+ .map((payload, index) => payload + this.attachmentPayloadLines(room, batch[index].message_id))
2634
+ .join('\n');
2635
+ }
2636
+ outputPatches(room, root, projection, ack, retainRootBody = true) {
2637
+ const members = this.store.listMembers(room);
2638
+ return [...projection.referencedMessageIds]
2639
+ .sort((left, right) => left - right)
2640
+ .map((id) => {
2641
+ const message = this.store.getMessage(room, id);
2642
+ if (message === undefined
2643
+ || (id !== root.id && message.run_parent_id !== root.id)) {
2644
+ throw new Error(`turn #${root.id} journal targets invalid output #${id}`);
2645
+ }
2646
+ const body = id === root.id && !retainRootBody
2647
+ ? ''
2648
+ : (projection.bodies.get(id) ?? '');
2649
+ const rowAck = ack && id === projection.resultMessageId;
2650
+ const parsed = rowAck
2651
+ ? { mentions: [], refs: [], ledger_refs: [] }
2652
+ : parseBody(body, members);
2653
+ return {
2654
+ id,
2655
+ body,
2656
+ mentions: parsed.mentions,
2657
+ refs: parsed.refs,
2658
+ ledger_refs: parsed.ledger_refs,
2659
+ ...(rowAck && { ack: true }),
2660
+ substantive: !ack && projection.substantiveMessageIds.has(id),
2661
+ };
2662
+ });
2663
+ }
2664
+ // harn:assume finalized-turn-routes-aggregate-from-terminal-output ref=finalize-and-route
2665
+ /**
2666
+ * Finalize every journal-owned output row while routing one complete aggregate
2667
+ * under the permanent terminal output id. The root alone owns lifecycle truth;
2668
+ * persisted continuation bodies remain their own chronological stretches.
2669
+ */
2670
+ finalizeTurn(room, memberId, runMsgId, completion, batch, toolCalls) {
2671
+ const runMsg = this.store.getMessage(room, runMsgId);
2672
+ // harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
2673
+ // harn:assume run-failure-evidence-is-surfaced ref=interrupted-error-evidence
2674
+ const failed = completion.status === 'failed';
2675
+ // The `?? completion.final_text` arm is LOAD-BEARING: codex/gemini/opencode/
2676
+ // copilot report failure detail in final_text, only claude uses error.
2677
+ // An operator interrupt can reclassify failed->interrupted after the
2678
+ // adapter already produced error detail — persist it there too, or the
2679
+ // "why" of the interrupt vanishes from every surface.
2680
+ const rawFailure = failed
2681
+ ? (completion.error ?? completion.final_text)
2682
+ : completion.status === 'interrupted'
2683
+ ? completion.error
2684
+ : undefined;
2685
+ const failure = rawFailure?.trim() === '' ? undefined : rawFailure;
2686
+ const journal = this.blobs.read(room, runMsg.run.events_ref);
2687
+ const projection = projectContinuationOutputs(runMsgId, journal);
2688
+ const projectedAggregate = [...projection.bodies]
2689
+ .sort(([left], [right]) => left - right)
2690
+ .map(([, text]) => text)
2691
+ .join('');
2692
+ // The journal projection is the one complete, normalized answer. Some
2693
+ // adapters report the whole response in final_text; others report only the
2694
+ // final suffix after streaming interim narration. Persisting the native
2695
+ // field directly would silently truncate the lifecycle aggregate in the
2696
+ // latter case even though every chronological row rendered correctly.
2697
+ const body = failed ? '' : projectedAggregate;
2698
+ // harn:end run-failure-evidence-is-surfaced
2699
+ // harn:end failed-run-details-never-route-as-replies
2700
+ // harn:assume substantive-routing-excludes-acknowledgements ref=exact-ack-finalization
2701
+ const ack = completion.status === 'completed' && body.trim() === '<ACK_OK>';
2702
+ const parsed = failed || ack
2703
+ ? { mentions: [], refs: [], ledger_refs: [], unresolved: [] }
2704
+ : parseBody(body, this.store.listMembers(room));
2705
+ const endedTs = new Date().toISOString();
2706
+ const accounting = accountRunUsage(runMsg.run, completion.usage);
2707
+ const outputPatches = this.outputPatches(room, runMsg, projection, ack, !failed);
2708
+ const resultMessageId = projection.resultMessageId;
2709
+ const messagePatch = {
2710
+ body: projection.bodies.get(runMsgId) ?? '',
2711
+ ...(ack && { ack: true }),
2712
+ mentions: parsed.mentions,
2713
+ refs: parsed.refs,
2714
+ ledger_refs: parsed.ledger_refs,
2715
+ run: {
2716
+ ...runMsg.run,
2717
+ status: completion.status,
2718
+ ended_ts: endedTs,
2719
+ stalled_since: undefined,
2720
+ tool_calls: toolCalls,
2721
+ usage: completion.usage,
2722
+ estimated_cost_usd: accounting.estimatedCostUsd,
2723
+ // harn:assume failed-run-details-never-route-as-replies ref=failed-run-finalization
2724
+ final_text: failed ? undefined : body,
2725
+ output_mode: 'messages',
2726
+ result_message_id: resultMessageId,
2727
+ error: failure,
2728
+ // harn:end failed-run-details-never-route-as-replies
2729
+ },
2730
+ };
2731
+ // harn:end substantive-routing-excludes-acknowledgements
2732
+ // harn:assume collaboration-lifecycle-interruption-is-nonterminal ref=lifecycle-collaboration-finalization
2733
+ // A turn THIS daemon's lifecycle interrupted is retryable work, not a
2734
+ // result. Settle the run evidence, the delivery's retry decision, and the
2735
+ // participant's barrier state as one fact — committing terminality first
2736
+ // and re-queueing afterwards is exactly what resurrected #598 into #599.
2737
+ // Operator Stop never reaches here: it is not marked as lifecycle.
2738
+ if (completion.status === 'interrupted' && this.lifecycleInterrupts.has(memberId)) {
2739
+ this.settleLifecycleInterruptedTurn(room, memberId, runMsgId, batch, messagePatch, endedTs);
2740
+ return;
2741
+ }
2742
+ // harn:end collaboration-lifecycle-interruption-is-nonterminal
2743
+ const resultRow = this.store.getMessage(room, resultMessageId);
2744
+ if (resultRow === undefined)
2745
+ throw new Error(`turn #${runMsgId} result #${resultMessageId} is missing`);
2746
+ const finalizedDraft = {
2747
+ ...resultRow,
2748
+ body,
2749
+ mentions: parsed.mentions,
2750
+ refs: parsed.refs,
2751
+ ledger_refs: parsed.ledger_refs,
2752
+ ...(ack && { ack: true }),
2753
+ run: messagePatch.run,
2754
+ };
2755
+ const lastDelivery = batch.at(-1);
2756
+ const triggerAuthor = lastDelivery
2757
+ ? this.store.getMessage(room, lastDelivery.message_id)?.author
2758
+ : undefined;
2759
+ // harn:assume batched-human-resets-hop-count ref=batched-onward-hop-reset
2760
+ const onwardHopCount = batch.length === 0
2761
+ ? 1
2762
+ : Math.min(...batch.map((delivery) => delivery.hop_count ?? 0)) + 1;
2763
+ // harn:end batched-human-resets-hop-count
2764
+ // harn:assume group-participant-terminality-commits-with-the-turn ref=collaboration-finalization-engine
2765
+ const groupedDelivery = batch.find((delivery) => delivery.group_id !== undefined);
2766
+ const planned = this.planRoutedMessage(room, finalizedDraft, triggerAuthor, onwardHopCount, false, groupedDelivery === undefined);
2767
+ const humanIds = new Set(planned.result.humans.map((human) => human.id));
2768
+ const fanout = groupedDelivery === undefined
2769
+ ? planned.plan.fanout
2770
+ : planned.plan.fanout.filter((delivery) => humanIds.has(delivery.recipient));
2771
+ const day = new Date().toISOString().slice(0, 10);
2772
+ const completed = this.store.completeTurn(room, {
2773
+ runMsgId,
2774
+ message: messagePatch,
2775
+ outputs: outputPatches,
2776
+ resultMessageId,
2777
+ inputDeliveryIds: batch.map((delivery) => delivery.id),
2778
+ memberId,
2779
+ memberPatch: {
2780
+ state: completion.status === 'failed'
2781
+ ? 'dead'
2782
+ : this.store.getMember(room, memberId)?.state === 'dead'
2783
+ ? 'dead'
2784
+ : this.store.getMember(room, memberId)?.state === 'paused'
2785
+ ? 'paused'
2786
+ : 'idle',
2787
+ ...(planned.result.misaddressed && { misaddressed: true }),
2788
+ },
2789
+ meterDay: day,
2790
+ meterDelta: {
2791
+ turns: 1,
2792
+ cost_usd: accounting.costUsd,
2793
+ estimated_cost_usd: accounting.estimatedCostUsd ?? 0,
2794
+ input_tokens: completion.usage?.input_tokens ?? 0,
2795
+ output_tokens: completion.usage?.output_tokens ?? 0,
2796
+ uncosted_tokens: accounting.uncostedTokens,
2797
+ },
2798
+ fanout,
2799
+ ...(groupedDelivery !== undefined && {
2800
+ participantTerminal: {
2801
+ deliveryId: groupedDelivery.id,
2802
+ status: completion.status,
2803
+ completedTs: endedTs,
2804
+ },
2805
+ }),
2806
+ ...(groupedDelivery === undefined && planned.plan.collaboration !== undefined && {
2807
+ collaboration: planned.plan.collaboration,
2808
+ }),
2809
+ });
2810
+ // harn:end group-participant-terminality-commits-with-the-turn
2811
+ // harn:assume live-agent-waits-are-transient ref=wait-clears-on-turn-end
2812
+ this.memberWaits.delete(memberId);
2813
+ this.groupWaits.delete(memberId);
2814
+ // harn:end live-agent-waits-are-transient
2815
+ for (const output of completed.outputMessages)
2816
+ this.emitMessage(room, output);
2817
+ this.emitMember(room, completed.member);
2818
+ // harn:assume agent-delivery-lifecycle-streams ref=delivery-consumed-emit
2819
+ // The turn just consumed its inputs — stream the settled rows so seen
2820
+ // ticks flip without a reconnect.
2821
+ for (const input of batch) {
2822
+ const settled = this.store.getDelivery(room, input.id);
2823
+ if (settled !== undefined)
2824
+ this.emitInbox(room, settled);
2825
+ }
2826
+ // harn:end agent-delivery-lifecycle-streams
2827
+ // harn:assume extensions-retire-with-parent-run ref=parent-finalization-extension-sweep
2828
+ for (const extension of this.store.listMembers(room)) {
2829
+ if (extension.kind !== 'extension' || extension.parent !== memberId || extension.state !== 'running')
2830
+ continue;
2831
+ this.emitMember(room, this.store.updateMember(room, extension.id, { state: 'dead' }));
2832
+ }
2833
+ // harn:end extensions-retire-with-parent-run
2834
+ this.emit(room, { type: 'meter', seq: this.store.currentSeq(room), meter: completed.meter });
2835
+ this.dispatchCreatedDeliveries(room, completed.deliveries);
2836
+ if (groupedDelivery?.group_id !== undefined && groupedDelivery.group_round !== undefined) {
2837
+ this.clearSatisfiedGroupWaits(room, groupedDelivery.group_id, groupedDelivery.group_round);
2838
+ this.advanceCollaborationRound(room, groupedDelivery.group_id, groupedDelivery.group_round);
2839
+ }
2840
+ this.runActivity.delete(`${room}:${runMsgId}`);
2841
+ if (completion.status === 'failed') {
2842
+ this.postSystemMessage(room, completed.member.session_ref
2843
+ ? `@${completed.member.handle} died mid-run (turn #${runMsgId} failed); revive to retry`
2844
+ : `@${completed.member.handle} died mid-run (turn #${runMsgId} failed); remove it and spawn a replacement`);
2845
+ }
2846
+ }
2847
+ // harn:end finalized-turn-routes-aggregate-from-terminal-output
2848
+ // harn:assume collaboration-round-release-is-one-barrier ref=collaboration-barrier-engine
2849
+ advanceCollaborationRound(room, groupId, roundNumber) {
2850
+ const projection = this.store.getCollaborationRoundProjection(room, groupId, roundNumber);
2851
+ if (!projection || projection.round.state !== 'collecting')
2852
+ return;
2853
+ if (projection.participants.some((participant) => participant.terminal_status === undefined))
2854
+ return;
2855
+ const root = this.store.getMessage(room, projection.group.root_message_id);
2856
+ if (!root)
2857
+ throw new Error(`collaboration group ${groupId} has no root message`);
2858
+ const results = [];
2859
+ const nextMembers = [];
2860
+ const seen = new Set();
2861
+ for (const participant of projection.participants) {
2862
+ const member = this.store.getMember(room, participant.member_id);
2863
+ const result = participant.result_message_id === undefined
2864
+ ? undefined
2865
+ : this.store.getMessage(room, participant.result_message_id);
2866
+ const resultRoot = result === undefined ? undefined : this.store.getRunRoot(room, result);
2867
+ const resultAck = result?.ack === true || resultRoot?.ack === true;
2868
+ const aggregateBody = resultRoot?.run?.final_text ?? result?.body ?? '';
2869
+ const aggregateParsed = parseBody(aggregateBody, this.store.listMembers(room));
2870
+ const status = participant.terminal_status === 'completed' && resultAck
2871
+ ? 'acknowledged'
2872
+ : participant.terminal_status;
2873
+ results.push({
2874
+ ordinal: participant.ordinal,
2875
+ memberHandle: member?.handle ?? participant.member_id,
2876
+ status,
2877
+ ...(result !== undefined && !resultAck && {
2878
+ messageId: result.id,
2879
+ // harn:assume run-failure-evidence-is-surfaced ref=round-result-error-evidence
2880
+ // A failed participant's body is empty by design; surface its run
2881
+ // error so peers see why the round member stopped.
2882
+ body: participant.terminal_status === 'completed'
2883
+ ? aggregateBody
2884
+ : this.runRefBody(resultRoot ?? result),
2885
+ // harn:end run-failure-evidence-is-surfaced
2886
+ }),
2887
+ });
2888
+ if (participant.terminal_status !== 'completed' || resultAck)
2889
+ continue;
2890
+ for (const mention of aggregateParsed.mentions) {
2891
+ if (mention.member_id === participant.member_id || seen.has(mention.member_id))
2892
+ continue;
2893
+ const recipient = this.store.getMember(room, mention.member_id);
2894
+ if (recipient?.kind !== 'agent' ||
2895
+ recipient.removed_ts !== undefined)
2896
+ continue;
2897
+ seen.add(recipient.id);
2898
+ nextMembers.push(recipient);
2899
+ }
2900
+ }
2901
+ const context = {
2902
+ ...this.groupPayloadContext(room, root, groupId, roundNumber + 1),
2903
+ priorRoundNumber: roundNumber,
2904
+ results,
2905
+ };
2906
+ const nextHop = Math.min(...projection.deliveries.map((delivery) => delivery.hop_count ?? 0)) + 1;
2907
+ const release = this.store.releaseCollaborationRound(room, {
2908
+ groupId,
2909
+ roundNumber,
2910
+ releasedTs: new Date().toISOString(),
2911
+ nextParticipants: nextMembers.map((member) => ({
2912
+ memberId: member.id,
2913
+ payloadSnapshot: this.groupPayloadSnapshot(composeGroupRoundPayload(context, member.handle)),
2914
+ state: 'queued',
2915
+ hopCount: nextHop,
2916
+ })),
2917
+ });
2918
+ if (release.status === 'released') {
2919
+ this.dispatchCreatedDeliveries(room, release.deliveries);
2920
+ }
2921
+ }
2922
+ // harn:end collaboration-round-release-is-one-barrier
2923
+ clearSatisfiedGroupWaits(room, groupId, roundNumber) {
2924
+ const participants = this.store.listCollaborationParticipants(room, groupId, roundNumber);
2925
+ const terminal = new Set(participants
2926
+ .filter((participant) => participant.terminal_status !== undefined)
2927
+ .map((participant) => participant.member_id));
2928
+ const participantIds = new Set(participants.map((participant) => participant.member_id));
2929
+ for (const [memberId, context] of this.groupWaits) {
2930
+ if (context.room !== room ||
2931
+ context.groupId !== groupId ||
2932
+ context.roundNumber !== roundNumber)
2933
+ continue;
2934
+ const waiting = this.memberWaits.get(memberId);
2935
+ if (!waiting ||
2936
+ !waiting.peers.every((peerId) => participantIds.has(peerId) && terminal.has(peerId)))
2937
+ continue;
2938
+ this.memberWaits.delete(memberId);
2939
+ this.groupWaits.delete(memberId);
2940
+ const member = this.store.getMember(room, memberId);
2941
+ if (member)
2942
+ this.emitMember(room, member);
2943
+ }
2944
+ }
2945
+ // harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-member-skip-engine
2946
+ skipUnavailableGroupDelivery(room, delivery) {
2947
+ if (delivery.group_id === undefined || delivery.group_round === undefined)
2948
+ return;
2949
+ const skipped = this.store.skipCollaborationParticipant(room, delivery.id, new Date().toISOString());
2950
+ if (delivery.state === 'held')
2951
+ this.emitInbox(room, skipped.delivery);
2952
+ this.clearSatisfiedGroupWaits(room, delivery.group_id, delivery.group_round);
2953
+ this.advanceCollaborationRound(room, delivery.group_id, delivery.group_round);
2954
+ }
2955
+ // harn:end open-collaboration-groups-reconcile-without-resurrection
2956
+ // ── interactions (PROTOCOL §2 state machine) ──────────────────────────
2957
+ // harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-orphan-daemon
2958
+ orphanInteraction(room, interaction) {
2959
+ const orphaned = this.store.orphanInteraction(room, interaction.id, new Date().toISOString());
2960
+ for (const delivery of orphaned.deliveries)
2961
+ this.emitInbox(room, delivery);
2962
+ return orphaned.interaction;
2963
+ }
2964
+ // harn:end approval-deliveries-project-resolution-separately
2965
+ handleInteractionRaised(room, member, card, kind) {
2966
+ const key = interactionKey(kind, card);
2967
+ const open = this.store
2968
+ .listInteractions(room)
2969
+ .filter((i) => i.member_id === member.id && (i.state === 'pending' || i.state === 'answered'));
2970
+ const match = open.find((i) => {
2971
+ const cardMsg = this.store.getMessage(room, i.message_id);
2972
+ return cardMsg?.ask !== undefined && interactionKey(i.kind, cardMsg.ask) === key;
2973
+ });
2974
+ if (match) {
2975
+ // Re-correlation after a crash: same semantic card, FRESH native id.
2976
+ const updated = this.store.upsertInteraction({ ...match, native_id: card.interaction_id });
2977
+ if (updated.state === 'answered') {
2978
+ if (updated.kind === 'ask') {
2979
+ // Idempotent replay of the persisted answer (P0.2 fixtures).
2980
+ void this.deliverAnswer(room, updated).catch(() => undefined);
2981
+ }
2982
+ else {
2983
+ // NEVER auto-resend an approval: orphan it and raise a fresh card.
2984
+ this.orphanInteraction(room, updated);
2985
+ this.postSystemMessage(room, `approval card #${updated.message_id} expired (answered before a restart; approvals are never auto-resent)`);
2986
+ this.createInteraction(room, member, card, kind);
2987
+ }
2988
+ }
2989
+ return;
2990
+ }
2991
+ this.createInteraction(room, member, card, kind);
2992
+ }
2993
+ createInteraction(room, member, card, kind) {
2994
+ const cardMsg = this.store.postMessage(room, {
2995
+ author: member.id,
2996
+ kind,
2997
+ body: card.prompt,
2998
+ ask: card,
2999
+ });
3000
+ this.emitMessage(room, cardMsg);
3001
+ const targets = this.store
3002
+ .listMembers(room)
3003
+ .filter((m) => m.kind === 'human' && (m.role === 'owner' || m.role === 'admin' || m.role === 'member'))
3004
+ .map((m) => m.id);
3005
+ this.store.upsertInteraction({
3006
+ id: ulid(),
3007
+ room,
3008
+ member_id: member.id,
3009
+ message_id: cardMsg.id,
3010
+ native_id: card.interaction_id,
3011
+ kind,
3012
+ targets,
3013
+ state: 'pending',
3014
+ });
3015
+ for (const target of targets) {
3016
+ const delivery = this.store.createDelivery(room, {
3017
+ message_id: cardMsg.id,
3018
+ recipient: target,
3019
+ state: 'consumed',
3020
+ });
3021
+ this.emitInbox(room, delivery);
3022
+ }
3023
+ this.emitMember(room, this.store.updateMember(room, member.id, { state: 'awaiting_input' }));
3024
+ }
3025
+ /**
3026
+ * Resolves the client-supplied interaction handle: the store id, the
3027
+ * harness-native id, or the CARD MESSAGE id (what surfaces have — stable
3028
+ * across re-raises while native ids rotate).
3029
+ */
3030
+ resolveInteraction(room, handle) {
3031
+ const direct = this.store.getInteraction(handle);
3032
+ if (direct && direct.room === room)
3033
+ return direct;
3034
+ return this.store
3035
+ .listInteractions(room)
3036
+ .find((i) => i.native_id === handle || String(i.message_id) === handle);
3037
+ }
3038
+ /** The answer_interaction act: answered → respondInteraction ack → acked. */
3039
+ async answerInteraction(room, interactionId, answer, byMemberId) {
3040
+ const interaction = this.resolveInteraction(room, interactionId);
3041
+ if (!interaction)
3042
+ throw new Error(`no such interaction ${interactionId}`);
3043
+ if (interaction.state !== 'pending')
3044
+ throw new Error(`interaction ${interactionId} is ${interaction.state}`);
3045
+ const by = byMemberId ?? this.ownerOf(room).id;
3046
+ if (!interaction.targets.includes(by)) {
3047
+ throw new Error(`interaction ${interactionId} is not addressed to member ${by}`);
3048
+ }
3049
+ // harn:assume approval-answer-is-atomic-and-chatless ref=approval-answer-daemon
3050
+ let answered;
3051
+ if (interaction.kind === 'approval') {
3052
+ const committed = this.store.answerApproval(room, interaction.id, answer, by, new Date().toISOString());
3053
+ answered = committed.interaction;
3054
+ for (const delivery of committed.deliveries)
3055
+ this.emitInbox(room, delivery);
3056
+ }
3057
+ else {
3058
+ answered = this.store.upsertInteraction({
3059
+ ...interaction,
3060
+ state: 'answered',
3061
+ answer,
3062
+ answered_by: by,
3063
+ answered_ts: new Date().toISOString(),
3064
+ });
3065
+ // Question answers remain visible history. A reply to a card never routes.
3066
+ const audit = this.store.postMessage(room, {
3067
+ author: by,
3068
+ kind: 'chat',
3069
+ body: typeof answer === 'string' ? answer : JSON.stringify(answer),
3070
+ reply_to: interaction.message_id,
3071
+ });
3072
+ this.emitMessage(room, audit);
3073
+ }
3074
+ // harn:end approval-answer-is-atomic-and-chatless
3075
+ await this.deliverAnswer(room, answered);
3076
+ }
3077
+ async deliverAnswer(room, interaction) {
3078
+ const member = this.store.getMember(room, interaction.member_id);
3079
+ const session = member ? this.sessions.get(member.id) : undefined;
3080
+ if (!member || !session) {
3081
+ throw new Error('interaction answer persisted but its adapter turn is not in flight');
3082
+ }
3083
+ await this.requireAdapter(member.harness).respondInteraction(session, interaction.native_id, interaction.answer);
3084
+ this.store.upsertInteraction({ ...interaction, state: 'acked' });
3085
+ // harn:assume interaction-ack-preserves-finalized-member-state ref=interaction-ack-member-transition
3086
+ const current = this.store.getMember(room, member.id);
3087
+ if (current?.state === 'awaiting_input') {
3088
+ this.emitMember(room, this.store.updateMember(room, member.id, { state: 'running' }));
3089
+ }
3090
+ // harn:end interaction-ack-preserves-finalized-member-state
3091
+ }
3092
+ // ── boot reconcile ────────────────────────────────────────────────────
3093
+ // harn:assume delivery-attempt-wal-reconcile ref=boot-reconcile
3094
+ /**
3095
+ * Crash recovery, exactly-once-or-held: every `delivering` delivery is
3096
+ * reconciled against its run blob —
3097
+ * blob shows run.completed → finalize that turn from the journal;
3098
+ * blob empty on the first attempt → provably never started → retry ONCE
3099
+ * (the retry REUSES the same run message);
3100
+ * anything else (events but no completion, or a second failure) → HELD
3101
+ * with a system message; the operator releases or redelivers.
3102
+ * Interactions left pending/answered ride the retry: the re-raised card
3103
+ * re-correlates semantically (fresh native ids), answered asks replay
3104
+ * idempotently, answered approvals orphan (never auto-resent). If the
3105
+ * turn never re-raises, finalize orphans the leftover interaction.
3106
+ */
3107
+ async reconcile() {
3108
+ for (const room of this.store.listRooms()) {
3109
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-seeding
3110
+ for (const member of this.store.listMembers(room.id)) {
3111
+ this.seedContextUsage(room.id, member);
3112
+ }
3113
+ // harn:end last-agent-usage-is-transient-and-seeded
3114
+ const delivering = this.store.listDeliveries(room.id, { state: 'delivering' });
3115
+ const byRunMsg = new Map();
3116
+ for (const delivery of delivering) {
3117
+ if (delivery.run_msg_id === undefined)
3118
+ continue;
3119
+ const group = byRunMsg.get(delivery.run_msg_id) ?? [];
3120
+ group.push(delivery);
3121
+ byRunMsg.set(delivery.run_msg_id, group);
3122
+ }
3123
+ for (const [runMsgId, group] of byRunMsg) {
3124
+ const runMsg = this.store.getMessage(room.id, runMsgId);
3125
+ if (!runMsg?.run)
3126
+ continue;
3127
+ if (runMsg.run.status !== 'running') {
3128
+ // already finalized — just consume
3129
+ for (const d of group)
3130
+ this.store.updateDelivery(room.id, d.id, { state: 'consumed' });
3131
+ continue;
3132
+ }
3133
+ const events = this.blobs.read(room.id, runMsg.run.events_ref);
3134
+ const completed = events.find((e) => e.type === 'run.completed');
3135
+ const member = this.store.getMember(room.id, runMsg.author);
3136
+ if (this.isRemoteMember(member)) {
3137
+ if (!this.residency?.isReachable(member.host)) {
3138
+ if (member.state !== 'unreachable') {
3139
+ this.emitMember(room.id, this.store.updateMember(room.id, member.id, { state: 'unreachable' }));
3140
+ }
3141
+ continue;
3142
+ }
3143
+ if (!this.inflight.has(member.id)) {
3144
+ this.inflight.add(member.id);
3145
+ this.track(this.runTurn(room.id, member, group, runMsg)
3146
+ .finally(() => this.inflight.delete(member.id)));
3147
+ }
3148
+ continue;
3149
+ }
3150
+ const blockedInteractions = this.store
3151
+ .listInteractions(room.id)
3152
+ .filter((i) => i.member_id === member.id && (i.state === 'pending' || i.state === 'answered'));
3153
+ const hasProcessEvidence = group.some((delivery) => this.store.getDeliveryAttemptProcess(room.id, delivery.id) !== undefined);
3154
+ const processAlive = group.some((delivery) => {
3155
+ const process = this.store.getDeliveryAttemptProcess(room.id, delivery.id);
3156
+ return process !== undefined && this.processAlive(process);
3157
+ });
3158
+ if (completed) {
3159
+ // Provably completed → finalize from the journal, never re-run.
3160
+ const toolCalls = events.filter((e) => e.type === 'run.item' && e.item_type === 'tool_call').length;
3161
+ // A row like #599 cannot finalize normally: its participant already
3162
+ // holds a terminal result, so completeTurn would refuse and roll the
3163
+ // whole transaction back, leaving the row running exactly as it was
3164
+ // found. Boot uses the SAME repair the runtime seam does instead.
3165
+ if (this.normalFinalizationIsIllegal(room.id, group)) {
3166
+ this.reconcileFailedFinalization(room.id, member.id, runMsgId, group, { status: completed.status, usage: completed.usage }, new Error('its collaboration work was already settled'));
3167
+ this.orphanLeftoverInteractions(room.id, member.id);
3168
+ continue;
3169
+ }
3170
+ const completion = {
3171
+ status: completed.status,
3172
+ final_text: completed.final_text,
3173
+ error: completed.error,
3174
+ usage: completed.usage,
3175
+ };
3176
+ try {
3177
+ this.finalizeTurn(room.id, member.id, runMsgId,
3178
+ // harn:assume failed-run-details-never-route-as-replies ref=failed-run-recovery
3179
+ completion,
3180
+ // harn:end failed-run-details-never-route-as-replies
3181
+ group, toolCalls);
3182
+ }
3183
+ catch (error) {
3184
+ this.reconcileFailedFinalization(room.id, member.id, runMsgId, group, completion, error);
3185
+ }
3186
+ this.orphanLeftoverInteractions(room.id, member.id);
3187
+ }
3188
+ else if (processAlive) {
3189
+ this.holdAmbiguousTurn(room.id, member, group, runMsgId, 'its adapter process may still be alive');
3190
+ }
3191
+ else if (blockedInteractions.length > 0 && group.every((d) => d.attempt_count <= 2)) {
3192
+ // Crashed while BLOCKED on an ask/approval: re-deliver so the
3193
+ // session can re-raise — the raise handler re-correlates the card
3194
+ // semantically (fresh native ids), replays answered asks, and
3195
+ // orphans answered approvals. The retried turn may block again on
3196
+ // a human, so it is TRACKED, never awaited; whatever never
3197
+ // re-raised is orphaned once the turn finalizes.
3198
+ const refusal = this.retryTurn(room.id, member, group, runMsg, true);
3199
+ if (refusal && !refusal.alreadyHeld) {
3200
+ this.holdAmbiguousTurn(room.id, member, group, runMsgId, refusal.reason);
3201
+ }
3202
+ }
3203
+ else if (events.length === 0 &&
3204
+ !hasProcessEvidence &&
3205
+ group.every((d) => d.attempt_count <= 1)) {
3206
+ // Provably never started → retry once, REUSING the run message.
3207
+ const refusal = this.retryTurn(room.id, member, group, runMsg, false);
3208
+ if (refusal && !refusal.alreadyHeld) {
3209
+ this.holdAmbiguousTurn(room.id, member, group, runMsgId, refusal.reason);
3210
+ }
3211
+ }
3212
+ else {
3213
+ // Ambiguous → held + system message; operator decides.
3214
+ this.holdAmbiguousTurn(room.id, member, group, runMsgId);
3215
+ }
3216
+ }
3217
+ // harn:assume lifecycle-retries-only-live-collaboration-work ref=recovery-requeue-contract
3218
+ // A run still `running` here was stranded by a CRASH mid-turn — a graceful
3219
+ // stop settles its own turns in close(), before the store closes (#492), so
3220
+ // anything left running got no chance to. It takes the SAME atomic
3221
+ // retry-or-terminal settlement, so a crash cannot resurrect closed work
3222
+ // that a graceful stop would have refused.
3223
+ for (const runMsg of this.store.listMessages(room.id, { limit: Number.MAX_SAFE_INTEGER })) {
3224
+ if (runMsg.kind !== 'run' || runMsg.run?.status !== 'running')
3225
+ continue;
3226
+ // Exact run, not author-wide: another turn by the same author being
3227
+ // reconciled says nothing about whether THIS run is still owned.
3228
+ if (byRunMsg.has(runMsg.id))
3229
+ continue; // this pass already reconciled it
3230
+ const bound = this.boundLifecycleDeliveries(room.id, runMsg.id);
3231
+ if (bound.length === 0)
3232
+ continue;
3233
+ const endedTs = new Date().toISOString();
3234
+ this.runActivity.delete(`${room.id}:${runMsg.id}`);
3235
+ this.settleLifecycleInterruptedTurn(room.id, runMsg.author, runMsg.id, bound, {
3236
+ body: '',
3237
+ mentions: [],
3238
+ refs: [],
3239
+ ledger_refs: [],
3240
+ run: {
3241
+ ...runMsg.run,
3242
+ status: 'interrupted',
3243
+ ended_ts: endedTs,
3244
+ stalled_since: undefined,
3245
+ final_text: undefined,
3246
+ },
3247
+ }, endedTs, this.inflight.has(runMsg.author));
3248
+ }
3249
+ // harn:end lifecycle-retries-only-live-collaboration-work
3250
+ this.reconcileCollaborationGroups(room.id);
3251
+ // drain anything still queued (tracked — a turn may block on an ask)
3252
+ for (const member of this.store.listMembers(room.id)) {
3253
+ if (member.kind === 'agent')
3254
+ this.track(this.maybeStartTurn(room.id, member.id));
3255
+ }
3256
+ }
3257
+ }
3258
+ // harn:end delivery-attempt-wal-reconcile
3259
+ // harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-reconciliation-engine
3260
+ reconcileCollaborationGroups(room) {
3261
+ for (const group of this.store.listCollaborationGroups(room, 'open')) {
3262
+ for (const round of this.store.listCollaborationRounds(room, group.id)) {
3263
+ if (round.state !== 'collecting')
3264
+ continue;
3265
+ for (const participant of this.store.listCollaborationParticipants(room, group.id, round.round_number)) {
3266
+ if (participant.terminal_status !== undefined)
3267
+ continue;
3268
+ const delivery = this.store.getDelivery(room, participant.delivery_id);
3269
+ const member = this.store.getMember(room, participant.member_id);
3270
+ const result = delivery?.run_msg_id === undefined
3271
+ ? undefined
3272
+ : this.store.getMessage(room, delivery.run_msg_id);
3273
+ if (result?.run && result.run.status !== 'running') {
3274
+ this.store.recoverCollaborationParticipantTerminal(room, {
3275
+ deliveryId: participant.delivery_id,
3276
+ status: result.run.status,
3277
+ resultMessageId: result.id,
3278
+ completedTs: result.run.ended_ts ?? result.ts,
3279
+ });
3280
+ }
3281
+ else if (delivery !== undefined &&
3282
+ delivery.run_msg_id === undefined &&
3283
+ (member?.state === 'dead' || member?.removed_ts !== undefined)) {
3284
+ this.store.skipCollaborationParticipant(room, delivery.id, new Date().toISOString());
3285
+ }
3286
+ }
3287
+ this.advanceCollaborationRound(room, group.id, round.round_number);
3288
+ }
3289
+ }
3290
+ }
3291
+ // harn:end open-collaboration-groups-reconcile-without-resurrection
3292
+ processAlive(attempt) {
3293
+ const target = processProbeTarget(process.platform, attempt.pid, attempt.process_group_id);
3294
+ if (target === undefined)
3295
+ return false;
3296
+ return this.processProbe(target);
3297
+ }
3298
+ holdAmbiguousTurn(room, member, group, runMsgId, detail) {
3299
+ for (const delivery of group)
3300
+ this.store.updateDelivery(room, delivery.id, { state: 'held' });
3301
+ this.postSystemMessage(room, `delivery to @${member.handle} held after an ambiguous crash (turn #${runMsgId}${detail ? `; ${detail}` : ''}) — release_hold to retry or redeliver`);
3302
+ const current = this.store.getMember(room, member.id);
3303
+ // harn:assume live-agent-waits-are-transient ref=wait-clears-on-turn-end
3304
+ this.memberWaits.delete(member.id);
3305
+ this.groupWaits.delete(member.id);
3306
+ // harn:end live-agent-waits-are-transient
3307
+ if (current?.custody === 'owned' &&
3308
+ current.state !== 'paused' &&
3309
+ current.state !== 'dead' &&
3310
+ current.state !== 'custody_uncertain') {
3311
+ this.emitMember(room, this.store.updateMember(room, member.id, { state: 'idle' }));
3312
+ }
3313
+ this.orphanLeftoverInteractions(room, member.id);
3314
+ }
3315
+ /** Reconcile retry: re-runs the SAME turn without blocking reconcile. */
3316
+ retryTurn(room, member, group, runMsg, orphanAfter) {
3317
+ const eligible = this.turnStartEligibility(room, member.id);
3318
+ if (!eligible.member) {
3319
+ return {
3320
+ reason: eligible.refusal ?? `member @${member.handle} cannot start a turn`,
3321
+ alreadyHeld: false,
3322
+ };
3323
+ }
3324
+ const runnable = this.applyTurnStartBrakes(room, group, true);
3325
+ if (runnable.length === 0) {
3326
+ return { reason: 'delivery batch was held by current room brakes', alreadyHeld: true };
3327
+ }
3328
+ this.inflight.add(eligible.member.id);
3329
+ const turn = this.runTurn(room, eligible.member, runnable, runMsg)
3330
+ .finally(() => this.inflight.delete(eligible.member.id))
3331
+ .then(() => {
3332
+ if (orphanAfter)
3333
+ this.orphanLeftoverInteractions(room, eligible.member.id);
3334
+ });
3335
+ this.track(turn);
3336
+ return undefined;
3337
+ }
3338
+ /** Pending/answered interactions whose run never re-raised them → orphaned. */
3339
+ orphanLeftoverInteractions(room, memberId) {
3340
+ for (const interaction of this.store.listInteractions(room)) {
3341
+ if (interaction.member_id !== memberId)
3342
+ continue;
3343
+ if (interaction.state !== 'pending' && interaction.state !== 'answered')
3344
+ continue;
3345
+ this.orphanInteraction(room, interaction);
3346
+ this.postSystemMessage(room, `${interaction.kind} card #${interaction.message_id} expired (could not be re-correlated after restart) — redeliver to retry`);
3347
+ }
3348
+ }
3349
+ // ── operator acts ─────────────────────────────────────────────────────
3350
+ // harn:assume redeliver-interrupts-stranded-run ref=redeliver-run-retirement
3351
+ redeliver(room, deliveryId) {
3352
+ const delivery = this.store.getDelivery(room, deliveryId);
3353
+ if (!delivery)
3354
+ throw new Error(`no such delivery ${deliveryId}`);
3355
+ const abandonedRunId = delivery.run_msg_id;
3356
+ this.releasedDeliveries.delete(deliveryId);
3357
+ const updated = this.store.updateDelivery(room, deliveryId, {
3358
+ state: 'queued',
3359
+ run_msg_id: undefined,
3360
+ attempt_count: 0,
3361
+ });
3362
+ this.store.setDeliveryAttemptProcess(room, [deliveryId], undefined);
3363
+ if (abandonedRunId !== undefined) {
3364
+ const stillBound = this.store
3365
+ .listDeliveries(room)
3366
+ .some((candidate) => candidate.id !== deliveryId && candidate.run_msg_id === abandonedRunId);
3367
+ const abandoned = this.store.getMessage(room, abandonedRunId);
3368
+ if (!stillBound && abandoned?.run?.status === 'running') {
3369
+ const interrupted = this.store.updateMessage(room, abandoned.id, {
3370
+ body: '',
3371
+ mentions: [],
3372
+ refs: [],
3373
+ ledger_refs: [],
3374
+ run: {
3375
+ ...abandoned.run,
3376
+ status: 'interrupted',
3377
+ ended_ts: new Date().toISOString(),
3378
+ stalled_since: undefined,
3379
+ final_text: undefined,
3380
+ },
3381
+ });
3382
+ this.runActivity.delete(`${room}:${abandoned.id}`);
3383
+ this.emitMessage(room, interrupted);
3384
+ }
3385
+ }
3386
+ this.emitInbox(room, updated);
3387
+ this.track(this.maybeStartTurn(room, delivery.recipient));
3388
+ }
3389
+ // harn:end redeliver-interrupts-stranded-run
3390
+ // harn:assume retried-runs-revive-and-redeliver ref=retry-run-contract
3391
+ /**
3392
+ * Retry a failed or interrupted run: re-deliver the instructions it fed so the
3393
+ * agent takes them again as a fresh turn producing a NEW run; the original run
3394
+ * stays in history untouched. Only human owners/admins may retry (the server
3395
+ * gate enforces the role; this refuses others defensively). Deliveries whose
3396
+ * trigger message was since deleted are skipped — a purge must not resurrect —
3397
+ * and a run with none surviving is refused. A failed run leaves its agent dead,
3398
+ * so it is revived first; reuses redeliver unmodified for the re-queue.
3399
+ */
3400
+ retryRun(room, messageId, byMemberId) {
3401
+ const actor = this.store.getMember(room, byMemberId);
3402
+ if (actor?.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin')) {
3403
+ throw new Error('forbidden: only owners and admins can retry runs');
3404
+ }
3405
+ const message = this.store.getMessage(room, messageId);
3406
+ if (!message)
3407
+ throw new Error(`no such message: #${messageId}`);
3408
+ if (message.kind !== 'run' || message.run === undefined) {
3409
+ throw new Error(`only run messages can be retried, not ${message.kind}`);
3410
+ }
3411
+ const status = message.run.status;
3412
+ if (status !== 'failed' && status !== 'interrupted') {
3413
+ throw new Error(`only failed or interrupted runs can be retried, not ${status}`);
3414
+ }
3415
+ // A deleted trigger stays purged: skip its snapshotted delivery.
3416
+ const survivors = this.store
3417
+ .listDeliveries(room)
3418
+ .filter((delivery) => delivery.run_msg_id === messageId)
3419
+ .filter((delivery) => this.store.getMessage(room, delivery.message_id)?.deleted !== true);
3420
+ if (survivors.length === 0) {
3421
+ throw new Error('nothing to retry: the run has no surviving instructions to re-deliver');
3422
+ }
3423
+ // A failed run killed its agent; bring it back so the re-queue can run.
3424
+ const agent = this.store.getMember(room, message.author);
3425
+ if (agent?.kind === 'agent' && agent.state === 'dead')
3426
+ this.reviveMember(room, agent.id);
3427
+ for (const delivery of survivors)
3428
+ this.redeliver(room, delivery.id);
3429
+ }
3430
+ // harn:end retried-runs-revive-and-redeliver
3431
+ releaseHold(room, deliveryId) {
3432
+ const delivery = this.store.getDelivery(room, deliveryId);
3433
+ if (!delivery || delivery.state !== 'held')
3434
+ throw new Error(`delivery ${deliveryId} is not held`);
3435
+ const attemptProcess = this.store.getDeliveryAttemptProcess(room, deliveryId);
3436
+ if (attemptProcess && this.processAlive(attemptProcess)) {
3437
+ throw new Error(`delivery ${deliveryId} cannot be released while its adapter process is alive`);
3438
+ }
3439
+ if (delivery.run_msg_id !== undefined) {
3440
+ const runMsg = this.store.getMessage(room, delivery.run_msg_id);
3441
+ const member = this.store.getMember(room, delivery.recipient);
3442
+ if (runMsg?.run?.status === 'running' && member?.kind === 'agent') {
3443
+ const group = this.store
3444
+ .listDeliveries(room, { recipient: member.id, state: 'held' })
3445
+ .filter((candidate) => candidate.run_msg_id === runMsg.id);
3446
+ for (const candidate of group)
3447
+ this.releasedDeliveries.add(candidate.id);
3448
+ const refusal = this.retryTurn(room, member, group, runMsg, false);
3449
+ if (refusal) {
3450
+ for (const candidate of group)
3451
+ this.releasedDeliveries.delete(candidate.id);
3452
+ throw new Error(`delivery ${deliveryId} cannot be released: ${refusal.reason}`);
3453
+ }
3454
+ return;
3455
+ }
3456
+ }
3457
+ this.releasedDeliveries.add(deliveryId);
3458
+ const updated = this.store.updateDelivery(room, deliveryId, { state: 'queued' });
3459
+ this.emitInbox(room, updated);
3460
+ this.track(this.maybeStartTurn(room, delivery.recipient));
3461
+ }
3462
+ /** Operator hold: parks a queued delivery until release_hold (also the brake hook). */
3463
+ holdDelivery(room, deliveryId, reason) {
3464
+ const delivery = this.store.getDelivery(room, deliveryId);
3465
+ if (!delivery)
3466
+ throw new Error(`no such delivery ${deliveryId}`);
3467
+ this.releasedDeliveries.delete(deliveryId);
3468
+ const updated = this.store.updateDelivery(room, deliveryId, { state: 'held' });
3469
+ this.emitInbox(room, updated);
3470
+ const recipient = this.store.getMember(room, delivery.recipient);
3471
+ const body = `delivery to @${recipient?.handle ?? delivery.recipient} held (${reason}) — release_hold to run it`;
3472
+ this.postSystemMessage(room, body);
3473
+ this.pushLog.push({ room, body, ts: new Date().toISOString() });
3474
+ if (reason.startsWith('turn brake') || reason.startsWith('spend brake')) {
3475
+ this.queueHumanPush(room, delivery.message_id, 'hold', body, [this.ownerOf(room).id], delivery.id);
3476
+ }
3477
+ }
3478
+ markRead(room, deliveryId, byMemberId) {
3479
+ const delivery = this.store.getDelivery(room, deliveryId);
3480
+ if (!delivery)
3481
+ throw new Error(`no such delivery ${deliveryId}`);
3482
+ const by = byMemberId ?? this.ownerOf(room).id;
3483
+ if (delivery.recipient !== by)
3484
+ throw new Error(`delivery ${deliveryId} does not belong to member ${by}`);
3485
+ const updated = this.store.updateDelivery(room, deliveryId, { read_ts: new Date().toISOString() });
3486
+ this.emitInbox(room, updated);
3487
+ return updated;
3488
+ }
3489
+ // harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
3490
+ markRoomRead(room, throughSeq, byMemberId) {
3491
+ const by = byMemberId ?? this.ownerOf(room).id;
3492
+ const result = this.store.markRoomRead(room, by, throughSeq);
3493
+ for (const delivery of result.deliveries)
3494
+ this.emitInbox(room, delivery);
3495
+ return this.roomSupport(room, by);
3496
+ }
3497
+ // harn:end human-room-read-cursors-are-durable-and-monotonic
3498
+ // harn:assume live-delivery-consumption-is-idempotent ref=consume-delivery-daemon
3499
+ consumeDelivery(room, deliveryId, byMemberId) {
3500
+ return this.project(room, this.store.consumeQueuedDelivery(room, deliveryId, byMemberId));
3501
+ }
3502
+ // harn:end live-delivery-consumption-is-idempotent
3503
+ // harn:assume member-status-is-bounded-and-identity-safe ref=status-aggregation
3504
+ memberStatus(room, memberId, now = new Date()) {
3505
+ const member = this.store.getMember(room, memberId);
3506
+ if (!member || member.removed_ts !== undefined)
3507
+ throw new Error(`no such member: ${memberId}`);
3508
+ const latestRun = this.store.listRunMessages(room, { author: memberId, limit: 1 })[0];
3509
+ const currentRun = latestRun?.run?.status === 'running' ? latestRun : undefined;
3510
+ const resultByCall = new Map();
3511
+ const events = latestRun ? this.readRunBlob(room, latestRun.id) : [];
3512
+ for (const event of events) {
3513
+ if (event.type !== 'run.item' || event.item_type !== 'tool_result')
3514
+ continue;
3515
+ const parsed = parseRunItemPayload('tool_result', event.payload);
3516
+ if (!parsed.success)
3517
+ continue;
3518
+ resultByCall.set(parsed.data.call_id, {
3519
+ status: parsed.data.status,
3520
+ ...(parsed.data.duration_ms !== undefined && { duration_ms: parsed.data.duration_ms }),
3521
+ });
3522
+ }
3523
+ const recent = [];
3524
+ let observedToolCalls = 0;
3525
+ if (latestRun?.run) {
3526
+ for (const event of events) {
3527
+ if (event.type !== 'run.item' || event.item_type !== 'tool_call')
3528
+ continue;
3529
+ const parsed = parseRunItemPayload('tool_call', event.payload);
3530
+ if (!parsed.success)
3531
+ continue;
3532
+ observedToolCalls++;
3533
+ const result = resultByCall.get(parsed.data.call_id);
3534
+ recent.push({
3535
+ kind: 'tool',
3536
+ title: parsed.data.title.slice(0, 500),
3537
+ ...(result?.status !== undefined && { status: result.status }),
3538
+ ...(result?.duration_ms !== undefined && { duration_ms: result.duration_ms }),
3539
+ ts: event.ts ?? latestRun.run.started_ts,
3540
+ });
3541
+ }
3542
+ for (const post of this.store.listChatMessagesByAuthorWithin(room, memberId, latestRun.run.started_ts, latestRun.run.ended_ts, 5)) {
3543
+ recent.push({ kind: 'post', title: post.body.slice(0, 500), ts: post.ts });
3544
+ }
3545
+ }
3546
+ recent.sort((left, right) => Date.parse(right.ts) - Date.parse(left.ts));
3547
+ const waiting = this.memberWaits.get(memberId);
3548
+ const response = {
3549
+ member: {
3550
+ handle: member.handle,
3551
+ state: member.state ?? 'idle',
3552
+ ...(waiting && {
3553
+ waiting: {
3554
+ peers: waiting.peers
3555
+ .map((peerId) => this.store.getMember(room, peerId)?.handle)
3556
+ .filter((handle) => handle !== undefined),
3557
+ reason: waiting.reason,
3558
+ since_ts: waiting.since_ts,
3559
+ until_ts: waiting.until_ts,
3560
+ },
3561
+ }),
3562
+ },
3563
+ ...(currentRun?.run && {
3564
+ current_run: {
3565
+ message_id: currentRun.id,
3566
+ started_ts: currentRun.run.started_ts,
3567
+ elapsed_ms: Math.max(0, now.getTime() - Date.parse(currentRun.run.started_ts)),
3568
+ tool_calls: Math.max(currentRun.run.tool_calls, observedToolCalls),
3569
+ },
3570
+ }),
3571
+ recent: recent.slice(0, 5),
3572
+ };
3573
+ return MemberStatusResponseSchema.parse(this.project(room, response));
3574
+ }
3575
+ // harn:end member-status-is-bounded-and-identity-safe
3576
+ // harn:assume run-evidence-search-is-bounded-and-redacted ref=bounded-run-evidence-scan
3577
+ searchRunEvidence(room, query, scanLimit = 50) {
3578
+ if (!Number.isSafeInteger(scanLimit) || scanLimit < 1 || scanLimit > 200) {
3579
+ throw new Error('run search limit must be an integer from 1 to 200');
3580
+ }
3581
+ const needle = query.toLowerCase();
3582
+ const hits = [];
3583
+ const excerpt = (value) => {
3584
+ const text = value.replace(/\s+/g, ' ').trim();
3585
+ const match = text.toLowerCase().indexOf(needle);
3586
+ const start = Math.max(0, match - 80);
3587
+ return text.slice(start, start + 240);
3588
+ };
3589
+ for (const run of this.store.listRunMessages(room, { limit: scanLimit })) {
3590
+ const events = this.readRunBlob(room, run.id);
3591
+ for (let itemIndex = 0; itemIndex < events.length; itemIndex++) {
3592
+ const event = events[itemIndex];
3593
+ if (event.type !== 'run.item')
3594
+ continue;
3595
+ let value;
3596
+ if (event.item_type === 'tool_call') {
3597
+ const parsed = parseRunItemPayload('tool_call', event.payload);
3598
+ if (parsed.success)
3599
+ value = parsed.data.title;
3600
+ }
3601
+ else if (event.item_type === 'tool_result') {
3602
+ const parsed = parseRunItemPayload('tool_result', event.payload);
3603
+ if (parsed.success)
3604
+ value = parsed.data.output_text;
3605
+ }
3606
+ if (value === undefined || !value.toLowerCase().includes(needle))
3607
+ continue;
3608
+ hits.push({
3609
+ message_id: run.id,
3610
+ item_index: itemIndex,
3611
+ kind: event.item_type,
3612
+ excerpt: excerpt(value),
3613
+ });
3614
+ if (hits.length === scanLimit)
3615
+ return hits;
3616
+ }
3617
+ }
3618
+ return hits;
3619
+ }
3620
+ // harn:end run-evidence-search-is-bounded-and-redacted
3621
+ unreadCount(room, memberId) {
3622
+ return this.store
3623
+ .listDeliveries(room, { recipient: memberId })
3624
+ .filter((d) => d.read_ts === undefined && d.state === 'consumed').length;
3625
+ }
3626
+ // harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-projection
3627
+ roomSupport(room, memberId) {
3628
+ return this.project(room, this.store.roomSupport(room, memberId));
3629
+ }
3630
+ // harn:end addressed-cold-hydration-is-strict-and-legacy-safe
3631
+ // harn:assume live-agent-waits-are-transient ref=wait-member-projection
3632
+ sync(room, sinceSeq, opts = {}) {
3633
+ const sync = this.store.sync(room, sinceSeq, opts);
3634
+ const members = new Map(sync.members.map((member) => [member.id, member]));
3635
+ for (const member of this.store.listMembers(room))
3636
+ members.set(member.id, member);
3637
+ return this.project(room, {
3638
+ ...sync,
3639
+ // Transient waits have no change-log row, so every hydration gets the
3640
+ // authoritative active roster plus any removed-member delta from Store.
3641
+ members: [...members.values()].map((member) => {
3642
+ // harn:assume last-agent-usage-is-transient-and-seeded ref=last-usage-member-projection
3643
+ const waiting = this.memberWaits.get(member.id);
3644
+ return {
3645
+ ...this.memberWithLastUsage(room, member),
3646
+ ...(waiting && { waiting }),
3647
+ };
3648
+ // harn:end last-agent-usage-is-transient-and-seeded
3649
+ }),
3650
+ });
3651
+ }
3652
+ // harn:end live-agent-waits-are-transient
3653
+ readRunBlob(room, msgId) {
3654
+ const message = this.store.getMessage(room, msgId);
3655
+ if (!message?.run)
3656
+ return [];
3657
+ return this.project(room, this.blobs.read(room, message.run.events_ref));
3658
+ }
3659
+ // harn:assume room-git-state-read-only-from-known-cwds ref=room-git-state-contract
3660
+ /**
3661
+ * The diff explorer's live git working-state for one of the room's known
3662
+ * directories. `requestedCwd` is a SELECTOR into the room's recorded cwd set,
3663
+ * never a free path: a value outside the set is refused before any git runs.
3664
+ * A non-git or clean directory yields an empty, clean state.
3665
+ */
3666
+ async gitWorkingState(room, requestedCwd) {
3667
+ const cwds = this.roomKnownCwds(room);
3668
+ if (requestedCwd !== undefined && !cwds.includes(requestedCwd)) {
3669
+ throw new Error("cwd is not one of the room's known directories");
3670
+ }
3671
+ const selected = requestedCwd ?? cwds[0] ?? null;
3672
+ if (selected === null)
3673
+ return { cwds, selected, clean: true, files: [] };
3674
+ const files = await this.readGitWorkingFiles(selected);
3675
+ return { cwds, selected, clean: files.length === 0, files };
3676
+ }
3677
+ /** The room's known directories: distinct existing cwds of its agent members
3678
+ * plus the room's recorded folder, canonicalized. Missing dirs are dropped so
3679
+ * a stale cwd is never offered and never reaches git. */
3680
+ roomKnownCwds(room) {
3681
+ const raw = [];
3682
+ for (const member of this.store.listMembers(room)) {
3683
+ if (member.kind === 'agent' && member.cwd !== undefined)
3684
+ raw.push(member.cwd);
3685
+ }
3686
+ const roomCwd = this.store.getRoom(room)?.config.cwd;
3687
+ if (roomCwd !== undefined)
3688
+ raw.push(roomCwd);
3689
+ const seen = new Set();
3690
+ const out = [];
3691
+ for (const value of raw) {
3692
+ let normalized;
3693
+ try {
3694
+ normalized = normalizeWorkingDirectory(value, this.homeDir);
3695
+ }
3696
+ catch {
3697
+ continue; // a removed/invalid directory is simply not offered
3698
+ }
3699
+ if (!seen.has(normalized)) {
3700
+ seen.add(normalized);
3701
+ out.push(normalized);
3702
+ }
3703
+ }
3704
+ return out;
3705
+ }
3706
+ /** Read-only git working-tree read for one already-validated directory. Only
3707
+ * read-only subcommands run, each via execFile with a timeout — no shell, no
3708
+ * mutation. A non-git or clean directory returns no files. */
3709
+ async readGitWorkingFiles(cwd) {
3710
+ const git = async (args, tolerateDiff = false) => {
3711
+ try {
3712
+ const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
3713
+ timeout: GIT_TIMEOUT_MS,
3714
+ maxBuffer: GIT_MAX_BUFFER,
3715
+ windowsHide: true,
3716
+ });
3717
+ return stdout;
3718
+ }
3719
+ catch (error) {
3720
+ // `diff --no-index` exits 1 when the files differ — expected, not failure.
3721
+ const code = error.code;
3722
+ const stdout = error.stdout;
3723
+ if (tolerateDiff && code === 1 && typeof stdout === 'string')
3724
+ return stdout;
3725
+ throw error;
3726
+ }
3727
+ };
3728
+ try {
3729
+ if ((await git(['rev-parse', '--is-inside-work-tree'])).trim() !== 'true')
3730
+ return [];
3731
+ }
3732
+ catch {
3733
+ return []; // not a git repository (or git unavailable)
3734
+ }
3735
+ let statusOut;
3736
+ try {
3737
+ statusOut = await git(['status', '--porcelain=v1', '-z']);
3738
+ }
3739
+ catch {
3740
+ return [];
3741
+ }
3742
+ const entries = parsePorcelainStatus(statusOut);
3743
+ if (entries.length === 0)
3744
+ return [];
3745
+ const numstat = new Map();
3746
+ try {
3747
+ const out = await git(['diff', 'HEAD', '--numstat']);
3748
+ for (const line of out.split('\n')) {
3749
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(line);
3750
+ if (!match)
3751
+ continue;
3752
+ const raw = match[3];
3753
+ const path = raw.includes(' => ') ? raw.replace(/^.* => /, '') : raw;
3754
+ numstat.set(path, {
3755
+ additions: match[1] === '-' ? 0 : Number(match[1]),
3756
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
3757
+ });
3758
+ }
3759
+ }
3760
+ catch {
3761
+ // No HEAD yet (empty repo): counts fall back to per-file line counting.
3762
+ }
3763
+ const files = [];
3764
+ for (const entry of entries) {
3765
+ let diff = '';
3766
+ try {
3767
+ diff = entry.status === 'untracked'
3768
+ ? await git(['diff', '--no-index', '--', '/dev/null', entry.path], true)
3769
+ : await git(['diff', 'HEAD', '--', entry.path]);
3770
+ }
3771
+ catch {
3772
+ diff = '';
3773
+ }
3774
+ let truncated = false;
3775
+ if (diff.length > MAX_FILE_DIFF_CHARS) {
3776
+ diff = diff.slice(0, MAX_FILE_DIFF_CHARS) + DIFF_TRUNCATED_MARKER;
3777
+ truncated = true;
3778
+ }
3779
+ const counts = numstat.get(entry.path) ?? countDiffLines(diff);
3780
+ files.push({
3781
+ path: entry.path,
3782
+ status: entry.status,
3783
+ additions: counts.additions,
3784
+ deletions: counts.deletions,
3785
+ diff,
3786
+ truncated,
3787
+ });
3788
+ }
3789
+ return files;
3790
+ }
3791
+ // harn:end room-git-state-read-only-from-known-cwds
3792
+ // harn:assume attachments-are-capped-files-served-inert ref=attachment-contract
3793
+ // The attachment contract: files live under the data dir keyed by a server-issued
3794
+ // hex id (never a client path), staged by upload, validated on post, delivered to
3795
+ // agents as absolute path lines, unlinked on delete, and swept when orphaned.
3796
+ /** A fresh, path-safe attachment id — also the on-disk file name. */
3797
+ newAttachmentId() {
3798
+ return randomBytes(16).toString('hex');
3799
+ }
3800
+ /** Absolute path to an attachment's bytes; throws unless id is a valid handle,
3801
+ * so a client-supplied id can never escape the room's attachment directory. */
3802
+ attachmentPath(room, id) {
3803
+ if (!ATTACHMENT_ID.test(id))
3804
+ throw new Error('invalid attachment id');
3805
+ return join(this.attachmentsRoot, room, id);
3806
+ }
3807
+ attachmentMetaPath(room, id) {
3808
+ return `${this.attachmentPath(room, id)}.json`;
3809
+ }
3810
+ /** Ensure a room's attachment directory exists before an upload writes into it. */
3811
+ ensureAttachmentDir(room) {
3812
+ const dir = join(this.attachmentsRoot, room);
3813
+ mkdirSync(dir, { recursive: true });
3814
+ return dir;
3815
+ }
3816
+ /** Persist an uploaded file's metadata sidecar (name/mime/size by id). */
3817
+ recordAttachment(room, meta) {
3818
+ writeFileSync(this.attachmentMetaPath(room, meta.id), JSON.stringify(meta));
3819
+ }
3820
+ /** An attachment's stored metadata, or undefined if it was never staged here. */
3821
+ getAttachmentMeta(room, id) {
3822
+ if (!ATTACHMENT_ID.test(id))
3823
+ return undefined;
3824
+ try {
3825
+ return AttachmentSchema.parse(JSON.parse(readFileSync(this.attachmentMetaPath(room, id), 'utf8')));
3826
+ }
3827
+ catch {
3828
+ return undefined;
3829
+ }
3830
+ }
3831
+ /** Validate post-time attachment ids against this room's staging and return
3832
+ * their metadata to stamp onto the message. Throws on any unknown id (which
3833
+ * also refuses another room's ids) or over the per-message cap. */
3834
+ resolveAttachmentsForPost(room, ids) {
3835
+ if (ids === undefined || ids.length === 0)
3836
+ return [];
3837
+ if (ids.length > MAX_ATTACHMENTS_PER_MESSAGE) {
3838
+ throw new Error(`at most ${String(MAX_ATTACHMENTS_PER_MESSAGE)} attachments per message`);
3839
+ }
3840
+ return ids.map((id) => {
3841
+ const meta = this.getAttachmentMeta(room, id);
3842
+ if (meta === undefined)
3843
+ throw new Error(`unknown attachment ${id}`);
3844
+ return meta;
3845
+ });
3846
+ }
3847
+ /** Unlink a deleted message's attachment files (bytes + sidecar) from disk. */
3848
+ unlinkAttachments(room, attachments) {
3849
+ for (const attachment of attachments ?? []) {
3850
+ if (!ATTACHMENT_ID.test(attachment.id))
3851
+ continue;
3852
+ rmSync(this.attachmentPath(room, attachment.id), { force: true });
3853
+ rmSync(this.attachmentMetaPath(room, attachment.id), { force: true });
3854
+ }
3855
+ }
3856
+ /** Boot-time sweep: unlink staged files that no message referenced within the
3857
+ * orphan window. Referenced files stay until their message is deleted. */
3858
+ sweepOrphanAttachments(now = Date.now()) {
3859
+ let rooms;
3860
+ try {
3861
+ rooms = readdirSync(this.attachmentsRoot);
3862
+ }
3863
+ catch {
3864
+ return; // nothing staged yet
3865
+ }
3866
+ for (const room of rooms) {
3867
+ const dir = join(this.attachmentsRoot, room);
3868
+ const referenced = new Set();
3869
+ if (this.store.getRoom(room) !== undefined) {
3870
+ for (const message of this.store.listMessages(room, { limit: Number.MAX_SAFE_INTEGER })) {
3871
+ for (const attachment of message.attachments ?? [])
3872
+ referenced.add(attachment.id);
3873
+ }
3874
+ }
3875
+ let entries;
3876
+ try {
3877
+ entries = readdirSync(dir);
3878
+ }
3879
+ catch {
3880
+ continue;
3881
+ }
3882
+ for (const entry of entries) {
3883
+ const id = entry.endsWith('.json') ? entry.slice(0, -'.json'.length) : entry;
3884
+ if (!ATTACHMENT_ID.test(id) || referenced.has(id))
3885
+ continue;
3886
+ const path = join(dir, entry);
3887
+ try {
3888
+ if (now - statSync(path).mtimeMs > ORPHAN_ATTACHMENT_MS)
3889
+ rmSync(path, { force: true });
3890
+ }
3891
+ catch {
3892
+ // raced away — nothing to unlink
3893
+ }
3894
+ }
3895
+ }
3896
+ }
3897
+ /** Absolute-path lines appended to an agent's delivered text for a message's
3898
+ * files — agents share this machine, so a path is the honest delivery form. */
3899
+ attachmentPayloadLines(room, messageId) {
3900
+ const attachments = this.store.getMessage(room, messageId)?.attachments ?? [];
3901
+ if (attachments.length === 0)
3902
+ return '';
3903
+ const lines = attachments.map((attachment) => `- ${this.attachmentPath(room, attachment.id)} (${attachment.name}, ${formatBytes(attachment.size)})`);
3904
+ return `\n\nAttachments:\n${lines.join('\n')}`;
3905
+ }
3906
+ }
3907
+ //# sourceMappingURL=daemon.js.map