borgmcp 1.1.14 → 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 +86 -37
  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,4 +1,857 @@
1
1
  #!/usr/bin/env node
2
- import{spawn as V}from"node:child_process";import{createHash as Z,randomBytes as F}from"node:crypto";import{chmodSync as k,linkSync as tt,lstatSync as x,mkdirSync as A,readFileSync as E,realpathSync as h,statSync as H,unlinkSync as _,writeFileSync as M}from"node:fs";import{basename as B,dirname as v,isAbsolute as et,join as d,relative as rt,resolve as b,sep as ot}from"node:path";import{createInterface as nt}from"node:readline";import{fileURLToPath as it}from"node:url";const st=/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/,ct=1024;class at{cap;seen=new Set;order=[];constructor(e=ct){if(this.cap=e,!Number.isInteger(e)||e<1)throw new Error("cap must be a positive integer")}remember(e){if(this.seen.has(e))return!1;for(this.seen.add(e),this.order.push(e);this.order.length>this.cap;){const r=this.order.shift();r&&this.seen.delete(r)}return!0}}function G(t){const e=st.exec(t);if(!e)return null;const[,,r,o,n]=e,i=n.trim();return`${r} (${o}): ${i}`}function lt(t,e){const r=G(t);return r===null?null:e.remember(t)?r:null}function ut(t,e,r=512){if(!Number.isInteger(r)||r<1)throw new Error("maxLines must be a positive integer");let o;try{o=E(t,"utf-8")}catch(i){if(i?.code==="ENOENT")return;throw i}const n=o.split(/\r?\n/);n.at(-1)===""&&n.pop();for(const i of n.slice(-r))G(i)!==null&&e.remember(i)}function ft(t,e,r,o){if(t<e.lastEmittedOffset)return{kind:"rotation",state:{lastEmittedOffset:t,grewSince:null}};if(t===e.lastEmittedOffset)return{kind:"ok",state:{lastEmittedOffset:e.lastEmittedOffset,grewSince:null}};const n=e.grewSince??r,i={lastEmittedOffset:e.lastEmittedOffset,grewSince:n};return r-n>=o?{kind:"respawn",state:i}:{kind:"ok",state:i}}function P(t){const e=t.indexOf(":");return e===-1?{pid:Number.parseInt(t,10),nonce:null}:{pid:Number.parseInt(t.slice(0,e),10),nonce:t.slice(e+1)||null}}function mt(t,e,r){if(!e||!r.readHeartbeat)return!1;const o=r.readHeartbeat(t);if(o===null)return!1;const n=r.now?r.now():Date.now(),i=r.heartbeatStaleMs??J;return n-o.mtimeMs>=i&&o.nonce===e}function At(t){if(typeof t!="string"||t.length===0)throw new Error(`invalid monitor worktree path: ${t}`);return d(b(t),".borgmcp","inbox-monitor")}function W(t){return`${t}.monitor.pid`}function K(t){return`${t}.monitor.heartbeat`}function q(t){return Z("sha256").update(b(t)).digest("hex")}function dt(t,e){return e?d(b(e),`${q(t)}.monitor.pid`):W(t)}function pt(t,e){return e?d(b(e),`${q(t)}.monitor.heartbeat`):K(t)}function ht(t){const e=b(t);if(B(e)!=="inbox-monitor"||B(v(e))!==".borgmcp")throw new Error(`unsafe monitor state path (expected <worktree>/.borgmcp/inbox-monitor): ${e}`);const r=h(v(v(e))),o=d(r,".borgmcp"),n=d(o,"inbox-monitor");if(!z(n,r))throw new Error(`unsafe monitor state root outside worktree: ${n}`);const i=X(o);if(i&&h(o)!==o)throw new Error(`unsafe monitor state ancestor changed: ${o}`);const c=i?X(n):null;if(c&&(U(n,c),Y(d(n,".gitignore"),n)),i||j(o,448),h(o)!==o)throw new Error(`unsafe monitor state ancestor changed: ${o}`);const s=!c;s&&j(n,448);const u=h(n);if(u!==n||!z(u,r))throw new Error(`unsafe monitor state root escaped worktree: ${n}`);const m=x(u);U(u,m);const f=d(u,".gitignore"),w=`*
3
- `;if(s||Y(f,u),k(u,448),s&&(M(f,w,{encoding:"utf8",mode:384}),k(f,384)),h(n)!==n)throw new Error(`unsafe monitor state root changed while preparing: ${n}`);return n}function X(t){try{const e=x(t);if(!e.isDirectory()||e.isSymbolicLink())throw new Error(`unsafe monitor state ancestor (not a real directory): ${t}`);return e}catch(e){if(e?.code==="ENOENT")return null;throw e}}function j(t,e){try{A(t,{mode:e})}catch(o){throw o?.code==="EEXIST"?new Error(`unsafe monitor state path appeared during preparation: ${t}`):o}const r=x(t);if(!r.isDirectory()||r.isSymbolicLink())throw new Error(`unsafe monitor state ancestor (not a real directory): ${t}`)}function U(t,e){if(!e.isDirectory()||e.isSymbolicLink())throw new Error(`unsafe monitor state path (not a real directory): ${t}`);if(typeof process.getuid=="function"&&e.uid!==process.getuid())throw new Error(`unsafe monitor state directory owner: ${t}`)}function Y(t,e){let r;try{r=x(t)}catch(o){throw o?.code==="ENOENT"?new Error(`unsafe monitor state root missing Borg ownership marker: ${e}`):o}if(!r.isFile()||r.isSymbolicLink())throw new Error(`unsafe monitor state ignore path: ${t}`);if(E(t,"utf8")!==`*
4
- `)throw new Error(`unsafe monitor state ignore path (not Borg-owned): ${t}`);if((r.mode&511)!==384)throw new Error(`unsafe monitor state ignore path (unexpected mode): ${t}`);if(typeof process.getuid=="function"&&r.uid!==process.getuid())throw new Error(`unsafe monitor state ignore path (unexpected owner): ${t}`)}function z(t,e){const r=rt(e,t);return r!==""&&r!==".."&&!r.startsWith(`..${ot}`)&&!et(r)}const T=3e4,bt=5*T,J=5*T;function wt(t,e){return e===null?["-F","-n","0",t]:["-F","-c",`+${e+1}`,t]}function N(t){try{return H(t).size}catch{return 0}}function yt(t,e,r,o=3,n){const i=n?`${e}:${n}`:String(e);for(let c=0;c<o;c++){if(r.claim(t,i))return!0;const s=r.read(t);if(s===null)continue;const u=s.trim();if(u===""){r.removeIfContent(t,s);continue}const{pid:m,nonce:f}=P(u);if(!Number.isNaN(m)&&r.isAlive(m)){if(mt(t,f,r)){r.removeIfContent(t,s);continue}return!1}r.removeIfContent(t,s)}return!1}function gt(t,e){const r=W(t),o=K(t),n=e.exists(r),i=e.exists(o);if(!n&&!i)return"absent";if(n){const c=e.read(r);if(c!==null){const{pid:s}=P(c.trim());if(!Number.isNaN(s)&&e.isAlive(s))return"live"}}return"blocked"}function xt(t){if(!t.claimMutation())return"mutation-busy";try{const e=t.legacyState();if(e==="live")return"legacy-live";if(e==="blocked")return"legacy-blocked";if(!t.claimModern())return"modern-live";const r=t.legacyState();return r!=="absent"?(t.releaseModern(),r==="live"?"legacy-live":"legacy-blocked"):"claimed"}finally{t.releaseMutation()}}function Et(){return{claim:(t,e)=>{const r=`${t}.tmp.${process.pid}.${F(6).toString("hex")}`;try{M(r,e,{mode:384});try{return tt(r,t),!0}catch(o){if(o?.code==="EEXIST")return!1;throw o}}finally{try{_(r)}catch{}}},read:t=>{try{return E(t,"utf8")}catch{return null}},removeIfContent:(t,e)=>{try{E(t,"utf8")===e&&_(t)}catch{}},isAlive:t=>{try{return process.kill(t,0),!0}catch(e){return e?.code==="EPERM"}},readHeartbeat:St,now:()=>Date.now(),heartbeatStaleMs:J}}function St(t){const e=$t(t);try{return{mtimeMs:H(e).mtimeMs,nonce:E(e,"utf8").trim()}}catch{return null}}function $t(t){return t.replace(/\.monitor\.pid$/,".monitor.heartbeat")}function It(t){return t.replace(/\.monitor\.pid$/,".monitor.mutation")}function vt(t){try{return x(t),!0}catch(e){return e?.code!=="ENOENT"}}function Q(t,e){M(t,e,{mode:384}),k(t,384)}function kt(t){A(v(t),{recursive:!0})}function Mt(t){return t.length===1&&t[0]?{inboxPath:t[0],stateRoot:null}:t.length===3&&t[0]==="--state-root"&&t[1]&&t[2]?{inboxPath:t[2],stateRoot:b(t[1])}:null}function Tt(){const t=Mt(process.argv.slice(2));t||(console.error("borg-inbox-monitor: usage: borg-inbox-monitor --state-root <worktree-runtime-root> <inbox-path>"),process.exit(2));const{inboxPath:e}=t;let r=t.stateRoot;try{r?r=ht(r):kt(e)}catch(l){const a=l instanceof Error?l.message:String(l),C=r?"runtime state directory":`inbox directory for ${e}`;console.error(`borg-inbox-monitor: cannot create ${C}: ${a}`),process.exit(1)}const o=Et(),n=dt(e,r),i=It(n),c=F(16).toString("hex"),s=`${process.pid}:${c}`,m=xt({claimMutation:()=>o.claim(i,s),releaseMutation:()=>o.removeIfContent(i,s),legacyState:()=>r?gt(e,{exists:vt,read:o.read,isAlive:o.isAlive}):"absent",claimModern:()=>yt(n,process.pid,o,3,c),releaseModern:()=>o.removeIfContent(n,`${process.pid}:${c}`)});m==="mutation-busy"&&(console.error(`borg-inbox-monitor: another modern monitor startup is mutating this inbox state; re-arm after it finishes (if it persists after that process stops, confirm it is stopped, remove ${i}, then re-arm)`),process.exit(1)),m==="legacy-live"&&process.exit(0),m==="legacy-blocked"&&(console.error(`borg-inbox-monitor: legacy monitor artifact remains beside ${e}; stop/confirm the old monitor, remove its .monitor.pid/.monitor.heartbeat manually, then re-arm`),process.exit(1)),m==="modern-live"&&process.exit(0);const f=()=>{if(o.claim(i,s))try{o.removeIfContent(n,`${process.pid}:${c}`)}finally{o.removeIfContent(i,s)}},w=pt(e,r),S=()=>{if(o.claim(i,s))try{o.removeIfContent(w,c)}finally{o.removeIfContent(i,s)}};try{Q(w,c)}catch(l){f();const a=l instanceof Error?l.message:String(l);console.error(`borg-inbox-monitor: cannot write runtime heartbeat: ${a}`),process.exit(1)}const O=new at;ut(e,O);let p={lastEmittedOffset:N(e),grewSince:null},$=!1,y=null;const R=l=>{const a=V("tail",wt(e,l),{stdio:["ignore","pipe","inherit"]});y=a,a.stdout||(console.error("borg-inbox-monitor: tail subprocess has no stdout"),S(),f(),process.exit(1)),nt({input:a.stdout,crlfDelay:1/0}).on("line",g=>{const I=lt(g,O);I!==null&&(console.log(I),p={lastEmittedOffset:N(e),grewSince:null})}),a.on("error",g=>{a===y&&(console.error(`borg-inbox-monitor: tail failed: ${g.message}`),S(),f(),process.exit(1))}),a.on("exit",(g,I)=>{a===y&&(S(),f(),$&&process.exit(0),I&&process.exit(0),process.exit(g??0))})};R(null);const D=setInterval(()=>{try{Q(w,c)}catch{}const l=ft(N(e),p,Date.now(),bt);if(p=l.state,l.kind==="respawn"&&!$){const a=y;R(p.lastEmittedOffset);try{a?.kill("SIGKILL")}catch{}p={lastEmittedOffset:p.lastEmittedOffset,grewSince:null}}},T);D.unref();const L=l=>{if($)return;$=!0,clearInterval(D),S(),f();const a=y;a&&!a.killed&&!a.kill(l)&&process.exit(0),setTimeout(()=>process.exit(0),1e3).unref()};process.once("SIGTERM",()=>L("SIGTERM")),process.once("SIGINT",()=>L("SIGINT"))}function Nt(t,e){try{return h(t)===it(e)}catch{return!1}}Nt(process.argv[1],import.meta.url)&&Tt();export{J as HEARTBEAT_STALE_MS,ct as RECENT_EMITTED_LINE_CAP,at as RecentLineDeduper,yt as acquireInboxLock,xt as claimModernMonitorSafely,Et as defaultInboxLockDeps,kt as ensureInboxDir,ht as ensureMonitorStateDir,ft as evaluateInboxTailStall,G as formatEventLine,lt as formatFreshEventLine,pt as heartbeatPathFor,Nt as isEntryInvocation,mt as isHolderWedged,K as legacyHeartbeatPathFor,gt as legacyMonitorArtifactState,W as legacyPidfilePathFor,At as monitorStateRootForWorktree,It as mutationLockPathFor,Mt as parseMonitorInvocation,P as parsePidfileContent,dt as pidfilePathFor,St as readHeartbeatSidecar,ut as seedDeduperFromInboxTail,wt as tailArgsFor,Q as writeHeartbeat};
2
+ /**
3
+ * borg-inbox-monitor per-entry pretty-printer for borgmcp inbox files.
4
+ *
5
+ * Per gh#8: Claude Code's task-notification title is the Monitor's
6
+ * `description`, set once at arm-time. When the Monitor command is
7
+ * `tail -F <inbox>`, every event's notification title is the same
8
+ * static "Monitor event: ..." string regardless of which drone posted
9
+ * what. Recipients have to read the body to triage.
10
+ *
11
+ * Replacement: tail the inbox file and emit one stdout line per cube
12
+ * log entry, summarizing drone label + role + first ~80 chars of the
13
+ * message body. Claude Code's Monitor batching then uses that single
14
+ * line as the per-event task-notification title.
15
+ *
16
+ * Inbox file format (per src/log-stream.ts formatInboxLine):
17
+ * <iso-ts> <drone-label> (<role-name>): <message>
18
+ *
19
+ * Multi-line messages are appended as a single fs.appendFile() call
20
+ * with embedded `\n` characters, so they become multiple physical
21
+ * lines in the file. Continuation lines (those that don't start with
22
+ * an ISO-8601 timestamp) are dropped — only the first line of each
23
+ * entry surfaces, which is the part that summarizes the entry.
24
+ *
25
+ * Usage:
26
+ * borg-inbox-monitor --state-root <worktree-runtime-root> <inbox-file-path>
27
+ *
28
+ * The state-root form is the supported launch path. The legacy positional-only
29
+ * form remains accepted for old hand-authored Monitor commands, and keeps its
30
+ * inbox-adjacent sidecars for compatibility while fleets transition.
31
+ */
32
+ import { spawn } from 'node:child_process';
33
+ import { createHash, randomBytes } from 'node:crypto';
34
+ import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
35
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
36
+ import { createInterface } from 'node:readline';
37
+ import { fileURLToPath } from 'node:url';
38
+ const ENTRY_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/;
39
+ export const RECENT_EMITTED_LINE_CAP = 1024;
40
+ export class RecentLineDeduper {
41
+ cap;
42
+ seen = new Set();
43
+ order = [];
44
+ constructor(cap = RECENT_EMITTED_LINE_CAP) {
45
+ this.cap = cap;
46
+ if (!Number.isInteger(cap) || cap < 1) {
47
+ throw new Error('cap must be a positive integer');
48
+ }
49
+ }
50
+ remember(line) {
51
+ if (this.seen.has(line))
52
+ return false;
53
+ this.seen.add(line);
54
+ this.order.push(line);
55
+ while (this.order.length > this.cap) {
56
+ const oldLine = this.order.shift();
57
+ if (oldLine)
58
+ this.seen.delete(oldLine);
59
+ }
60
+ return true;
61
+ }
62
+ }
63
+ /**
64
+ * Pure: parse one inbox-file line and produce the pretty summary line
65
+ * (or null if the line is a continuation or unrecognized shape).
66
+ *
67
+ * Pass-through — no truncation. Claude Code does not impose a hard cap
68
+ * on task-notification title length. The 200-char `MAX_SUMMARY_LEN` cap
69
+ * removed here (and the 80-char predecessor) were borg-mcp conventions
70
+ * built on a misunderstanding of the renderer's limits. Drones now see
71
+ * the full first line of every entry; multi-signal batched posts no
72
+ * longer have their second signal hidden.
73
+ *
74
+ * Exported so tests can exercise the parsing without spawning tail.
75
+ */
76
+ export function formatEventLine(inboxLine) {
77
+ const match = ENTRY_LINE_RE.exec(inboxLine);
78
+ if (!match)
79
+ return null;
80
+ const [, , label, role, body] = match;
81
+ const summary = body.trim();
82
+ return `${label} (${role}): ${summary}`;
83
+ }
84
+ export function formatFreshEventLine(inboxLine, deduper) {
85
+ const pretty = formatEventLine(inboxLine);
86
+ if (pretty === null)
87
+ return null;
88
+ return deduper.remember(inboxLine) ? pretty : null;
89
+ }
90
+ export function seedDeduperFromInboxTail(inboxPath, deduper, maxLines = 512) {
91
+ if (!Number.isInteger(maxLines) || maxLines < 1) {
92
+ throw new Error('maxLines must be a positive integer');
93
+ }
94
+ let raw;
95
+ try {
96
+ raw = readFileSync(inboxPath, 'utf-8');
97
+ }
98
+ catch (err) {
99
+ if (err?.code === 'ENOENT')
100
+ return;
101
+ throw err;
102
+ }
103
+ const lines = raw.split(/\r?\n/);
104
+ if (lines.at(-1) === '')
105
+ lines.pop();
106
+ for (const line of lines.slice(-maxLines)) {
107
+ if (formatEventLine(line) !== null)
108
+ deduper.remember(line);
109
+ }
110
+ }
111
+ /**
112
+ * gh#822: PURE stall evaluator. Given the current inbox size + the holder's
113
+ * stat-anchored state, decide whether the tail is healthy, rotated, or stalled.
114
+ * False-reap-safe by construction (CR 131dcd78):
115
+ * - ROTATION (item 2): `inboxSize < lastEmittedOffset` ⇒ truncation/rotation —
116
+ * re-anchor offset to the NEW size + clear the streak; NEVER treated as
117
+ * negative growth, so the detector keeps working after the very rotation
118
+ * that triggers Subclass B.
119
+ * - QUIET cube: `inboxSize === lastEmittedOffset` ⇒ no un-emitted growth ⇒ ok
120
+ * (clears any streak). A silent cube can NEVER trip a respawn.
121
+ * - GREW-but-not-emitted: `inboxSize > lastEmittedOffset`. Only when that
122
+ * un-emitted growth PERSISTS continuously past `stallThresholdMs` ⇒ respawn.
123
+ * A brief/slow grow that the tail then delivers (a later tick re-anchors
124
+ * lastEmittedOffset → size==offset) clears the streak first. So "slow" never
125
+ * trips it; only SUSTAINED un-emitted growth does. Err toward not-respawning.
126
+ */
127
+ export function evaluateInboxTailStall(inboxSize, state, nowMs, stallThresholdMs) {
128
+ if (inboxSize < state.lastEmittedOffset) {
129
+ return { kind: 'rotation', state: { lastEmittedOffset: inboxSize, grewSince: null } };
130
+ }
131
+ if (inboxSize === state.lastEmittedOffset) {
132
+ return { kind: 'ok', state: { lastEmittedOffset: state.lastEmittedOffset, grewSince: null } };
133
+ }
134
+ // inboxSize > lastEmittedOffset → un-emitted growth; start/continue the streak.
135
+ const grewSince = state.grewSince ?? nowMs;
136
+ const next = { lastEmittedOffset: state.lastEmittedOffset, grewSince };
137
+ return nowMs - grewSince >= stallThresholdMs
138
+ ? { kind: 'respawn', state: next }
139
+ : { kind: 'ok', state: next };
140
+ }
141
+ /** gh#840: pidfile content is `<pid>` (legacy) or `<pid>:<nonce>` (identity-tagged). */
142
+ export function parsePidfileContent(trimmed) {
143
+ const colon = trimmed.indexOf(':');
144
+ if (colon === -1)
145
+ return { pid: Number.parseInt(trimmed, 10), nonce: null };
146
+ return {
147
+ pid: Number.parseInt(trimmed.slice(0, colon), 10),
148
+ nonce: trimmed.slice(colon + 1) || null,
149
+ };
150
+ }
151
+ /**
152
+ * gh#840: is the LIVE pidfile holder node-WEDGED (reapable)? True ONLY when BOTH
153
+ * (a) the heartbeat sidecar mtime is stale past the threshold, AND (b) the
154
+ * heartbeat's nonce MATCHES the pidfile holder's nonce (same identity wrote
155
+ * both). A nonce MISMATCH ⇒ the stale heartbeat belongs to a DIFFERENT identity
156
+ * than the currently-alive pidfile holder (PID reuse, or a young reclaimer that
157
+ * hasn't written its first heartbeat yet) ⇒ NOT wedged ⇒ NEVER reap. Err toward
158
+ * NOT reaping: no readHeartbeat dep, no heartbeat file, or a legacy no-nonce
159
+ * pidfile all return false (a false-reap is the deafness we prevent).
160
+ */
161
+ export function isHolderWedged(pidfilePath, holderNonce, deps) {
162
+ if (!holderNonce || !deps.readHeartbeat)
163
+ return false;
164
+ const hb = deps.readHeartbeat(pidfilePath);
165
+ if (hb === null)
166
+ return false;
167
+ const now = deps.now ? deps.now() : Date.now();
168
+ const staleMs = deps.heartbeatStaleMs ?? HEARTBEAT_STALE_MS;
169
+ const stale = now - hb.mtimeMs >= staleMs;
170
+ return stale && hb.nonce === holderNonce;
171
+ }
172
+ /**
173
+ * gh#979: a worktree is the durable local identity for a seat. Drone UUIDs
174
+ * change on re-mint, but a reused worktree must retain the same monitor
175
+ * runtime home. Keep it inside the worktree (where workspace-only sandboxes
176
+ * can write), never under TMPDIR/XDG, and make its contents self-ignored so
177
+ * runtime lock churn never dirties the repository.
178
+ */
179
+ export function monitorStateRootForWorktree(worktreePath) {
180
+ if (typeof worktreePath !== 'string' || worktreePath.length === 0) {
181
+ throw new Error(`invalid monitor worktree path: ${worktreePath}`);
182
+ }
183
+ return join(resolve(worktreePath), '.borgmcp', 'inbox-monitor');
184
+ }
185
+ /** Legacy sidecars written by pre-gh#979 monitors beside the config inbox. */
186
+ export function legacyPidfilePathFor(inboxPath) {
187
+ return `${inboxPath}.monitor.pid`;
188
+ }
189
+ export function legacyHeartbeatPathFor(inboxPath) {
190
+ return `${inboxPath}.monitor.heartbeat`;
191
+ }
192
+ function monitorStateKey(inboxPath) {
193
+ return createHash('sha256').update(resolve(inboxPath)).digest('hex');
194
+ }
195
+ /**
196
+ * State paths are keyed by the absolute inbox path within the explicitly
197
+ * supplied worktree runtime root. Omitting the root intentionally preserves
198
+ * the legacy inbox-adjacent layout for old manual commands; supported launch
199
+ * and orientation paths always pass the root explicitly.
200
+ */
201
+ export function pidfilePathFor(inboxPath, stateRoot) {
202
+ if (!stateRoot)
203
+ return legacyPidfilePathFor(inboxPath);
204
+ return join(resolve(stateRoot), `${monitorStateKey(inboxPath)}.monitor.pid`);
205
+ }
206
+ /** gh#822: the holder-liveness heartbeat sidecar (mtime touched each tick). */
207
+ export function heartbeatPathFor(inboxPath, stateRoot) {
208
+ if (!stateRoot)
209
+ return legacyHeartbeatPathFor(inboxPath);
210
+ return join(resolve(stateRoot), `${monitorStateKey(inboxPath)}.monitor.heartbeat`);
211
+ }
212
+ /**
213
+ * Prepare a private, worktree-local monitor runtime root. The supplied root
214
+ * must have the exact `<worktree>/.borgmcp/inbox-monitor` shape generated by
215
+ * `monitorStateRootForWorktree()`. Before any write, resolve the saved
216
+ * worktree canonically and reject a symlinked `.borgmcp` or `inbox-monitor`
217
+ * ancestor. Its local `.gitignore` ignores itself and all descendants, so
218
+ * runtime state produces no repository dirt without mutating tracked ignores.
219
+ */
220
+ export function ensureMonitorStateDir(stateRoot) {
221
+ const requestedRoot = resolve(stateRoot);
222
+ if (basename(requestedRoot) !== 'inbox-monitor' || basename(dirname(requestedRoot)) !== '.borgmcp') {
223
+ throw new Error(`unsafe monitor state path (expected <worktree>/.borgmcp/inbox-monitor): ${requestedRoot}`);
224
+ }
225
+ // `realpath` the existing worktree FIRST. Do not mkdir through the supplied
226
+ // path: a pre-existing `.borgmcp` symlink would otherwise redirect writes
227
+ // outside the workspace before we had a chance to inspect it.
228
+ const canonicalWorktree = realpathSync(dirname(dirname(requestedRoot)));
229
+ const canonicalParent = join(canonicalWorktree, '.borgmcp');
230
+ const canonicalRoot = join(canonicalParent, 'inbox-monitor');
231
+ if (!isStrictDescendant(canonicalRoot, canonicalWorktree)) {
232
+ throw new Error(`unsafe monitor state root outside worktree: ${canonicalRoot}`);
233
+ }
234
+ // Preflight every pre-existing artifact before making any filesystem
235
+ // mutation. In particular, a project-owned root or ignore marker must fail
236
+ // without a mode normalization, write, or sidecar creation.
237
+ const parentBefore = inspectRealDirectory(canonicalParent);
238
+ if (parentBefore && realpathSync(canonicalParent) !== canonicalParent) {
239
+ throw new Error(`unsafe monitor state ancestor changed: ${canonicalParent}`);
240
+ }
241
+ const rootBefore = parentBefore ? inspectRealDirectory(canonicalRoot) : null;
242
+ if (rootBefore) {
243
+ assertOwnedMonitorStateRoot(canonicalRoot, rootBefore);
244
+ assertOwnedMonitorIgnore(join(canonicalRoot, '.gitignore'), canonicalRoot);
245
+ }
246
+ // Nothing pre-existing has been mutated. Create missing path components only
247
+ // after containment + ownership preflight. A component that appears between
248
+ // check and create is a safe failure, not an implicit ownership claim.
249
+ if (!parentBefore) {
250
+ createRealDirectory(canonicalParent, 0o700);
251
+ }
252
+ if (realpathSync(canonicalParent) !== canonicalParent) {
253
+ throw new Error(`unsafe monitor state ancestor changed: ${canonicalParent}`);
254
+ }
255
+ const createdRoot = !rootBefore;
256
+ if (createdRoot) {
257
+ createRealDirectory(canonicalRoot, 0o700);
258
+ }
259
+ const resolvedRoot = realpathSync(canonicalRoot);
260
+ if (resolvedRoot !== canonicalRoot || !isStrictDescendant(resolvedRoot, canonicalWorktree)) {
261
+ throw new Error(`unsafe monitor state root escaped worktree: ${canonicalRoot}`);
262
+ }
263
+ const rootStat = lstatSync(resolvedRoot);
264
+ assertOwnedMonitorStateRoot(resolvedRoot, rootStat);
265
+ const ignorePath = join(resolvedRoot, '.gitignore');
266
+ const monitorIgnoreContent = '*\n';
267
+ if (!createdRoot) {
268
+ // Re-check directly before the first mutation: the initial preflight could
269
+ // otherwise be invalidated by a replacement while a sibling was creating.
270
+ assertOwnedMonitorIgnore(ignorePath, resolvedRoot);
271
+ }
272
+ // Ownership is now proven. mkdir's mode does not alter a pre-existing root,
273
+ // so reassert privacy only AFTER a foreign root/ignore can no longer be
274
+ // modified by a failed arm.
275
+ chmodSync(resolvedRoot, 0o700);
276
+ // A nested .gitignore with `*` also ignores itself. This leaves neither the
277
+ // runtime directory nor its control file in `git status --porcelain`.
278
+ if (createdRoot) {
279
+ writeFileSync(ignorePath, monitorIgnoreContent, { encoding: 'utf8', mode: 0o600 });
280
+ chmodSync(ignorePath, 0o600);
281
+ }
282
+ // One last post-write revalidation narrows a parent replacement race to a
283
+ // detected failure. Node has no dirfd-relative mkdir/write API, so callers
284
+ // fail closed and retry rather than trusting a changed ancestor.
285
+ if (realpathSync(canonicalRoot) !== canonicalRoot) {
286
+ throw new Error(`unsafe monitor state root changed while preparing: ${canonicalRoot}`);
287
+ }
288
+ return canonicalRoot;
289
+ }
290
+ function inspectRealDirectory(path) {
291
+ try {
292
+ const stat = lstatSync(path);
293
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
294
+ throw new Error(`unsafe monitor state ancestor (not a real directory): ${path}`);
295
+ }
296
+ return stat;
297
+ }
298
+ catch (err) {
299
+ if (err?.code === 'ENOENT')
300
+ return null;
301
+ throw err;
302
+ }
303
+ }
304
+ function createRealDirectory(path, mode) {
305
+ try {
306
+ mkdirSync(path, { mode });
307
+ }
308
+ catch (err) {
309
+ if (err?.code === 'EEXIST') {
310
+ throw new Error(`unsafe monitor state path appeared during preparation: ${path}`);
311
+ }
312
+ throw err;
313
+ }
314
+ const stat = lstatSync(path);
315
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
316
+ throw new Error(`unsafe monitor state ancestor (not a real directory): ${path}`);
317
+ }
318
+ }
319
+ function assertOwnedMonitorStateRoot(path, stat) {
320
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
321
+ throw new Error(`unsafe monitor state path (not a real directory): ${path}`);
322
+ }
323
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
324
+ throw new Error(`unsafe monitor state directory owner: ${path}`);
325
+ }
326
+ }
327
+ function assertOwnedMonitorIgnore(ignorePath, rootPath) {
328
+ let ignoreStat;
329
+ try {
330
+ ignoreStat = lstatSync(ignorePath);
331
+ }
332
+ catch (err) {
333
+ if (err?.code === 'ENOENT') {
334
+ throw new Error(`unsafe monitor state root missing Borg ownership marker: ${rootPath}`);
335
+ }
336
+ throw err;
337
+ }
338
+ if (!ignoreStat.isFile() || ignoreStat.isSymbolicLink()) {
339
+ throw new Error(`unsafe monitor state ignore path: ${ignorePath}`);
340
+ }
341
+ // The state root is intentionally self-ignored, but it may overlap a
342
+ // user-created/tracked directory. Never overwrite or chmod a pre-existing
343
+ // project ignore file merely because it happens to occupy this path.
344
+ if (readFileSync(ignorePath, 'utf8') !== '*\n') {
345
+ throw new Error(`unsafe monitor state ignore path (not Borg-owned): ${ignorePath}`);
346
+ }
347
+ if ((ignoreStat.mode & 0o777) !== 0o600) {
348
+ throw new Error(`unsafe monitor state ignore path (unexpected mode): ${ignorePath}`);
349
+ }
350
+ if (typeof process.getuid === 'function' && ignoreStat.uid !== process.getuid()) {
351
+ throw new Error(`unsafe monitor state ignore path (unexpected owner): ${ignorePath}`);
352
+ }
353
+ }
354
+ function isStrictDescendant(candidate, ancestor) {
355
+ const rel = relative(ancestor, candidate);
356
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
357
+ }
358
+ // gh#822 thresholds — conservative / GC-safe (≥5× the tick), err toward NOT
359
+ // respawning / NOT warning (CR 131dcd78: a false-reap IS the deafness we fix).
360
+ const MONITOR_TICK_MS = 30_000;
361
+ const STALL_THRESHOLD_MS = 5 * MONITOR_TICK_MS; // 150s sustained un-emitted growth
362
+ export const HEARTBEAT_STALE_MS = 5 * MONITOR_TICK_MS; // SLI: present-but-stale ⇒ unhealthy
363
+ /**
364
+ * gh#822: `tail` args — ARM (`-n 0`, skip history, matches the prior shape) vs
365
+ * RECOVERY byte-seek (`-c +<N+1>`, re-read the un-emitted bytes from offset N
366
+ * FORWARD — CR build-gate item 3: NOT `-n 0`, which starts at the new EOF and
367
+ * skips exactly the bytes a stalled tail dropped).
368
+ */
369
+ export function tailArgsFor(inboxPath, fromByteOffset) {
370
+ return fromByteOffset === null
371
+ ? ['-F', '-n', '0', inboxPath]
372
+ : ['-F', '-c', `+${fromByteOffset + 1}`, inboxPath];
373
+ }
374
+ /** Current inbox file size in bytes, or 0 if it can't be stat'd (treated as empty). */
375
+ function inboxSizeOf(inboxPath) {
376
+ try {
377
+ return statSync(inboxPath).size;
378
+ }
379
+ catch {
380
+ return 0;
381
+ }
382
+ }
383
+ /**
384
+ * Try to become the SOLE monitor for this inbox. Returns true if we claimed the
385
+ * pidfile (caller proceeds to tail + must release it on exit); false if a LIVE
386
+ * holder already owns it (caller yields/exits without tailing). The runtime
387
+ * calls it only while holding the modern per-inbox mutation lock, so stale
388
+ * reaping and successor claims are serialized. It never mutates legacy
389
+ * inbox-adjacent artifacts.
390
+ */
391
+ export function acquireInboxLock(pidfilePath, ownPid, deps, maxAttempts = 3, ownNonce) {
392
+ // gh#840: tag the pidfile with our identity nonce (`<pid>:<nonce>`) so a
393
+ // future reaper can confirm same-process IDENTITY before reaping a wedged
394
+ // holder. Legacy callers (no nonce) keep the bare `<pid>` content.
395
+ const own = ownNonce ? `${ownPid}:${ownNonce}` : String(ownPid);
396
+ for (let i = 0; i < maxAttempts; i++) {
397
+ if (deps.claim(pidfilePath, own)) {
398
+ return true; // pidfile was absent → claimed atomically
399
+ }
400
+ const existing = deps.read(pidfilePath);
401
+ if (existing === null) {
402
+ continue; // vanished between claim+read → retry the claim
403
+ }
404
+ const trimmed = existing.trim();
405
+ if (trimmed === '') {
406
+ // Empty pidfile. Atomic claim() means OUR code never produces one, so
407
+ // this is a stray/foreign empty file — reap it ONLY if it is still empty
408
+ // (compare-and-delete), then retry. Never deletes a live holder's file.
409
+ deps.removeIfContent(pidfilePath, existing);
410
+ continue;
411
+ }
412
+ const { pid, nonce: holderNonce } = parsePidfileContent(trimmed);
413
+ if (!Number.isNaN(pid) && deps.isAlive(pid)) {
414
+ // gh#840: a LIVE PID is normally untouchable, EXCEPT a node-WEDGED holder
415
+ // (heartbeat stale past the threshold AND the heartbeat nonce matches this
416
+ // pidfile holder's identity) — reap it (compare-and-delete) and retry, so
417
+ // a stuck monitor can be replaced cross-instance. A healthy holder (fresh
418
+ // heartbeat) or an ambiguous identity (nonce mismatch ⇒ PID reuse / young
419
+ // reclaimer) is NEVER reaped.
420
+ if (isHolderWedged(pidfilePath, holderNonce, deps)) {
421
+ deps.removeIfContent(pidfilePath, existing); // gh#795 compare-and-delete
422
+ continue; // retry the claim
423
+ }
424
+ return false; // healthy / ambiguous LIVE holder → yield, NEVER kill
425
+ }
426
+ // provably stale (dead PID / unparseable) → compare-and-delete (only if it
427
+ // STILL holds this exact content — a racer may have reclaimed it live) →
428
+ // retry the claim.
429
+ deps.removeIfContent(pidfilePath, existing);
430
+ }
431
+ return false; // persistent claim race → yield (safe default: never double-tail)
432
+ }
433
+ /**
434
+ * Conservative cross-version migration boundary (gh#979): an extant legacy
435
+ * pidfile OR heartbeat is never replaced or unlinked by modern code. A proven
436
+ * live PID yields to the existing old monitor; every other artifact is a
437
+ * blocked migration requiring explicit operator cleanup. This avoids trying to
438
+ * emulate an unavailable atomic unlink-if-content primitive across binaries
439
+ * that do not share the modern mutation lock.
440
+ */
441
+ export function legacyMonitorArtifactState(inboxPath, deps) {
442
+ const pidfilePath = legacyPidfilePathFor(inboxPath);
443
+ const heartbeatPath = legacyHeartbeatPathFor(inboxPath);
444
+ const pidExists = deps.exists(pidfilePath);
445
+ const heartbeatExists = deps.exists(heartbeatPath);
446
+ if (!pidExists && !heartbeatExists)
447
+ return 'absent';
448
+ if (pidExists) {
449
+ const raw = deps.read(pidfilePath);
450
+ if (raw !== null) {
451
+ const { pid } = parsePidfileContent(raw.trim());
452
+ if (!Number.isNaN(pid) && deps.isAlive(pid))
453
+ return 'live';
454
+ }
455
+ }
456
+ return 'blocked';
457
+ }
458
+ /**
459
+ * Serialize every modern startup mutation for one inbox. The mutation lock is
460
+ * acquired BEFORE the first legacy read, spans modern lock claim, and protects
461
+ * the final legacy revalidation. An old binary that creates a legacy artifact
462
+ * at the former check→claim gap therefore makes the final check yield while
463
+ * preserving that artifact untouched.
464
+ */
465
+ export function claimModernMonitorSafely(deps) {
466
+ if (!deps.claimMutation())
467
+ return 'mutation-busy';
468
+ try {
469
+ const before = deps.legacyState();
470
+ if (before === 'live')
471
+ return 'legacy-live';
472
+ if (before === 'blocked')
473
+ return 'legacy-blocked';
474
+ if (!deps.claimModern())
475
+ return 'modern-live';
476
+ const after = deps.legacyState();
477
+ if (after !== 'absent') {
478
+ deps.releaseModern();
479
+ return after === 'live' ? 'legacy-live' : 'legacy-blocked';
480
+ }
481
+ return 'claimed';
482
+ }
483
+ finally {
484
+ deps.releaseMutation();
485
+ }
486
+ }
487
+ export function defaultInboxLockDeps() {
488
+ return {
489
+ claim: (p, content) => {
490
+ // Atomic create-with-content: write a unique temp file (already holding
491
+ // the PID), then hardlink it into place. link() fails if the target
492
+ // exists (O_EXCL-equivalent) AND the linked file is non-empty from the
493
+ // first instant — closing the create-then-write empty-read window.
494
+ const tmp = `${p}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
495
+ try {
496
+ writeFileSync(tmp, content, { mode: 0o600 });
497
+ try {
498
+ linkSync(tmp, p);
499
+ return true;
500
+ }
501
+ catch (err) {
502
+ if (err?.code === 'EEXIST')
503
+ return false;
504
+ throw err;
505
+ }
506
+ }
507
+ finally {
508
+ try {
509
+ unlinkSync(tmp);
510
+ }
511
+ catch {
512
+ /* best-effort temp cleanup */
513
+ }
514
+ }
515
+ },
516
+ read: (p) => {
517
+ try {
518
+ return readFileSync(p, 'utf8');
519
+ }
520
+ catch {
521
+ return null;
522
+ }
523
+ },
524
+ removeIfContent: (p, expected) => {
525
+ try {
526
+ if (readFileSync(p, 'utf8') === expected) {
527
+ unlinkSync(p);
528
+ }
529
+ }
530
+ catch {
531
+ /* gone/unreadable → nothing to remove */
532
+ }
533
+ },
534
+ isAlive: (pid) => {
535
+ try {
536
+ process.kill(pid, 0);
537
+ return true;
538
+ }
539
+ catch (err) {
540
+ // EPERM = process exists but we may not signal it (still alive → yield);
541
+ // ESRCH = no such process (dead → reapable).
542
+ return err?.code === 'EPERM';
543
+ }
544
+ },
545
+ readHeartbeat: readHeartbeatSidecar,
546
+ now: () => Date.now(),
547
+ heartbeatStaleMs: HEARTBEAT_STALE_MS,
548
+ };
549
+ }
550
+ /**
551
+ * gh#840: read the holder heartbeat sidecar for a pidfile's inbox.
552
+ * Freshness = file mtime; identity = file content (the holder's nonce). Returns
553
+ * null if the sidecar is absent/unreadable.
554
+ */
555
+ export function readHeartbeatSidecar(pidfilePath) {
556
+ // Modern state files are hash-named while legacy ones are inbox-adjacent;
557
+ // both forms retain the paired `.monitor.pid` → `.monitor.heartbeat` suffix.
558
+ const heartbeatPath = pairedHeartbeatPathForPidfile(pidfilePath);
559
+ try {
560
+ return {
561
+ mtimeMs: statSync(heartbeatPath).mtimeMs,
562
+ nonce: readFileSync(heartbeatPath, 'utf8').trim(),
563
+ };
564
+ }
565
+ catch {
566
+ return null;
567
+ }
568
+ }
569
+ function pairedHeartbeatPathForPidfile(pidfilePath) {
570
+ return pidfilePath.replace(/\.monitor\.pid$/, '.monitor.heartbeat');
571
+ }
572
+ /** A short-lived, atomic state-root guard for all modern lock mutations. */
573
+ export function mutationLockPathFor(pidfilePath) {
574
+ return pidfilePath.replace(/\.monitor\.pid$/, '.monitor.mutation');
575
+ }
576
+ function pathExistsFailClosed(path) {
577
+ try {
578
+ lstatSync(path);
579
+ return true;
580
+ }
581
+ catch (err) {
582
+ return err?.code !== 'ENOENT';
583
+ }
584
+ }
585
+ /**
586
+ * gh#840: write the holder heartbeat sidecar — the per-holder identity nonce as
587
+ * content; the FILE MTIME (touched on every write) is the freshness signal the
588
+ * SLI + the wedge reaper read. Replaces the old timestamp-as-content (nothing
589
+ * read that content; mtime was always the freshness source).
590
+ */
591
+ export function writeHeartbeat(heartbeatPath, nonce) {
592
+ writeFileSync(heartbeatPath, nonce, { mode: 0o600 });
593
+ // `mode` only governs creation. A stale pre-existing sidecar may have had
594
+ // broad permissions, so tighten it every time the current holder refreshes.
595
+ chmodSync(heartbeatPath, 0o600);
596
+ }
597
+ /**
598
+ * 2026-07-02 incident: first-drone-in-a-new-cube arm race. The kickoff Monitor
599
+ * pre-gh#979 `borg-inbox-monitor <inbox>` at session start, but the per-cube
600
+ * inbox directory (~/.config/borgmcp/inboxes/<cubeId>/) is created by the MCP
601
+ * server child only when the SSE stream first writes — so the legacy monitor's
602
+ * FIRST fs act (the pidfile-claim writeFileSync) threw ENOENT. The supported
603
+ * explicit-state-root mode no longer writes beside the inbox at all; this
604
+ * helper remains only for positional legacy compatibility. The inbox FILE is
605
+ * still the stream owner's; `tail -F` retries on a missing file.
606
+ */
607
+ export function ensureInboxDir(inboxPath) {
608
+ mkdirSync(dirname(inboxPath), { recursive: true });
609
+ }
610
+ /** Parse the supported explicit-root command plus the legacy positional form. */
611
+ export function parseMonitorInvocation(argv) {
612
+ if (argv.length === 1 && argv[0]) {
613
+ return { inboxPath: argv[0], stateRoot: null };
614
+ }
615
+ if (argv.length === 3 && argv[0] === '--state-root' && argv[1] && argv[2]) {
616
+ return { inboxPath: argv[2], stateRoot: resolve(argv[1]) };
617
+ }
618
+ return null;
619
+ }
620
+ function main() {
621
+ const invocation = parseMonitorInvocation(process.argv.slice(2));
622
+ if (!invocation) {
623
+ console.error('borg-inbox-monitor: usage: borg-inbox-monitor --state-root <worktree-runtime-root> <inbox-path>');
624
+ process.exit(2);
625
+ }
626
+ const { inboxPath } = invocation;
627
+ let stateRoot = invocation.stateRoot;
628
+ try {
629
+ if (stateRoot) {
630
+ // Supported path: config inboxes are intentionally read-only to this
631
+ // sandboxed process. Do not mkdir or place any state beside them.
632
+ stateRoot = ensureMonitorStateDir(stateRoot);
633
+ }
634
+ else {
635
+ // Compatibility path for a pre-gh#979 hand-authored command.
636
+ ensureInboxDir(inboxPath);
637
+ }
638
+ }
639
+ catch (err) {
640
+ const message = err instanceof Error ? err.message : String(err);
641
+ const target = stateRoot ? 'runtime state directory' : `inbox directory for ${inboxPath}`;
642
+ console.error(`borg-inbox-monitor: cannot create ${target}: ${message}`);
643
+ process.exit(1);
644
+ }
645
+ const lockDeps = defaultInboxLockDeps();
646
+ const pidfilePath = pidfilePathFor(inboxPath, stateRoot);
647
+ const mutationPath = mutationLockPathFor(pidfilePath);
648
+ // gh#840: our per-holder identity nonce — written into BOTH the pidfile
649
+ // (`<pid>:<nonce>`) and the heartbeat sidecar, so a future reaper can confirm
650
+ // same-process identity before reaping a wedged holder (PID-reuse safe).
651
+ const ownNonce = randomBytes(16).toString('hex');
652
+ const mutationContent = `${process.pid}:${ownNonce}`;
653
+ const legacyState = () => stateRoot
654
+ ? legacyMonitorArtifactState(inboxPath, {
655
+ exists: pathExistsFailClosed,
656
+ read: lockDeps.read,
657
+ isAlive: lockDeps.isAlive,
658
+ })
659
+ : 'absent';
660
+ const claimResult = claimModernMonitorSafely({
661
+ claimMutation: () => lockDeps.claim(mutationPath, mutationContent),
662
+ releaseMutation: () => lockDeps.removeIfContent(mutationPath, mutationContent),
663
+ legacyState,
664
+ claimModern: () => acquireInboxLock(pidfilePath, process.pid, lockDeps, 3, ownNonce),
665
+ releaseModern: () => lockDeps.removeIfContent(pidfilePath, `${process.pid}:${ownNonce}`),
666
+ });
667
+ if (claimResult === 'mutation-busy') {
668
+ console.error('borg-inbox-monitor: another modern monitor startup is mutating this inbox state; ' +
669
+ `re-arm after it finishes (if it persists after that process stops, confirm it is stopped, remove ${mutationPath}, then re-arm)`);
670
+ process.exit(1);
671
+ }
672
+ if (claimResult === 'legacy-live') {
673
+ // An already-running old monitor still owns the wake path; yield without
674
+ // touching its inbox-adjacent state.
675
+ process.exit(0);
676
+ }
677
+ if (claimResult === 'legacy-blocked') {
678
+ console.error(`borg-inbox-monitor: legacy monitor artifact remains beside ${inboxPath}; ` +
679
+ 'stop/confirm the old monitor, remove its .monitor.pid/.monitor.heartbeat manually, then re-arm');
680
+ process.exit(1);
681
+ }
682
+ if (claimResult === 'modern-live') {
683
+ process.exit(0);
684
+ }
685
+ // Releases occur under the same mutation guard as acquisition/reaping. If a
686
+ // startup mutation is already in flight, leave our state for that contender
687
+ // to inspect rather than risk deleting a successor's lock or heartbeat.
688
+ const releasePidfile = () => {
689
+ if (!lockDeps.claim(mutationPath, mutationContent))
690
+ return;
691
+ try {
692
+ lockDeps.removeIfContent(pidfilePath, `${process.pid}:${ownNonce}`);
693
+ }
694
+ finally {
695
+ lockDeps.removeIfContent(mutationPath, mutationContent);
696
+ }
697
+ };
698
+ const heartbeatPath = heartbeatPathFor(inboxPath, stateRoot);
699
+ const removeHeartbeat = () => {
700
+ if (!lockDeps.claim(mutationPath, mutationContent))
701
+ return;
702
+ try {
703
+ lockDeps.removeIfContent(heartbeatPath, ownNonce);
704
+ }
705
+ finally {
706
+ lockDeps.removeIfContent(mutationPath, mutationContent);
707
+ }
708
+ };
709
+ // Establish the SLI before spawning tail. If the monitor cannot create its
710
+ // state heartbeat, fail the arm rather than leave a Monitor task that looks
711
+ // armed while its holder cannot prove liveness.
712
+ try {
713
+ writeHeartbeat(heartbeatPath, ownNonce);
714
+ }
715
+ catch (err) {
716
+ releasePidfile();
717
+ const message = err instanceof Error ? err.message : String(err);
718
+ console.error(`borg-inbox-monitor: cannot write runtime heartbeat: ${message}`);
719
+ process.exit(1);
720
+ }
721
+ // `tail -F` for rotation/truncation resilience on macOS + Linux.
722
+ // Node's fs.watch is unreliable across file rotation; subprocess
723
+ // tail matches the prior kickoff-Monitor shape (`tail -F`) so the
724
+ // wire behavior is identical — only the per-line projection changes.
725
+ // `-n 0` skips backfilling history so fresh sessions don't replay
726
+ // old entries on every restart.
727
+ const deduper = new RecentLineDeduper();
728
+ seedDeduperFromInboxTail(inboxPath, deduper);
729
+ // gh#822: stat-anchored offset — seed to the inbox SIZE at arm (EOF, matching
730
+ // `tail -F -n 0`'s skip-history) so a non-empty inbox does NOT false-stall on
731
+ // the first tick (CR build-gate item 1). Re-anchored to the live file size on
732
+ // every delivery (rotation-robust — item 2 — via the pure evaluator).
733
+ let stall = { lastEmittedOffset: inboxSizeOf(inboxPath), grewSince: null };
734
+ let shuttingDown = false;
735
+ let currentTail = null;
736
+ // Spawn (or re-spawn) the tail. `fromByteOffset === null` → fresh `-n 0`
737
+ // (skip history, the arm path); otherwise byte-seek `-c +<N+1>` to re-read the
738
+ // un-emitted bytes a stalled tail dropped (CR item 3 — NOT `-n 0`, which would
739
+ // skip exactly those bytes). Lines route through the SHARED deduper so an
740
+ // overlap on respawn can't double-emit an already-seen line.
741
+ const spawnTail = (fromByteOffset) => {
742
+ const tail = spawn('tail', tailArgsFor(inboxPath, fromByteOffset), {
743
+ stdio: ['ignore', 'pipe', 'inherit'],
744
+ });
745
+ currentTail = tail;
746
+ if (!tail.stdout) {
747
+ console.error('borg-inbox-monitor: tail subprocess has no stdout');
748
+ removeHeartbeat();
749
+ releasePidfile();
750
+ process.exit(1);
751
+ }
752
+ const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
753
+ rl.on('line', (line) => {
754
+ const pretty = formatFreshEventLine(line, deduper);
755
+ if (pretty !== null) {
756
+ console.log(pretty);
757
+ // Delivered → the tail is current; re-anchor the offset to the live
758
+ // file size (stat-anchored = rotation-robust) and clear any stall streak.
759
+ stall = { lastEmittedOffset: inboxSizeOf(inboxPath), grewSince: null };
760
+ }
761
+ });
762
+ tail.on('error', (err) => {
763
+ if (tail !== currentTail)
764
+ return; // a superseded (respawned-over) tail — ignore.
765
+ console.error(`borg-inbox-monitor: tail failed: ${err.message}`);
766
+ removeHeartbeat();
767
+ releasePidfile();
768
+ process.exit(1);
769
+ });
770
+ tail.on('exit', (code, signal) => {
771
+ // gh#822: a tail we intentionally killed for a self-heal respawn is no
772
+ // longer `currentTail` — ignore its exit (it is NOT holder death).
773
+ if (tail !== currentTail)
774
+ return;
775
+ removeHeartbeat();
776
+ releasePidfile(); // gh#795: free the inbox so the next arm sees absent.
777
+ if (shuttingDown)
778
+ process.exit(0);
779
+ if (signal)
780
+ process.exit(0);
781
+ process.exit(code ?? 0);
782
+ });
783
+ };
784
+ spawnTail(null);
785
+ // gh#822: holder tick — (1) touch the heartbeat sidecar (proves node liveness
786
+ // even in a quiet cube; the SLI + the deferred node-wedge reaper read its
787
+ // mtime), (2) stat the inbox + run the PURE stall evaluator; on a SUSTAINED-
788
+ // un-emitted-growth verdict, self-heal by re-spawning our OWN tail byte-seeking
789
+ // from the last delivered offset (zero cross-process kill). Unref'd so the
790
+ // tick never keeps the process alive on its own.
791
+ const tick = setInterval(() => {
792
+ try {
793
+ // gh#840: write our identity nonce; the file mtime is the freshness signal.
794
+ writeHeartbeat(heartbeatPath, ownNonce);
795
+ }
796
+ catch {
797
+ /* best-effort heartbeat */
798
+ }
799
+ const verdict = evaluateInboxTailStall(inboxSizeOf(inboxPath), stall, Date.now(), STALL_THRESHOLD_MS);
800
+ stall = verdict.state;
801
+ if (verdict.kind === 'respawn' && !shuttingDown) {
802
+ const stalled = currentTail;
803
+ spawnTail(stall.lastEmittedOffset); // sets currentTail = the new tail FIRST
804
+ try {
805
+ stalled?.kill('SIGKILL'); // old tail's exit now sees tail !== currentTail → ignored
806
+ }
807
+ catch {
808
+ /* already gone */
809
+ }
810
+ // Reset the streak so the fresh tail gets a full window to catch up before
811
+ // it could re-trip on the same un-emitted bytes.
812
+ stall = { lastEmittedOffset: stall.lastEmittedOffset, grewSince: null };
813
+ }
814
+ }, MONITOR_TICK_MS);
815
+ tick.unref();
816
+ const shutdown = (signal) => {
817
+ if (shuttingDown)
818
+ return;
819
+ shuttingDown = true;
820
+ clearInterval(tick);
821
+ removeHeartbeat();
822
+ releasePidfile(); // gh#795: release our pidfile on graceful stop
823
+ const tail = currentTail;
824
+ if (tail && !tail.killed && !tail.kill(signal)) {
825
+ process.exit(0);
826
+ }
827
+ setTimeout(() => process.exit(0), 1000).unref();
828
+ };
829
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
830
+ process.once('SIGINT', () => shutdown('SIGINT'));
831
+ }
832
+ /**
833
+ * Is this module being invoked as the bin entry point?
834
+ *
835
+ * gh#114: under `npm install`, `process.argv[1]` is the npm-bin symlink
836
+ * path while `fileURLToPath(import.meta.url)` is the realpath of the
837
+ * installed file. A naive `===` check never matches → `main()` never
838
+ * runs → the documented `borg-inbox-monitor` Monitor command silently
839
+ * no-ops and drones go deaf without the wake-path-self-heal (gh#43)
840
+ * triggering. Resolve the symlink before comparing.
841
+ *
842
+ * Exported for unit testing.
843
+ */
844
+ export function isEntryInvocation(argv1, importMetaUrl) {
845
+ try {
846
+ return realpathSync(argv1) === fileURLToPath(importMetaUrl);
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
852
+ // Only run main() when invoked as the bin entry — allow importing the
853
+ // pure formatEventLine for unit testing without spawning tail.
854
+ if (isEntryInvocation(process.argv[1], import.meta.url)) {
855
+ main();
856
+ }
857
+ //# sourceMappingURL=inbox-monitor.js.map