borgmcp 1.1.15 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (459) hide show
  1. package/CONTRIBUTING.md +15 -0
  2. package/LICENSE +201 -33
  3. package/NOTICE +2 -0
  4. package/README.md +85 -36
  5. package/SECURITY.md +7 -0
  6. package/dist/agent-runtime.d.ts.map +1 -0
  7. package/dist/agent-runtime.js +63 -1
  8. package/dist/agent-runtime.js.map +1 -0
  9. package/dist/assimilate-cmd.d.ts +96 -17
  10. package/dist/assimilate-cmd.d.ts.map +1 -0
  11. package/dist/assimilate-cmd.js +1396 -48
  12. package/dist/assimilate-cmd.js.map +1 -0
  13. package/dist/assimilate-deps.d.ts.map +1 -0
  14. package/dist/assimilate-deps.js +384 -3
  15. package/dist/assimilate-deps.js.map +1 -0
  16. package/dist/assimilate-guard.d.ts +5 -7
  17. package/dist/assimilate-guard.d.ts.map +1 -0
  18. package/dist/assimilate-guard.js +60 -2
  19. package/dist/assimilate-guard.js.map +1 -0
  20. package/dist/assimilate-welcome.d.ts +1 -1
  21. package/dist/assimilate-welcome.d.ts.map +1 -0
  22. package/dist/assimilate-welcome.js +32 -2
  23. package/dist/assimilate-welcome.js.map +1 -0
  24. package/dist/auth-env.d.ts +7 -50
  25. package/dist/auth-env.d.ts.map +1 -0
  26. package/dist/auth-env.js +22 -1
  27. package/dist/auth-env.js.map +1 -0
  28. package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
  29. package/dist/backends/launch-all-pastelist.js +11 -4
  30. package/dist/backends/launch-all-pastelist.js.map +1 -0
  31. package/dist/backends/launch-all-tmux.d.ts.map +1 -0
  32. package/dist/backends/launch-all-tmux.js +49 -1
  33. package/dist/backends/launch-all-tmux.js.map +1 -0
  34. package/dist/backends/launch-all-windows.d.ts.map +1 -0
  35. package/dist/backends/launch-all-windows.js +86 -12
  36. package/dist/backends/launch-all-windows.js.map +1 -0
  37. package/dist/bare-launch-menu.d.ts.map +1 -0
  38. package/dist/bare-launch-menu.js +83 -6
  39. package/dist/bare-launch-menu.js.map +1 -0
  40. package/dist/claude-launch-args.d.ts.map +1 -0
  41. package/dist/claude-launch-args.js +34 -1
  42. package/dist/claude-launch-args.js.map +1 -0
  43. package/dist/claude.d.ts.map +1 -0
  44. package/dist/claude.js +486 -14
  45. package/dist/claude.js.map +1 -0
  46. package/dist/cleanup-cmd.d.ts +5 -5
  47. package/dist/cleanup-cmd.d.ts.map +1 -0
  48. package/dist/cleanup-cmd.js +453 -14
  49. package/dist/cleanup-cmd.js.map +1 -0
  50. package/dist/clear-rewake-core.d.ts.map +1 -0
  51. package/dist/clear-rewake-core.js +23 -2
  52. package/dist/clear-rewake-core.js.map +1 -0
  53. package/dist/clear-rewake.d.ts.map +1 -0
  54. package/dist/clear-rewake.js +19 -1
  55. package/dist/clear-rewake.js.map +1 -0
  56. package/dist/cli-help.d.ts +5 -0
  57. package/dist/cli-help.d.ts.map +1 -0
  58. package/dist/cli-help.js +104 -56
  59. package/dist/cli-help.js.map +1 -0
  60. package/dist/cli-platform.d.ts.map +1 -0
  61. package/dist/cli-platform.js +106 -4
  62. package/dist/cli-platform.js.map +1 -0
  63. package/dist/cli-tool-approval.d.ts +84 -0
  64. package/dist/cli-tool-approval.d.ts.map +1 -0
  65. package/dist/cli-tool-approval.js +583 -0
  66. package/dist/cli-tool-approval.js.map +1 -0
  67. package/dist/codex-app-server.d.ts.map +1 -0
  68. package/dist/codex-app-server.js +228 -4
  69. package/dist/codex-app-server.js.map +1 -0
  70. package/dist/codex-app-wake.d.ts.map +1 -0
  71. package/dist/codex-app-wake.js +432 -2
  72. package/dist/codex-app-wake.js.map +1 -0
  73. package/dist/codex-launch.d.ts.map +1 -0
  74. package/dist/codex-launch.js +102 -2
  75. package/dist/codex-launch.js.map +1 -0
  76. package/dist/codex-remote.d.ts +6 -1
  77. package/dist/codex-remote.d.ts.map +1 -0
  78. package/dist/codex-remote.js +394 -1
  79. package/dist/codex-remote.js.map +1 -0
  80. package/dist/codex-wake-resolve.d.ts.map +1 -0
  81. package/dist/codex-wake-resolve.js +127 -1
  82. package/dist/codex-wake-resolve.js.map +1 -0
  83. package/dist/config-utils.d.ts +2 -2
  84. package/dist/config-utils.d.ts.map +1 -0
  85. package/dist/config-utils.js +766 -3
  86. package/dist/config-utils.js.map +1 -0
  87. package/dist/config.d.ts +98 -61
  88. package/dist/config.d.ts.map +1 -0
  89. package/dist/config.js +426 -1
  90. package/dist/config.js.map +1 -0
  91. package/dist/console-prefix.d.ts.map +1 -0
  92. package/dist/console-prefix.js +96 -1
  93. package/dist/console-prefix.js.map +1 -0
  94. package/dist/cube-name.d.ts.map +1 -0
  95. package/dist/cube-name.js +67 -1
  96. package/dist/cube-name.js.map +1 -0
  97. package/dist/cubes.d.ts +126 -18
  98. package/dist/cubes.d.ts.map +1 -0
  99. package/dist/cubes.js +412 -4
  100. package/dist/cubes.js.map +1 -0
  101. package/dist/debug.d.ts.map +1 -0
  102. package/dist/debug.js +71 -1
  103. package/dist/debug.js.map +1 -0
  104. package/dist/direct-log.d.ts.map +1 -0
  105. package/dist/direct-log.js +11 -1
  106. package/dist/direct-log.js.map +1 -0
  107. package/dist/docs-sections.d.ts +11 -10
  108. package/dist/docs-sections.d.ts.map +1 -0
  109. package/dist/docs-sections.js +113 -3
  110. package/dist/docs-sections.js.map +1 -0
  111. package/dist/drone-lifecycle.d.ts +5 -21
  112. package/dist/drone-lifecycle.d.ts.map +1 -0
  113. package/dist/drone-lifecycle.js +57 -8
  114. package/dist/drone-lifecycle.js.map +1 -0
  115. package/dist/drone-management.d.ts +29 -0
  116. package/dist/drone-management.d.ts.map +1 -0
  117. package/dist/drone-management.js +109 -0
  118. package/dist/drone-management.js.map +1 -0
  119. package/dist/ensure-mcp-config.d.ts.map +1 -0
  120. package/dist/ensure-mcp-config.js +36 -1
  121. package/dist/ensure-mcp-config.js.map +1 -0
  122. package/dist/evict-drone.d.ts +10 -2
  123. package/dist/evict-drone.d.ts.map +1 -0
  124. package/dist/evict-drone.js +60 -1
  125. package/dist/evict-drone.js.map +1 -0
  126. package/dist/gc-orphan-inboxes.d.ts.map +1 -0
  127. package/dist/gc-orphan-inboxes.js +177 -1
  128. package/dist/gc-orphan-inboxes.js.map +1 -0
  129. package/dist/inbox-monitor.d.ts +1 -1
  130. package/dist/inbox-monitor.d.ts.map +1 -0
  131. package/dist/inbox-monitor.js +856 -3
  132. package/dist/inbox-monitor.js.map +1 -0
  133. package/dist/index.d.ts +6 -4
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +1034 -37
  136. package/dist/index.js.map +1 -0
  137. package/dist/launch-all-cmd.d.ts.map +1 -0
  138. package/dist/launch-all-cmd.js +377 -37
  139. package/dist/launch-all-cmd.js.map +1 -0
  140. package/dist/launch-all-command.d.ts.map +1 -0
  141. package/dist/launch-all-command.js +24 -1
  142. package/dist/launch-all-command.js.map +1 -0
  143. package/dist/launch-all-deps.d.ts +3 -8
  144. package/dist/launch-all-deps.d.ts.map +1 -0
  145. package/dist/launch-all-deps.js +99 -1
  146. package/dist/launch-all-deps.js.map +1 -0
  147. package/dist/launch-all-discovery.d.ts +1 -0
  148. package/dist/launch-all-discovery.d.ts.map +1 -0
  149. package/dist/launch-all-discovery.js +90 -4
  150. package/dist/launch-all-discovery.js.map +1 -0
  151. package/dist/launch-all-locks.d.ts.map +1 -0
  152. package/dist/launch-all-locks.js +54 -1
  153. package/dist/launch-all-locks.js.map +1 -0
  154. package/dist/launch-gate.d.ts +2 -2
  155. package/dist/launch-gate.d.ts.map +1 -0
  156. package/dist/launch-gate.js +73 -1
  157. package/dist/launch-gate.js.map +1 -0
  158. package/dist/lifecycle-log-guard.d.ts.map +1 -0
  159. package/dist/lifecycle-log-guard.js +93 -2
  160. package/dist/lifecycle-log-guard.js.map +1 -0
  161. package/dist/list-roles-render.d.ts.map +1 -0
  162. package/dist/list-roles-render.js +40 -6
  163. package/dist/list-roles-render.js.map +1 -0
  164. package/dist/local-log-routing.d.ts +19 -0
  165. package/dist/local-log-routing.d.ts.map +1 -0
  166. package/dist/local-log-routing.js +67 -0
  167. package/dist/local-log-routing.js.map +1 -0
  168. package/dist/local-manage-tool-result.d.ts +8 -0
  169. package/dist/local-manage-tool-result.d.ts.map +1 -0
  170. package/dist/local-manage-tool-result.js +10 -0
  171. package/dist/local-manage-tool-result.js.map +1 -0
  172. package/dist/local-server-cursor.d.ts +34 -0
  173. package/dist/local-server-cursor.d.ts.map +1 -0
  174. package/dist/local-server-cursor.js +151 -0
  175. package/dist/local-server-cursor.js.map +1 -0
  176. package/dist/log-audit.d.ts.map +1 -0
  177. package/dist/log-audit.js +194 -3
  178. package/dist/log-audit.js.map +1 -0
  179. package/dist/log-stream.d.ts +31 -18
  180. package/dist/log-stream.d.ts.map +1 -0
  181. package/dist/log-stream.js +1214 -12
  182. package/dist/log-stream.js.map +1 -0
  183. package/dist/model-presets.d.ts.map +1 -0
  184. package/dist/model-presets.js +19 -1
  185. package/dist/model-presets.js.map +1 -0
  186. package/dist/name-validator.d.ts.map +1 -0
  187. package/dist/name-validator.js +22 -1
  188. package/dist/name-validator.js.map +1 -0
  189. package/dist/opencode-drone.d.ts.map +1 -0
  190. package/dist/opencode-drone.js +438 -4
  191. package/dist/opencode-drone.js.map +1 -0
  192. package/dist/opencode-plugin.d.ts +1 -1
  193. package/dist/opencode-plugin.d.ts.map +1 -0
  194. package/dist/opencode-plugin.js +29 -4
  195. package/dist/opencode-plugin.js.map +1 -0
  196. package/dist/parse-assimilate-args.d.ts +1 -1
  197. package/dist/parse-assimilate-args.d.ts.map +1 -0
  198. package/dist/parse-assimilate-args.js +125 -1
  199. package/dist/parse-assimilate-args.js.map +1 -0
  200. package/dist/parse-launch-all-args.d.ts.map +1 -0
  201. package/dist/parse-launch-all-args.js +70 -1
  202. package/dist/parse-launch-all-args.js.map +1 -0
  203. package/dist/postinstall-banner.d.ts.map +1 -0
  204. package/dist/postinstall-banner.js +27 -2
  205. package/dist/postinstall-banner.js.map +1 -0
  206. package/dist/postinstall.d.ts.map +1 -0
  207. package/dist/postinstall.js +32 -5
  208. package/dist/postinstall.js.map +1 -0
  209. package/dist/readiness-probe.d.ts.map +1 -0
  210. package/dist/readiness-probe.js +9 -1
  211. package/dist/readiness-probe.js.map +1 -0
  212. package/dist/regen-format.d.ts.map +1 -0
  213. package/dist/regen-format.js +545 -13
  214. package/dist/regen-format.js.map +1 -0
  215. package/dist/regen.d.ts.map +1 -0
  216. package/dist/regen.js +141 -5
  217. package/dist/regen.js.map +1 -0
  218. package/dist/remote-client.d.ts +65 -175
  219. package/dist/remote-client.d.ts.map +1 -0
  220. package/dist/remote-client.js +942 -1
  221. package/dist/remote-client.js.map +1 -0
  222. package/dist/reset-local-seat-cmd.d.ts +50 -0
  223. package/dist/reset-local-seat-cmd.d.ts.map +1 -0
  224. package/dist/reset-local-seat-cmd.js +169 -0
  225. package/dist/reset-local-seat-cmd.js.map +1 -0
  226. package/dist/role-match.d.ts.map +1 -0
  227. package/dist/role-match.js +23 -1
  228. package/dist/role-match.js.map +1 -0
  229. package/dist/role-resolver.d.ts.map +1 -0
  230. package/dist/role-resolver.js +60 -1
  231. package/dist/role-resolver.js.map +1 -0
  232. package/dist/roster-render.d.ts.map +1 -0
  233. package/dist/roster-render.js +121 -3
  234. package/dist/roster-render.js.map +1 -0
  235. package/dist/seat-probe.d.ts +32 -13
  236. package/dist/seat-probe.d.ts.map +1 -0
  237. package/dist/seat-probe.js +78 -1
  238. package/dist/seat-probe.js.map +1 -0
  239. package/dist/seat-store.d.ts +76 -0
  240. package/dist/seat-store.d.ts.map +1 -0
  241. package/dist/seat-store.js +309 -0
  242. package/dist/seat-store.js.map +1 -0
  243. package/dist/seats.d.ts +303 -0
  244. package/dist/seats.d.ts.map +1 -0
  245. package/dist/seats.js +551 -0
  246. package/dist/seats.js.map +1 -0
  247. package/dist/self-path.d.ts +27 -0
  248. package/dist/self-path.d.ts.map +1 -0
  249. package/dist/self-path.js +43 -0
  250. package/dist/self-path.js.map +1 -0
  251. package/dist/server-endpoint.d.ts +9 -0
  252. package/dist/server-endpoint.d.ts.map +1 -0
  253. package/dist/server-endpoint.js +38 -0
  254. package/dist/server-endpoint.js.map +1 -0
  255. package/dist/server-errors.d.ts +50 -0
  256. package/dist/server-errors.d.ts.map +1 -0
  257. package/dist/server-errors.js +79 -0
  258. package/dist/server-errors.js.map +1 -0
  259. package/dist/server-handshake.d.ts +197 -0
  260. package/dist/server-handshake.d.ts.map +1 -0
  261. package/dist/server-handshake.js +505 -0
  262. package/dist/server-handshake.js.map +1 -0
  263. package/dist/server-response.d.ts +3 -0
  264. package/dist/server-response.d.ts.map +1 -0
  265. package/dist/server-response.js +67 -0
  266. package/dist/server-response.js.map +1 -0
  267. package/dist/server-trust.d.ts +14 -0
  268. package/dist/server-trust.d.ts.map +1 -0
  269. package/dist/server-trust.js +225 -0
  270. package/dist/server-trust.js.map +1 -0
  271. package/dist/setup-confirm.d.ts +3 -3
  272. package/dist/setup-confirm.d.ts.map +1 -0
  273. package/dist/setup-confirm.js +126 -2
  274. package/dist/setup-confirm.js.map +1 -0
  275. package/dist/setup.d.ts +5 -3
  276. package/dist/setup.d.ts.map +1 -0
  277. package/dist/setup.js +169 -46
  278. package/dist/setup.js.map +1 -0
  279. package/dist/shell-escape.d.ts.map +1 -0
  280. package/dist/shell-escape.js +22 -1
  281. package/dist/shell-escape.js.map +1 -0
  282. package/dist/spawn.d.ts +2 -2
  283. package/dist/spawn.d.ts.map +1 -0
  284. package/dist/spawn.js +29 -10
  285. package/dist/spawn.js.map +1 -0
  286. package/dist/startup-services.d.ts +0 -1
  287. package/dist/startup-services.d.ts.map +1 -0
  288. package/dist/startup-services.js +26 -1
  289. package/dist/startup-services.js.map +1 -0
  290. package/dist/stream-owner.d.ts +6 -2
  291. package/dist/stream-owner.d.ts.map +1 -0
  292. package/dist/stream-owner.js +547 -2
  293. package/dist/stream-owner.js.map +1 -0
  294. package/dist/stream-status.d.ts +1 -1
  295. package/dist/stream-status.d.ts.map +1 -0
  296. package/dist/stream-status.js +268 -3
  297. package/dist/stream-status.js.map +1 -0
  298. package/dist/sync-roles-render.d.ts.map +1 -0
  299. package/dist/sync-roles-render.js +118 -3
  300. package/dist/sync-roles-render.js.map +1 -0
  301. package/dist/sync.d.ts +1 -1
  302. package/dist/sync.d.ts.map +1 -0
  303. package/dist/sync.js +286 -22
  304. package/dist/sync.js.map +1 -0
  305. package/dist/terminal-title.d.ts.map +1 -0
  306. package/dist/terminal-title.js +68 -1
  307. package/dist/terminal-title.js.map +1 -0
  308. package/dist/token-store.d.ts +17 -111
  309. package/dist/token-store.d.ts.map +1 -0
  310. package/dist/token-store.js +81 -1
  311. package/dist/token-store.js.map +1 -0
  312. package/dist/tool-manifest.d.ts +4 -8
  313. package/dist/tool-manifest.d.ts.map +1 -0
  314. package/dist/tool-manifest.js +554 -1
  315. package/dist/tool-manifest.js.map +1 -0
  316. package/dist/tool-scope.d.ts +8 -12
  317. package/dist/tool-scope.d.ts.map +1 -0
  318. package/dist/tool-scope.js +97 -1
  319. package/dist/tool-scope.js.map +1 -0
  320. package/dist/types.d.ts +1 -5
  321. package/dist/types.d.ts.map +1 -0
  322. package/dist/types.js +5 -0
  323. package/dist/types.js.map +1 -0
  324. package/dist/unknown-subcommand.d.ts +1 -1
  325. package/dist/unknown-subcommand.d.ts.map +1 -0
  326. package/dist/unknown-subcommand.js +37 -1
  327. package/dist/unknown-subcommand.js.map +1 -0
  328. package/dist/version.d.ts +1 -1
  329. package/dist/version.d.ts.map +1 -0
  330. package/dist/version.js +78 -2
  331. package/dist/version.js.map +1 -0
  332. package/dist/working-repo.d.ts.map +1 -0
  333. package/dist/working-repo.js +81 -1
  334. package/dist/working-repo.js.map +1 -0
  335. package/dist/worktree-lifecycle.d.ts.map +1 -0
  336. package/dist/worktree-lifecycle.js +205 -2
  337. package/dist/worktree-lifecycle.js.map +1 -0
  338. package/docs/EXTRACTION_PROVENANCE.md +40 -0
  339. package/docs/LOCAL_SERVER.md +103 -0
  340. package/docs/RELEASING.md +120 -0
  341. package/package.json +52 -34
  342. package/src/agent-runtime.ts +69 -0
  343. package/src/assimilate-cmd.ts +1864 -0
  344. package/src/assimilate-deps.ts +444 -0
  345. package/src/assimilate-guard.ts +84 -0
  346. package/src/assimilate-welcome.ts +39 -0
  347. package/src/auth-env.ts +21 -0
  348. package/src/backends/launch-all-pastelist.ts +18 -0
  349. package/src/backends/launch-all-tmux.ts +75 -0
  350. package/src/backends/launch-all-windows.ts +110 -0
  351. package/src/bare-launch-menu.ts +148 -0
  352. package/src/claude-launch-args.ts +35 -0
  353. package/src/claude.ts +561 -0
  354. package/src/cleanup-cmd.ts +581 -0
  355. package/src/clear-rewake-core.ts +34 -0
  356. package/src/clear-rewake.ts +18 -0
  357. package/src/cli-help.ts +116 -0
  358. package/src/cli-platform.ts +127 -0
  359. package/src/cli-tool-approval.ts +716 -0
  360. package/src/codex-app-server.ts +238 -0
  361. package/src/codex-app-wake.ts +512 -0
  362. package/src/codex-launch.ts +140 -0
  363. package/src/codex-remote.ts +500 -0
  364. package/src/codex-wake-resolve.ts +155 -0
  365. package/src/config-utils.ts +811 -0
  366. package/src/config.ts +595 -0
  367. package/src/console-prefix.ts +99 -0
  368. package/src/cube-name.ts +69 -0
  369. package/src/cubes.ts +583 -0
  370. package/src/debug.ts +73 -0
  371. package/src/direct-log.ts +9 -0
  372. package/src/docs-sections.ts +129 -0
  373. package/src/drone-lifecycle.ts +62 -0
  374. package/src/drone-management.ts +162 -0
  375. package/src/ensure-mcp-config.ts +54 -0
  376. package/src/evict-drone.ts +82 -0
  377. package/src/gc-orphan-inboxes.ts +229 -0
  378. package/src/inbox-monitor.ts +1005 -0
  379. package/src/index.ts +1201 -0
  380. package/src/launch-all-cmd.ts +450 -0
  381. package/src/launch-all-command.ts +30 -0
  382. package/src/launch-all-deps.ts +188 -0
  383. package/src/launch-all-discovery.ts +118 -0
  384. package/src/launch-all-locks.ts +74 -0
  385. package/src/launch-gate.ts +80 -0
  386. package/src/lifecycle-log-guard.ts +144 -0
  387. package/src/list-roles-render.ts +52 -0
  388. package/src/local-log-routing.ts +106 -0
  389. package/src/local-manage-tool-result.ts +10 -0
  390. package/src/local-server-cursor.ts +188 -0
  391. package/src/log-audit.ts +207 -0
  392. package/src/log-stream.ts +1505 -0
  393. package/src/model-presets.ts +22 -0
  394. package/src/name-validator.ts +23 -0
  395. package/src/opencode-drone.ts +528 -0
  396. package/src/opencode-plugin.ts +38 -0
  397. package/src/parse-assimilate-args.ts +125 -0
  398. package/src/parse-launch-all-args.ts +100 -0
  399. package/src/postinstall-banner.ts +37 -0
  400. package/src/postinstall.ts +36 -0
  401. package/src/readiness-probe.ts +12 -0
  402. package/src/regen-format.ts +651 -0
  403. package/src/regen.ts +150 -0
  404. package/src/remote-client.ts +1379 -0
  405. package/src/reset-local-seat-cmd.ts +227 -0
  406. package/src/role-match.ts +29 -0
  407. package/src/role-resolver.ts +93 -0
  408. package/src/roster-render.ts +196 -0
  409. package/src/seat-probe.ts +115 -0
  410. package/src/seat-store.ts +333 -0
  411. package/src/seats.ts +741 -0
  412. package/src/self-path.ts +51 -0
  413. package/src/server-endpoint.ts +38 -0
  414. package/src/server-errors.ts +93 -0
  415. package/src/server-handshake.ts +783 -0
  416. package/src/server-response.ts +76 -0
  417. package/src/server-trust.ts +245 -0
  418. package/src/setup-confirm.ts +171 -0
  419. package/src/setup.ts +186 -0
  420. package/src/shell-escape.ts +22 -0
  421. package/src/spawn.ts +31 -0
  422. package/src/startup-services.ts +36 -0
  423. package/src/stream-owner.ts +686 -0
  424. package/src/stream-status.ts +337 -0
  425. package/src/sync-roles-render.ts +163 -0
  426. package/src/sync.ts +363 -0
  427. package/src/terminal-title.ts +76 -0
  428. package/src/token-store.ts +102 -0
  429. package/src/tool-manifest.ts +610 -0
  430. package/src/tool-scope.ts +110 -0
  431. package/src/types.ts +5 -0
  432. package/src/unknown-subcommand.ts +35 -0
  433. package/src/version.ts +82 -0
  434. package/src/working-repo.ts +99 -0
  435. package/src/worktree-lifecycle.ts +254 -0
  436. package/dist/auth-recovery.d.ts +0 -33
  437. package/dist/auth-recovery.js +0 -1
  438. package/dist/auth.d.ts +0 -103
  439. package/dist/auth.js +0 -39
  440. package/dist/device-auth.d.ts +0 -75
  441. package/dist/device-auth.js +0 -1
  442. package/dist/drone-address.d.ts +0 -26
  443. package/dist/drone-address.js +0 -1
  444. package/dist/get-started.d.ts +0 -20
  445. package/dist/get-started.js +0 -2
  446. package/dist/health-beat.d.ts +0 -134
  447. package/dist/health-beat.js +0 -1
  448. package/dist/role-section.d.ts +0 -110
  449. package/dist/role-section.js +0 -8
  450. package/dist/setup-action.d.ts +0 -20
  451. package/dist/setup-action.js +0 -1
  452. package/dist/stale-version-check.d.ts +0 -40
  453. package/dist/stale-version-check.js +0 -1
  454. package/dist/subscription-retry.d.ts +0 -40
  455. package/dist/subscription-retry.js +0 -1
  456. package/dist/templates.d.ts +0 -107
  457. package/dist/templates.js +0 -359
  458. package/dist/token-crypto.d.ts +0 -50
  459. package/dist/token-crypto.js +0 -1
