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.
- package/CONTRIBUTING.md +15 -0
- package/LICENSE +201 -33
- package/NOTICE +2 -0
- package/README.md +85 -36
- package/SECURITY.md +7 -0
- package/dist/agent-runtime.d.ts.map +1 -0
- package/dist/agent-runtime.js +63 -1
- package/dist/agent-runtime.js.map +1 -0
- package/dist/assimilate-cmd.d.ts +96 -17
- package/dist/assimilate-cmd.d.ts.map +1 -0
- package/dist/assimilate-cmd.js +1396 -48
- package/dist/assimilate-cmd.js.map +1 -0
- package/dist/assimilate-deps.d.ts.map +1 -0
- package/dist/assimilate-deps.js +384 -3
- package/dist/assimilate-deps.js.map +1 -0
- package/dist/assimilate-guard.d.ts +5 -7
- package/dist/assimilate-guard.d.ts.map +1 -0
- package/dist/assimilate-guard.js +60 -2
- package/dist/assimilate-guard.js.map +1 -0
- package/dist/assimilate-welcome.d.ts +1 -1
- package/dist/assimilate-welcome.d.ts.map +1 -0
- package/dist/assimilate-welcome.js +32 -2
- package/dist/assimilate-welcome.js.map +1 -0
- package/dist/auth-env.d.ts +7 -50
- package/dist/auth-env.d.ts.map +1 -0
- package/dist/auth-env.js +22 -1
- package/dist/auth-env.js.map +1 -0
- package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
- package/dist/backends/launch-all-pastelist.js +11 -4
- package/dist/backends/launch-all-pastelist.js.map +1 -0
- package/dist/backends/launch-all-tmux.d.ts.map +1 -0
- package/dist/backends/launch-all-tmux.js +49 -1
- package/dist/backends/launch-all-tmux.js.map +1 -0
- package/dist/backends/launch-all-windows.d.ts.map +1 -0
- package/dist/backends/launch-all-windows.js +86 -12
- package/dist/backends/launch-all-windows.js.map +1 -0
- package/dist/bare-launch-menu.d.ts.map +1 -0
- package/dist/bare-launch-menu.js +83 -6
- package/dist/bare-launch-menu.js.map +1 -0
- package/dist/claude-launch-args.d.ts.map +1 -0
- package/dist/claude-launch-args.js +34 -1
- package/dist/claude-launch-args.js.map +1 -0
- package/dist/claude.d.ts.map +1 -0
- package/dist/claude.js +486 -14
- package/dist/claude.js.map +1 -0
- package/dist/cleanup-cmd.d.ts +5 -5
- package/dist/cleanup-cmd.d.ts.map +1 -0
- package/dist/cleanup-cmd.js +453 -14
- package/dist/cleanup-cmd.js.map +1 -0
- package/dist/clear-rewake-core.d.ts.map +1 -0
- package/dist/clear-rewake-core.js +23 -2
- package/dist/clear-rewake-core.js.map +1 -0
- package/dist/clear-rewake.d.ts.map +1 -0
- package/dist/clear-rewake.js +19 -1
- package/dist/clear-rewake.js.map +1 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.d.ts.map +1 -0
- package/dist/cli-help.js +104 -56
- package/dist/cli-help.js.map +1 -0
- package/dist/cli-platform.d.ts.map +1 -0
- package/dist/cli-platform.js +106 -4
- package/dist/cli-platform.js.map +1 -0
- package/dist/cli-tool-approval.d.ts +84 -0
- package/dist/cli-tool-approval.d.ts.map +1 -0
- package/dist/cli-tool-approval.js +583 -0
- package/dist/cli-tool-approval.js.map +1 -0
- package/dist/codex-app-server.d.ts.map +1 -0
- package/dist/codex-app-server.js +228 -4
- package/dist/codex-app-server.js.map +1 -0
- package/dist/codex-app-wake.d.ts.map +1 -0
- package/dist/codex-app-wake.js +432 -2
- package/dist/codex-app-wake.js.map +1 -0
- package/dist/codex-launch.d.ts.map +1 -0
- package/dist/codex-launch.js +102 -2
- package/dist/codex-launch.js.map +1 -0
- package/dist/codex-remote.d.ts +6 -1
- package/dist/codex-remote.d.ts.map +1 -0
- package/dist/codex-remote.js +394 -1
- package/dist/codex-remote.js.map +1 -0
- package/dist/codex-wake-resolve.d.ts.map +1 -0
- package/dist/codex-wake-resolve.js +127 -1
- package/dist/codex-wake-resolve.js.map +1 -0
- package/dist/config-utils.d.ts +2 -2
- package/dist/config-utils.d.ts.map +1 -0
- package/dist/config-utils.js +766 -3
- package/dist/config-utils.js.map +1 -0
- package/dist/config.d.ts +98 -61
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +426 -1
- package/dist/config.js.map +1 -0
- package/dist/console-prefix.d.ts.map +1 -0
- package/dist/console-prefix.js +96 -1
- package/dist/console-prefix.js.map +1 -0
- package/dist/cube-name.d.ts.map +1 -0
- package/dist/cube-name.js +67 -1
- package/dist/cube-name.js.map +1 -0
- package/dist/cubes.d.ts +126 -18
- package/dist/cubes.d.ts.map +1 -0
- package/dist/cubes.js +412 -4
- package/dist/cubes.js.map +1 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +71 -1
- package/dist/debug.js.map +1 -0
- package/dist/direct-log.d.ts.map +1 -0
- package/dist/direct-log.js +11 -1
- package/dist/direct-log.js.map +1 -0
- package/dist/docs-sections.d.ts +11 -10
- package/dist/docs-sections.d.ts.map +1 -0
- package/dist/docs-sections.js +113 -3
- package/dist/docs-sections.js.map +1 -0
- package/dist/drone-lifecycle.d.ts +5 -21
- package/dist/drone-lifecycle.d.ts.map +1 -0
- package/dist/drone-lifecycle.js +57 -8
- package/dist/drone-lifecycle.js.map +1 -0
- package/dist/drone-management.d.ts +29 -0
- package/dist/drone-management.d.ts.map +1 -0
- package/dist/drone-management.js +109 -0
- package/dist/drone-management.js.map +1 -0
- package/dist/ensure-mcp-config.d.ts.map +1 -0
- package/dist/ensure-mcp-config.js +36 -1
- package/dist/ensure-mcp-config.js.map +1 -0
- package/dist/evict-drone.d.ts +10 -2
- package/dist/evict-drone.d.ts.map +1 -0
- package/dist/evict-drone.js +60 -1
- package/dist/evict-drone.js.map +1 -0
- package/dist/gc-orphan-inboxes.d.ts.map +1 -0
- package/dist/gc-orphan-inboxes.js +177 -1
- package/dist/gc-orphan-inboxes.js.map +1 -0
- package/dist/inbox-monitor.d.ts +1 -1
- package/dist/inbox-monitor.d.ts.map +1 -0
- package/dist/inbox-monitor.js +856 -3
- package/dist/inbox-monitor.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1034 -37
- package/dist/index.js.map +1 -0
- package/dist/launch-all-cmd.d.ts.map +1 -0
- package/dist/launch-all-cmd.js +377 -37
- package/dist/launch-all-cmd.js.map +1 -0
- package/dist/launch-all-command.d.ts.map +1 -0
- package/dist/launch-all-command.js +24 -1
- package/dist/launch-all-command.js.map +1 -0
- package/dist/launch-all-deps.d.ts +3 -8
- package/dist/launch-all-deps.d.ts.map +1 -0
- package/dist/launch-all-deps.js +99 -1
- package/dist/launch-all-deps.js.map +1 -0
- package/dist/launch-all-discovery.d.ts +1 -0
- package/dist/launch-all-discovery.d.ts.map +1 -0
- package/dist/launch-all-discovery.js +90 -4
- package/dist/launch-all-discovery.js.map +1 -0
- package/dist/launch-all-locks.d.ts.map +1 -0
- package/dist/launch-all-locks.js +54 -1
- package/dist/launch-all-locks.js.map +1 -0
- package/dist/launch-gate.d.ts +2 -2
- package/dist/launch-gate.d.ts.map +1 -0
- package/dist/launch-gate.js +73 -1
- package/dist/launch-gate.js.map +1 -0
- package/dist/lifecycle-log-guard.d.ts.map +1 -0
- package/dist/lifecycle-log-guard.js +93 -2
- package/dist/lifecycle-log-guard.js.map +1 -0
- package/dist/list-roles-render.d.ts.map +1 -0
- package/dist/list-roles-render.js +40 -6
- package/dist/list-roles-render.js.map +1 -0
- package/dist/local-log-routing.d.ts +19 -0
- package/dist/local-log-routing.d.ts.map +1 -0
- package/dist/local-log-routing.js +67 -0
- package/dist/local-log-routing.js.map +1 -0
- package/dist/local-manage-tool-result.d.ts +8 -0
- package/dist/local-manage-tool-result.d.ts.map +1 -0
- package/dist/local-manage-tool-result.js +10 -0
- package/dist/local-manage-tool-result.js.map +1 -0
- package/dist/local-server-cursor.d.ts +34 -0
- package/dist/local-server-cursor.d.ts.map +1 -0
- package/dist/local-server-cursor.js +151 -0
- package/dist/local-server-cursor.js.map +1 -0
- package/dist/log-audit.d.ts.map +1 -0
- package/dist/log-audit.js +194 -3
- package/dist/log-audit.js.map +1 -0
- package/dist/log-stream.d.ts +31 -18
- package/dist/log-stream.d.ts.map +1 -0
- package/dist/log-stream.js +1214 -12
- package/dist/log-stream.js.map +1 -0
- package/dist/model-presets.d.ts.map +1 -0
- package/dist/model-presets.js +19 -1
- package/dist/model-presets.js.map +1 -0
- package/dist/name-validator.d.ts.map +1 -0
- package/dist/name-validator.js +22 -1
- package/dist/name-validator.js.map +1 -0
- package/dist/opencode-drone.d.ts.map +1 -0
- package/dist/opencode-drone.js +438 -4
- package/dist/opencode-drone.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -1
- package/dist/opencode-plugin.d.ts.map +1 -0
- package/dist/opencode-plugin.js +29 -4
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/parse-assimilate-args.d.ts +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -0
- package/dist/parse-assimilate-args.js +125 -1
- package/dist/parse-assimilate-args.js.map +1 -0
- package/dist/parse-launch-all-args.d.ts.map +1 -0
- package/dist/parse-launch-all-args.js +70 -1
- package/dist/parse-launch-all-args.js.map +1 -0
- package/dist/postinstall-banner.d.ts.map +1 -0
- package/dist/postinstall-banner.js +27 -2
- package/dist/postinstall-banner.js.map +1 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +32 -5
- package/dist/postinstall.js.map +1 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +9 -1
- package/dist/readiness-probe.js.map +1 -0
- package/dist/regen-format.d.ts.map +1 -0
- package/dist/regen-format.js +545 -13
- package/dist/regen-format.js.map +1 -0
- package/dist/regen.d.ts.map +1 -0
- package/dist/regen.js +141 -5
- package/dist/regen.js.map +1 -0
- package/dist/remote-client.d.ts +65 -175
- package/dist/remote-client.d.ts.map +1 -0
- package/dist/remote-client.js +942 -1
- package/dist/remote-client.js.map +1 -0
- package/dist/reset-local-seat-cmd.d.ts +50 -0
- package/dist/reset-local-seat-cmd.d.ts.map +1 -0
- package/dist/reset-local-seat-cmd.js +169 -0
- package/dist/reset-local-seat-cmd.js.map +1 -0
- package/dist/role-match.d.ts.map +1 -0
- package/dist/role-match.js +23 -1
- package/dist/role-match.js.map +1 -0
- package/dist/role-resolver.d.ts.map +1 -0
- package/dist/role-resolver.js +60 -1
- package/dist/role-resolver.js.map +1 -0
- package/dist/roster-render.d.ts.map +1 -0
- package/dist/roster-render.js +121 -3
- package/dist/roster-render.js.map +1 -0
- package/dist/seat-probe.d.ts +32 -13
- package/dist/seat-probe.d.ts.map +1 -0
- package/dist/seat-probe.js +78 -1
- package/dist/seat-probe.js.map +1 -0
- package/dist/seat-store.d.ts +76 -0
- package/dist/seat-store.d.ts.map +1 -0
- package/dist/seat-store.js +309 -0
- package/dist/seat-store.js.map +1 -0
- package/dist/seats.d.ts +303 -0
- package/dist/seats.d.ts.map +1 -0
- package/dist/seats.js +551 -0
- package/dist/seats.js.map +1 -0
- package/dist/self-path.d.ts +27 -0
- package/dist/self-path.d.ts.map +1 -0
- package/dist/self-path.js +43 -0
- package/dist/self-path.js.map +1 -0
- package/dist/server-endpoint.d.ts +9 -0
- package/dist/server-endpoint.d.ts.map +1 -0
- package/dist/server-endpoint.js +38 -0
- package/dist/server-endpoint.js.map +1 -0
- package/dist/server-errors.d.ts +50 -0
- package/dist/server-errors.d.ts.map +1 -0
- package/dist/server-errors.js +79 -0
- package/dist/server-errors.js.map +1 -0
- package/dist/server-handshake.d.ts +197 -0
- package/dist/server-handshake.d.ts.map +1 -0
- package/dist/server-handshake.js +505 -0
- package/dist/server-handshake.js.map +1 -0
- package/dist/server-response.d.ts +3 -0
- package/dist/server-response.d.ts.map +1 -0
- package/dist/server-response.js +67 -0
- package/dist/server-response.js.map +1 -0
- package/dist/server-trust.d.ts +14 -0
- package/dist/server-trust.d.ts.map +1 -0
- package/dist/server-trust.js +225 -0
- package/dist/server-trust.js.map +1 -0
- package/dist/setup-confirm.d.ts +3 -3
- package/dist/setup-confirm.d.ts.map +1 -0
- package/dist/setup-confirm.js +126 -2
- package/dist/setup-confirm.js.map +1 -0
- package/dist/setup.d.ts +5 -3
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +169 -46
- package/dist/setup.js.map +1 -0
- package/dist/shell-escape.d.ts.map +1 -0
- package/dist/shell-escape.js +22 -1
- package/dist/shell-escape.js.map +1 -0
- package/dist/spawn.d.ts +2 -2
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +29 -10
- package/dist/spawn.js.map +1 -0
- package/dist/startup-services.d.ts +0 -1
- package/dist/startup-services.d.ts.map +1 -0
- package/dist/startup-services.js +26 -1
- package/dist/startup-services.js.map +1 -0
- package/dist/stream-owner.d.ts +6 -2
- package/dist/stream-owner.d.ts.map +1 -0
- package/dist/stream-owner.js +547 -2
- package/dist/stream-owner.js.map +1 -0
- package/dist/stream-status.d.ts +1 -1
- package/dist/stream-status.d.ts.map +1 -0
- package/dist/stream-status.js +268 -3
- package/dist/stream-status.js.map +1 -0
- package/dist/sync-roles-render.d.ts.map +1 -0
- package/dist/sync-roles-render.js +118 -3
- package/dist/sync-roles-render.js.map +1 -0
- package/dist/sync.d.ts +1 -1
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +286 -22
- package/dist/sync.js.map +1 -0
- package/dist/terminal-title.d.ts.map +1 -0
- package/dist/terminal-title.js +68 -1
- package/dist/terminal-title.js.map +1 -0
- package/dist/token-store.d.ts +17 -111
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +81 -1
- package/dist/token-store.js.map +1 -0
- package/dist/tool-manifest.d.ts +4 -8
- package/dist/tool-manifest.d.ts.map +1 -0
- package/dist/tool-manifest.js +554 -1
- package/dist/tool-manifest.js.map +1 -0
- package/dist/tool-scope.d.ts +8 -12
- package/dist/tool-scope.d.ts.map +1 -0
- package/dist/tool-scope.js +97 -1
- package/dist/tool-scope.js.map +1 -0
- package/dist/types.d.ts +1 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -0
- package/dist/unknown-subcommand.js +37 -1
- package/dist/unknown-subcommand.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +78 -2
- package/dist/version.js.map +1 -0
- package/dist/working-repo.d.ts.map +1 -0
- package/dist/working-repo.js +81 -1
- package/dist/working-repo.js.map +1 -0
- package/dist/worktree-lifecycle.d.ts.map +1 -0
- package/dist/worktree-lifecycle.js +205 -2
- package/dist/worktree-lifecycle.js.map +1 -0
- package/docs/EXTRACTION_PROVENANCE.md +40 -0
- package/docs/LOCAL_SERVER.md +103 -0
- package/docs/RELEASING.md +120 -0
- package/package.json +52 -34
- package/src/agent-runtime.ts +69 -0
- package/src/assimilate-cmd.ts +1864 -0
- package/src/assimilate-deps.ts +444 -0
- package/src/assimilate-guard.ts +84 -0
- package/src/assimilate-welcome.ts +39 -0
- package/src/auth-env.ts +21 -0
- package/src/backends/launch-all-pastelist.ts +18 -0
- package/src/backends/launch-all-tmux.ts +75 -0
- package/src/backends/launch-all-windows.ts +110 -0
- package/src/bare-launch-menu.ts +148 -0
- package/src/claude-launch-args.ts +35 -0
- package/src/claude.ts +561 -0
- package/src/cleanup-cmd.ts +581 -0
- package/src/clear-rewake-core.ts +34 -0
- package/src/clear-rewake.ts +18 -0
- package/src/cli-help.ts +116 -0
- package/src/cli-platform.ts +127 -0
- package/src/cli-tool-approval.ts +716 -0
- package/src/codex-app-server.ts +238 -0
- package/src/codex-app-wake.ts +512 -0
- package/src/codex-launch.ts +140 -0
- package/src/codex-remote.ts +500 -0
- package/src/codex-wake-resolve.ts +155 -0
- package/src/config-utils.ts +811 -0
- package/src/config.ts +595 -0
- package/src/console-prefix.ts +99 -0
- package/src/cube-name.ts +69 -0
- package/src/cubes.ts +583 -0
- package/src/debug.ts +73 -0
- package/src/direct-log.ts +9 -0
- package/src/docs-sections.ts +129 -0
- package/src/drone-lifecycle.ts +62 -0
- package/src/drone-management.ts +162 -0
- package/src/ensure-mcp-config.ts +54 -0
- package/src/evict-drone.ts +82 -0
- package/src/gc-orphan-inboxes.ts +229 -0
- package/src/inbox-monitor.ts +1005 -0
- package/src/index.ts +1201 -0
- package/src/launch-all-cmd.ts +450 -0
- package/src/launch-all-command.ts +30 -0
- package/src/launch-all-deps.ts +188 -0
- package/src/launch-all-discovery.ts +118 -0
- package/src/launch-all-locks.ts +74 -0
- package/src/launch-gate.ts +80 -0
- package/src/lifecycle-log-guard.ts +144 -0
- package/src/list-roles-render.ts +52 -0
- package/src/local-log-routing.ts +106 -0
- package/src/local-manage-tool-result.ts +10 -0
- package/src/local-server-cursor.ts +188 -0
- package/src/log-audit.ts +207 -0
- package/src/log-stream.ts +1505 -0
- package/src/model-presets.ts +22 -0
- package/src/name-validator.ts +23 -0
- package/src/opencode-drone.ts +528 -0
- package/src/opencode-plugin.ts +38 -0
- package/src/parse-assimilate-args.ts +125 -0
- package/src/parse-launch-all-args.ts +100 -0
- package/src/postinstall-banner.ts +37 -0
- package/src/postinstall.ts +36 -0
- package/src/readiness-probe.ts +12 -0
- package/src/regen-format.ts +651 -0
- package/src/regen.ts +150 -0
- package/src/remote-client.ts +1379 -0
- package/src/reset-local-seat-cmd.ts +227 -0
- package/src/role-match.ts +29 -0
- package/src/role-resolver.ts +93 -0
- package/src/roster-render.ts +196 -0
- package/src/seat-probe.ts +115 -0
- package/src/seat-store.ts +333 -0
- package/src/seats.ts +741 -0
- package/src/self-path.ts +51 -0
- package/src/server-endpoint.ts +38 -0
- package/src/server-errors.ts +93 -0
- package/src/server-handshake.ts +783 -0
- package/src/server-response.ts +76 -0
- package/src/server-trust.ts +245 -0
- package/src/setup-confirm.ts +171 -0
- package/src/setup.ts +186 -0
- package/src/shell-escape.ts +22 -0
- package/src/spawn.ts +31 -0
- package/src/startup-services.ts +36 -0
- package/src/stream-owner.ts +686 -0
- package/src/stream-status.ts +337 -0
- package/src/sync-roles-render.ts +163 -0
- package/src/sync.ts +363 -0
- package/src/terminal-title.ts +76 -0
- package/src/token-store.ts +102 -0
- package/src/tool-manifest.ts +610 -0
- package/src/tool-scope.ts +110 -0
- package/src/types.ts +5 -0
- package/src/unknown-subcommand.ts +35 -0
- package/src/version.ts +82 -0
- package/src/working-repo.ts +99 -0
- package/src/worktree-lifecycle.ts +254 -0
- package/dist/auth-recovery.d.ts +0 -33
- package/dist/auth-recovery.js +0 -1
- package/dist/auth.d.ts +0 -103
- package/dist/auth.js +0 -39
- package/dist/device-auth.d.ts +0 -75
- package/dist/device-auth.js +0 -1
- package/dist/drone-address.d.ts +0 -26
- package/dist/drone-address.js +0 -1
- package/dist/get-started.d.ts +0 -20
- package/dist/get-started.js +0 -2
- package/dist/health-beat.d.ts +0 -134
- package/dist/health-beat.js +0 -1
- package/dist/role-section.d.ts +0 -110
- package/dist/role-section.js +0 -8
- package/dist/setup-action.d.ts +0 -20
- package/dist/setup-action.js +0 -1
- package/dist/stale-version-check.d.ts +0 -40
- package/dist/stale-version-check.js +0 -1
- package/dist/subscription-retry.d.ts +0 -40
- package/dist/subscription-retry.js +0 -1
- package/dist/templates.d.ts +0 -107
- package/dist/templates.js +0 -359
- package/dist/token-crypto.d.ts +0 -50
- package/dist/token-crypto.js +0 -1
package/dist/inbox-monitor.js
CHANGED
|
@@ -1,4 +1,857 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|