borgmcp 1.1.15 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (459) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/LICENSE +201 -33
  3. package/NOTICE +2 -0
  4. package/README.md +85 -36
  5. package/SECURITY.md +7 -0
  6. package/dist/agent-runtime.d.ts.map +1 -0
  7. package/dist/agent-runtime.js +63 -1
  8. package/dist/agent-runtime.js.map +1 -0
  9. package/dist/assimilate-cmd.d.ts +96 -17
  10. package/dist/assimilate-cmd.d.ts.map +1 -0
  11. package/dist/assimilate-cmd.js +1396 -48
  12. package/dist/assimilate-cmd.js.map +1 -0
  13. package/dist/assimilate-deps.d.ts.map +1 -0
  14. package/dist/assimilate-deps.js +384 -3
  15. package/dist/assimilate-deps.js.map +1 -0
  16. package/dist/assimilate-guard.d.ts +5 -7
  17. package/dist/assimilate-guard.d.ts.map +1 -0
  18. package/dist/assimilate-guard.js +60 -2
  19. package/dist/assimilate-guard.js.map +1 -0
  20. package/dist/assimilate-welcome.d.ts +1 -1
  21. package/dist/assimilate-welcome.d.ts.map +1 -0
  22. package/dist/assimilate-welcome.js +32 -2
  23. package/dist/assimilate-welcome.js.map +1 -0
  24. package/dist/auth-env.d.ts +7 -50
  25. package/dist/auth-env.d.ts.map +1 -0
  26. package/dist/auth-env.js +22 -1
  27. package/dist/auth-env.js.map +1 -0
  28. package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
  29. package/dist/backends/launch-all-pastelist.js +11 -4
  30. package/dist/backends/launch-all-pastelist.js.map +1 -0
  31. package/dist/backends/launch-all-tmux.d.ts.map +1 -0
  32. package/dist/backends/launch-all-tmux.js +49 -1
  33. package/dist/backends/launch-all-tmux.js.map +1 -0
  34. package/dist/backends/launch-all-windows.d.ts.map +1 -0
  35. package/dist/backends/launch-all-windows.js +86 -12
  36. package/dist/backends/launch-all-windows.js.map +1 -0
  37. package/dist/bare-launch-menu.d.ts.map +1 -0
  38. package/dist/bare-launch-menu.js +83 -6
  39. package/dist/bare-launch-menu.js.map +1 -0
  40. package/dist/claude-launch-args.d.ts.map +1 -0
  41. package/dist/claude-launch-args.js +34 -1
  42. package/dist/claude-launch-args.js.map +1 -0
  43. package/dist/claude.d.ts.map +1 -0
  44. package/dist/claude.js +486 -14
  45. package/dist/claude.js.map +1 -0
  46. package/dist/cleanup-cmd.d.ts +5 -5
  47. package/dist/cleanup-cmd.d.ts.map +1 -0
  48. package/dist/cleanup-cmd.js +453 -14
  49. package/dist/cleanup-cmd.js.map +1 -0
  50. package/dist/clear-rewake-core.d.ts.map +1 -0
  51. package/dist/clear-rewake-core.js +23 -2
  52. package/dist/clear-rewake-core.js.map +1 -0
  53. package/dist/clear-rewake.d.ts.map +1 -0
  54. package/dist/clear-rewake.js +19 -1
  55. package/dist/clear-rewake.js.map +1 -0
  56. package/dist/cli-help.d.ts +5 -0
  57. package/dist/cli-help.d.ts.map +1 -0
  58. package/dist/cli-help.js +104 -56
  59. package/dist/cli-help.js.map +1 -0
  60. package/dist/cli-platform.d.ts.map +1 -0
  61. package/dist/cli-platform.js +106 -4
  62. package/dist/cli-platform.js.map +1 -0
  63. package/dist/cli-tool-approval.d.ts +84 -0
  64. package/dist/cli-tool-approval.d.ts.map +1 -0
  65. package/dist/cli-tool-approval.js +583 -0
  66. package/dist/cli-tool-approval.js.map +1 -0
  67. package/dist/codex-app-server.d.ts.map +1 -0
  68. package/dist/codex-app-server.js +228 -4
  69. package/dist/codex-app-server.js.map +1 -0
  70. package/dist/codex-app-wake.d.ts.map +1 -0
  71. package/dist/codex-app-wake.js +432 -2
  72. package/dist/codex-app-wake.js.map +1 -0
  73. package/dist/codex-launch.d.ts.map +1 -0
  74. package/dist/codex-launch.js +102 -2
  75. package/dist/codex-launch.js.map +1 -0
  76. package/dist/codex-remote.d.ts +6 -1
  77. package/dist/codex-remote.d.ts.map +1 -0
  78. package/dist/codex-remote.js +394 -1
  79. package/dist/codex-remote.js.map +1 -0
  80. package/dist/codex-wake-resolve.d.ts.map +1 -0
  81. package/dist/codex-wake-resolve.js +127 -1
  82. package/dist/codex-wake-resolve.js.map +1 -0
  83. package/dist/config-utils.d.ts +2 -2
  84. package/dist/config-utils.d.ts.map +1 -0
  85. package/dist/config-utils.js +766 -3
  86. package/dist/config-utils.js.map +1 -0
  87. package/dist/config.d.ts +98 -61
  88. package/dist/config.d.ts.map +1 -0
  89. package/dist/config.js +426 -1
  90. package/dist/config.js.map +1 -0
  91. package/dist/console-prefix.d.ts.map +1 -0
  92. package/dist/console-prefix.js +96 -1
  93. package/dist/console-prefix.js.map +1 -0
  94. package/dist/cube-name.d.ts.map +1 -0
  95. package/dist/cube-name.js +67 -1
  96. package/dist/cube-name.js.map +1 -0
  97. package/dist/cubes.d.ts +126 -18
  98. package/dist/cubes.d.ts.map +1 -0
  99. package/dist/cubes.js +412 -4
  100. package/dist/cubes.js.map +1 -0
  101. package/dist/debug.d.ts.map +1 -0
  102. package/dist/debug.js +71 -1
  103. package/dist/debug.js.map +1 -0
  104. package/dist/direct-log.d.ts.map +1 -0
  105. package/dist/direct-log.js +11 -1
  106. package/dist/direct-log.js.map +1 -0
  107. package/dist/docs-sections.d.ts +11 -10
  108. package/dist/docs-sections.d.ts.map +1 -0
  109. package/dist/docs-sections.js +113 -3
  110. package/dist/docs-sections.js.map +1 -0
  111. package/dist/drone-lifecycle.d.ts +5 -21
  112. package/dist/drone-lifecycle.d.ts.map +1 -0
  113. package/dist/drone-lifecycle.js +57 -8
  114. package/dist/drone-lifecycle.js.map +1 -0
  115. package/dist/drone-management.d.ts +29 -0
  116. package/dist/drone-management.d.ts.map +1 -0
  117. package/dist/drone-management.js +109 -0
  118. package/dist/drone-management.js.map +1 -0
  119. package/dist/ensure-mcp-config.d.ts.map +1 -0
  120. package/dist/ensure-mcp-config.js +36 -1
  121. package/dist/ensure-mcp-config.js.map +1 -0
  122. package/dist/evict-drone.d.ts +10 -2
  123. package/dist/evict-drone.d.ts.map +1 -0
  124. package/dist/evict-drone.js +60 -1
  125. package/dist/evict-drone.js.map +1 -0
  126. package/dist/gc-orphan-inboxes.d.ts.map +1 -0
  127. package/dist/gc-orphan-inboxes.js +177 -1
  128. package/dist/gc-orphan-inboxes.js.map +1 -0
  129. package/dist/inbox-monitor.d.ts +1 -1
  130. package/dist/inbox-monitor.d.ts.map +1 -0
  131. package/dist/inbox-monitor.js +856 -3
  132. package/dist/inbox-monitor.js.map +1 -0
  133. package/dist/index.d.ts +6 -4
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +1034 -37
  136. package/dist/index.js.map +1 -0
  137. package/dist/launch-all-cmd.d.ts.map +1 -0
  138. package/dist/launch-all-cmd.js +377 -37
  139. package/dist/launch-all-cmd.js.map +1 -0
  140. package/dist/launch-all-command.d.ts.map +1 -0
  141. package/dist/launch-all-command.js +24 -1
  142. package/dist/launch-all-command.js.map +1 -0
  143. package/dist/launch-all-deps.d.ts +3 -8
  144. package/dist/launch-all-deps.d.ts.map +1 -0
  145. package/dist/launch-all-deps.js +99 -1
  146. package/dist/launch-all-deps.js.map +1 -0
  147. package/dist/launch-all-discovery.d.ts +1 -0
  148. package/dist/launch-all-discovery.d.ts.map +1 -0
  149. package/dist/launch-all-discovery.js +90 -4
  150. package/dist/launch-all-discovery.js.map +1 -0
  151. package/dist/launch-all-locks.d.ts.map +1 -0
  152. package/dist/launch-all-locks.js +54 -1
  153. package/dist/launch-all-locks.js.map +1 -0
  154. package/dist/launch-gate.d.ts +2 -2
  155. package/dist/launch-gate.d.ts.map +1 -0
  156. package/dist/launch-gate.js +73 -1
  157. package/dist/launch-gate.js.map +1 -0
  158. package/dist/lifecycle-log-guard.d.ts.map +1 -0
  159. package/dist/lifecycle-log-guard.js +93 -2
  160. package/dist/lifecycle-log-guard.js.map +1 -0
  161. package/dist/list-roles-render.d.ts.map +1 -0
  162. package/dist/list-roles-render.js +40 -6
  163. package/dist/list-roles-render.js.map +1 -0
  164. package/dist/local-log-routing.d.ts +19 -0
  165. package/dist/local-log-routing.d.ts.map +1 -0
  166. package/dist/local-log-routing.js +67 -0
  167. package/dist/local-log-routing.js.map +1 -0
  168. package/dist/local-manage-tool-result.d.ts +8 -0
  169. package/dist/local-manage-tool-result.d.ts.map +1 -0
  170. package/dist/local-manage-tool-result.js +10 -0
  171. package/dist/local-manage-tool-result.js.map +1 -0
  172. package/dist/local-server-cursor.d.ts +34 -0
  173. package/dist/local-server-cursor.d.ts.map +1 -0
  174. package/dist/local-server-cursor.js +151 -0
  175. package/dist/local-server-cursor.js.map +1 -0
  176. package/dist/log-audit.d.ts.map +1 -0
  177. package/dist/log-audit.js +194 -3
  178. package/dist/log-audit.js.map +1 -0
  179. package/dist/log-stream.d.ts +31 -18
  180. package/dist/log-stream.d.ts.map +1 -0
  181. package/dist/log-stream.js +1214 -12
  182. package/dist/log-stream.js.map +1 -0
  183. package/dist/model-presets.d.ts.map +1 -0
  184. package/dist/model-presets.js +19 -1
  185. package/dist/model-presets.js.map +1 -0
  186. package/dist/name-validator.d.ts.map +1 -0
  187. package/dist/name-validator.js +22 -1
  188. package/dist/name-validator.js.map +1 -0
  189. package/dist/opencode-drone.d.ts.map +1 -0
  190. package/dist/opencode-drone.js +438 -4
  191. package/dist/opencode-drone.js.map +1 -0
  192. package/dist/opencode-plugin.d.ts +1 -1
  193. package/dist/opencode-plugin.d.ts.map +1 -0
  194. package/dist/opencode-plugin.js +29 -4
  195. package/dist/opencode-plugin.js.map +1 -0
  196. package/dist/parse-assimilate-args.d.ts +1 -1
  197. package/dist/parse-assimilate-args.d.ts.map +1 -0
  198. package/dist/parse-assimilate-args.js +125 -1
  199. package/dist/parse-assimilate-args.js.map +1 -0
  200. package/dist/parse-launch-all-args.d.ts.map +1 -0
  201. package/dist/parse-launch-all-args.js +70 -1
  202. package/dist/parse-launch-all-args.js.map +1 -0
  203. package/dist/postinstall-banner.d.ts.map +1 -0
  204. package/dist/postinstall-banner.js +27 -2
  205. package/dist/postinstall-banner.js.map +1 -0
  206. package/dist/postinstall.d.ts.map +1 -0
  207. package/dist/postinstall.js +32 -5
  208. package/dist/postinstall.js.map +1 -0
  209. package/dist/readiness-probe.d.ts.map +1 -0
  210. package/dist/readiness-probe.js +9 -1
  211. package/dist/readiness-probe.js.map +1 -0
  212. package/dist/regen-format.d.ts.map +1 -0
  213. package/dist/regen-format.js +545 -13
  214. package/dist/regen-format.js.map +1 -0
  215. package/dist/regen.d.ts.map +1 -0
  216. package/dist/regen.js +141 -5
  217. package/dist/regen.js.map +1 -0
  218. package/dist/remote-client.d.ts +65 -175
  219. package/dist/remote-client.d.ts.map +1 -0
  220. package/dist/remote-client.js +942 -1
  221. package/dist/remote-client.js.map +1 -0
  222. package/dist/reset-local-seat-cmd.d.ts +50 -0
  223. package/dist/reset-local-seat-cmd.d.ts.map +1 -0
  224. package/dist/reset-local-seat-cmd.js +169 -0
  225. package/dist/reset-local-seat-cmd.js.map +1 -0
  226. package/dist/role-match.d.ts.map +1 -0
  227. package/dist/role-match.js +23 -1
  228. package/dist/role-match.js.map +1 -0
  229. package/dist/role-resolver.d.ts.map +1 -0
  230. package/dist/role-resolver.js +60 -1
  231. package/dist/role-resolver.js.map +1 -0
  232. package/dist/roster-render.d.ts.map +1 -0
  233. package/dist/roster-render.js +121 -3
  234. package/dist/roster-render.js.map +1 -0
  235. package/dist/seat-probe.d.ts +32 -13
  236. package/dist/seat-probe.d.ts.map +1 -0
  237. package/dist/seat-probe.js +78 -1
  238. package/dist/seat-probe.js.map +1 -0
  239. package/dist/seat-store.d.ts +76 -0
  240. package/dist/seat-store.d.ts.map +1 -0
  241. package/dist/seat-store.js +309 -0
  242. package/dist/seat-store.js.map +1 -0
  243. package/dist/seats.d.ts +303 -0
  244. package/dist/seats.d.ts.map +1 -0
  245. package/dist/seats.js +551 -0
  246. package/dist/seats.js.map +1 -0
  247. package/dist/self-path.d.ts +27 -0
  248. package/dist/self-path.d.ts.map +1 -0
  249. package/dist/self-path.js +43 -0
  250. package/dist/self-path.js.map +1 -0
  251. package/dist/server-endpoint.d.ts +9 -0
  252. package/dist/server-endpoint.d.ts.map +1 -0
  253. package/dist/server-endpoint.js +38 -0
  254. package/dist/server-endpoint.js.map +1 -0
  255. package/dist/server-errors.d.ts +50 -0
  256. package/dist/server-errors.d.ts.map +1 -0
  257. package/dist/server-errors.js +79 -0
  258. package/dist/server-errors.js.map +1 -0
  259. package/dist/server-handshake.d.ts +197 -0
  260. package/dist/server-handshake.d.ts.map +1 -0
  261. package/dist/server-handshake.js +505 -0
  262. package/dist/server-handshake.js.map +1 -0
  263. package/dist/server-response.d.ts +3 -0
  264. package/dist/server-response.d.ts.map +1 -0
  265. package/dist/server-response.js +67 -0
  266. package/dist/server-response.js.map +1 -0
  267. package/dist/server-trust.d.ts +14 -0
  268. package/dist/server-trust.d.ts.map +1 -0
  269. package/dist/server-trust.js +225 -0
  270. package/dist/server-trust.js.map +1 -0
  271. package/dist/setup-confirm.d.ts +3 -3
  272. package/dist/setup-confirm.d.ts.map +1 -0
  273. package/dist/setup-confirm.js +126 -2
  274. package/dist/setup-confirm.js.map +1 -0
  275. package/dist/setup.d.ts +5 -3
  276. package/dist/setup.d.ts.map +1 -0
  277. package/dist/setup.js +169 -46
  278. package/dist/setup.js.map +1 -0
  279. package/dist/shell-escape.d.ts.map +1 -0
  280. package/dist/shell-escape.js +22 -1
  281. package/dist/shell-escape.js.map +1 -0
  282. package/dist/spawn.d.ts +2 -2
  283. package/dist/spawn.d.ts.map +1 -0
  284. package/dist/spawn.js +29 -10
  285. package/dist/spawn.js.map +1 -0
  286. package/dist/startup-services.d.ts +0 -1
  287. package/dist/startup-services.d.ts.map +1 -0
  288. package/dist/startup-services.js +26 -1
  289. package/dist/startup-services.js.map +1 -0
  290. package/dist/stream-owner.d.ts +6 -2
  291. package/dist/stream-owner.d.ts.map +1 -0
  292. package/dist/stream-owner.js +547 -2
  293. package/dist/stream-owner.js.map +1 -0
  294. package/dist/stream-status.d.ts +1 -1
  295. package/dist/stream-status.d.ts.map +1 -0
  296. package/dist/stream-status.js +268 -3
  297. package/dist/stream-status.js.map +1 -0
  298. package/dist/sync-roles-render.d.ts.map +1 -0
  299. package/dist/sync-roles-render.js +118 -3
  300. package/dist/sync-roles-render.js.map +1 -0
  301. package/dist/sync.d.ts +1 -1
  302. package/dist/sync.d.ts.map +1 -0
  303. package/dist/sync.js +286 -22
  304. package/dist/sync.js.map +1 -0
  305. package/dist/terminal-title.d.ts.map +1 -0
  306. package/dist/terminal-title.js +68 -1
  307. package/dist/terminal-title.js.map +1 -0
  308. package/dist/token-store.d.ts +17 -111
  309. package/dist/token-store.d.ts.map +1 -0
  310. package/dist/token-store.js +81 -1
  311. package/dist/token-store.js.map +1 -0
  312. package/dist/tool-manifest.d.ts +4 -8
  313. package/dist/tool-manifest.d.ts.map +1 -0
  314. package/dist/tool-manifest.js +554 -1
  315. package/dist/tool-manifest.js.map +1 -0
  316. package/dist/tool-scope.d.ts +8 -12
  317. package/dist/tool-scope.d.ts.map +1 -0
  318. package/dist/tool-scope.js +97 -1
  319. package/dist/tool-scope.js.map +1 -0
  320. package/dist/types.d.ts +1 -5
  321. package/dist/types.d.ts.map +1 -0
  322. package/dist/types.js +5 -0
  323. package/dist/types.js.map +1 -0
  324. package/dist/unknown-subcommand.d.ts +1 -1
  325. package/dist/unknown-subcommand.d.ts.map +1 -0
  326. package/dist/unknown-subcommand.js +37 -1
  327. package/dist/unknown-subcommand.js.map +1 -0
  328. package/dist/version.d.ts +1 -1
  329. package/dist/version.d.ts.map +1 -0
  330. package/dist/version.js +78 -2
  331. package/dist/version.js.map +1 -0
  332. package/dist/working-repo.d.ts.map +1 -0
  333. package/dist/working-repo.js +81 -1
  334. package/dist/working-repo.js.map +1 -0
  335. package/dist/worktree-lifecycle.d.ts.map +1 -0
  336. package/dist/worktree-lifecycle.js +205 -2
  337. package/dist/worktree-lifecycle.js.map +1 -0
  338. package/docs/EXTRACTION_PROVENANCE.md +40 -0
  339. package/docs/LOCAL_SERVER.md +103 -0
  340. package/docs/RELEASING.md +120 -0
  341. package/package.json +52 -34
  342. package/src/agent-runtime.ts +69 -0
  343. package/src/assimilate-cmd.ts +1864 -0
  344. package/src/assimilate-deps.ts +444 -0
  345. package/src/assimilate-guard.ts +84 -0
  346. package/src/assimilate-welcome.ts +39 -0
  347. package/src/auth-env.ts +21 -0
  348. package/src/backends/launch-all-pastelist.ts +18 -0
  349. package/src/backends/launch-all-tmux.ts +75 -0
  350. package/src/backends/launch-all-windows.ts +110 -0
  351. package/src/bare-launch-menu.ts +148 -0
  352. package/src/claude-launch-args.ts +35 -0
  353. package/src/claude.ts +561 -0
  354. package/src/cleanup-cmd.ts +581 -0
  355. package/src/clear-rewake-core.ts +34 -0
  356. package/src/clear-rewake.ts +18 -0
  357. package/src/cli-help.ts +116 -0
  358. package/src/cli-platform.ts +127 -0
  359. package/src/cli-tool-approval.ts +716 -0
  360. package/src/codex-app-server.ts +238 -0
  361. package/src/codex-app-wake.ts +512 -0
  362. package/src/codex-launch.ts +140 -0
  363. package/src/codex-remote.ts +500 -0
  364. package/src/codex-wake-resolve.ts +155 -0
  365. package/src/config-utils.ts +811 -0
  366. package/src/config.ts +595 -0
  367. package/src/console-prefix.ts +99 -0
  368. package/src/cube-name.ts +69 -0
  369. package/src/cubes.ts +583 -0
  370. package/src/debug.ts +73 -0
  371. package/src/direct-log.ts +9 -0
  372. package/src/docs-sections.ts +129 -0
  373. package/src/drone-lifecycle.ts +62 -0
  374. package/src/drone-management.ts +162 -0
  375. package/src/ensure-mcp-config.ts +54 -0
  376. package/src/evict-drone.ts +82 -0
  377. package/src/gc-orphan-inboxes.ts +229 -0
  378. package/src/inbox-monitor.ts +1005 -0
  379. package/src/index.ts +1201 -0
  380. package/src/launch-all-cmd.ts +450 -0
  381. package/src/launch-all-command.ts +30 -0
  382. package/src/launch-all-deps.ts +188 -0
  383. package/src/launch-all-discovery.ts +118 -0
  384. package/src/launch-all-locks.ts +74 -0
  385. package/src/launch-gate.ts +80 -0
  386. package/src/lifecycle-log-guard.ts +144 -0
  387. package/src/list-roles-render.ts +52 -0
  388. package/src/local-log-routing.ts +106 -0
  389. package/src/local-manage-tool-result.ts +10 -0
  390. package/src/local-server-cursor.ts +188 -0
  391. package/src/log-audit.ts +207 -0
  392. package/src/log-stream.ts +1505 -0
  393. package/src/model-presets.ts +22 -0
  394. package/src/name-validator.ts +23 -0
  395. package/src/opencode-drone.ts +528 -0
  396. package/src/opencode-plugin.ts +38 -0
  397. package/src/parse-assimilate-args.ts +125 -0
  398. package/src/parse-launch-all-args.ts +100 -0
  399. package/src/postinstall-banner.ts +37 -0
  400. package/src/postinstall.ts +36 -0
  401. package/src/readiness-probe.ts +12 -0
  402. package/src/regen-format.ts +651 -0
  403. package/src/regen.ts +150 -0
  404. package/src/remote-client.ts +1379 -0
  405. package/src/reset-local-seat-cmd.ts +227 -0
  406. package/src/role-match.ts +29 -0
  407. package/src/role-resolver.ts +93 -0
  408. package/src/roster-render.ts +196 -0
  409. package/src/seat-probe.ts +115 -0
  410. package/src/seat-store.ts +333 -0
  411. package/src/seats.ts +741 -0
  412. package/src/self-path.ts +51 -0
  413. package/src/server-endpoint.ts +38 -0
  414. package/src/server-errors.ts +93 -0
  415. package/src/server-handshake.ts +783 -0
  416. package/src/server-response.ts +76 -0
  417. package/src/server-trust.ts +245 -0
  418. package/src/setup-confirm.ts +171 -0
  419. package/src/setup.ts +186 -0
  420. package/src/shell-escape.ts +22 -0
  421. package/src/spawn.ts +31 -0
  422. package/src/startup-services.ts +36 -0
  423. package/src/stream-owner.ts +686 -0
  424. package/src/stream-status.ts +337 -0
  425. package/src/sync-roles-render.ts +163 -0
  426. package/src/sync.ts +363 -0
  427. package/src/terminal-title.ts +76 -0
  428. package/src/token-store.ts +102 -0
  429. package/src/tool-manifest.ts +610 -0
  430. package/src/tool-scope.ts +110 -0
  431. package/src/types.ts +5 -0
  432. package/src/unknown-subcommand.ts +35 -0
  433. package/src/version.ts +82 -0
  434. package/src/working-repo.ts +99 -0
  435. package/src/worktree-lifecycle.ts +254 -0
  436. package/dist/auth-recovery.d.ts +0 -33
  437. package/dist/auth-recovery.js +0 -1
  438. package/dist/auth.d.ts +0 -103
  439. package/dist/auth.js +0 -39
  440. package/dist/device-auth.d.ts +0 -75
  441. package/dist/device-auth.js +0 -1
  442. package/dist/drone-address.d.ts +0 -26
  443. package/dist/drone-address.js +0 -1
  444. package/dist/get-started.d.ts +0 -20
  445. package/dist/get-started.js +0 -2
  446. package/dist/health-beat.d.ts +0 -134
  447. package/dist/health-beat.js +0 -1
  448. package/dist/role-section.d.ts +0 -110
  449. package/dist/role-section.js +0 -8
  450. package/dist/setup-action.d.ts +0 -20
  451. package/dist/setup-action.js +0 -1
  452. package/dist/stale-version-check.d.ts +0 -40
  453. package/dist/stale-version-check.js +0 -1
  454. package/dist/subscription-retry.d.ts +0 -40
  455. package/dist/subscription-retry.js +0 -1
  456. package/dist/templates.d.ts +0 -107
  457. package/dist/templates.js +0 -359
  458. package/dist/token-crypto.d.ts +0 -50
  459. package/dist/token-crypto.js +0 -1