@@ -0,0 +1,1005 @@
1
+ #!/usr/bin/env node
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
+
33
+ import { spawn } from 'node:child_process';
34
+ import { createHash, randomBytes } from 'node:crypto';
35
+ import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync, type Stats } from 'node:fs';
36
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
37
+ import { createInterface } from 'node:readline';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const ENTRY_LINE_RE =
41
+ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/;
42
+
43
+ export const RECENT_EMITTED_LINE_CAP = 1024;
44
+
45
+ export class RecentLineDeduper {
46
+ private readonly seen = new Set<string>();
47
+ private readonly order: string[] = [];
48
+
49
+ constructor(private readonly cap = RECENT_EMITTED_LINE_CAP) {
50
+ if (!Number.isInteger(cap) || cap < 1) {
51
+ throw new Error('cap must be a positive integer');
52
+ }
53
+ }
54
+
55
+ remember(line: string): boolean {
56
+ if (this.seen.has(line)) return false;
57
+ this.seen.add(line);
58
+ this.order.push(line);
59
+ while (this.order.length > this.cap) {
60
+ const oldLine = this.order.shift();
61
+ if (oldLine) this.seen.delete(oldLine);
62
+ }
63
+ return true;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Pure: parse one inbox-file line and produce the pretty summary line
69
+ * (or null if the line is a continuation or unrecognized shape).
70
+ *
71
+ * Pass-through — no truncation. Claude Code does not impose a hard cap
72
+ * on task-notification title length. The 200-char `MAX_SUMMARY_LEN` cap
73
+ * removed here (and the 80-char predecessor) were borg-mcp conventions
74
+ * built on a misunderstanding of the renderer's limits. Drones now see
75
+ * the full first line of every entry; multi-signal batched posts no
76
+ * longer have their second signal hidden.
77
+ *
78
+ * Exported so tests can exercise the parsing without spawning tail.
79
+ */
80
+ export function formatEventLine(inboxLine: string): string | null {
81
+ const match = ENTRY_LINE_RE.exec(inboxLine);
82
+ if (!match) return null;
83
+ const [, , label, role, body] = match;
84
+ const summary = body.trim();
85
+ return `${label} (${role}): ${summary}`;
86
+ }
87
+
88
+ export function formatFreshEventLine(
89
+ inboxLine: string,
90
+ deduper: RecentLineDeduper
91
+ ): string | null {
92
+ const pretty = formatEventLine(inboxLine);
93
+ if (pretty === null) return null;
94
+ return deduper.remember(inboxLine) ? pretty : null;
95
+ }
96
+
97
+ export function seedDeduperFromInboxTail(
98
+ inboxPath: string,
99
+ deduper: RecentLineDeduper,
100
+ maxLines = 512
101
+ ): void {
102
+ if (!Number.isInteger(maxLines) || maxLines < 1) {
103
+ throw new Error('maxLines must be a positive integer');
104
+ }
105
+
106
+ let raw: string;
107
+ try {
108
+ raw = readFileSync(inboxPath, 'utf-8');
109
+ } catch (err: any) {
110
+ if (err?.code === 'ENOENT') return;
111
+ throw err;
112
+ }
113
+
114
+ const lines = raw.split(/\r?\n/);
115
+ if (lines.at(-1) === '') lines.pop();
116
+ for (const line of lines.slice(-maxLines)) {
117
+ if (formatEventLine(line) !== null) deduper.remember(line);
118
+ }
119
+ }
120
+
121
+ // ---- gh#822 subclass B: hung-tail (alive-but-not-following) self-heal ----
122
+ // A `tail -F` can stay ALIVE but stop following after a rotation/truncation
123
+ // (the classic macOS kqueue stall): PID alive → the gh#795 dedup yields → the
124
+ // drone goes silently deaf while the Monitor "looks" held. DETECT the stall via
125
+ // a STAT-ANCHORED offset (NOT a pure byte accumulator — CR build-gate item 2):
126
+ // the holder records `lastEmittedOffset` = the inbox file SIZE at the last tail
127
+ // delivery, and seeds it to the file size at arm (EOF, matching `tail -F -n 0`;
128
+ // item 1). On each periodic tick the holder stats the inbox and calls this PURE
129
+ // evaluator. RECOVER by self-respawning the holder's OWN tail (zero cross-
130
+ // instance reap → zero false-reap risk).
131
+
132
+ /** Holder-tracked stall state. `lastEmittedOffset` is stat-anchored. */
133
+ export interface TailStallState {
134
+ /** Inbox file size (bytes) as of the last tail delivery; seeded to EOF at arm. */
135
+ lastEmittedOffset: number;
136
+ /** Epoch ms when the CURRENT un-emitted-growth streak began; null = none. */
137
+ grewSince: number | null;
138
+ }
139
+
140
+ export type TailStallVerdict =
141
+ | { kind: 'ok'; state: TailStallState } // caught up / quiet — no action
142
+ | { kind: 'rotation'; state: TailStallState } // file shrank → re-anchor, no respawn
143
+ | { kind: 'respawn'; state: TailStallState }; // grew-but-not-emitted SUSTAINED → self-heal
144
+
145
+ /**
146
+ * gh#822: PURE stall evaluator. Given the current inbox size + the holder's
147
+ * stat-anchored state, decide whether the tail is healthy, rotated, or stalled.
148
+ * False-reap-safe by construction (CR 131dcd78):
149
+ * - ROTATION (item 2): `inboxSize < lastEmittedOffset` ⇒ truncation/rotation —
150
+ * re-anchor offset to the NEW size + clear the streak; NEVER treated as
151
+ * negative growth, so the detector keeps working after the very rotation
152
+ * that triggers Subclass B.
153
+ * - QUIET cube: `inboxSize === lastEmittedOffset` ⇒ no un-emitted growth ⇒ ok
154
+ * (clears any streak). A silent cube can NEVER trip a respawn.
155
+ * - GREW-but-not-emitted: `inboxSize > lastEmittedOffset`. Only when that
156
+ * un-emitted growth PERSISTS continuously past `stallThresholdMs` ⇒ respawn.
157
+ * A brief/slow grow that the tail then delivers (a later tick re-anchors
158
+ * lastEmittedOffset → size==offset) clears the streak first. So "slow" never
159
+ * trips it; only SUSTAINED un-emitted growth does. Err toward not-respawning.
160
+ */
161
+ export function evaluateInboxTailStall(
162
+ inboxSize: number,
163
+ state: TailStallState,
164
+ nowMs: number,
165
+ stallThresholdMs: number
166
+ ): TailStallVerdict {
167
+ if (inboxSize < state.lastEmittedOffset) {
168
+ return { kind: 'rotation', state: { lastEmittedOffset: inboxSize, grewSince: null } };
169
+ }
170
+ if (inboxSize === state.lastEmittedOffset) {
171
+ return { kind: 'ok', state: { lastEmittedOffset: state.lastEmittedOffset, grewSince: null } };
172
+ }
173
+ // inboxSize > lastEmittedOffset → un-emitted growth; start/continue the streak.
174
+ const grewSince = state.grewSince ?? nowMs;
175
+ const next: TailStallState = { lastEmittedOffset: state.lastEmittedOffset, grewSince };
176
+ return nowMs - grewSince >= stallThresholdMs
177
+ ? { kind: 'respawn', state: next }
178
+ : { kind: 'ok', state: next };
179
+ }
180
+
181
+ // ---- gh#795: single-instance-per-inbox self-dedup ----
182
+ // A session restart / re-arm can spawn a 2nd borg-inbox-monitor on the SAME
183
+ // inbox while a prior one is still live — and still BRIDGED to the current
184
+ // session across an /mcp reconnect (gh#795 / ed55abea), so it is NOT a dead
185
+ // prior-session process. Two tails on one inbox = N-fold duplicate wakes +
186
+ // leaked tail processes. Guard: a per-inbox pidfile. A new instance YIELDS
187
+ // (exits 0, never spawns tail) when a live holder exists — it NEVER kills the
188
+ // live one (that would break the sole wake path → deaf seat). It reaps ONLY a
189
+ // pidfile whose PID is PROVABLY dead (kill-0 → ESRCH), then claims it. kill-0
190
+ // alone can never reap a live monitor, so a working wake path can't be killed
191
+ // (gh#795 gate: kill-0-only; mtime-heartbeat deferred — it could false-reap a
192
+ // working tail whose touch stalls).
193
+
194
+ export interface InboxLockDeps {
195
+ /**
196
+ * ATOMICALLY create the pidfile WITH `content` iff it does not exist. True =
197
+ * claimed, false = already exists. Atomic-with-content (no create-then-write
198
+ * gap) so a concurrent reader never sees an empty pidfile (gh#795 TOCTOU
199
+ * window 2).
200
+ */
201
+ claim(path: string, content: string): boolean;
202
+ /** File contents, or null if absent/unreadable. */
203
+ read(path: string): string | null;
204
+ /**
205
+ * Verify-then-unlink the file when its content equals `expected`.
206
+ *
207
+ * This primitive is NOT an atomic filesystem compare-and-swap. Callers must
208
+ * hold the per-inbox modern mutation lock around any destructive use; it is
209
+ * never used to mutate legacy inbox-adjacent artifacts.
210
+ */
211
+ removeIfContent(path: string, expected: string): void;
212
+ /** kill(pid,0) liveness: true if the process exists (alive), false if gone (ESRCH). */
213
+ isAlive(pid: number): boolean;
214
+ /**
215
+ * gh#840 (optional — enables the node-WEDGE reap): read the holder heartbeat
216
+ * sidecar for this pidfile's inbox → { mtimeMs, nonce } or null if absent /
217
+ * unreadable. Absent dep ⇒ no wedge reap (legacy behavior).
218
+ */
219
+ readHeartbeat?(pidfilePath: string): { mtimeMs: number; nonce: string } | null;
220
+ /** gh#840: clock for heartbeat staleness (injected for tests; defaults to Date.now). */
221
+ now?(): number;
222
+ /** gh#840: heartbeat staleness threshold; defaults to HEARTBEAT_STALE_MS. */
223
+ heartbeatStaleMs?: number;
224
+ }
225
+
226
+ /** gh#840: pidfile content is `<pid>` (legacy) or `<pid>:<nonce>` (identity-tagged). */
227
+ export function parsePidfileContent(trimmed: string): { pid: number; nonce: string | null } {
228
+ const colon = trimmed.indexOf(':');
229
+ if (colon === -1) return { pid: Number.parseInt(trimmed, 10), nonce: null };
230
+ return {
231
+ pid: Number.parseInt(trimmed.slice(0, colon), 10),
232
+ nonce: trimmed.slice(colon + 1) || null,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * gh#840: is the LIVE pidfile holder node-WEDGED (reapable)? True ONLY when BOTH
238
+ * (a) the heartbeat sidecar mtime is stale past the threshold, AND (b) the
239
+ * heartbeat's nonce MATCHES the pidfile holder's nonce (same identity wrote
240
+ * both). A nonce MISMATCH ⇒ the stale heartbeat belongs to a DIFFERENT identity
241
+ * than the currently-alive pidfile holder (PID reuse, or a young reclaimer that
242
+ * hasn't written its first heartbeat yet) ⇒ NOT wedged ⇒ NEVER reap. Err toward
243
+ * NOT reaping: no readHeartbeat dep, no heartbeat file, or a legacy no-nonce
244
+ * pidfile all return false (a false-reap is the deafness we prevent).
245
+ */
246
+ export function isHolderWedged(
247
+ pidfilePath: string,
248
+ holderNonce: string | null,
249
+ deps: InboxLockDeps
250
+ ): boolean {
251
+ if (!holderNonce || !deps.readHeartbeat) return false;
252
+ const hb = deps.readHeartbeat(pidfilePath);
253
+ if (hb === null) return false;
254
+ const now = deps.now ? deps.now() : Date.now();
255
+ const staleMs = deps.heartbeatStaleMs ?? HEARTBEAT_STALE_MS;
256
+ const stale = now - hb.mtimeMs >= staleMs;
257
+ return stale && hb.nonce === holderNonce;
258
+ }
259
+
260
+ /**
261
+ * gh#979: a worktree is the durable local identity for a seat. Drone UUIDs
262
+ * change on re-mint, but a reused worktree must retain the same monitor
263
+ * runtime home. Keep it inside the worktree (where workspace-only sandboxes
264
+ * can write), never under TMPDIR/XDG, and make its contents self-ignored so
265
+ * runtime lock churn never dirties the repository.
266
+ */
267
+ export function monitorStateRootForWorktree(worktreePath: string): string {
268
+ if (typeof worktreePath !== 'string' || worktreePath.length === 0) {
269
+ throw new Error(`invalid monitor worktree path: ${worktreePath}`);
270
+ }
271
+ return join(resolve(worktreePath), '.borgmcp', 'inbox-monitor');
272
+ }
273
+
274
+ /** Legacy sidecars written by pre-gh#979 monitors beside the config inbox. */
275
+ export function legacyPidfilePathFor(inboxPath: string): string {
276
+ return `${inboxPath}.monitor.pid`;
277
+ }
278
+
279
+ export function legacyHeartbeatPathFor(inboxPath: string): string {
280
+ return `${inboxPath}.monitor.heartbeat`;
281
+ }
282
+
283
+ function monitorStateKey(inboxPath: string): string {
284
+ return createHash('sha256').update(resolve(inboxPath)).digest('hex');
285
+ }
286
+
287
+ /**
288
+ * State paths are keyed by the absolute inbox path within the explicitly
289
+ * supplied worktree runtime root. Omitting the root intentionally preserves
290
+ * the legacy inbox-adjacent layout for old manual commands; supported launch
291
+ * and orientation paths always pass the root explicitly.
292
+ */
293
+ export function pidfilePathFor(inboxPath: string, stateRoot?: string | null): string {
294
+ if (!stateRoot) return legacyPidfilePathFor(inboxPath);
295
+ return join(resolve(stateRoot), `${monitorStateKey(inboxPath)}.monitor.pid`);
296
+ }
297
+
298
+ /** gh#822: the holder-liveness heartbeat sidecar (mtime touched each tick). */
299
+ export function heartbeatPathFor(inboxPath: string, stateRoot?: string | null): string {
300
+ if (!stateRoot) return legacyHeartbeatPathFor(inboxPath);
301
+ return join(resolve(stateRoot), `${monitorStateKey(inboxPath)}.monitor.heartbeat`);
302
+ }
303
+
304
+ /**
305
+ * Prepare a private, worktree-local monitor runtime root. The supplied root
306
+ * must have the exact `<worktree>/.borgmcp/inbox-monitor` shape generated by
307
+ * `monitorStateRootForWorktree()`. Before any write, resolve the saved
308
+ * worktree canonically and reject a symlinked `.borgmcp` or `inbox-monitor`
309
+ * ancestor. Its local `.gitignore` ignores itself and all descendants, so
310
+ * runtime state produces no repository dirt without mutating tracked ignores.
311
+ */
312
+ export function ensureMonitorStateDir(stateRoot: string): string {
313
+ const requestedRoot = resolve(stateRoot);
314
+ if (basename(requestedRoot) !== 'inbox-monitor' || basename(dirname(requestedRoot)) !== '.borgmcp') {
315
+ throw new Error(`unsafe monitor state path (expected <worktree>/.borgmcp/inbox-monitor): ${requestedRoot}`);
316
+ }
317
+
318
+ // `realpath` the existing worktree FIRST. Do not mkdir through the supplied
319
+ // path: a pre-existing `.borgmcp` symlink would otherwise redirect writes
320
+ // outside the workspace before we had a chance to inspect it.
321
+ const canonicalWorktree = realpathSync(dirname(dirname(requestedRoot)));
322
+ const canonicalParent = join(canonicalWorktree, '.borgmcp');
323
+ const canonicalRoot = join(canonicalParent, 'inbox-monitor');
324
+ if (!isStrictDescendant(canonicalRoot, canonicalWorktree)) {
325
+ throw new Error(`unsafe monitor state root outside worktree: ${canonicalRoot}`);
326
+ }
327
+
328
+ // Preflight every pre-existing artifact before making any filesystem
329
+ // mutation. In particular, a project-owned root or ignore marker must fail
330
+ // without a mode normalization, write, or sidecar creation.
331
+ const parentBefore = inspectRealDirectory(canonicalParent);
332
+ if (parentBefore && realpathSync(canonicalParent) !== canonicalParent) {
333
+ throw new Error(`unsafe monitor state ancestor changed: ${canonicalParent}`);
334
+ }
335
+ const rootBefore = parentBefore ? inspectRealDirectory(canonicalRoot) : null;
336
+ if (rootBefore) {
337
+ assertOwnedMonitorStateRoot(canonicalRoot, rootBefore);
338
+ assertOwnedMonitorIgnore(join(canonicalRoot, '.gitignore'), canonicalRoot);
339
+ }
340
+
341
+ // Nothing pre-existing has been mutated. Create missing path components only
342
+ // after containment + ownership preflight. A component that appears between
343
+ // check and create is a safe failure, not an implicit ownership claim.
344
+ if (!parentBefore) {
345
+ createRealDirectory(canonicalParent, 0o700);
346
+ }
347
+ if (realpathSync(canonicalParent) !== canonicalParent) {
348
+ throw new Error(`unsafe monitor state ancestor changed: ${canonicalParent}`);
349
+ }
350
+ const createdRoot = !rootBefore;
351
+ if (createdRoot) {
352
+ createRealDirectory(canonicalRoot, 0o700);
353
+ }
354
+ const resolvedRoot = realpathSync(canonicalRoot);
355
+ if (resolvedRoot !== canonicalRoot || !isStrictDescendant(resolvedRoot, canonicalWorktree)) {
356
+ throw new Error(`unsafe monitor state root escaped worktree: ${canonicalRoot}`);
357
+ }
358
+
359
+ const rootStat = lstatSync(resolvedRoot);
360
+ assertOwnedMonitorStateRoot(resolvedRoot, rootStat);
361
+ const ignorePath = join(resolvedRoot, '.gitignore');
362
+ const monitorIgnoreContent = '*\n';
363
+ if (!createdRoot) {
364
+ // Re-check directly before the first mutation: the initial preflight could
365
+ // otherwise be invalidated by a replacement while a sibling was creating.
366
+ assertOwnedMonitorIgnore(ignorePath, resolvedRoot);
367
+ }
368
+ // Ownership is now proven. mkdir's mode does not alter a pre-existing root,
369
+ // so reassert privacy only AFTER a foreign root/ignore can no longer be
370
+ // modified by a failed arm.
371
+ chmodSync(resolvedRoot, 0o700);
372
+ // A nested .gitignore with `*` also ignores itself. This leaves neither the
373
+ // runtime directory nor its control file in `git status --porcelain`.
374
+ if (createdRoot) {
375
+ writeFileSync(ignorePath, monitorIgnoreContent, { encoding: 'utf8', mode: 0o600 });
376
+ chmodSync(ignorePath, 0o600);
377
+ }
378
+ // One last post-write revalidation narrows a parent replacement race to a
379
+ // detected failure. Node has no dirfd-relative mkdir/write API, so callers
380
+ // fail closed and retry rather than trusting a changed ancestor.
381
+ if (realpathSync(canonicalRoot) !== canonicalRoot) {
382
+ throw new Error(`unsafe monitor state root changed while preparing: ${canonicalRoot}`);
383
+ }
384
+ return canonicalRoot;
385
+ }
386
+
387
+ function inspectRealDirectory(path: string): Stats | null {
388
+ try {
389
+ const stat = lstatSync(path);
390
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
391
+ throw new Error(`unsafe monitor state ancestor (not a real directory): ${path}`);
392
+ }
393
+ return stat;
394
+ } catch (err: any) {
395
+ if (err?.code === 'ENOENT') return null;
396
+ throw err;
397
+ }
398
+ }
399
+
400
+ function createRealDirectory(path: string, mode: number): void {
401
+ try {
402
+ mkdirSync(path, { mode });
403
+ } catch (err: any) {
404
+ if (err?.code === 'EEXIST') {
405
+ throw new Error(`unsafe monitor state path appeared during preparation: ${path}`);
406
+ }
407
+ throw err;
408
+ }
409
+ const stat = lstatSync(path);
410
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
411
+ throw new Error(`unsafe monitor state ancestor (not a real directory): ${path}`);
412
+ }
413
+ }
414
+
415
+ function assertOwnedMonitorStateRoot(path: string, stat: Stats): void {
416
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
417
+ throw new Error(`unsafe monitor state path (not a real directory): ${path}`);
418
+ }
419
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
420
+ throw new Error(`unsafe monitor state directory owner: ${path}`);
421
+ }
422
+ }
423
+
424
+ function assertOwnedMonitorIgnore(ignorePath: string, rootPath: string): void {
425
+ let ignoreStat: Stats;
426
+ try {
427
+ ignoreStat = lstatSync(ignorePath);
428
+ } catch (err: any) {
429
+ if (err?.code === 'ENOENT') {
430
+ throw new Error(`unsafe monitor state root missing Borg ownership marker: ${rootPath}`);
431
+ }
432
+ throw err;
433
+ }
434
+ if (!ignoreStat.isFile() || ignoreStat.isSymbolicLink()) {
435
+ throw new Error(`unsafe monitor state ignore path: ${ignorePath}`);
436
+ }
437
+ // The state root is intentionally self-ignored, but it may overlap a
438
+ // user-created/tracked directory. Never overwrite or chmod a pre-existing
439
+ // project ignore file merely because it happens to occupy this path.
440
+ if (readFileSync(ignorePath, 'utf8') !== '*\n') {
441
+ throw new Error(`unsafe monitor state ignore path (not Borg-owned): ${ignorePath}`);
442
+ }
443
+ if ((ignoreStat.mode & 0o777) !== 0o600) {
444
+ throw new Error(`unsafe monitor state ignore path (unexpected mode): ${ignorePath}`);
445
+ }
446
+ if (typeof process.getuid === 'function' && ignoreStat.uid !== process.getuid()) {
447
+ throw new Error(`unsafe monitor state ignore path (unexpected owner): ${ignorePath}`);
448
+ }
449
+ }
450
+
451
+ function isStrictDescendant(candidate: string, ancestor: string): boolean {
452
+ const rel = relative(ancestor, candidate);
453
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
454
+ }
455
+
456
+ // gh#822 thresholds — conservative / GC-safe (≥5× the tick), err toward NOT
457
+ // respawning / NOT warning (CR 131dcd78: a false-reap IS the deafness we fix).
458
+ const MONITOR_TICK_MS = 30_000;
459
+ const STALL_THRESHOLD_MS = 5 * MONITOR_TICK_MS; // 150s sustained un-emitted growth
460
+ export const HEARTBEAT_STALE_MS = 5 * MONITOR_TICK_MS; // SLI: present-but-stale ⇒ unhealthy
461
+
462
+ /**
463
+ * gh#822: `tail` args — ARM (`-n 0`, skip history, matches the prior shape) vs
464
+ * RECOVERY byte-seek (`-c +<N+1>`, re-read the un-emitted bytes from offset N
465
+ * FORWARD — CR build-gate item 3: NOT `-n 0`, which starts at the new EOF and
466
+ * skips exactly the bytes a stalled tail dropped).
467
+ */
468
+ export function tailArgsFor(inboxPath: string, fromByteOffset: number | null): string[] {
469
+ return fromByteOffset === null
470
+ ? ['-F', '-n', '0', inboxPath]
471
+ : ['-F', '-c', `+${fromByteOffset + 1}`, inboxPath];
472
+ }
473
+
474
+ /** Current inbox file size in bytes, or 0 if it can't be stat'd (treated as empty). */
475
+ function inboxSizeOf(inboxPath: string): number {
476
+ try {
477
+ return statSync(inboxPath).size;
478
+ } catch {
479
+ return 0;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Try to become the SOLE monitor for this inbox. Returns true if we claimed the
485
+ * pidfile (caller proceeds to tail + must release it on exit); false if a LIVE
486
+ * holder already owns it (caller yields/exits without tailing). The runtime
487
+ * calls it only while holding the modern per-inbox mutation lock, so stale
488
+ * reaping and successor claims are serialized. It never mutates legacy
489
+ * inbox-adjacent artifacts.
490
+ */
491
+ export function acquireInboxLock(
492
+ pidfilePath: string,
493
+ ownPid: number,
494
+ deps: InboxLockDeps,
495
+ maxAttempts = 3,
496
+ ownNonce?: string
497
+ ): boolean {
498
+ // gh#840: tag the pidfile with our identity nonce (`<pid>:<nonce>`) so a
499
+ // future reaper can confirm same-process IDENTITY before reaping a wedged
500
+ // holder. Legacy callers (no nonce) keep the bare `<pid>` content.
501
+ const own = ownNonce ? `${ownPid}:${ownNonce}` : String(ownPid);
502
+ for (let i = 0; i < maxAttempts; i++) {
503
+ if (deps.claim(pidfilePath, own)) {
504
+ return true; // pidfile was absent → claimed atomically
505
+ }
506
+ const existing = deps.read(pidfilePath);
507
+ if (existing === null) {
508
+ continue; // vanished between claim+read → retry the claim
509
+ }
510
+ const trimmed = existing.trim();
511
+ if (trimmed === '') {
512
+ // Empty pidfile. Atomic claim() means OUR code never produces one, so
513
+ // this is a stray/foreign empty file — reap it ONLY if it is still empty
514
+ // (compare-and-delete), then retry. Never deletes a live holder's file.
515
+ deps.removeIfContent(pidfilePath, existing);
516
+ continue;
517
+ }
518
+ const { pid, nonce: holderNonce } = parsePidfileContent(trimmed);
519
+ if (!Number.isNaN(pid) && deps.isAlive(pid)) {
520
+ // gh#840: a LIVE PID is normally untouchable, EXCEPT a node-WEDGED holder
521
+ // (heartbeat stale past the threshold AND the heartbeat nonce matches this
522
+ // pidfile holder's identity) — reap it (compare-and-delete) and retry, so
523
+ // a stuck monitor can be replaced cross-instance. A healthy holder (fresh
524
+ // heartbeat) or an ambiguous identity (nonce mismatch ⇒ PID reuse / young
525
+ // reclaimer) is NEVER reaped.
526
+ if (isHolderWedged(pidfilePath, holderNonce, deps)) {
527
+ deps.removeIfContent(pidfilePath, existing); // gh#795 compare-and-delete
528
+ continue; // retry the claim
529
+ }
530
+ return false; // healthy / ambiguous LIVE holder → yield, NEVER kill
531
+ }
532
+ // provably stale (dead PID / unparseable) → compare-and-delete (only if it
533
+ // STILL holds this exact content — a racer may have reclaimed it live) →
534
+ // retry the claim.
535
+ deps.removeIfContent(pidfilePath, existing);
536
+ }
537
+ return false; // persistent claim race → yield (safe default: never double-tail)
538
+ }
539
+
540
+ /** Legacy migration outcome. `blocked` includes stale or unreadable artifacts. */
541
+ export type LegacyMonitorArtifactState = 'absent' | 'live' | 'blocked';
542
+
543
+ export interface LegacyArtifactDeps {
544
+ /** Fail closed: true when the artifact exists OR cannot be inspected. */
545
+ exists(path: string): boolean;
546
+ read(path: string): string | null;
547
+ isAlive(pid: number): boolean;
548
+ }
549
+
550
+ /**
551
+ * Conservative cross-version migration boundary (gh#979): an extant legacy
552
+ * pidfile OR heartbeat is never replaced or unlinked by modern code. A proven
553
+ * live PID yields to the existing old monitor; every other artifact is a
554
+ * blocked migration requiring explicit operator cleanup. This avoids trying to
555
+ * emulate an unavailable atomic unlink-if-content primitive across binaries
556
+ * that do not share the modern mutation lock.
557
+ */
558
+ export function legacyMonitorArtifactState(
559
+ inboxPath: string,
560
+ deps: LegacyArtifactDeps
561
+ ): LegacyMonitorArtifactState {
562
+ const pidfilePath = legacyPidfilePathFor(inboxPath);
563
+ const heartbeatPath = legacyHeartbeatPathFor(inboxPath);
564
+ const pidExists = deps.exists(pidfilePath);
565
+ const heartbeatExists = deps.exists(heartbeatPath);
566
+ if (!pidExists && !heartbeatExists) return 'absent';
567
+
568
+ if (pidExists) {
569
+ const raw = deps.read(pidfilePath);
570
+ if (raw !== null) {
571
+ const { pid } = parsePidfileContent(raw.trim());
572
+ if (!Number.isNaN(pid) && deps.isAlive(pid)) return 'live';
573
+ }
574
+ }
575
+ return 'blocked';
576
+ }
577
+
578
+ export interface ModernMonitorClaimDeps {
579
+ claimMutation(): boolean;
580
+ releaseMutation(): void;
581
+ legacyState(): LegacyMonitorArtifactState;
582
+ claimModern(): boolean;
583
+ releaseModern(): void;
584
+ }
585
+
586
+ export type ModernMonitorClaimResult =
587
+ | 'claimed'
588
+ | 'mutation-busy'
589
+ | 'modern-live'
590
+ | 'legacy-live'
591
+ | 'legacy-blocked';
592
+
593
+ /**
594
+ * Serialize every modern startup mutation for one inbox. The mutation lock is
595
+ * acquired BEFORE the first legacy read, spans modern lock claim, and protects
596
+ * the final legacy revalidation. An old binary that creates a legacy artifact
597
+ * at the former check→claim gap therefore makes the final check yield while
598
+ * preserving that artifact untouched.
599
+ */
600
+ export function claimModernMonitorSafely(deps: ModernMonitorClaimDeps): ModernMonitorClaimResult {
601
+ if (!deps.claimMutation()) return 'mutation-busy';
602
+ try {
603
+ const before = deps.legacyState();
604
+ if (before === 'live') return 'legacy-live';
605
+ if (before === 'blocked') return 'legacy-blocked';
606
+ if (!deps.claimModern()) return 'modern-live';
607
+
608
+ const after = deps.legacyState();
609
+ if (after !== 'absent') {
610
+ deps.releaseModern();
611
+ return after === 'live' ? 'legacy-live' : 'legacy-blocked';
612
+ }
613
+ return 'claimed';
614
+ } finally {
615
+ deps.releaseMutation();
616
+ }
617
+ }
618
+
619
+ export function defaultInboxLockDeps(): InboxLockDeps {
620
+ return {
621
+ claim: (p, content) => {
622
+ // Atomic create-with-content: write a unique temp file (already holding
623
+ // the PID), then hardlink it into place. link() fails if the target
624
+ // exists (O_EXCL-equivalent) AND the linked file is non-empty from the
625
+ // first instant — closing the create-then-write empty-read window.
626
+ const tmp = `${p}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
627
+ try {
628
+ writeFileSync(tmp, content, { mode: 0o600 });
629
+ try {
630
+ linkSync(tmp, p);
631
+ return true;
632
+ } catch (err: any) {
633
+ if (err?.code === 'EEXIST') return false;
634
+ throw err;
635
+ }
636
+ } finally {
637
+ try {
638
+ unlinkSync(tmp);
639
+ } catch {
640
+ /* best-effort temp cleanup */
641
+ }
642
+ }
643
+ },
644
+ read: (p) => {
645
+ try {
646
+ return readFileSync(p, 'utf8');
647
+ } catch {
648
+ return null;
649
+ }
650
+ },
651
+ removeIfContent: (p, expected) => {
652
+ try {
653
+ if (readFileSync(p, 'utf8') === expected) {
654
+ unlinkSync(p);
655
+ }
656
+ } catch {
657
+ /* gone/unreadable → nothing to remove */
658
+ }
659
+ },
660
+ isAlive: (pid) => {
661
+ try {
662
+ process.kill(pid, 0);
663
+ return true;
664
+ } catch (err: any) {
665
+ // EPERM = process exists but we may not signal it (still alive → yield);
666
+ // ESRCH = no such process (dead → reapable).
667
+ return err?.code === 'EPERM';
668
+ }
669
+ },
670
+ readHeartbeat: readHeartbeatSidecar,
671
+ now: () => Date.now(),
672
+ heartbeatStaleMs: HEARTBEAT_STALE_MS,
673
+ };
674
+ }
675
+
676
+ /**
677
+ * gh#840: read the holder heartbeat sidecar for a pidfile's inbox.
678
+ * Freshness = file mtime; identity = file content (the holder's nonce). Returns
679
+ * null if the sidecar is absent/unreadable.
680
+ */
681
+ export function readHeartbeatSidecar(pidfilePath: string): { mtimeMs: number; nonce: string } | null {
682
+ // Modern state files are hash-named while legacy ones are inbox-adjacent;
683
+ // both forms retain the paired `.monitor.pid` → `.monitor.heartbeat` suffix.
684
+ const heartbeatPath = pairedHeartbeatPathForPidfile(pidfilePath);
685
+ try {
686
+ return {
687
+ mtimeMs: statSync(heartbeatPath).mtimeMs,
688
+ nonce: readFileSync(heartbeatPath, 'utf8').trim(),
689
+ };
690
+ } catch {
691
+ return null;
692
+ }
693
+ }
694
+
695
+ function pairedHeartbeatPathForPidfile(pidfilePath: string): string {
696
+ return pidfilePath.replace(/\.monitor\.pid$/, '.monitor.heartbeat');
697
+ }
698
+
699
+ /** A short-lived, atomic state-root guard for all modern lock mutations. */
700
+ export function mutationLockPathFor(pidfilePath: string): string {
701
+ return pidfilePath.replace(/\.monitor\.pid$/, '.monitor.mutation');
702
+ }
703
+
704
+ function pathExistsFailClosed(path: string): boolean {
705
+ try {
706
+ lstatSync(path);
707
+ return true;
708
+ } catch (err: any) {
709
+ return err?.code !== 'ENOENT';
710
+ }
711
+ }
712
+
713
+ /**
714
+ * gh#840: write the holder heartbeat sidecar — the per-holder identity nonce as
715
+ * content; the FILE MTIME (touched on every write) is the freshness signal the
716
+ * SLI + the wedge reaper read. Replaces the old timestamp-as-content (nothing
717
+ * read that content; mtime was always the freshness source).
718
+ */
719
+ export function writeHeartbeat(heartbeatPath: string, nonce: string): void {
720
+ writeFileSync(heartbeatPath, nonce, { mode: 0o600 });
721
+ // `mode` only governs creation. A stale pre-existing sidecar may have had
722
+ // broad permissions, so tighten it every time the current holder refreshes.
723
+ chmodSync(heartbeatPath, 0o600);
724
+ }
725
+
726
+ /**
727
+ * 2026-07-02 incident: first-drone-in-a-new-cube arm race. The kickoff Monitor
728
+ * pre-gh#979 `borg-inbox-monitor <inbox>` at session start, but the per-cube
729
+ * inbox directory (~/.config/borgmcp/inboxes/<cubeId>/) is created by the MCP
730
+ * server child only when the SSE stream first writes — so the legacy monitor's
731
+ * FIRST fs act (the pidfile-claim writeFileSync) threw ENOENT. The supported
732
+ * explicit-state-root mode no longer writes beside the inbox at all; this
733
+ * helper remains only for positional legacy compatibility. The inbox FILE is
734
+ * still the stream owner's; `tail -F` retries on a missing file.
735
+ */
736
+ export function ensureInboxDir(inboxPath: string): void {
737
+ mkdirSync(dirname(inboxPath), { recursive: true });
738
+ }
739
+
740
+ interface MonitorInvocation {
741
+ inboxPath: string;
742
+ stateRoot: string | null;
743
+ }
744
+
745
+ /** Parse the supported explicit-root command plus the legacy positional form. */
746
+ export function parseMonitorInvocation(argv: string[]): MonitorInvocation | null {
747
+ if (argv.length === 1 && argv[0]) {
748
+ return { inboxPath: argv[0], stateRoot: null };
749
+ }
750
+ if (argv.length === 3 && argv[0] === '--state-root' && argv[1] && argv[2]) {
751
+ return { inboxPath: argv[2], stateRoot: resolve(argv[1]) };
752
+ }
753
+ return null;
754
+ }
755
+
756
+ function main(): void {
757
+ const invocation = parseMonitorInvocation(process.argv.slice(2));
758
+ if (!invocation) {
759
+ console.error(
760
+ 'borg-inbox-monitor: usage: borg-inbox-monitor --state-root <worktree-runtime-root> <inbox-path>'
761
+ );
762
+ process.exit(2);
763
+ }
764
+ const { inboxPath } = invocation;
765
+ let stateRoot = invocation.stateRoot;
766
+
767
+ try {
768
+ if (stateRoot) {
769
+ // Supported path: config inboxes are intentionally read-only to this
770
+ // sandboxed process. Do not mkdir or place any state beside them.
771
+ stateRoot = ensureMonitorStateDir(stateRoot);
772
+ } else {
773
+ // Compatibility path for a pre-gh#979 hand-authored command.
774
+ ensureInboxDir(inboxPath);
775
+ }
776
+ } catch (err) {
777
+ const message = err instanceof Error ? err.message : String(err);
778
+ const target = stateRoot ? 'runtime state directory' : `inbox directory for ${inboxPath}`;
779
+ console.error(`borg-inbox-monitor: cannot create ${target}: ${message}`);
780
+ process.exit(1);
781
+ }
782
+
783
+ const lockDeps = defaultInboxLockDeps();
784
+ const pidfilePath = pidfilePathFor(inboxPath, stateRoot);
785
+ const mutationPath = mutationLockPathFor(pidfilePath);
786
+ // gh#840: our per-holder identity nonce — written into BOTH the pidfile
787
+ // (`<pid>:<nonce>`) and the heartbeat sidecar, so a future reaper can confirm
788
+ // same-process identity before reaping a wedged holder (PID-reuse safe).
789
+ const ownNonce = randomBytes(16).toString('hex');
790
+ const mutationContent = `${process.pid}:${ownNonce}`;
791
+ const legacyState = () => stateRoot
792
+ ? legacyMonitorArtifactState(inboxPath, {
793
+ exists: pathExistsFailClosed,
794
+ read: lockDeps.read,
795
+ isAlive: lockDeps.isAlive,
796
+ })
797
+ : 'absent' as const;
798
+ const claimResult = claimModernMonitorSafely({
799
+ claimMutation: () => lockDeps.claim(mutationPath, mutationContent),
800
+ releaseMutation: () => lockDeps.removeIfContent(mutationPath, mutationContent),
801
+ legacyState,
802
+ claimModern: () => acquireInboxLock(pidfilePath, process.pid, lockDeps, 3, ownNonce),
803
+ releaseModern: () => lockDeps.removeIfContent(pidfilePath, `${process.pid}:${ownNonce}`),
804
+ });
805
+ if (claimResult === 'mutation-busy') {
806
+ console.error(
807
+ 'borg-inbox-monitor: another modern monitor startup is mutating this inbox state; ' +
808
+ `re-arm after it finishes (if it persists after that process stops, confirm it is stopped, remove ${mutationPath}, then re-arm)`
809
+ );
810
+ process.exit(1);
811
+ }
812
+ if (claimResult === 'legacy-live') {
813
+ // An already-running old monitor still owns the wake path; yield without
814
+ // touching its inbox-adjacent state.
815
+ process.exit(0);
816
+ }
817
+ if (claimResult === 'legacy-blocked') {
818
+ console.error(
819
+ `borg-inbox-monitor: legacy monitor artifact remains beside ${inboxPath}; ` +
820
+ 'stop/confirm the old monitor, remove its .monitor.pid/.monitor.heartbeat manually, then re-arm'
821
+ );
822
+ process.exit(1);
823
+ }
824
+ if (claimResult === 'modern-live') {
825
+ process.exit(0);
826
+ }
827
+
828
+ // Releases occur under the same mutation guard as acquisition/reaping. If a
829
+ // startup mutation is already in flight, leave our state for that contender
830
+ // to inspect rather than risk deleting a successor's lock or heartbeat.
831
+ const releasePidfile = () => {
832
+ if (!lockDeps.claim(mutationPath, mutationContent)) return;
833
+ try {
834
+ lockDeps.removeIfContent(pidfilePath, `${process.pid}:${ownNonce}`);
835
+ } finally {
836
+ lockDeps.removeIfContent(mutationPath, mutationContent);
837
+ }
838
+ };
839
+
840
+ const heartbeatPath = heartbeatPathFor(inboxPath, stateRoot);
841
+ const removeHeartbeat = () => {
842
+ if (!lockDeps.claim(mutationPath, mutationContent)) return;
843
+ try {
844
+ lockDeps.removeIfContent(heartbeatPath, ownNonce);
845
+ } finally {
846
+ lockDeps.removeIfContent(mutationPath, mutationContent);
847
+ }
848
+ };
849
+ // Establish the SLI before spawning tail. If the monitor cannot create its
850
+ // state heartbeat, fail the arm rather than leave a Monitor task that looks
851
+ // armed while its holder cannot prove liveness.
852
+ try {
853
+ writeHeartbeat(heartbeatPath, ownNonce);
854
+ } catch (err) {
855
+ releasePidfile();
856
+ const message = err instanceof Error ? err.message : String(err);
857
+ console.error(`borg-inbox-monitor: cannot write runtime heartbeat: ${message}`);
858
+ process.exit(1);
859
+ }
860
+
861
+ // `tail -F` for rotation/truncation resilience on macOS + Linux.
862
+ // Node's fs.watch is unreliable across file rotation; subprocess
863
+ // tail matches the prior kickoff-Monitor shape (`tail -F`) so the
864
+ // wire behavior is identical — only the per-line projection changes.
865
+ // `-n 0` skips backfilling history so fresh sessions don't replay
866
+ // old entries on every restart.
867
+ const deduper = new RecentLineDeduper();
868
+ seedDeduperFromInboxTail(inboxPath, deduper);
869
+
870
+ // gh#822: stat-anchored offset — seed to the inbox SIZE at arm (EOF, matching
871
+ // `tail -F -n 0`'s skip-history) so a non-empty inbox does NOT false-stall on
872
+ // the first tick (CR build-gate item 1). Re-anchored to the live file size on
873
+ // every delivery (rotation-robust — item 2 — via the pure evaluator).
874
+ let stall: TailStallState = { lastEmittedOffset: inboxSizeOf(inboxPath), grewSince: null };
875
+ let shuttingDown = false;
876
+ let currentTail: ReturnType<typeof spawn> | null = null;
877
+
878
+ // Spawn (or re-spawn) the tail. `fromByteOffset === null` → fresh `-n 0`
879
+ // (skip history, the arm path); otherwise byte-seek `-c +<N+1>` to re-read the
880
+ // un-emitted bytes a stalled tail dropped (CR item 3 — NOT `-n 0`, which would
881
+ // skip exactly those bytes). Lines route through the SHARED deduper so an
882
+ // overlap on respawn can't double-emit an already-seen line.
883
+ const spawnTail = (fromByteOffset: number | null): void => {
884
+ const tail = spawn('tail', tailArgsFor(inboxPath, fromByteOffset), {
885
+ stdio: ['ignore', 'pipe', 'inherit'],
886
+ });
887
+ currentTail = tail;
888
+
889
+ if (!tail.stdout) {
890
+ console.error('borg-inbox-monitor: tail subprocess has no stdout');
891
+ removeHeartbeat();
892
+ releasePidfile();
893
+ process.exit(1);
894
+ }
895
+
896
+ const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
897
+ rl.on('line', (line) => {
898
+ const pretty = formatFreshEventLine(line, deduper);
899
+ if (pretty !== null) {
900
+ console.log(pretty);
901
+ // Delivered → the tail is current; re-anchor the offset to the live
902
+ // file size (stat-anchored = rotation-robust) and clear any stall streak.
903
+ stall = { lastEmittedOffset: inboxSizeOf(inboxPath), grewSince: null };
904
+ }
905
+ });
906
+
907
+ tail.on('error', (err) => {
908
+ if (tail !== currentTail) return; // a superseded (respawned-over) tail — ignore.
909
+ console.error(`borg-inbox-monitor: tail failed: ${err.message}`);
910
+ removeHeartbeat();
911
+ releasePidfile();
912
+ process.exit(1);
913
+ });
914
+
915
+ tail.on('exit', (code, signal) => {
916
+ // gh#822: a tail we intentionally killed for a self-heal respawn is no
917
+ // longer `currentTail` — ignore its exit (it is NOT holder death).
918
+ if (tail !== currentTail) return;
919
+ removeHeartbeat();
920
+ releasePidfile(); // gh#795: free the inbox so the next arm sees absent.
921
+ if (shuttingDown) process.exit(0);
922
+ if (signal) process.exit(0);
923
+ process.exit(code ?? 0);
924
+ });
925
+ };
926
+
927
+ spawnTail(null);
928
+
929
+ // gh#822: holder tick — (1) touch the heartbeat sidecar (proves node liveness
930
+ // even in a quiet cube; the SLI + the deferred node-wedge reaper read its
931
+ // mtime), (2) stat the inbox + run the PURE stall evaluator; on a SUSTAINED-
932
+ // un-emitted-growth verdict, self-heal by re-spawning our OWN tail byte-seeking
933
+ // from the last delivered offset (zero cross-process kill). Unref'd so the
934
+ // tick never keeps the process alive on its own.
935
+ const tick = setInterval(() => {
936
+ try {
937
+ // gh#840: write our identity nonce; the file mtime is the freshness signal.
938
+ writeHeartbeat(heartbeatPath, ownNonce);
939
+ } catch {
940
+ /* best-effort heartbeat */
941
+ }
942
+ const verdict = evaluateInboxTailStall(
943
+ inboxSizeOf(inboxPath),
944
+ stall,
945
+ Date.now(),
946
+ STALL_THRESHOLD_MS
947
+ );
948
+ stall = verdict.state;
949
+ if (verdict.kind === 'respawn' && !shuttingDown) {
950
+ const stalled = currentTail;
951
+ spawnTail(stall.lastEmittedOffset); // sets currentTail = the new tail FIRST
952
+ try {
953
+ stalled?.kill('SIGKILL'); // old tail's exit now sees tail !== currentTail → ignored
954
+ } catch {
955
+ /* already gone */
956
+ }
957
+ // Reset the streak so the fresh tail gets a full window to catch up before
958
+ // it could re-trip on the same un-emitted bytes.
959
+ stall = { lastEmittedOffset: stall.lastEmittedOffset, grewSince: null };
960
+ }
961
+ }, MONITOR_TICK_MS);
962
+ tick.unref();
963
+
964
+ const shutdown = (signal: NodeJS.Signals) => {
965
+ if (shuttingDown) return;
966
+ shuttingDown = true;
967
+ clearInterval(tick);
968
+ removeHeartbeat();
969
+ releasePidfile(); // gh#795: release our pidfile on graceful stop
970
+ const tail = currentTail;
971
+ if (tail && !tail.killed && !tail.kill(signal)) {
972
+ process.exit(0);
973
+ }
974
+ setTimeout(() => process.exit(0), 1000).unref();
975
+ };
976
+
977
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
978
+ process.once('SIGINT', () => shutdown('SIGINT'));
979
+ }
980
+
981
+ /**
982
+ * Is this module being invoked as the bin entry point?
983
+ *
984
+ * gh#114: under `npm install`, `process.argv[1]` is the npm-bin symlink
985
+ * path while `fileURLToPath(import.meta.url)` is the realpath of the
986
+ * installed file. A naive `===` check never matches → `main()` never
987
+ * runs → the documented `borg-inbox-monitor` Monitor command silently
988
+ * no-ops and drones go deaf without the wake-path-self-heal (gh#43)
989
+ * triggering. Resolve the symlink before comparing.
990
+ *
991
+ * Exported for unit testing.
992
+ */
993
+ export function isEntryInvocation(argv1: string, importMetaUrl: string): boolean {
994
+ try {
995
+ return realpathSync(argv1) === fileURLToPath(importMetaUrl);
996
+ } catch {
997
+ return false;
998
+ }
999
+ }
1000
+
1001
+ // Only run main() when invoked as the bin entry — allow importing the
1002
+ // pure formatEventLine for unit testing without spawning tail.
1003
+ if (isEntryInvocation(process.argv[1], import.meta.url)) {
1004
+ main();
1005
+ }