borgmcp 1.1.15 → 2.0.2

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