@@ -1,12 +1,1214 @@
1
- import{promises as E}from"node:fs";import ie from"node:os";import L from"node:path";import{getActiveCube as se,inboxPathForDrone as U}from"./cubes.js";import{DroneEvictedError as q,DRONE_EVICTED_CODE as ce,EVICTED_RESULT_MARKER as le,errorCodeFromBody as de}from"./drone-lifecycle.js";import{CODEX_HEARTBEAT_CADENCE_MS as z,fireCodexHeartbeatTick as ue,formatCodexWakePrompt as fe,resolveSessionAgentKind as pe,startCodexHeartbeat as me,wakeCodexViaAppServer as we}from"./codex-app-wake.js";import{getValidToken as he}from"./remote-client.js";import{recordEventReceipt as be,emitHealthBeat as ge,getCachedMonitorHealthy as Ie,getCachedWakeArmed as _e}from"./health-beat.js";import{getPackageVersion as ye}from"./version.js";import{acquireStreamLease as Y,readOwnershipSnapshot as S}from"./stream-owner.js";const Q=9e4,Ee=2e3,Se=500,Te=3e4;let P;function tt(e){P=e}const xe=50,W=512,nt=W*2;function Ae(){try{const e=ie.hostname();return e&&e.trim()?e.trim().slice(0,255):null}catch{return null}}const Ce=Date.now(),a={connected:!1,lastWireActivityAt:null,lastContentEventAt:null,lastHeartbeatAt:null,lastPersistedEventId:null,reconnectAttempts:0,runLoopRestartCount:0,ownership:{state:"unowned"}};function ke(e,t=Date.now()-Ce){return e.connected&&e.lastWireActivityAt?"connected":!e.connected&&e.reconnectAttempts>0?"reconnecting":!e.connected&&!e.lastWireActivityAt&&e.reconnectAttempts===0&&t>1e4?"silent-inert":"never-started"}function rt(){return{...a,runLoopHealth:ke(a)}}function ot(){a.connected=!1,a.lastWireActivityAt=null,a.lastContentEventAt=null,a.lastHeartbeatAt=null,a.lastPersistedEventId=null,a.reconnectAttempts=0,a.runLoopRestartCount=0,a.ownership={state:"unowned"}}let D=null,T=null,B=z;function j(e=He){D||(D=e())}function He(){return me({tick:()=>{ue({isStreamOwner:()=>a.ownership?.state==="owner",onAppServerSocketDead:De})}})}function De(){V(),ve()}function ve(e=B){T||(T=setTimeout(()=>{T=null,j()},e),T.unref?.())}function V(){D&&clearInterval(D),D=null,T&&clearTimeout(T),T=null}function at(){V(),B=z}function it(e){B=e}function Oe(){(async()=>{for(;;){try{await ee(),process.stderr.write(`[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s
2
- `)}catch(e){process.stderr.write(`[borg-mcp log stream] runLoop threw: ${e?.message??e}; restarting in 5s
3
- `)}a.runLoopRestartCount+=1,await oe(5e3)}})()}function st(e={}){j(),(e.runForever??Oe)()}const Z={fetchImpl:globalThis.fetch.bind(globalThis),appendLine:We,hasInboxEntryId:Ge,getToken:he,wakeCodex:we,heartbeatTimeoutMs:Q,hwmDivergenceGraceMs:Ee,abortSignal:new AbortController().signal,ownerDeps:{},ownerStaleMs:7e4,onInboxReceipt:Re,injectOpenCode:e=>P?P(e):Promise.resolve(!1)};function Re(e,t){be(),ge(e,{sseConnected:!0,inboxMonitorHealthy:Ie(),wakeArmed:_e(),agentKind:pe(),hostname:Ae(),version:ye(),getToken:async()=>t,fetchImpl:globalThis.fetch.bind(globalThis)})}async function ee(e={}){const t=e.getActiveCube??se,i=e.acquireStreamLease??Y,r=e.sleep??oe,c=e.maxIterations??1/0;let o=0,s=0,l=null,g=null,u=null,w=null;for(;o<c;){o+=1;const d=await t();if(!d){u&&(await u.release(),u=null,w=null),a.connected=!1,a.ownership={state:"unowned"},V(),await r(5e3);continue}j(),d.cubeId!==g&&(g=d.cubeId,l=null);const k=`${d.cubeId}:${d.droneId}`;if(u&&w!==k&&(await u.release(),u=null,w=null),u||(u=await i(d.cubeId,d.droneId),w=u?k:null),!u){a.connected=!1,a.ownership=await S(d.cubeId,d.droneId),await r(5e3);continue}a.ownership=await S(d.cubeId,d.droneId);let x=!1;try{const m=new AbortController,I=async()=>{try{await u.refresh()||(x=!0,m.abort(new Error("stream ownership lost")))}catch(h){x=!0,m.abort(h instanceof Error?h:new Error(String(h)))}},A=setInterval(()=>{I()},Math.max(1e3,Math.floor(Q/2)));try{await te(d,l,h=>{l=h},{abortSignal:m.signal})}finally{clearInterval(A)}if(x){u=null,w=null,a.connected=!1,a.ownership=await S(d.cubeId,d.droneId),await r(5e3);continue}s=0,a.reconnectAttempts=0}catch(m){if(x){u=null,w=null,a.connected=!1,a.ownership=await S(d.cubeId,d.droneId),await r(5e3);continue}if(m instanceof q){u&&await u.release().catch(()=>{}),u=null,w=null,a.connected=!1,a.ownership=await S(d.cubeId,d.droneId),process.stderr.write(`[borg-mcp log stream] drone evicted \u2014 stream terminated (no reconnect).
4
- `);return}a.connected=!1;const I=Math.min(Se*2**s,Te)+Math.random()*500;process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(I)}ms: ${m?.message??m}
5
- `),s+=1,a.reconnectAttempts=s,await r(I)}}}function ct(e){return ee(e)}async function te(e,t,i,r={}){const{fetchImpl:c,appendLine:o,hasInboxEntryId:s,getToken:l,wakeCodex:g,heartbeatTimeoutMs:u,hwmDivergenceGraceMs:w,abortSignal:d,onInboxReceipt:k,injectOpenCode:x}={...Z,...r},m=await l(),I={Authorization:`Bearer ${m}`,"X-Drone-Session":e.sessionToken,Accept:"text/event-stream"};t&&(I["Last-Event-ID"]=t);const A=new AbortController,h=()=>{try{A.abort(d.reason??new Error("external abort"))}catch{}};d.aborted&&h(),d.addEventListener("abort",h,{once:!0});let b=null;const G=()=>{b&&clearTimeout(b),b=setTimeout(()=>{try{A.abort(new Error("heartbeat watchdog timeout"))}catch{}},u)};G();let K=t,p=null,_=null;const H=()=>{_&&(clearTimeout(_.timer),_=null)};let v=null;const O=(n,f)=>{const R={id:n,created_at:f};v&&f&&v.created_at&&C(R,v)<=0||(v=R,K=n,a.lastPersistedEventId=n,i(n))},M=n=>{n&&(p=!p||C(n,p)>0?n:p,_&&C(p,_.hwm)>=0&&H())},ae=n=>{if(_?.hwm.id===n.id)return;H();const f=setTimeout(()=>{if(p&&C(p,n)>=0){H();return}try{A.abort(new Error("hwm divergence \u2014 reconnect for catchup"))}catch{}},w);_={hwm:n,timer:f}},N=new Set,$=[];let X=t!==null;const J=async n=>{const f=Fe(Ne(n.data,n.id));return X&&await s(e.cubeId,e.droneId,n.id,f)?(O(n.id,n.data?.created_at??""),"persisted-skip"):await x(f)?(k(e,m),"written"):(await o(e.cubeId,e.droneId,f),g(fe(f)),k(e,m),"written")},F=n=>{for(N.add(n.id),$.push(n.id);$.length>xe;){const f=$.shift();f&&N.delete(f)}O(n.id,n.data?.created_at??""),M(re(n))};let y;try{y=await c(`${e.apiUrl}/api/drone/stream`,{method:"GET",headers:I,signal:A.signal})}catch(n){throw b&&clearTimeout(b),n}if(!y.ok||!y.body){if(b&&clearTimeout(b),y.status===410){const n=await y.text().catch(()=>"");if(de(n)===ce)throw new q}throw new Error(`stream HTTP ${y.status}`)}a.connected=!0;try{for await(const n of Le(y.body)){G();const f=new Date().toISOString();if(a.lastWireActivityAt=f,(n.type==="log"||n.type==="bookmark")&&(a.lastContentEventAt=f),n.type==="eviction"){a.lastContentEventAt=f;try{await o(e.cubeId,e.droneId,Pe(n.reason))}catch{}break}if(n.type==="heartbeat"){if(a.lastHeartbeatAt=f,n.hwm&&p===null){M(n.hwm),K===null&&O(n.hwm.id,n.hwm.created_at);continue}if(n.hwm&&p&&C(n.hwm,p)<=0){H();continue}n.hwm&&p&&C(n.hwm,p)>0&&ae(n.hwm);continue}if(n.type==="bookmark"){X=!1;continue}if(n.type==="log"){if(N.has(n.id)){O(n.id,n.data?.created_at??""),M(re(n));continue}const R=typeof n.data?.message=="string"&&n.data.message.startsWith("[HEARTBEAT-PING]");if(n.data?.kind==="ack"){if(n.data?.author_drone_id===e.droneId&&await J(n)==="persisted-skip")continue;F(n);continue}if(n.data?.drone_id===e.droneId&&!R){F(n);continue}if(await J(n)==="persisted-skip")continue;F(n)}}}finally{d.removeEventListener("abort",h),b&&clearTimeout(b),H(),a.connected=!1}}async function lt(e,t,i,r={}){const{ownerDeps:c,ownerStaleMs:o}={...Z,...r},s=await Y(e.cubeId,e.droneId,o,c);if(!s)return a.connected=!1,a.ownership=await S(e.cubeId,e.droneId,c),"skipped";a.ownership=await S(e.cubeId,e.droneId,c);try{return await te(e,t,i,r),"streamed"}finally{await s.release()}}async function*Le(e){const t=e.getReader(),i=new TextDecoder;let r="";try{for(;;){const{value:c,done:o}=await t.read();if(o){if(r.trim()){const l=ne(r);l&&(yield l)}return}r+=i.decode(c,{stream:!0});let s;for(;(s=r.indexOf(`
6
-
7
- `))!==-1;){const l=r.slice(0,s);r=r.slice(s+2);const g=ne(l);g&&(yield g)}}}finally{try{t.releaseLock()}catch{}}}function ne(e){let t=null,i=null,r=[];for(const o of e.split(`
8
- `))o.startsWith("event:")?t=o.slice(6).trim():o.startsWith("id:")?i=o.slice(3).trim():o.startsWith("data:")&&r.push(o.slice(5).trim());const c=r.join(`
9
- `);if(!t)return null;if(t==="log"){if(!i)return null;let o;try{o=JSON.parse(c)}catch{return null}return{type:"log",id:i,data:o}}if(t==="heartbeat"){let o=null,s=null;try{const l=JSON.parse(c);o=typeof l.ts=="string"?l.ts:null,s=Me(l.hwm)}catch{}return{type:"heartbeat",ts:o,hwm:s}}if(t==="bookmark"){let o=null;try{const s=JSON.parse(c);o=typeof s.as_of=="string"?s.as_of:null}catch{}return{type:"bookmark",as_of:o}}if(t==="eviction"){let o=null,s=null;try{const l=JSON.parse(c);o=typeof l.cube_id=="string"?l.cube_id:null,s=typeof l.reason=="string"?l.reason:null}catch{}return{type:"eviction",cube_id:o,reason:s}}return{type:"unknown",raw:e}}function Me(e){if(!e||typeof e!="object")return null;const t=e;return typeof t.id=="string"&&t.id.length>0&&typeof t.created_at=="string"&&t.created_at.length>0?{id:t.id,created_at:t.created_at}:null}function C(e,t){const i=Date.parse(e.created_at),r=Date.parse(t.created_at);return Number.isFinite(i)&&Number.isFinite(r)&&i!==r?i-r:e.created_at!==t.created_at?e.created_at<t.created_at?-1:1:e.id.localeCompare(t.id)}function re(e){if(e.data?.visibility==="direct"||e.data?.kind==="ack")return null;const t=e.data?.created_at;return typeof t=="string"&&t.length>0?{id:e.id,created_at:t}:null}function Ne(e,t){return!e||typeof e!="object"?{id:t}:typeof e.id=="string"&&e.id.length>0?e:{...e,id:t}}function $e(...e){for(const t of e)if(typeof t=="string"&&t.length>0)return t;return""}function Fe(e){const t=typeof e.created_at=="string"?new Date(e.created_at).toISOString():new Date().toISOString(),i=e.drone_label??"?",r=e.role_name??"?",c=typeof e.message=="string"?e.message:"",o=$e(e.id,e.entry_id),s=o?`[entry_id: ${o}] `:"",l=c.replace(/\r\n|\r|\n/g," \u23CE ");return`${t} ${i} (${r}): ${s}${l}`}function Pe(e){const t=new Date().toISOString(),i=e&&e.trim().length>0?e:"evicted from cube";return`${t} SYSTEM (eviction): ${le} ${i} \u2014 confirm with any borg_* call; on DRONE_EVICTED (410) shut down: print the terminal message, TaskStop the inbox Monitor, do NOT reschedule /loop.`}async function We(e,t,i){const r=U(e,t);await Be(r,i,W)}async function Be(e,t,i=W,r=i*2){await E.mkdir(L.dirname(e),{recursive:!0}),await E.appendFile(e,t+`
10
- `,"utf-8"),await je(e,i,r)}async function je(e,t,i=t){if(!Number.isInteger(t)||t<1)throw new Error("maxLines must be a positive integer");if(!Number.isInteger(i)||i<t)throw new Error("trimThresholdLines must be an integer >= maxLines");const c=(await E.readFile(e,"utf-8")).split(/\r?\n/);if(c.at(-1)===""&&c.pop(),c.length<=i)return;const o=c.slice(-t),s=L.join(L.dirname(e),`.${L.basename(e)}.${process.pid}.${Date.now()}.tmp`);try{await E.writeFile(s,o.join(`
11
- `)+`
12
- `,"utf-8"),await E.rename(s,e)}catch(l){throw await E.rm(s,{force:!0}),l}}function Ve(e,t,i){if(t&&e.includes(`[entry_id: ${t}]`))return!0;const r=t?i.replace(`[entry_id: ${t}] `,""):i;return!!(r&&e.split(/\r?\n/).includes(r))}async function Ge(e,t,i,r){const c=U(e,t);let o;try{o=await E.readFile(c,"utf-8")}catch(s){if(s?.code==="ENOENT")return!1;throw s}return Ve(o,i,r)}function oe(e){return new Promise(t=>setTimeout(t,e))}export{W as INBOX_TAIL_LINES_CAP,nt as INBOX_TAIL_TRIM_THRESHOLD_LINES,at as __resetCodexHeartbeatForTest,ot as __resetStreamStateForTest,ct as __runLoopForTest,it as __setCodexReArmDelayForTest,Be as appendCappedInboxLine,ke as classifyRunLoopHealth,C as compareBroadcastHwm,j as ensureCodexHeartbeatStarted,Pe as formatEvictionSentinelLine,Fe as formatInboxLine,rt as getStreamStatus,Ve as inboxRawHasEntry,Le as parseSSE,tt as setModuleInjectOpenCode,st as startLogStream,V as stopCodexHeartbeat,te as streamOnce,lt as streamOnceIfOwner,je as trimInboxFileToRecentLines};
1
+ /**
2
+ * Real-time drone wakeup via Server-Sent Events.
3
+ *
4
+ * Replaces the former long-poll + inbox-file-shim wire path
5
+ * for the wire layer; the local inbox file is preserved as the
6
+ * Claude-side wake primitive (Monitor on tail -F) because Claude Code
7
+ * does not currently wake idle agent loops on MCP-protocol
8
+ * notifications. See spec:
9
+ * docs/superpowers/specs/2026-05-11-server-push-log-subscription.md
10
+ *
11
+ * Lifetimes:
12
+ * - One persistent fetch-streaming connection to the cube-scoped stream
13
+ * per active cube.
14
+ * - On every received `event: log`, a single line is appended to the
15
+ * per-drone inbox file (same format the old poller wrote).
16
+ * - On every `event: heartbeat`, any carried hwm is compared against
17
+ * `lastPersistedEventId`; divergence starts a short grace timer
18
+ * before reconnect so an in-flight live broadcast can arrive first.
19
+ * - Outer `runLoop` reconnects with exponential backoff on any
20
+ * stream-level error, including heartbeat watchdog firing.
21
+ *
22
+ * State exposed via `getStreamStatus()` so the `borg_stream-status`
23
+ * MCP tool can probe without perturbing the stream (no second
24
+ * connection, no second auth — just an in-process state snapshot).
25
+ */
26
+ import { Buffer } from 'node:buffer';
27
+ import { promises as fs } from 'node:fs';
28
+ import path from 'node:path';
29
+ import { compareBroadcastHwm } from 'borgmcp-shared/log-stream-hwm';
30
+ import { getActiveCube, inboxPathForDrone } from './cubes.js';
31
+ import { assertUuidShape } from './evict-drone.js';
32
+ import { loadBorgServerTrust } from './server-trust.js';
33
+ import { advanceLocalServerCursor, clearLocalServerCursor, encodeLocalServerCursor, getLocalServerCursor, } from './local-server-cursor.js';
34
+ import { DroneEvictedError, DRONE_EVICTED_CODE, EVICTED_RESULT_MARKER, errorCodeFromBody, } from './drone-lifecycle.js';
35
+ import { CODEX_HEARTBEAT_CADENCE_MS, fireCodexHeartbeatTick, formatCodexWakePrompt, startCodexHeartbeat, wakeCodexViaAppServer, } from './codex-app-wake.js';
36
+ import { readBoundedResponseBody } from './server-response.js';
37
+ import { acquireStreamLease, readOwnershipSnapshot, STREAM_OWNER_STALE_MS, } from './stream-owner.js';
38
+ // ------------------------------------------------------------------
39
+ // Tuning constants
40
+ // ------------------------------------------------------------------
41
+ /** Server emits heartbeats every 20s; allow several misses before reconnecting. */
42
+ const HEARTBEAT_TIMEOUT_MS = 90_000;
43
+ /** Grace window for a heartbeat HWM that is ahead of the local cursor. */
44
+ const HWM_DIVERGENCE_GRACE_MS = 2_000;
45
+ const RECONNECT_MIN_MS = 500;
46
+ const RECONNECT_MAX_MS = 30_000;
47
+ export const LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES = 64 * 1024;
48
+ /**
49
+ * client#42: the server's structured code for an expired stream resume cursor
50
+ * (server 410 CURSOR_EXPIRED — the pointed-at entry was pruned). Recoverable,
51
+ * NOT terminal: distinct from DRONE_EVICTED.
52
+ */
53
+ export const CURSOR_EXPIRED_CODE = 'CURSOR_EXPIRED';
54
+ /**
55
+ * client#42: recoverable stream error thrown when the server rejects the resume
56
+ * cursor as expired. The resume cursor is reset BEFORE this is thrown, so the
57
+ * reconnect loop's next connect re-establishes from a fresh valid point instead
58
+ * of looping forever on the dead cursor. Deliberately NOT a DroneEvictedError —
59
+ * the reconnect loop treats only DroneEvictedError as terminal, so this falls
60
+ * through to the ordinary backoff-reconnect (which now recovers).
61
+ */
62
+ export class StreamCursorExpiredError extends Error {
63
+ constructor(message = 'stream resume cursor expired — reset for catchup, reconnecting') {
64
+ super(message);
65
+ this.name = 'StreamCursorExpiredError';
66
+ }
67
+ }
68
+ function isProcessAlive(pid) {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ return error.code === 'EPERM';
75
+ }
76
+ }
77
+ /**
78
+ * gh#opencode: module-level mutable opencode entry injector. Set after
79
+ * startup (from index.ts) once the opencode drone module is initialized.
80
+ * Used by defaultDeps when no explicit injectOpenCode is supplied.
81
+ */
82
+ let _moduleInjectOpenCode;
83
+ export function setModuleInjectOpenCode(fn) {
84
+ _moduleInjectOpenCode = fn;
85
+ }
86
+ /**
87
+ * Bounded recent-id set sized for the SSE replay window per spec §(3).
88
+ * The catchup query returns up to 200 entries on reconnect; 50 is
89
+ * comfortable headroom for the typical reorder/dedup case (1–3
90
+ * entries) without the memory cost of the old long-poll 500-entry
91
+ * ring buffer.
92
+ */
93
+ const RECENT_IDS_CAP = 50;
94
+ export const INBOX_TAIL_LINES_CAP = 512;
95
+ export const INBOX_TAIL_TRIM_THRESHOLD_LINES = INBOX_TAIL_LINES_CAP * 2;
96
+ const processStartMs = Date.now();
97
+ const streamState = {
98
+ connected: false,
99
+ lastWireActivityAt: null,
100
+ lastContentEventAt: null,
101
+ lastHeartbeatAt: null,
102
+ lastPersistedEventId: null,
103
+ reconnectAttempts: 0,
104
+ runLoopRestartCount: 0,
105
+ ownership: { state: 'unowned' },
106
+ };
107
+ /**
108
+ * Snapshot of the current stream status. Safe to call at any time
109
+ * (returns a copy, does not interact with the running connection).
110
+ */
111
+ export function classifyRunLoopHealth(state, uptimeMs = Date.now() - processStartMs) {
112
+ if (state.connected && state.lastWireActivityAt)
113
+ return 'connected';
114
+ if (!state.connected && state.reconnectAttempts > 0)
115
+ return 'reconnecting';
116
+ if (!state.connected && !state.lastWireActivityAt && state.reconnectAttempts === 0 && uptimeMs > 10_000)
117
+ return 'silent-inert';
118
+ return 'never-started';
119
+ }
120
+ export function getStreamStatus() {
121
+ return { ...streamState, runLoopHealth: classifyRunLoopHealth(streamState) };
122
+ }
123
+ /**
124
+ * Reset the module-level stream state. EXPORTED FOR TESTS ONLY — the
125
+ * stream state is a singleton that accumulates across the process
126
+ * lifetime in normal operation. Tests asserting on the new
127
+ * content-vs-wire field semantics need a clean slate between
128
+ * scenarios; nothing in production code should call this.
129
+ *
130
+ * @internal — test-only surface; not part of the public client API. The
131
+ * `__`-prefix + `ForTest` suffix mark it as such at every call site.
132
+ */
133
+ export function __resetStreamStateForTest() {
134
+ streamState.connected = false;
135
+ streamState.lastWireActivityAt = null;
136
+ streamState.lastContentEventAt = null;
137
+ streamState.lastHeartbeatAt = null;
138
+ streamState.lastPersistedEventId = null;
139
+ streamState.reconnectAttempts = 0;
140
+ streamState.runLoopRestartCount = 0;
141
+ streamState.ownership = { state: 'unowned' };
142
+ }
143
+ // ------------------------------------------------------------------
144
+ // Entry point
145
+ // ------------------------------------------------------------------
146
+ /**
147
+ * Spawn the background SSE consumer loop. Fire-and-forget; the loop
148
+ * runs until process exit. Errors are written to stderr (so they don't
149
+ * pollute the MCP stdio channel) and the loop continues.
150
+ *
151
+ * Idempotent in the sense that calling twice would create two parallel
152
+ * loops; the caller (`index.ts`) wires this once at startup.
153
+ */
154
+ let codexHeartbeatTimer = null;
155
+ // gh#866 item 3: a one-shot deferred re-arm scheduled by the ENOENT teardown
156
+ // path so a mid-session app-server restart self-heals within the session (see
157
+ // onCodexAppServerSocketDead). null when no re-arm is pending.
158
+ let codexReArmTimer = null;
159
+ // Re-arm delay = one tick cadence: closes the mid-session permanent-deaf gap to
160
+ // a single-cadence window (== the no-teardown baseline) without busy-probing a
161
+ // dead socket. Overridable for tests.
162
+ let codexReArmDelayMs = CODEX_HEARTBEAT_CADENCE_MS;
163
+ /**
164
+ * gh#857 WI-2: start the codex /loop-equivalent heartbeat AT MOST ONCE — a
165
+ * re-entrant startLogStream must not leak a second interval (idempotent start).
166
+ * No-op for claude (startCodexHeartbeat returns null on a non-codex session, so
167
+ * no timer is ever stored). Extracted + injectable (`start`) so the production
168
+ * wiring is unit-testable without running the real stream loop (QA e75339e7).
169
+ */
170
+ export function ensureCodexHeartbeatStarted(start = defaultStartCodexHeartbeat) {
171
+ if (codexHeartbeatTimer)
172
+ return; // already started → don't leak a second timer
173
+ codexHeartbeatTimer = start();
174
+ }
175
+ /**
176
+ * Production heartbeat start: schedule the codex tick with the two gh#861 gates
177
+ * wired from the stream loop's own state —
178
+ * - finding 2: `isStreamOwner` mirrors the per-entry path's lease-silencing so a
179
+ * lease-LOSING duplicate child never injects;
180
+ * - finding 3: `onAppServerSocketDead` tears the timer down when the tick finds
181
+ * the app-server socket gone, then schedules a one-shot deferred re-arm
182
+ * (gh#866 item 3) so a mid-session restart self-heals — see
183
+ * onCodexAppServerSocketDead.
184
+ */
185
+ function defaultStartCodexHeartbeat() {
186
+ return startCodexHeartbeat({
187
+ tick: () => void fireCodexHeartbeatTick({
188
+ isStreamOwner: () => streamState.ownership?.state === 'owner',
189
+ onAppServerSocketDead: onCodexAppServerSocketDead,
190
+ }),
191
+ });
192
+ }
193
+ /**
194
+ * gh#866 item 3: the tick's ENOENT (positively-dead app-server socket) teardown
195
+ * handler. The recurring timer is torn down (gh#861 finding 3 — don't keep
196
+ * ticking against a dead socket), BUT because the only other re-arm site is the
197
+ * runLoop top — unreachable while `streamOnce` holds the connected SSE session,
198
+ * which can outlive an app-server restart — we ALSO schedule a one-shot deferred
199
+ * re-arm. A transient ENOENT (codex app-server restart) therefore self-heals
200
+ * within the session instead of leaving the seat permanently deaf until the next
201
+ * reconnect. The re-armed timer's first tick re-probes: still dead → tears down +
202
+ * reschedules (a gentle one-cadence probe loop); alive → resumes normal cadence.
203
+ * Distinct from the runLoop cube-cleared teardown, which calls stopCodexHeartbeat
204
+ * directly and is correctly re-armed by runLoop when an active cube returns.
205
+ */
206
+ function onCodexAppServerSocketDead() {
207
+ stopCodexHeartbeat();
208
+ scheduleCodexHeartbeatReArm();
209
+ }
210
+ /**
211
+ * Schedule a single deferred re-arm of the codex heartbeat (gh#866 item 3).
212
+ * Idempotent: a re-arm already pending is not stacked. The timer is unref'd so
213
+ * it never alone keeps the process alive.
214
+ */
215
+ function scheduleCodexHeartbeatReArm(delayMs = codexReArmDelayMs) {
216
+ if (codexReArmTimer)
217
+ return; // a re-arm is already pending → don't stack
218
+ codexReArmTimer = setTimeout(() => {
219
+ codexReArmTimer = null;
220
+ ensureCodexHeartbeatStarted();
221
+ }, delayMs);
222
+ codexReArmTimer.unref?.();
223
+ }
224
+ /**
225
+ * gh#861 finding 3: tear down the codex heartbeat timer — the teardown seam for
226
+ * the periodic interval. Called when the active cube is cleared (nothing to inject
227
+ * into) or the tick detects a dead app-server socket. Also clears any pending
228
+ * deferred re-arm (gh#866 item 3) so a cube-cleared teardown doesn't leave a
229
+ * stray re-arm queued. Re-armable: ensureCodexHeartbeatStarted starts a fresh
230
+ * timer once an active cube returns.
231
+ */
232
+ export function stopCodexHeartbeat() {
233
+ if (codexHeartbeatTimer)
234
+ clearInterval(codexHeartbeatTimer);
235
+ codexHeartbeatTimer = null;
236
+ if (codexReArmTimer)
237
+ clearTimeout(codexReArmTimer);
238
+ codexReArmTimer = null;
239
+ }
240
+ /** Test-only alias of the production teardown seam (re-testable idempotence). */
241
+ export function __resetCodexHeartbeatForTest() {
242
+ stopCodexHeartbeat();
243
+ codexReArmDelayMs = CODEX_HEARTBEAT_CADENCE_MS;
244
+ }
245
+ /**
246
+ * Test-only override of the gh#866-item3 deferred re-arm delay so the
247
+ * mid-session re-arm lifecycle is drivable with a tiny real delay instead of the
248
+ * 20-minute cadence. Reset by __resetCodexHeartbeatForTest.
249
+ * @internal
250
+ */
251
+ export function __setCodexReArmDelayForTest(ms) {
252
+ codexReArmDelayMs = ms;
253
+ }
254
+ /** The forever stream loop (extracted so startLogStream's wiring is testable). */
255
+ function runStreamLoopForever() {
256
+ void (async () => {
257
+ while (true) {
258
+ try {
259
+ await runLoop();
260
+ process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
261
+ }
262
+ catch (err) {
263
+ process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
264
+ }
265
+ streamState.runLoopRestartCount += 1;
266
+ await sleep(5000);
267
+ }
268
+ })();
269
+ }
270
+ export function startLogStream(opts = {}) {
271
+ // gh#857 WI-2: start the codex heartbeat (independent periodic drain backstop),
272
+ // guarded so a re-entrant call can't leak a second interval. Lives for the
273
+ // child's life, runs regardless of SSE connection state.
274
+ ensureCodexHeartbeatStarted();
275
+ // The forever loop is injectable so tests can pin THIS wiring (the heartbeat
276
+ // start above) without spawning the real network/keychain loop (QA 75f18e8f).
277
+ (opts.runForever ?? runStreamLoopForever)();
278
+ }
279
+ const defaultDeps = {
280
+ fetchImpl: globalThis.fetch.bind(globalThis),
281
+ loadTrust: loadBorgServerTrust,
282
+ getCursor: getLocalServerCursor,
283
+ appendLine: defaultAppendLine,
284
+ hasInboxEntryId: defaultHasInboxEntryId,
285
+ wakeCodex: wakeCodexViaAppServer,
286
+ heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
287
+ hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
288
+ abortSignal: new AbortController().signal,
289
+ ownerDeps: {},
290
+ ownerStaleMs: 70_000,
291
+ injectOpenCode: (text) => _moduleInjectOpenCode ? _moduleInjectOpenCode(text) : Promise.resolve(false),
292
+ };
293
+ async function runLoop(testDeps = {}) {
294
+ const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
295
+ const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
296
+ const _streamOnce = testDeps.streamOnce ?? streamOnce;
297
+ const _sleep = testDeps.sleep ?? sleep;
298
+ const _maxIterations = testDeps.maxIterations ?? Infinity;
299
+ let _iterations = 0;
300
+ let attempt = 0;
301
+ let lastEventId = null;
302
+ let currentCubeId = null;
303
+ let lease = null;
304
+ let leaseKey = null;
305
+ try {
306
+ while (_iterations < _maxIterations) {
307
+ _iterations += 1;
308
+ const active = await _getActiveCube();
309
+ if (!active) {
310
+ if (lease) {
311
+ await lease.release();
312
+ lease = null;
313
+ leaseKey = null;
314
+ }
315
+ streamState.connected = false;
316
+ streamState.ownership = { state: 'unowned' };
317
+ // gh#861 finding 3: active cube cleared → tear down the codex heartbeat
318
+ // (nothing to inject into); re-armed below once an active cube returns.
319
+ stopCodexHeartbeat();
320
+ await _sleep(5000);
321
+ continue;
322
+ }
323
+ // gh#861 finding 3: an active cube is present → ensure the codex heartbeat is
324
+ // running. Idempotent (no-op when already armed); re-arms after a prior
325
+ // teardown (cube-cleared / dead app-server socket).
326
+ ensureCodexHeartbeatStarted();
327
+ // Reset resume cursor on cube switch — entries from a prior cube
328
+ // mean nothing for the new cube's stream.
329
+ if (active.cubeId !== currentCubeId) {
330
+ currentCubeId = active.cubeId;
331
+ lastEventId = null;
332
+ }
333
+ const nextLeaseKey = `${active.cubeId}:${active.droneId}`;
334
+ if (lease && leaseKey !== nextLeaseKey) {
335
+ await lease.release();
336
+ lease = null;
337
+ leaseKey = null;
338
+ }
339
+ if (!lease) {
340
+ lease = await _acquireStreamLease(active.cubeId, active.droneId, STREAM_OWNER_STALE_MS, { isPidAlive: isProcessAlive });
341
+ leaseKey = lease ? nextLeaseKey : null;
342
+ }
343
+ if (!lease) {
344
+ streamState.connected = false;
345
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
346
+ await _sleep(5000);
347
+ continue;
348
+ }
349
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
350
+ let ownerLost = false;
351
+ try {
352
+ const ownerAbort = new AbortController();
353
+ const refresh = async () => {
354
+ try {
355
+ if (!(await lease.refresh())) {
356
+ ownerLost = true;
357
+ ownerAbort.abort(new Error('stream ownership lost'));
358
+ }
359
+ }
360
+ catch (err) {
361
+ ownerLost = true;
362
+ ownerAbort.abort(err instanceof Error ? err : new Error(String(err)));
363
+ }
364
+ };
365
+ const refreshTimer = setInterval(() => {
366
+ void refresh();
367
+ }, Math.max(1000, Math.floor(HEARTBEAT_TIMEOUT_MS / 2)));
368
+ try {
369
+ await _streamOnce(active, lastEventId, (id) => {
370
+ lastEventId = id;
371
+ }, { abortSignal: ownerAbort.signal });
372
+ }
373
+ finally {
374
+ clearInterval(refreshTimer);
375
+ }
376
+ if (ownerLost) {
377
+ lease = null;
378
+ leaseKey = null;
379
+ streamState.connected = false;
380
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
381
+ await _sleep(5000);
382
+ continue;
383
+ }
384
+ // Clean disconnect (e.g. server-side rollout). Reset backoff.
385
+ attempt = 0;
386
+ streamState.reconnectAttempts = 0;
387
+ }
388
+ catch (err) {
389
+ if (ownerLost) {
390
+ lease = null;
391
+ leaseKey = null;
392
+ streamState.connected = false;
393
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
394
+ await _sleep(5000);
395
+ continue;
396
+ }
397
+ // gh#877 Path-B (B25): an authoritative DRONE_EVICTED is TERMINAL — the
398
+ // seat is gone. Stop reconnecting (do NOT back off and retry forever
399
+ // against a dead seat); release the lease and return so the child's SSE
400
+ // loop quiesces cleanly. The agent's graceful shutdown (TaskStop Monitor,
401
+ // no /loop reschedule) is driven separately by the EVICTED tool-result it
402
+ // already received on the authed call that produced this verdict.
403
+ if (err instanceof DroneEvictedError) {
404
+ if (lease)
405
+ await lease.release().catch(() => { });
406
+ lease = null;
407
+ leaseKey = null;
408
+ streamState.connected = false;
409
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
410
+ process.stderr.write(`[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`);
411
+ return;
412
+ }
413
+ streamState.connected = false;
414
+ const delay = Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
415
+ Math.random() * 500;
416
+ process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(delay)}ms: ${err?.message ?? err}\n`);
417
+ attempt += 1;
418
+ streamState.reconnectAttempts = attempt;
419
+ await _sleep(delay);
420
+ }
421
+ }
422
+ }
423
+ finally {
424
+ if (lease)
425
+ await lease.release().catch(() => { });
426
+ streamState.connected = false;
427
+ }
428
+ }
429
+ /**
430
+ * Test-only entry to the bounded form of runLoop (gh#866 item 2). Drives a
431
+ * fixed number of iterations with injected deps so the heartbeat teardown
432
+ * (cleared cube → `stopCodexHeartbeat`) and re-arm (active cube →
433
+ * `ensureCodexHeartbeatStarted`) seams are exercisable without real
434
+ * network/keychain IO. Never called in production.
435
+ *
436
+ * @internal — test-only surface; mirrors the `__…ForTest` convention used by
437
+ * `__resetStreamStateForTest` / `__resetCodexHeartbeatForTest`.
438
+ */
439
+ export function __runLoopForTest(testDeps) {
440
+ return runLoop(testDeps);
441
+ }
442
+ export async function streamOnce(active, lastEventId, onEventId, deps = {}) {
443
+ const { fetchImpl, loadTrust, getCursor, appendLine, hasInboxEntryId, wakeCodex, heartbeatTimeoutMs, hwmDivergenceGraceMs, abortSignal, injectOpenCode, } = { ...defaultDeps, ...deps };
444
+ assertUuidShape(active.cubeId, 'cube_id');
445
+ assertUuidShape(active.droneId, 'drone_id');
446
+ if (active.serverTrustIdentity === undefined) {
447
+ throw new Error('Selected Borg server authority state is missing or unreadable');
448
+ }
449
+ const token = active.sessionToken;
450
+ const headers = {
451
+ Authorization: `Bearer ${token}`,
452
+ Accept: 'text/event-stream',
453
+ };
454
+ let requestFetch = fetchImpl;
455
+ if (deps.fetchImpl === undefined) {
456
+ const trust = await loadTrust(active.apiUrl);
457
+ if (trust.identity !== active.serverTrustIdentity) {
458
+ throw new Error('Borg server trust identity changed; refusing the stream');
459
+ }
460
+ requestFetch = trust.fetchImpl;
461
+ }
462
+ const cursor = await getCursor({
463
+ origin: active.apiUrl,
464
+ trustIdentity: active.serverTrustIdentity,
465
+ cubeId: active.cubeId,
466
+ droneId: active.droneId,
467
+ purpose: 'stream',
468
+ });
469
+ const query = cursor ? `?cursor=${encodeURIComponent(encodeLocalServerCursor(cursor))}` : '';
470
+ const streamPath = `/api/cubes/${active.cubeId}/stream${query}`;
471
+ const ac = new AbortController();
472
+ const abortFromExternal = () => {
473
+ try {
474
+ ac.abort(abortSignal.reason ?? new Error('external abort'));
475
+ }
476
+ catch {
477
+ // ignore
478
+ }
479
+ };
480
+ if (abortSignal.aborted)
481
+ abortFromExternal();
482
+ abortSignal.addEventListener('abort', abortFromExternal, { once: true });
483
+ // Heartbeat watchdog: if no event of any type arrives within
484
+ // heartbeatTimeoutMs, abort the request so the outer loop reconnects.
485
+ let watchdog = null;
486
+ const bumpWatchdog = () => {
487
+ if (watchdog)
488
+ clearTimeout(watchdog);
489
+ watchdog = setTimeout(() => {
490
+ try {
491
+ ac.abort(new Error('heartbeat watchdog timeout'));
492
+ }
493
+ catch {
494
+ // ignore
495
+ }
496
+ }, heartbeatTimeoutMs);
497
+ };
498
+ bumpWatchdog();
499
+ // Local mirror of the resume cursor, updated AFTER each successful
500
+ // disk write (or dedup-recognized replay). Heartbeat-hwm comparison
501
+ // reads this value.
502
+ let lastPersistedEventId = lastEventId;
503
+ let lastBroadcastHwm = null;
504
+ let pendingHwmDivergence = null;
505
+ const clearPendingHwmDivergence = () => {
506
+ if (!pendingHwmDivergence)
507
+ return;
508
+ clearTimeout(pendingHwmDivergence.timer);
509
+ pendingHwmDivergence = null;
510
+ };
511
+ // gh#402 replay-storm amplifier fix (c80b1aaa #1): advance the resume cursor
512
+ // MONOTONICALLY by (created_at, id). An out-of-order older broadcast (or a
513
+ // catchup replay of an older entry) must NOT regress lastPersistedEventId —
514
+ // a regressed resume cursor widens the next reconnect's catchup window and
515
+ // re-replays entries (the tail -F storm). Reuses compareBroadcastHwm
516
+ // ((created_at,id) tiebreak), the SAME key the server orders broadcasts by.
517
+ //
518
+ // FAIL-OPEN on a missing created_at: every real SSE payload carries a
519
+ // non-empty created_at (EnrichedEntry / ack / heartbeat-hwm all guarantee
520
+ // it server-side), so the guard engages for every production event. But an
521
+ // absent/empty created_at is NOT ordinally comparable (UUID ids aren't
522
+ // either — see the dedup branch), so we only BLOCK a regression we can
523
+ // PROVE: both the incoming entry and the current cursor carry a real
524
+ // created_at AND the incoming is older-or-equal. Otherwise ADVANCE —
525
+ // freezing the resume cursor on a fresh forward entry that happens to lack
526
+ // a created_at would itself widen the next reconnect's window (the very
527
+ // storm this fixes). Proven older-or-equal events are still recorded in
528
+ // recentIds for dedup but do not move the cursor or re-fire onEventId.
529
+ let lastPersistedHwm = null;
530
+ const markEventPersisted = (id, createdAt) => {
531
+ const next = { id, created_at: createdAt };
532
+ if (lastPersistedHwm &&
533
+ createdAt &&
534
+ lastPersistedHwm.created_at &&
535
+ compareBroadcastHwm(next, lastPersistedHwm) <= 0) {
536
+ return;
537
+ }
538
+ lastPersistedHwm = next;
539
+ lastPersistedEventId = id;
540
+ streamState.lastPersistedEventId = id;
541
+ onEventId(id);
542
+ };
543
+ const markBroadcastPersisted = (hwm) => {
544
+ if (!hwm)
545
+ return;
546
+ lastBroadcastHwm =
547
+ !lastBroadcastHwm || compareBroadcastHwm(hwm, lastBroadcastHwm) > 0
548
+ ? hwm
549
+ : lastBroadcastHwm;
550
+ if (pendingHwmDivergence &&
551
+ compareBroadcastHwm(lastBroadcastHwm, pendingHwmDivergence.hwm) >= 0) {
552
+ clearPendingHwmDivergence();
553
+ }
554
+ };
555
+ const scheduleHwmDivergenceReconnect = (hwm) => {
556
+ if (pendingHwmDivergence?.hwm.id === hwm.id)
557
+ return;
558
+ clearPendingHwmDivergence();
559
+ const timer = setTimeout(() => {
560
+ if (lastBroadcastHwm && compareBroadcastHwm(lastBroadcastHwm, hwm) >= 0) {
561
+ clearPendingHwmDivergence();
562
+ return;
563
+ }
564
+ try {
565
+ ac.abort(new Error('hwm divergence — reconnect for catchup'));
566
+ }
567
+ catch {
568
+ // ignore
569
+ }
570
+ }, hwmDivergenceGraceMs);
571
+ pendingHwmDivergence = { hwm, timer };
572
+ };
573
+ // Bounded recent-id set for replay-on-reconnect dedup per spec §(3).
574
+ // Set + FIFO array for O(1) membership + bounded memory.
575
+ const recentIds = new Set();
576
+ const recentIdsOrder = [];
577
+ let isCatchingUp = lastEventId !== null;
578
+ // gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
579
+ // extracted from the previously-duplicated ack / regular-log branches in the
580
+ // event loop below. Behavior-preserving — the per-branch comments document
581
+ // the load-bearing semantics each path relies on.
582
+ // Format + (catchup-dedup-aware) append the entry's inbox line.
583
+ // LOAD-BEARING ORDER: the disk write must complete before the cursor
584
+ // advances (recordSeen), so an append failure (disk full, EACCES, path race
585
+ // during cube switch) replays the entry on reconnect rather than being
586
+ // skipped past by an already-advanced Last-Event-ID — the §(3) durability
587
+ // contract. Returns 'persisted-skip' when the entry is already on disk from
588
+ // an earlier catchup receive (caller advances the cursor via markEventPersisted
589
+ // here, then continues WITHOUT re-recording); 'written' after a fresh append.
590
+ const writeInboxLine = async (ev) => {
591
+ const line = formatInboxLine(withSseEventId(ev.data, ev.id));
592
+ if (isCatchingUp &&
593
+ // gh#441: pass the rendered line so the dedup can also recognize LEGACY
594
+ // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
595
+ (await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line))) {
596
+ markEventPersisted(ev.id, ev.data?.created_at ?? '');
597
+ return 'persisted-skip';
598
+ }
599
+ // gh#opencode: try autonomous opencode injection first. When the drone's
600
+ // child session processes entries directly, skip the inbox write (the
601
+ // Monitor/tail-F path is unused for opencode). Falls through to inbox on
602
+ // failure so the entry is never lost.
603
+ if (await injectOpenCode(line)) {
604
+ return 'written';
605
+ }
606
+ await appendLine(active.cubeId, active.droneId, line);
607
+ wakeCodex(formatCodexWakePrompt(line));
608
+ return 'written';
609
+ };
610
+ // Record the event in the bounded recent-ids dedup set (FIFO-capped) and
611
+ // advance both cursors. The broadcast-HWM cursor advances via
612
+ // broadcastHwmFromLogEvent, which returns null for visibility==='direct' and
613
+ // kind==='ack' (those do NOT advance the server's DO broadcast HWM either —
614
+ // D6), so it advances ONLY for broadcast entries. gh#402 replay-storm fix
615
+ // (583aed7e): this runs for OWN-POST broadcasts too — the author's own
616
+ // broadcast IS counted by the server HWM, so the client broadcast cursor
617
+ // must advance to match; otherwise the next heartbeat reads server-hwm >
618
+ // client-cursor and fires a spurious divergence-reconnect (the storm
619
+ // trigger). The null-for-direct/ack guard keeps own-direct echoes correct.
620
+ const recordSeen = async (ev) => {
621
+ recentIds.add(ev.id);
622
+ recentIdsOrder.push(ev.id);
623
+ while (recentIdsOrder.length > RECENT_IDS_CAP) {
624
+ const oldId = recentIdsOrder.shift();
625
+ if (oldId)
626
+ recentIds.delete(oldId);
627
+ }
628
+ markEventPersisted(ev.id, ev.data?.created_at ?? '');
629
+ markBroadcastPersisted(broadcastHwmFromLogEvent(ev));
630
+ if (ev.cursor) {
631
+ // client#41: delivery advances the STREAM cursor only. The unread
632
+ // watermark (read-log unread_only) is deliberately NOT touched here, so a
633
+ // wake-triggering entry stays unread until the agent drains it — SSE
634
+ // delivery must not consume the unread state (silent missed wake).
635
+ await advanceLocalServerCursor({
636
+ origin: active.apiUrl,
637
+ trustIdentity: active.serverTrustIdentity,
638
+ cubeId: active.cubeId,
639
+ droneId: active.droneId,
640
+ purpose: 'stream',
641
+ }, ev.cursor);
642
+ }
643
+ };
644
+ let response;
645
+ try {
646
+ response = await requestFetch(`${active.apiUrl}${streamPath}`, {
647
+ method: 'GET',
648
+ headers,
649
+ signal: ac.signal,
650
+ });
651
+ }
652
+ catch (err) {
653
+ if (watchdog)
654
+ clearTimeout(watchdog);
655
+ throw err;
656
+ }
657
+ if (!response.ok || !response.body) {
658
+ if (watchdog)
659
+ clearTimeout(watchdog);
660
+ // gh#877 Path-B (stream bootstrap): an evicted drone's stream re-subscribe
661
+ // returns the authoritative 410 DRONE_EVICTED. Surface it as the terminal
662
+ // typed error so the reconnect loop stops retrying (B25) instead of backing
663
+ // off forever against a dead seat. Keyed on the structured code, not the
664
+ // bare status (SEC R2). Any other status falls through to the generic throw
665
+ // so the reconnect loop keeps retrying transient failures.
666
+ if (response.status === 410) {
667
+ const body = await readBoundedResponseBody(response, LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES, 'Local Borg server SSE response exceeded the response limit', ac.signal).catch(() => '');
668
+ const code = errorCodeFromBody(body);
669
+ if (code === DRONE_EVICTED_CODE) {
670
+ throw new DroneEvictedError();
671
+ }
672
+ // client#42: an expired resume cursor is RECOVERABLE, not terminal. The
673
+ // pointed-at entry was pruned server-side, so retrying the SAME cursor
674
+ // 410s forever (a wedged, silently-dead wake path). Reset the stream's
675
+ // resume cursor to a fresh valid point (clear it → the next connect omits
676
+ // ?cursor= and the server re-establishes from the current tail), then
677
+ // throw the distinct recoverable error so the reconnect loop backs off and
678
+ // resumes — NOT the terminal DRONE_EVICTED path, NOT an infinite retry of
679
+ // the dead cursor. The unread watermark (client#41) is untouched, so no
680
+ // undrained wake is lost across the reset.
681
+ if (code === CURSOR_EXPIRED_CODE) {
682
+ await clearLocalServerCursor({
683
+ origin: active.apiUrl,
684
+ trustIdentity: active.serverTrustIdentity,
685
+ cubeId: active.cubeId,
686
+ droneId: active.droneId,
687
+ purpose: 'stream',
688
+ });
689
+ throw new StreamCursorExpiredError();
690
+ }
691
+ }
692
+ throw new Error(`stream HTTP ${response.status}`);
693
+ }
694
+ streamState.connected = true;
695
+ try {
696
+ for await (const event of parseSSE(response.body, LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES)) {
697
+ bumpWatchdog();
698
+ const nowIso = new Date().toISOString();
699
+ streamState.lastWireActivityAt = nowIso;
700
+ // Content vs wire split (T1.2): content freshness is what a reader
701
+ // skimming the top-line verdict actually cares about. Heartbeats
702
+ // bump wire-activity only; log and bookmark events bump both.
703
+ if (event.type === 'log' || event.type === 'bookmark') {
704
+ streamState.lastContentEventAt = nowIso;
705
+ }
706
+ // gh#877 Path-A: terminal eviction control frame. Handled EARLY (before
707
+ // log/heartbeat) so a replayed frame on reconnect still fires. This is a
708
+ // WAKE HINT with ZERO authority (SEC R2): write a sentinel line to the
709
+ // inbox file so the (possibly asleep) agent's Monitor wakes it, then close
710
+ // this stream. The agent CONFIRMS via an authed call that returns 410
711
+ // DRONE_EVICTED before tearing down — a spurious frame on a live seat is
712
+ // inert (the confirm returns normally). We do NOT terminate the agent here
713
+ // (the client process cannot reach the agent loop); we only deliver the
714
+ // wake. The reconnect's stream-bootstrap 410 (authoritative) is what flips
715
+ // this loop terminal below.
716
+ if (event.type === 'eviction') {
717
+ streamState.lastContentEventAt = nowIso;
718
+ try {
719
+ await appendLine(active.cubeId, active.droneId, formatEvictionSentinelLine(event.reason));
720
+ }
721
+ catch {
722
+ // Inbox write failed — the Path-B 410 backstop still tears the drone
723
+ // down on its next authed call. Best-effort wake only.
724
+ }
725
+ // Close this SSE session; the reconnect will hit the authoritative 410.
726
+ break;
727
+ }
728
+ if (event.type === 'heartbeat') {
729
+ streamState.lastHeartbeatAt = nowIso;
730
+ // First/baseline heartbeat absorb: until this session has seen
731
+ // a broadcast entry, the server's broadcast HWM is our baseline.
732
+ // Direct messages may advance the persistence cursor past this
733
+ // value; directional comparison must not treat that mirror case
734
+ // as missed-broadcast evidence.
735
+ if (event.hwm && lastBroadcastHwm === null) {
736
+ markBroadcastPersisted(event.hwm);
737
+ if (lastPersistedEventId === null) {
738
+ markEventPersisted(event.hwm.id, event.hwm.created_at);
739
+ }
740
+ continue;
741
+ }
742
+ if (event.hwm &&
743
+ lastBroadcastHwm &&
744
+ compareBroadcastHwm(event.hwm, lastBroadcastHwm) <= 0) {
745
+ clearPendingHwmDivergence();
746
+ continue;
747
+ }
748
+ // §(5) divergence-detection: reconnect only when the server's
749
+ // broadcast HWM is AHEAD of the client's broadcast cursor. A
750
+ // recipient persisting a direct entry legitimately advances
751
+ // lastPersistedEventId beyond the broadcast HWM; strict
752
+ // inequality would reintroduce gh#402 churn for that mirror case.
753
+ if (event.hwm &&
754
+ lastBroadcastHwm &&
755
+ compareBroadcastHwm(event.hwm, lastBroadcastHwm) > 0) {
756
+ scheduleHwmDivergenceReconnect(event.hwm);
757
+ }
758
+ continue;
759
+ }
760
+ if (event.type === 'bookmark') {
761
+ isCatchingUp = false;
762
+ continue;
763
+ }
764
+ if (event.type === 'log') {
765
+ // DEDUP per §(3) recent-ids contract: an out-of-order DO
766
+ // broadcast followed by reconnect+catchup can replay an entry
767
+ // we already persisted. The entry IS on disk from an earlier
768
+ // receive in this session — just not from THIS iteration's
769
+ // appendLine. So we skip the duplicate write AND advance the
770
+ // cursor (so the heartbeat-hwm comparison converges and so
771
+ // Last-Event-ID on the next reconnect reflects the highest
772
+ // id we've actually got persisted). UUIDs are not ordinally
773
+ // comparable with created_at, so set membership is the check.
774
+ if (recentIds.has(event.id)) {
775
+ markEventPersisted(event.id, event.data?.created_at ?? '');
776
+ markBroadcastPersisted(broadcastHwmFromLogEvent(event));
777
+ continue;
778
+ }
779
+ // OWN-DRONE FILTER: restore the silent-self property — parity
780
+ // with pre-cutover inbox.ts:87-88. The DO broadcasts every
781
+ // entry to every connected drone INCLUDING the originator;
782
+ // without this skip, posting a log entry would wake the
783
+ // posting drone on its own message (visible via Monitor on
784
+ // the inbox file). The entry IS on disk (we wrote it via
785
+ // appendLog), so skip the inbox echo but still advance the
786
+ // cursor + record in recentIds so heartbeat-hwm comparison
787
+ // converges and the next reconnect's Last-Event-ID reflects
788
+ // the highest id we've actually got persisted. Structurally
789
+ // identical to the dedup branch above — same "skip write,
790
+ // advance state" shape, different trigger condition.
791
+ //
792
+ // HEARTBEAT-PING CARVE-OUT (gh#71): the gh#39 cron watchdog
793
+ // authors heartbeat-pings WITH the silent target as drone_id
794
+ // so each ping is attributed to the drone it intends to wake.
795
+ // Without this carve-out, the own-drone filter would silently
796
+ // skip the target's own ping → inbox file never written →
797
+ // Monitor never fires → the platform-level wake guarantee is
798
+ // broken for the cube-wide-silent class gh#39 was designed to
799
+ // prevent. We let heartbeat-pings authored "by" the target
800
+ // drone through the disk-write path; the existing rate-limit
801
+ // in the server heartbeat contract (max 1 ping per drone per ~1h)
802
+ // bounds the silent-self property's relaxation.
803
+ const isHeartbeatPing = typeof event.data?.message === 'string' &&
804
+ event.data.message.startsWith('[HEARTBEAT-PING]');
805
+ // Sprint 26 ack-fan-out: ack events have `kind: 'ack'` plus an
806
+ // `author_drone_id` field naming the recipient (the author of
807
+ // the entry that got acked). Only the author writes the ack
808
+ // line to their inbox — all other subscribers drop the event
809
+ // but still advance the cursor. The legacy entry-shaped fields
810
+ // on the ack payload exist for pre-Sprint-26 clients that
811
+ // don't recognize the `kind` discriminator; new clients route
812
+ // here BEFORE the legacy own-drone filter so the ack-specific
813
+ // semantic takes precedence.
814
+ if (event.data?.kind === 'ack') {
815
+ if (event.data?.author_drone_id === active.droneId) {
816
+ if ((await writeInboxLine(event)) === 'persisted-skip')
817
+ continue;
818
+ }
819
+ await recordSeen(event);
820
+ continue;
821
+ }
822
+ // Local-server ActivityHub publishes the transparent shared log to
823
+ // every cube stream. `visibility:direct` is routing/wake metadata, not
824
+ // a read ACL: borg_read-log still returns the entry to every member,
825
+ // while only an addressed drone writes it to its inbox and wakes. A
826
+ // malformed direct frame with no matching recipient fails closed in
827
+ // the wake direction (cursor advances; no cube-wide wake storm).
828
+ if (event.data?.visibility === 'direct') {
829
+ const recipients = Array.isArray(event.data?.recipient_drone_ids)
830
+ ? event.data.recipient_drone_ids.filter((recipient) => typeof recipient === 'string')
831
+ : [];
832
+ if (!recipients.includes(active.droneId)) {
833
+ await recordSeen(event);
834
+ continue;
835
+ }
836
+ }
837
+ if (event.data?.drone_id === active.droneId && !isHeartbeatPing) {
838
+ // Own post: silent-self (no inbox echo — already on disk via
839
+ // appendLog). recordSeen still advances BOTH cursors, including the
840
+ // broadcast cursor for an own broadcast — see recordSeen's gh#402
841
+ // (583aed7e) note for why skipping it here would storm.
842
+ await recordSeen(event);
843
+ continue;
844
+ }
845
+ // Regular inbound entry: write the inbox line (catchup-dedup aware),
846
+ // then advance both cursors.
847
+ if ((await writeInboxLine(event)) === 'persisted-skip')
848
+ continue;
849
+ await recordSeen(event);
850
+ }
851
+ }
852
+ }
853
+ finally {
854
+ abortSignal.removeEventListener('abort', abortFromExternal);
855
+ if (watchdog)
856
+ clearTimeout(watchdog);
857
+ clearPendingHwmDivergence();
858
+ streamState.connected = false;
859
+ }
860
+ }
861
+ export async function streamOnceIfOwner(active, lastEventId, onEventId, deps = {}) {
862
+ const { ownerDeps, ownerStaleMs } = { ...defaultDeps, ...deps };
863
+ const lease = await acquireStreamLease(active.cubeId, active.droneId, ownerStaleMs, ownerDeps);
864
+ if (!lease) {
865
+ streamState.connected = false;
866
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
867
+ return 'skipped';
868
+ }
869
+ streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
870
+ try {
871
+ await streamOnce(active, lastEventId, onEventId, deps);
872
+ return 'streamed';
873
+ }
874
+ finally {
875
+ await lease.release();
876
+ }
877
+ }
878
+ /**
879
+ * Async generator over an SSE response body. Yields one ParsedEvent
880
+ * per "event:/data:" block (separated by blank lines per RFC 5234).
881
+ *
882
+ * Exported so tests can pump a synthetic ReadableStream through it.
883
+ */
884
+ export async function* parseSSE(body, maxFrameBytes) {
885
+ const reader = body.getReader();
886
+ const decoder = new TextDecoder();
887
+ let buffer = '';
888
+ let bufferBytes = 0;
889
+ const appendFragment = (fragment) => {
890
+ const nextBytes = bufferBytes + Buffer.byteLength(fragment, 'utf8');
891
+ if (maxFrameBytes !== undefined && nextBytes > maxFrameBytes) {
892
+ throw new Error('Local Borg server SSE frame exceeded the response limit');
893
+ }
894
+ buffer += fragment;
895
+ bufferBytes = nextBytes;
896
+ };
897
+ const drainText = function* (text) {
898
+ let remaining = text;
899
+ while (remaining.length > 0) {
900
+ // Preserve a terminator split across two stream chunks without ever
901
+ // allowing the retained partial frame itself to grow past the cap.
902
+ if (buffer.endsWith('\n') && remaining.startsWith('\n')) {
903
+ buffer = buffer.slice(0, -1);
904
+ bufferBytes = Buffer.byteLength(buffer, 'utf8');
905
+ remaining = remaining.slice(1);
906
+ const parsed = parseEventBlock(buffer);
907
+ buffer = '';
908
+ bufferBytes = 0;
909
+ if (parsed)
910
+ yield parsed;
911
+ continue;
912
+ }
913
+ const idx = remaining.indexOf('\n\n');
914
+ if (idx === -1) {
915
+ appendFragment(remaining);
916
+ return;
917
+ }
918
+ appendFragment(remaining.slice(0, idx));
919
+ remaining = remaining.slice(idx + 2);
920
+ const parsed = parseEventBlock(buffer);
921
+ buffer = '';
922
+ bufferBytes = 0;
923
+ if (parsed)
924
+ yield parsed;
925
+ }
926
+ };
927
+ try {
928
+ while (true) {
929
+ const { value, done } = await reader.read();
930
+ if (done) {
931
+ const tail = decoder.decode();
932
+ if (tail) {
933
+ for (const parsed of drainText(tail))
934
+ yield parsed;
935
+ }
936
+ if (buffer.trim()) {
937
+ const parsed = parseEventBlock(buffer);
938
+ if (parsed)
939
+ yield parsed;
940
+ }
941
+ return;
942
+ }
943
+ for (const parsed of drainText(decoder.decode(value, { stream: true }))) {
944
+ yield parsed;
945
+ }
946
+ }
947
+ }
948
+ catch (error) {
949
+ await reader.cancel(error).catch(() => { });
950
+ throw error;
951
+ }
952
+ finally {
953
+ try {
954
+ reader.releaseLock();
955
+ }
956
+ catch {
957
+ // ignore — stream may already be closed
958
+ }
959
+ }
960
+ }
961
+ function parseEventBlock(block) {
962
+ let eventName = null;
963
+ let id = null;
964
+ let dataLines = [];
965
+ for (const line of block.split('\n')) {
966
+ if (line.startsWith('event:')) {
967
+ eventName = line.slice(6).trim();
968
+ }
969
+ else if (line.startsWith('id:')) {
970
+ id = line.slice(3).trim();
971
+ }
972
+ else if (line.startsWith('data:')) {
973
+ dataLines.push(line.slice(5).trim());
974
+ }
975
+ }
976
+ const dataStr = dataLines.join('\n');
977
+ if (!eventName)
978
+ return null;
979
+ if (eventName === 'log') {
980
+ if (!id)
981
+ return null;
982
+ let parsed;
983
+ try {
984
+ parsed = JSON.parse(dataStr);
985
+ }
986
+ catch {
987
+ return null;
988
+ }
989
+ const cursor = parsed?.cursor;
990
+ const validCursor = cursor &&
991
+ typeof cursor.id === 'string' &&
992
+ typeof cursor.created_at === 'string';
993
+ return {
994
+ type: 'log',
995
+ id,
996
+ data: parsed?.entry ?? parsed,
997
+ ...(validCursor ? { cursor } : {}),
998
+ };
999
+ }
1000
+ if (eventName === 'heartbeat') {
1001
+ let ts = null;
1002
+ let hwm = null;
1003
+ try {
1004
+ const parsed = JSON.parse(dataStr);
1005
+ ts = typeof parsed.ts === 'string' ? parsed.ts : null;
1006
+ hwm = parseHeartbeatHwm(parsed.hwm);
1007
+ }
1008
+ catch {
1009
+ // fall through with nulls
1010
+ }
1011
+ return { type: 'heartbeat', ts, hwm };
1012
+ }
1013
+ if (eventName === 'bookmark') {
1014
+ let as_of = null;
1015
+ try {
1016
+ const parsed = JSON.parse(dataStr);
1017
+ as_of = typeof parsed.as_of === 'string' ? parsed.as_of : null;
1018
+ }
1019
+ catch {
1020
+ // fall through with null
1021
+ }
1022
+ return { type: 'bookmark', as_of };
1023
+ }
1024
+ // gh#877 Path-A: terminal eviction control frame. Recognized explicitly so a
1025
+ // forged/garbage event type still falls through to `unknown` and no-ops (the
1026
+ // SEC R5 parser-default-ignore property is preserved).
1027
+ if (eventName === 'eviction') {
1028
+ let cube_id = null;
1029
+ let reason = null;
1030
+ try {
1031
+ const parsed = JSON.parse(dataStr);
1032
+ cube_id = typeof parsed.cube_id === 'string' ? parsed.cube_id : null;
1033
+ reason = typeof parsed.reason === 'string' ? parsed.reason : null;
1034
+ }
1035
+ catch {
1036
+ // fall through with nulls
1037
+ }
1038
+ return { type: 'eviction', cube_id, reason };
1039
+ }
1040
+ return { type: 'unknown', raw: block };
1041
+ }
1042
+ function parseHeartbeatHwm(value) {
1043
+ if (!value || typeof value !== 'object')
1044
+ return null;
1045
+ const candidate = value;
1046
+ return typeof candidate.id === 'string' &&
1047
+ candidate.id.length > 0 &&
1048
+ typeof candidate.created_at === 'string' &&
1049
+ candidate.created_at.length > 0
1050
+ ? { id: candidate.id, created_at: candidate.created_at }
1051
+ : null;
1052
+ }
1053
+ function broadcastHwmFromLogEvent(event) {
1054
+ if (event.data?.visibility === 'direct' || event.data?.kind === 'ack') {
1055
+ return null;
1056
+ }
1057
+ const createdAt = event.data?.created_at;
1058
+ return typeof createdAt === 'string' && createdAt.length > 0
1059
+ ? { id: event.id, created_at: createdAt }
1060
+ : null;
1061
+ }
1062
+ function withSseEventId(entry, eventId) {
1063
+ if (!entry || typeof entry !== 'object')
1064
+ return { id: eventId };
1065
+ if (typeof entry.id === 'string' && entry.id.length > 0)
1066
+ return entry;
1067
+ return { ...entry, id: eventId };
1068
+ }
1069
+ /**
1070
+ * First argument that is a non-empty string, else ''. Used to pick the
1071
+ * inbox entry id from the server `id` field, falling back to the legacy
1072
+ * `entry_id` field. Flattens what was a nested ternary at the call site.
1073
+ */
1074
+ function firstNonEmptyString(...candidates) {
1075
+ for (const candidate of candidates) {
1076
+ if (typeof candidate === 'string' && candidate.length > 0)
1077
+ return candidate;
1078
+ }
1079
+ return '';
1080
+ }
1081
+ /**
1082
+ * Format one inbox-file line. Preserves the long-poll inbox.ts prefix
1083
+ * shape (`<iso-ts> <drone-label> (<role-name>): ...`) so existing
1084
+ * Monitor tail consumers and audit tooling keep parsing it.
1085
+ *
1086
+ * Newlines in the message body are joined to ` ⏎ ` (U+23CE Return Symbol,
1087
+ * space-padded) so the entire entry is one physical line in the inbox
1088
+ * file. Rationale: Claude Code's Monitor primitive consumes stdout via
1089
+ * Node `readline` — every `\n` creates a NEW notification event, so a
1090
+ * multi-line cube-log entry would fire multiple notifications with only
1091
+ * the first line being a recognized entry-start (the rest dropped by
1092
+ * `formatEventLine`'s regex). Joining at write time delivers the full
1093
+ * entry content in a single notification body. Drones recognize ⏎ as
1094
+ * "newline was here" — convention noted in the regen-format playbook.
1095
+ */
1096
+ export function formatInboxLine(entry) {
1097
+ const ts = typeof entry.created_at === 'string'
1098
+ ? new Date(entry.created_at).toISOString()
1099
+ : new Date().toISOString();
1100
+ const label = entry.drone_label ?? '?';
1101
+ const role = entry.role_name ?? '?';
1102
+ const rawMessage = typeof entry.message === 'string' ? entry.message : '';
1103
+ // Server `id`, else legacy `entry_id`, else '' (behavior-identical to the
1104
+ // prior nested ternary).
1105
+ const entryId = firstNonEmptyString(entry.id, entry.entry_id);
1106
+ const idPrefix = entryId ? `[entry_id: ${entryId}] ` : '';
1107
+ // Normalize \r\n, \r, and \n all to ` ⏎ ` so the entry fits on one
1108
+ // physical line regardless of line-ending convention in the source.
1109
+ const message = rawMessage.replace(/\r\n|\r|\n/g, ' ⏎ ');
1110
+ return `${ts} ${label} (${role}): ${idPrefix}${message}`;
1111
+ }
1112
+ /**
1113
+ * gh#877 Path-A: format the terminal-eviction WAKE SENTINEL line written to the
1114
+ * inbox file. Same one-physical-line shape as formatInboxLine so the existing
1115
+ * `borg-inbox-monitor` tail recognizes it and fires a notification that wakes
1116
+ * the agent. The `[CUBE-EVICTED]` marker is a WAKE HINT only — the agent must
1117
+ * CONFIRM via an authed borg_* call returning 410 DRONE_EVICTED before tearing
1118
+ * down (so a peer posting sentinel-shaped log text can't force a false
1119
+ * shutdown of a live seat — SEC R2 / QA log-content-plane forgery guard).
1120
+ */
1121
+ export function formatEvictionSentinelLine(reason) {
1122
+ const ts = new Date().toISOString();
1123
+ const detail = reason && reason.trim().length > 0 ? reason : 'evicted from cube';
1124
+ return (`${ts} SYSTEM (eviction): ${EVICTED_RESULT_MARKER} ${detail} — ` +
1125
+ 'confirm with any borg_* call; on typed DRONE_EVICTED, stop this session without retrying or restarting the loop.');
1126
+ }
1127
+ async function defaultAppendLine(cubeId, droneId, line) {
1128
+ const p = inboxPathForDrone(cubeId, droneId);
1129
+ await appendCappedInboxLine(p, line, INBOX_TAIL_LINES_CAP);
1130
+ }
1131
+ export async function appendCappedInboxLine(inboxPath, line, maxLines = INBOX_TAIL_LINES_CAP, trimThresholdLines = maxLines * 2) {
1132
+ await fs.mkdir(path.dirname(inboxPath), { recursive: true });
1133
+ await fs.appendFile(inboxPath, line + '\n', 'utf-8');
1134
+ await trimInboxFileToRecentLines(inboxPath, maxLines, trimThresholdLines);
1135
+ }
1136
+ export async function trimInboxFileToRecentLines(inboxPath, maxLines, trimThresholdLines = maxLines) {
1137
+ if (!Number.isInteger(maxLines) || maxLines < 1) {
1138
+ throw new Error('maxLines must be a positive integer');
1139
+ }
1140
+ if (!Number.isInteger(trimThresholdLines) ||
1141
+ trimThresholdLines < maxLines) {
1142
+ throw new Error('trimThresholdLines must be an integer >= maxLines');
1143
+ }
1144
+ const raw = await fs.readFile(inboxPath, 'utf-8');
1145
+ const lines = raw.split(/\r?\n/);
1146
+ if (lines.at(-1) === '')
1147
+ lines.pop();
1148
+ if (lines.length <= trimThresholdLines)
1149
+ return;
1150
+ const kept = lines.slice(-maxLines);
1151
+ const tmpPath = path.join(path.dirname(inboxPath), `.${path.basename(inboxPath)}.${process.pid}.${Date.now()}.tmp`);
1152
+ try {
1153
+ await fs.writeFile(tmpPath, kept.join('\n') + '\n', 'utf-8');
1154
+ await fs.rename(tmpPath, inboxPath);
1155
+ }
1156
+ catch (err) {
1157
+ await fs.rm(tmpPath, { force: true });
1158
+ throw err;
1159
+ }
1160
+ }
1161
+ /**
1162
+ * @internal Exported for unit tests.
1163
+ *
1164
+ * Decide whether `raw` (the full inbox-file contents) already contains the
1165
+ * entry identified by `entryId` / rendered as `renderedLine`. Robust to BOTH
1166
+ * on-disk line formats (gh#441 — catchup-replay-flood fix):
1167
+ * - NEW format (0.9.39+): `…: [entry_id: <id>] <message>` — matched by the
1168
+ * `[entry_id: <id>]` marker (the #412 dedup, preserved verbatim).
1169
+ * - LEGACY format (pre-0.9.39 / old ACK lines): `…: <message>` with NO
1170
+ * entry_id prefix — matched by an EXACT, line-anchored comparison against
1171
+ * the entry's legacy rendering (`renderedLine` with the id-prefix stripped).
1172
+ *
1173
+ * Why this exists: a worker DEPLOY evicts the LogBroadcaster DO → every drone's
1174
+ * SSE drops → fleet-wide simultaneous reconnect → catchup. The old substring
1175
+ * check (`raw.includes('[entry_id: <id>]')`) missed legacy lines, so catchup
1176
+ * re-appended them → tail -F replay flood.
1177
+ *
1178
+ * The legacy match is LINE-ANCHORED (exact line equality), NOT a substring
1179
+ * match, on purpose: a substring match would false-positive-DROP a genuinely
1180
+ * new entry whose message merely EXTENDS an existing line (e.g. `…: hello` vs
1181
+ * `…: hello world`). A dropped entry is worse than a re-append — the drone
1182
+ * misses a wake — so when in doubt this fails toward re-append, never drop.
1183
+ */
1184
+ export function inboxRawHasEntry(raw, entryId, renderedLine) {
1185
+ // New-format: entry_id marker (preserves the #412 dedup).
1186
+ if (entryId && raw.includes(`[entry_id: ${entryId}]`))
1187
+ return true;
1188
+ // Legacy-format: exact, line-anchored match against the id-prefix-stripped
1189
+ // rendering. `String.prototype.replace` with a string pattern strips only the
1190
+ // first (sole) prefix occurrence; entryId is treated literally (not regex).
1191
+ const legacyForm = entryId
1192
+ ? renderedLine.replace(`[entry_id: ${entryId}] `, '')
1193
+ : renderedLine;
1194
+ if (legacyForm && raw.split(/\r?\n/).includes(legacyForm))
1195
+ return true;
1196
+ return false;
1197
+ }
1198
+ async function defaultHasInboxEntryId(cubeId, droneId, entryId, renderedLine) {
1199
+ const p = inboxPathForDrone(cubeId, droneId);
1200
+ let raw;
1201
+ try {
1202
+ raw = await fs.readFile(p, 'utf-8');
1203
+ }
1204
+ catch (err) {
1205
+ if (err?.code === 'ENOENT')
1206
+ return false;
1207
+ throw err;
1208
+ }
1209
+ return inboxRawHasEntry(raw, entryId, renderedLine);
1210
+ }
1211
+ function sleep(ms) {
1212
+ return new Promise((resolve) => setTimeout(resolve, ms));
1213
+ }
1214
+ //# sourceMappingURL=log-stream.js.map