botmux 3.10.0 → 3.11.0
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/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/herdr-backend.d.ts +4 -3
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +11 -8
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/pty-backend.d.ts +1 -1
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +4 -1
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts +29 -5
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +302 -31
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +6 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +7 -0
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +4 -1
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +37 -3
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +9 -3
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.js +12 -7
- package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +11 -2
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +35 -2
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +88 -7
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +15 -14
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +64 -26
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +20 -5
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +24 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +110 -5
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/fleet-shutdown.d.ts +47 -0
- package/dist/cli/fleet-shutdown.d.ts.map +1 -0
- package/dist/cli/fleet-shutdown.js +387 -0
- package/dist/cli/fleet-shutdown.js.map +1 -0
- package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
- package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
- package/dist/cli/pm2-descriptor-guard.js +122 -0
- package/dist/cli/pm2-descriptor-guard.js.map +1 -0
- package/dist/cli/pm2-exact-start.d.ts +16 -0
- package/dist/cli/pm2-exact-start.d.ts.map +1 -0
- package/dist/cli/pm2-exact-start.js +63 -0
- package/dist/cli/pm2-exact-start.js.map +1 -0
- package/dist/cli/pm2-god-admission.d.ts +8 -0
- package/dist/cli/pm2-god-admission.d.ts.map +1 -0
- package/dist/cli/pm2-god-admission.js +25 -0
- package/dist/cli/pm2-god-admission.js.map +1 -0
- package/dist/cli/pm2-jlist.d.ts +13 -0
- package/dist/cli/pm2-jlist.d.ts.map +1 -0
- package/dist/cli/pm2-jlist.js +108 -0
- package/dist/cli/pm2-jlist.js.map +1 -0
- package/dist/cli/pm2-preflight.d.ts +10 -0
- package/dist/cli/pm2-preflight.d.ts.map +1 -0
- package/dist/cli/pm2-preflight.js +23 -0
- package/dist/cli/pm2-preflight.js.map +1 -0
- package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
- package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
- package/dist/cli/pm2-shutdown-capability.js +161 -0
- package/dist/cli/pm2-shutdown-capability.js.map +1 -0
- package/dist/cli/pm2-start-transaction.d.ts +55 -0
- package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
- package/dist/cli/pm2-start-transaction.js +220 -0
- package/dist/cli/pm2-start-transaction.js.map +1 -0
- package/dist/cli/send-dispatch.d.ts +12 -0
- package/dist/cli/send-dispatch.d.ts.map +1 -1
- package/dist/cli/send-dispatch.js +31 -4
- package/dist/cli/send-dispatch.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -0
- package/dist/cli/session-list-liveness.d.ts.map +1 -1
- package/dist/cli/session-list-liveness.js +6 -0
- package/dist/cli/session-list-liveness.js.map +1 -1
- package/dist/cli/session-picker-layout.d.ts +26 -0
- package/dist/cli/session-picker-layout.d.ts.map +1 -0
- package/dist/cli/session-picker-layout.js +123 -0
- package/dist/cli/session-picker-layout.js.map +1 -0
- package/dist/cli/session-picker-viewport.d.ts +49 -0
- package/dist/cli/session-picker-viewport.d.ts.map +1 -0
- package/dist/cli/session-picker-viewport.js +46 -0
- package/dist/cli/session-picker-viewport.js.map +1 -0
- package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
- package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
- package/dist/cli/supervisor-shutdown-client.js +146 -0
- package/dist/cli/supervisor-shutdown-client.js.map +1 -0
- package/dist/cli/terminal-width.d.ts +48 -0
- package/dist/cli/terminal-width.d.ts.map +1 -0
- package/dist/cli/terminal-width.js +119 -0
- package/dist/cli/terminal-width.js.map +1 -0
- package/dist/cli/vc-agent.d.ts.map +1 -1
- package/dist/cli/vc-agent.js +1 -1
- package/dist/cli/vc-agent.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2269 -716
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +1630 -125
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/ask-api.d.ts +8 -1
- package/dist/core/ask-api.d.ts.map +1 -1
- package/dist/core/ask-api.js +18 -0
- package/dist/core/ask-api.js.map +1 -1
- package/dist/core/ask-broker.d.ts +20 -0
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +467 -47
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/ask-persist-store.d.ts +126 -0
- package/dist/core/ask-persist-store.d.ts.map +1 -0
- package/dist/core/ask-persist-store.js +188 -0
- package/dist/core/ask-persist-store.js.map +1 -0
- package/dist/core/ask-types.d.ts +80 -0
- package/dist/core/ask-types.d.ts.map +1 -1
- package/dist/core/ask-types.js +62 -0
- package/dist/core/ask-types.js.map +1 -1
- package/dist/core/cli-session-id-publisher.d.ts +20 -0
- package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
- package/dist/core/cli-session-id-publisher.js +21 -0
- package/dist/core/cli-session-id-publisher.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +427 -255
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +13 -0
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +385 -26
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +9 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +842 -288
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
- package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
- package/dist/core/explicit-session-backing-cleanup.js +76 -0
- package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
- package/dist/core/idle-worker-sweeper.d.ts +20 -0
- package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
- package/dist/core/idle-worker-sweeper.js +18 -0
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +10 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +7 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/managed-origin-attestation.d.ts +51 -0
- package/dist/core/managed-origin-attestation.d.ts.map +1 -0
- package/dist/core/managed-origin-attestation.js +208 -0
- package/dist/core/managed-origin-attestation.js.map +1 -0
- package/dist/core/managed-origin-capability.d.ts +62 -3
- package/dist/core/managed-origin-capability.d.ts.map +1 -1
- package/dist/core/managed-origin-capability.js +419 -9
- package/dist/core/managed-origin-capability.js.map +1 -1
- package/dist/core/pending-repo-journal.d.ts +6 -0
- package/dist/core/pending-repo-journal.d.ts.map +1 -0
- package/dist/core/pending-repo-journal.js +85 -0
- package/dist/core/pending-repo-journal.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +6 -1
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +12 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/process-start-identity.d.ts +3 -0
- package/dist/core/process-start-identity.d.ts.map +1 -0
- package/dist/core/process-start-identity.js +62 -0
- package/dist/core/process-start-identity.js.map +1 -0
- package/dist/core/raw-command-writer.d.ts +34 -0
- package/dist/core/raw-command-writer.d.ts.map +1 -0
- package/dist/core/raw-command-writer.js +48 -0
- package/dist/core/raw-command-writer.js.map +1 -0
- package/dist/core/reply-target.d.ts +85 -22
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +267 -32
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
- package/dist/core/resource-monitor/runtime.js +4 -1
- package/dist/core/resource-monitor/runtime.js.map +1 -1
- package/dist/core/restart-report.d.ts +6 -1
- package/dist/core/restart-report.d.ts.map +1 -1
- package/dist/core/restart-report.js +22 -4
- package/dist/core/restart-report.js.map +1 -1
- package/dist/core/riff-shutdown-detach.d.ts +164 -0
- package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
- package/dist/core/riff-shutdown-detach.js +787 -0
- package/dist/core/riff-shutdown-detach.js.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.js +21 -0
- package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/session-manager.d.ts +6 -6
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +1227 -594
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +1 -1
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +2 -2
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-mutation-guard.d.ts +17 -0
- package/dist/core/session-mutation-guard.d.ts.map +1 -0
- package/dist/core/session-mutation-guard.js +33 -0
- package/dist/core/session-mutation-guard.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +27 -0
- package/dist/core/shutdown-budgets.d.ts.map +1 -0
- package/dist/core/shutdown-budgets.js +47 -0
- package/dist/core/shutdown-budgets.js.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.js +10 -0
- package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.js +16 -0
- package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
- package/dist/core/trigger-final-suppression.d.ts +4 -0
- package/dist/core/trigger-final-suppression.d.ts.map +1 -1
- package/dist/core/trigger-final-suppression.js +6 -11
- package/dist/core/trigger-final-suppression.js.map +1 -1
- package/dist/core/trigger-session.d.ts +70 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +923 -145
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +94 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +194 -13
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2597 -547
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +76 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2255 -773
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +20 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +24 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/feed-groups.d.ts +17 -0
- package/dist/dashboard/feed-groups.d.ts.map +1 -0
- package/dist/dashboard/feed-groups.js +75 -0
- package/dist/dashboard/feed-groups.js.map +1 -0
- package/dist/dashboard/session-card-model.d.ts +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +8 -5
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +139 -46
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +40 -3
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +95 -2
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +86 -40
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/kanban-model.js +1 -1
- package/dist/dashboard/web/kanban-model.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts +3 -0
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +2 -0
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +2 -0
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +103 -2
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +3 -2
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +8 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +15 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
- package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
- package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
- package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
- package/dist/dashboard-web/style.css +255 -0
- package/dist/dashboard.js +172 -7
- package/dist/dashboard.js.map +1 -1
- package/dist/desktop/renderer/app.js +21 -0
- package/dist/desktop/renderer/app.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +35 -9
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +35 -9
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/ask-card.d.ts +23 -0
- package/dist/im/lark/ask-card.d.ts.map +1 -1
- package/dist/im/lark/ask-card.js +94 -4
- package/dist/im/lark/ask-card.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +6 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +112 -15
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +496 -310
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +7 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +39 -26
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts +7 -4
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +19 -6
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +16 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +88 -23
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
- package/dist/im/lark/forward-followup-content.js +9 -1
- package/dist/im/lark/forward-followup-content.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +7 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/md-card.d.ts +11 -0
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +46 -0
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +23 -2
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +58 -3
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +2 -1
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +6 -1
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +21 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +23 -1
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +96 -13
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +40 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +124 -24
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +8 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +9 -2
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +1 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +1 -0
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-service-tier.d.ts +2 -0
- package/dist/services/codex-service-tier.d.ts.map +1 -1
- package/dist/services/codex-service-tier.js +1 -0
- package/dist/services/codex-service-tier.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +27 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +340 -2
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/grant-policy.d.ts +3 -0
- package/dist/services/grant-policy.d.ts.map +1 -1
- package/dist/services/grant-policy.js +5 -0
- package/dist/services/grant-policy.js.map +1 -1
- package/dist/services/grant-prefs-store.d.ts +6 -3
- package/dist/services/grant-prefs-store.d.ts.map +1 -1
- package/dist/services/grant-prefs-store.js +42 -12
- package/dist/services/grant-prefs-store.js.map +1 -1
- package/dist/services/hook-runner.d.ts +20 -1
- package/dist/services/hook-runner.d.ts.map +1 -1
- package/dist/services/hook-runner.js +17 -13
- package/dist/services/hook-runner.js.map +1 -1
- package/dist/services/idempotency-store.d.ts +134 -0
- package/dist/services/idempotency-store.d.ts.map +1 -0
- package/dist/services/idempotency-store.js +299 -0
- package/dist/services/idempotency-store.js.map +1 -0
- package/dist/services/jsonl-cursor.d.ts +7 -3
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +55 -31
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +1 -108
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/restart-intent-store.d.ts +22 -2
- package/dist/services/restart-intent-store.d.ts.map +1 -1
- package/dist/services/restart-intent-store.js +160 -18
- package/dist/services/restart-intent-store.js.map +1 -1
- package/dist/services/sandbox-store.d.ts +22 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +37 -8
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/send-policy.d.ts +41 -33
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +39 -40
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +66 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +403 -67
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/submit-notification.d.ts +3 -0
- package/dist/services/submit-notification.d.ts.map +1 -0
- package/dist/services/submit-notification.js +15 -0
- package/dist/services/submit-notification.js.map +1 -0
- package/dist/services/traex-transcript.d.ts +25 -2
- package/dist/services/traex-transcript.d.ts.map +1 -1
- package/dist/services/traex-transcript.js +133 -1
- package/dist/services/traex-transcript.js.map +1 -1
- package/dist/services/trigger-types.d.ts +13 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +39 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/services/voice/index.d.ts +3 -2
- package/dist/services/voice/index.d.ts.map +1 -1
- package/dist/services/voice/index.js +5 -5
- package/dist/services/voice/index.js.map +1 -1
- package/dist/services/voice/openai.d.ts +2 -1
- package/dist/services/voice/openai.d.ts.map +1 -1
- package/dist/services/voice/openai.js +2 -1
- package/dist/services/voice/openai.js.map +1 -1
- package/dist/services/voice/sami.d.ts +5 -2
- package/dist/services/voice/sami.d.ts.map +1 -1
- package/dist/services/voice/sami.js +19 -4
- package/dist/services/voice/sami.js.map +1 -1
- package/dist/setup/bots-store.d.ts.map +1 -1
- package/dist/setup/bots-store.js +10 -4
- package/dist/setup/bots-store.js.map +1 -1
- package/dist/setup/lark-scopes.json +2 -0
- package/dist/setup/open-platform-automation.d.ts +9 -1
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +44 -15
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-visibility.d.ts +42 -0
- package/dist/setup/open-platform-visibility.d.ts.map +1 -0
- package/dist/setup/open-platform-visibility.js +133 -0
- package/dist/setup/open-platform-visibility.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -1
- package/dist/setup/verify-permissions.js +6 -0
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/skills/definitions.js +2 -2
- package/dist/types.d.ts +290 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +1 -0
- package/dist/utils/bot-routing.d.ts.map +1 -1
- package/dist/utils/bot-routing.js +1 -1
- package/dist/utils/bot-routing.js.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +9 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/codex-app-control.d.ts +438 -0
- package/dist/utils/codex-app-control.d.ts.map +1 -0
- package/dist/utils/codex-app-control.js +2336 -0
- package/dist/utils/codex-app-control.js.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.js +149 -0
- package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.js +141 -0
- package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
- package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
- package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-liveness.js +301 -0
- package/dist/utils/codex-app-turn-liveness.js.map +1 -0
- package/dist/utils/idle-detector.d.ts +12 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +19 -0
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/input-gate.d.ts +87 -0
- package/dist/utils/input-gate.d.ts.map +1 -1
- package/dist/utils/input-gate.js +86 -0
- package/dist/utils/input-gate.js.map +1 -1
- package/dist/utils/lark-upload.d.ts.map +1 -1
- package/dist/utils/lark-upload.js +52 -1
- package/dist/utils/lark-upload.js.map +1 -1
- package/dist/utils/pending-input-queue.d.ts +15 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +5 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/user-token.d.ts.map +1 -1
- package/dist/utils/user-token.js +53 -2
- package/dist/utils/user-token.js.map +1 -1
- package/dist/worker-ipc-preload.d.ts +12 -0
- package/dist/worker-ipc-preload.d.ts.map +1 -0
- package/dist/worker-ipc-preload.js +44 -0
- package/dist/worker-ipc-preload.js.map +1 -0
- package/dist/worker.js +2604 -325
- package/dist/worker.js.map +1 -1
- package/dist/workflows/v3/session-relay-client.d.ts +1 -0
- package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
- package/dist/workflows/v3/session-relay-client.js +12 -3
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
- package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* botmux setup list|add|configure|edit|remove — scripted (non-TUI) bot management, see `botmux setup help`
|
|
9
9
|
* botmux start — start daemon and auto plugin services
|
|
10
10
|
* botmux stop [--with-plugin] — stop daemon (optionally stop auto plugin services)
|
|
11
|
-
* botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services
|
|
11
|
+
* botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services;
|
|
12
|
+
* --include-pm2 is a zero-live-God admission fence, not authority to signal an existing PM2 God
|
|
13
|
+
* botmux restart --bootstrap-shutdown-protocol --yes — operator-approved one-time retirement
|
|
14
|
+
* of a pre-protocol fleet after independently confirming all Session/Riff work is idle
|
|
12
15
|
* botmux logs [--lines] — view daemon logs
|
|
13
16
|
* botmux status — show daemon status
|
|
14
17
|
* botmux upgrade|update — upgrade to latest version
|
|
@@ -21,21 +24,21 @@
|
|
|
21
24
|
* botmux whiteboard status|enable|disable|current|list|read|update|write — local project whiteboard
|
|
22
25
|
*/
|
|
23
26
|
import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process';
|
|
24
|
-
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
27
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, symlinkSync, appendFileSync, statSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
25
28
|
import { underReadIsolation, sendCredFilePath } from './adapters/cli/read-isolation.js';
|
|
26
29
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
27
30
|
import { join, dirname, basename, resolve } from 'node:path';
|
|
28
|
-
import { homedir } from 'node:os';
|
|
31
|
+
import { homedir, userInfo } from 'node:os';
|
|
29
32
|
import { fileURLToPath } from 'node:url';
|
|
30
33
|
import { createInterface } from 'node:readline';
|
|
31
34
|
import { createRequire } from 'node:module';
|
|
32
|
-
import { randomBytes } from 'node:crypto';
|
|
35
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
33
36
|
import { validateWorkingDir } from './core/working-dir.js';
|
|
34
|
-
import { resolveSessionContext } from './core/session-marker.js';
|
|
37
|
+
import { findAncestorSessionContext as findLiveAncestorSessionContext, resolveSessionContext, } from './core/session-marker.js';
|
|
35
38
|
import { resolveBotmuxDataDir } from './core/data-dir.js';
|
|
36
39
|
import { dashboardSecretPath } from './core/dashboard-secret.js';
|
|
37
40
|
import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, foldableChatSessionAppIds, offTopicSubBotTopic, resolveReportPlacement, resolveReportRecipient, resolveReportTarget, resolveSendTarget, threadRootForReachability } from './core/dispatch.js';
|
|
38
|
-
import { pickTurnReplyTarget } from './core/reply-target.js';
|
|
41
|
+
import { pickTurnReplyTarget, collectTurnWindowParticipants } from './core/reply-target.js';
|
|
39
42
|
import { recordDispatchRegistryEntry } from './core/dispatch-registry.js';
|
|
40
43
|
import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
|
|
41
44
|
import { tmuxEnv } from './setup/ensure-tmux.js';
|
|
@@ -48,38 +51,56 @@ import { blocksSetupBotStart, classifySetupOpenPlatformOutcome, scriptedSetupOpe
|
|
|
48
51
|
import { buildBotFromAddFlags, editInputFromFlags, isScriptedSetupInvocation, maskAppSecret, parseSetupCommand, SETUP_CLI_USAGE, } from './setup/setup-args.js';
|
|
49
52
|
import { interactiveSelect, pickChoice, pickCliSelection } from './setup/interactive-select.js';
|
|
50
53
|
import { buildPreset, serializePreset, presetFilename } from './setup/agent-preset.js';
|
|
54
|
+
import { validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
|
|
55
|
+
import { hasProtectedSessionMutationOwnership } from './core/session-mutation-guard.js';
|
|
51
56
|
import { logger } from './utils/logger.js';
|
|
57
|
+
import { withFileLock, withFileLockSync } from './utils/file-lock.js';
|
|
52
58
|
import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
|
|
53
59
|
import { scheduleTimeZone } from './utils/timezone.js';
|
|
54
60
|
import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js';
|
|
55
61
|
import { firstPositional } from './cli/arg-utils.js';
|
|
56
62
|
import { isColdResumeDormant, isRealManagedSession, sessionListDisposition } from './cli/session-list-liveness.js';
|
|
63
|
+
import { computeSessionPickerLayout, } from './cli/session-picker-layout.js';
|
|
64
|
+
import { computeSessionPickerScrollWindow } from './cli/session-picker-viewport.js';
|
|
65
|
+
import { terminalCellWidth } from './cli/terminal-width.js';
|
|
57
66
|
import { attachFrozenManagedZmxSession, freezeManagedZmxAttachTarget, } from './cli/zmx-managed-attach.js';
|
|
67
|
+
import { readSupervisorProcessStartIdentity } from './core/process-start-identity.js';
|
|
68
|
+
import { FLEET_DAEMON_EXIT_WAIT_MS, FLEET_SUCCESSOR_SETTLE_MS, PM2_DAEMON_KILL_TIMEOUT_MS, PM2_DAEMON_RESTART_DELAY_MS, } from './core/shutdown-budgets.js';
|
|
69
|
+
import { isFleetEntryProvenFreeOfAutorestartTimer, signalAndAwaitFleet, } from './cli/fleet-shutdown.js';
|
|
70
|
+
import { startExactPm2ProcessIds, } from './cli/pm2-exact-start.js';
|
|
58
71
|
import { dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateVideoAttachments } from './cli/send-dispatch.js';
|
|
59
72
|
import { dispatchDeferredTopicSend, reusableDeferredTopicRoot } from './cli/deferred-topic-send.js';
|
|
60
73
|
import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
|
|
61
74
|
import { resolveDaemonEnv } from './cli/daemon-lifecycle-env.js';
|
|
62
75
|
import { buildPm2SpawnCommand } from './cli/pm2-command.js';
|
|
63
76
|
import { pm2ManagedExitConfig } from './pm2-graceful-exit.js';
|
|
77
|
+
import { parseCanonicalPm2Id, parsePm2JlistOutput, parsePm2JlistOutputStrict, parsePm2Integer, } from './cli/pm2-jlist.js';
|
|
78
|
+
import { assertLinuxPm2GodExecutableUsable } from './cli/pm2-preflight.js';
|
|
79
|
+
import { assertNoUnregisteredLiveDaemonDescriptorsIn } from './cli/pm2-descriptor-guard.js';
|
|
80
|
+
import { assertPm2DaemonShutdownCapabilitiesIn } from './cli/pm2-shutdown-capability.js';
|
|
81
|
+
import { assertIncludePm2RestartAdmission } from './cli/pm2-god-admission.js';
|
|
82
|
+
import { requestAttestedDaemonShutdown, requestAttestedDaemonShutdownBatch, } from './cli/supervisor-shutdown-client.js';
|
|
83
|
+
import { assertDaemonPm2GracefulExitPolicy, assertConfiguredPm2FleetReady, assertExactAttestedDaemonSet, classifyStartBotFleetAdmission, normalizeRawPm2StopExitCodes, reconcileLatePm2StartPublication, runBoundedPm2StartTransaction, } from './cli/pm2-start-transaction.js';
|
|
64
84
|
import { callDashboard } from './cli/dashboard-endpoint.js';
|
|
65
85
|
import { globalInstallUpdateLockTargetIn, installLatestBotmuxSync } from './core/maintenance.js';
|
|
66
|
-
import { withFileLockSync } from './utils/file-lock.js';
|
|
67
86
|
import { formatGlobalInstallCommand, resolveGlobalInstallPlan, UnsupportedGlobalInstallError, } from './utils/global-install.js';
|
|
68
87
|
import { loadDashboardSecret } from './dashboard/auth.js';
|
|
69
88
|
import { postWorkflowDaemonMutation, } from './workflows/v3/daemon-ipc-client.js';
|
|
70
89
|
import { postWorkflowSessionRunMutation, readWorkflowSessionRelayContext, } from './workflows/v3/session-relay-client.js';
|
|
71
90
|
import { fetchDaemonIpc, loadDaemonIpcSecret } from './core/daemon-ipc-auth.js';
|
|
72
|
-
import {
|
|
91
|
+
import { isRetryableAskHttpStatus } from './core/ask-types.js';
|
|
92
|
+
import { hasManagedOriginIsolationMarker, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginLegacyIsolationProbeAccess, readManagedOriginRootLocator, readManagedOriginCapability, } from './core/managed-origin-capability.js';
|
|
93
|
+
import { attestManagedOrigin, } from './core/managed-origin-attestation.js';
|
|
73
94
|
import { rejectLikelyWindowsStdinMojibake, decodeStdinBytes } from './cli/stdin-encoding.js';
|
|
74
95
|
import { formatBotInfoEntriesForCli, formatChatBotsForCli, } from './cli/bots-list-output.js';
|
|
75
96
|
import { ensureBotChatGrantMatrix, requestExactChatGrant } from './cli/exact-chat-grant-client.js';
|
|
76
97
|
import { buildFooterAddressing, hasKnownBotMention, knownBotOpenIdsFromCrossRef, orderedFooterRecipients, stripCodeSpans, } from './utils/bot-routing.js';
|
|
77
|
-
import { isLocale, localeForBot, setDefaultLocale, SUPPORTED_LOCALES } from './i18n/index.js';
|
|
98
|
+
import { isLocale, localeForBot, setDefaultLocale, SUPPORTED_LOCALES, t } from './i18n/index.js';
|
|
78
99
|
import { chatAppLink, larkHosts, normalizeBrand, sdkDomain } from './im/lark/lark-hosts.js';
|
|
79
100
|
import { mergeDashboardConfig, mergeGlobalConfig, readGlobalConfig, setGlobalLocale, globalConfigPath } from './global-config.js';
|
|
80
101
|
import { createWhiteboard, ensureDefaultWhiteboard, getWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled, whiteboardPath, } from './services/whiteboard-store.js';
|
|
81
102
|
import { buildBridgeSendMarkerContent, buildBridgeSendPreviewText, } from './services/bridge-fallback-gate.js';
|
|
82
|
-
import { bindRestartLeaseTo,
|
|
103
|
+
import { bindRestartLeaseTo, commitRestartIntentAttemptTo, consumeRestartIntentTo, removeRestartIntentAttemptTo, writeRestartAttemptIntentTo, } from './services/restart-intent-store.js';
|
|
83
104
|
import { repairMissingChatScope, stripLegacyPendingCardFields } from './services/session-store.js';
|
|
84
105
|
import { evaluateVcMeetingManagedSend, isTrustedVcMeetingHostRelayParent, resolveVcMeetingImTurnOrigin, } from './services/vc-meeting-send-policy.js';
|
|
85
106
|
import { finishVcMeetingImReply, prepareVcMeetingDeliveryReply, prepareVcMeetingImReply, } from './services/vc-meeting-im-reply.js';
|
|
@@ -89,7 +110,7 @@ import { resolveEffectivePluginIds, updateBotPluginOverride } from './core/plugi
|
|
|
89
110
|
import { assertPluginBindingTransition, describePluginDependencyError, enabledPluginDependents, } from './core/plugins/dependencies.js';
|
|
90
111
|
import { authorizeV3DaemonCommand } from './workflows/v3/cli-daemon-command-authority.js';
|
|
91
112
|
import { resolveDaemonIpcPort } from './utils/daemon-discovery.js';
|
|
92
|
-
import { inspectBotmuxPm2Apps, isExactPm2BotActivationReceipt, managedActivationPm2Disposition,
|
|
113
|
+
import { inspectBotmuxPm2Apps, isExactPm2BotActivationReceipt, managedActivationPm2Disposition, } from './core/bot-live-control.js';
|
|
93
114
|
import { isSuspendableBackendType, killPersistentBackendTarget, probePersistentBackendTarget, probePersistentSessions, resolvePersistentBackendTarget, } from './core/persistent-backend.js';
|
|
94
115
|
// Resolve the CLI's UI locale once from the global config file, so subsequent
|
|
95
116
|
// CLI output (and any t() callers that don't pass an explicit locale) honour
|
|
@@ -124,6 +145,14 @@ const PM2_NAME = 'botmux';
|
|
|
124
145
|
* when those external pm2 installations get moved or removed.
|
|
125
146
|
*/
|
|
126
147
|
const PM2_HOME = join(CONFIG_DIR, 'pm2');
|
|
148
|
+
const PM2_FLEET_MUTATION_LOCK_TARGET = join(CONFIG_DIR, 'pm2-fleet-mutation');
|
|
149
|
+
const PM2_START_COMMAND_TIMEOUT_MS = 30_000;
|
|
150
|
+
const PM2_START_VERIFY_MIN_TIMEOUT_MS = 60_000;
|
|
151
|
+
const PM2_START_VERIFY_PER_PROCESS_MS = 2_000;
|
|
152
|
+
const PM2_START_LATE_PUBLICATION_SETTLE_MS = 10_000;
|
|
153
|
+
function pm2StartVerifyTimeoutMs(processCount) {
|
|
154
|
+
return Math.max(PM2_START_VERIFY_MIN_TIMEOUT_MS, Math.max(1, Math.floor(processCount)) * PM2_START_VERIFY_PER_PROCESS_MS);
|
|
155
|
+
}
|
|
127
156
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
128
157
|
function ensureConfigDir() {
|
|
129
158
|
for (const dir of [CONFIG_DIR, DATA_DIR, LOG_DIR, HEAPSHOT_DIR, PM2_HOME]) {
|
|
@@ -175,12 +204,17 @@ function pm2Env(home = PM2_HOME) {
|
|
|
175
204
|
return env;
|
|
176
205
|
}
|
|
177
206
|
function listPm2GodDaemonPids(home = PM2_HOME) {
|
|
178
|
-
if (process.platform !== 'linux')
|
|
179
|
-
return [];
|
|
180
207
|
const marker = `God Daemon (${home})`;
|
|
181
208
|
const pids = [];
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
if (process.platform === 'linux') {
|
|
210
|
+
let entries;
|
|
211
|
+
try {
|
|
212
|
+
entries = readdirSync('/proc');
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw new Error(`cannot inspect /proc for duplicate PM2 Gods: ${err instanceof Error ? err.message : err}`);
|
|
216
|
+
}
|
|
217
|
+
for (const ent of entries) {
|
|
184
218
|
if (!/^\d+$/.test(ent))
|
|
185
219
|
continue;
|
|
186
220
|
const pid = parseInt(ent, 10);
|
|
@@ -191,48 +225,66 @@ function listPm2GodDaemonPids(home = PM2_HOME) {
|
|
|
191
225
|
if (cmd.includes('PM2 v') && cmd.includes(marker))
|
|
192
226
|
pids.push(pid);
|
|
193
227
|
}
|
|
194
|
-
catch { /*
|
|
228
|
+
catch { /* another user's or already-exited process */ }
|
|
195
229
|
}
|
|
230
|
+
return pids.sort((a, b) => a - b);
|
|
196
231
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
keepPid = pids[pids.length - 1];
|
|
216
|
-
const dupes = pids.filter(pid => pid !== keepPid);
|
|
217
|
-
if (dupes.length === 0)
|
|
218
|
-
return false;
|
|
219
|
-
for (const pid of dupes) {
|
|
220
|
-
try {
|
|
221
|
-
process.kill(pid, 'SIGTERM');
|
|
232
|
+
if (process.platform === 'win32') {
|
|
233
|
+
const windowsScan = spawnSync('powershell.exe', [
|
|
234
|
+
'-NoProfile',
|
|
235
|
+
'-NonInteractive',
|
|
236
|
+
'-Command',
|
|
237
|
+
"$needle = \"God Daemon ($env:BOTMUX_PM2_SCAN_HOME)\"; "
|
|
238
|
+
+ "Get-CimInstance Win32_Process | Where-Object { "
|
|
239
|
+
+ "$_.CommandLine -and $_.CommandLine.Contains('PM2 v') "
|
|
240
|
+
+ "-and $_.CommandLine.Contains($needle) } | ForEach-Object { $_.ProcessId }",
|
|
241
|
+
], {
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
|
+
timeout: 4_000,
|
|
245
|
+
env: { ...process.env, BOTMUX_PM2_SCAN_HOME: home },
|
|
246
|
+
});
|
|
247
|
+
if (windowsScan.status !== 0 || windowsScan.error) {
|
|
248
|
+
throw new Error(`cannot inspect Windows process table for duplicate PM2 Gods: `
|
|
249
|
+
+ `${windowsScan.error?.message ?? String(windowsScan.stderr || `status ${windowsScan.status}`).trim()}`);
|
|
222
250
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
251
|
+
for (const line of String(windowsScan.stdout).split(/\r?\n/)) {
|
|
252
|
+
const pid = parsePm2Integer(line.trim(), { nonNegative: true });
|
|
253
|
+
if (pid && pid > 1)
|
|
254
|
+
pids.push(pid);
|
|
227
255
|
}
|
|
228
|
-
|
|
256
|
+
return [...new Set(pids)].sort((a, b) => a - b);
|
|
229
257
|
}
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
const ps = spawnSync('ps', ['-axo', 'pid=,command='], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
261
|
+
timeout: 2_000,
|
|
262
|
+
});
|
|
263
|
+
if (ps.status !== 0 || ps.error) {
|
|
264
|
+
throw new Error(`cannot inspect process table for duplicate PM2 Gods: `
|
|
265
|
+
+ `${ps.error?.message ?? String(ps.stderr || `status ${ps.status}`).trim()}`);
|
|
232
266
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
267
|
+
for (const line of String(ps.stdout).split(/\r?\n/)) {
|
|
268
|
+
if (!line.includes('PM2 v') || !line.includes(marker))
|
|
269
|
+
continue;
|
|
270
|
+
const match = line.match(/^\s*(\d+)\s+/);
|
|
271
|
+
if (match)
|
|
272
|
+
pids.push(Number(match[1]));
|
|
273
|
+
}
|
|
274
|
+
return pids.sort((a, b) => a - b);
|
|
275
|
+
}
|
|
276
|
+
function listSingletonPm2GodDaemonPidsForMutation(home = PM2_HOME) {
|
|
277
|
+
const pids = listPm2GodDaemonPids(home);
|
|
278
|
+
if (pids.length <= 1)
|
|
279
|
+
return pids;
|
|
280
|
+
// Never signal a duplicate God automatically. Its SIGTERM handler may
|
|
281
|
+
// serially stop/force-kill managed children, including a Riff generation
|
|
282
|
+
// whose lineage is not yet durable in the surviving God's registry.
|
|
283
|
+
throw new Error(`refusing PM2 mutation: multiple PM2 God daemons share ${home} `
|
|
284
|
+
+ `(pids: ${pids.join(', ')}); no process was signalled`);
|
|
285
|
+
}
|
|
286
|
+
function assertNoDuplicatePm2GodDaemons(home = PM2_HOME) {
|
|
287
|
+
listSingletonPm2GodDaemonPidsForMutation(home);
|
|
236
288
|
}
|
|
237
289
|
function runPm2(args, inherit = true, home = PM2_HOME, timeoutMs) {
|
|
238
290
|
const pm2 = buildPm2SpawnCommand(pm2Bin(), args);
|
|
@@ -277,6 +329,62 @@ function pm2Capture(args, home = PM2_HOME, timeoutMs = 10_000) {
|
|
|
277
329
|
}
|
|
278
330
|
return typeof r.stdout === 'string' ? r.stdout : '';
|
|
279
331
|
}
|
|
332
|
+
async function cmdInternalPm2StartExact(args) {
|
|
333
|
+
const processIds = args.map(value => Number(value));
|
|
334
|
+
try {
|
|
335
|
+
const claimedParent = parsePm2Integer(process.env.BOTMUX_PM2_FLEET_LOCK_OWNER_PID, {
|
|
336
|
+
nonNegative: true,
|
|
337
|
+
});
|
|
338
|
+
if (claimedParent !== process.ppid) {
|
|
339
|
+
throw new Error('internal exact PM2 start requires its live parent fleet-lock owner');
|
|
340
|
+
}
|
|
341
|
+
const lockPayload = readFileSync(`${PM2_FLEET_MUTATION_LOCK_TARGET}.lock`, 'utf8').trim();
|
|
342
|
+
let lockPid;
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(lockPayload);
|
|
345
|
+
lockPid = parsed && typeof parsed === 'object'
|
|
346
|
+
? parsePm2Integer(parsed.pid, { nonNegative: true })
|
|
347
|
+
: undefined;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
lockPid = parsePm2Integer(lockPayload, { nonNegative: true });
|
|
351
|
+
}
|
|
352
|
+
if (lockPid !== process.ppid) {
|
|
353
|
+
throw new Error('internal exact PM2 start could not verify the parent fleet lock');
|
|
354
|
+
}
|
|
355
|
+
const pm2 = require('pm2');
|
|
356
|
+
await startExactPm2ProcessIds(processIds, pm2.Client);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
360
|
+
process.exitCode = 1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function runExactPm2Starts(entries, home, timeoutMs) {
|
|
364
|
+
const processIds = entries.map(entry => entry.pmId);
|
|
365
|
+
if (processIds.some(id => !Number.isInteger(id) || id < 0)) {
|
|
366
|
+
throw new Error('conditional PM2 compensation requires an exact pm_id for every entry');
|
|
367
|
+
}
|
|
368
|
+
const boundedTimeoutMs = Math.floor(timeoutMs);
|
|
369
|
+
if (boundedTimeoutMs <= 0) {
|
|
370
|
+
throw new Error('fleet deadline exhausted before conditional PM2 compensation');
|
|
371
|
+
}
|
|
372
|
+
const result = spawnSync(process.execPath, [__filename, '__pm2-start-exact', ...processIds.map(String)], {
|
|
373
|
+
stdio: 'pipe',
|
|
374
|
+
env: {
|
|
375
|
+
...pm2Env(home),
|
|
376
|
+
BOTMUX_PM2_FLEET_LOCK_OWNER_PID: String(process.pid),
|
|
377
|
+
},
|
|
378
|
+
timeout: boundedTimeoutMs,
|
|
379
|
+
encoding: 'utf8',
|
|
380
|
+
});
|
|
381
|
+
if (result.status !== 0) {
|
|
382
|
+
const detail = result.error?.message
|
|
383
|
+
?? result.stderr?.trim()
|
|
384
|
+
?? `status ${result.status}`;
|
|
385
|
+
throw new Error(`conditional PM2 compensation failed: ${detail}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
280
388
|
function loadBotsJson() {
|
|
281
389
|
// NOTE: this stays FATAL on a read error, deliberately. Several callers treat
|
|
282
390
|
// an empty list as "nothing references this" and go on to delete things
|
|
@@ -419,35 +527,81 @@ async function cmdServe(args) {
|
|
|
419
527
|
});
|
|
420
528
|
});
|
|
421
529
|
}
|
|
422
|
-
|
|
530
|
+
/**
|
|
531
|
+
* pm2-safe interpreter path.
|
|
532
|
+
*
|
|
533
|
+
* pm2 (>=6, lib/Common.js) treats ANY interpreter path containing the
|
|
534
|
+
* substring `node@` as an nvm version handle and tries to `nvm install` it —
|
|
535
|
+
* so a Homebrew keg-only Node (e.g.
|
|
536
|
+
* `/home/linuxbrew/.linuxbrew/Cellar/node@22/22.23.1/bin/node`) is misread as
|
|
537
|
+
* version `22/22.23.1/bin/node`, and every `botmux start/restart` dies with
|
|
538
|
+
* `Version '22/22.23.1/bin/node' not found`.
|
|
539
|
+
*
|
|
540
|
+
* We can't just point at a different existing binary: the keg-only formula's
|
|
541
|
+
* only paths (Cellar/, opt/, and — when it's not the default — bin/) all
|
|
542
|
+
* contain `node@`. So when process.execPath carries `node@`, we materialize a
|
|
543
|
+
* stable `@`-free symlink under ~/.botmux and hand pm2 THAT. The symlink still
|
|
544
|
+
* resolves to the exact Node that launched this CLI (same version → native
|
|
545
|
+
* modules like node-pty keep working), but its path no longer trips pm2's
|
|
546
|
+
* nvm heuristic. Non-Homebrew installs keep using process.execPath verbatim.
|
|
547
|
+
*/
|
|
548
|
+
function pm2SafeInterpreter() {
|
|
549
|
+
const exec = process.execPath;
|
|
550
|
+
if (!exec.includes('node@'))
|
|
551
|
+
return exec;
|
|
552
|
+
const link = join(CONFIG_DIR, 'node-interpreter');
|
|
553
|
+
try {
|
|
554
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
555
|
+
// Refresh the link if missing or pointing at a stale Node (e.g. after a
|
|
556
|
+
// Homebrew upgrade bumped the patch version under the same install).
|
|
557
|
+
let current;
|
|
558
|
+
try {
|
|
559
|
+
current = readlinkSync(link);
|
|
560
|
+
}
|
|
561
|
+
catch { /* not a symlink / absent */ }
|
|
562
|
+
if (current !== exec) {
|
|
563
|
+
try {
|
|
564
|
+
unlinkSync(link);
|
|
565
|
+
}
|
|
566
|
+
catch { /* absent */ }
|
|
567
|
+
symlinkSync(exec, link);
|
|
568
|
+
}
|
|
569
|
+
// Sanity: the link must still resolve to a real node. If anything is off,
|
|
570
|
+
// fall back to the raw path rather than handing pm2 a dangling symlink.
|
|
571
|
+
if (realpathSync(link) && !link.includes('node@'))
|
|
572
|
+
return link;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
/* fall through to raw execPath */
|
|
576
|
+
}
|
|
577
|
+
return exec;
|
|
578
|
+
}
|
|
579
|
+
function ecosystemConfig(bots = loadBotsJson(), activationAppId) {
|
|
423
580
|
const daemonScript = join(PKG_ROOT, 'dist', 'index-daemon.js');
|
|
424
|
-
const bots = loadBotsJson();
|
|
425
581
|
ensureUniqueBotProcessNames(bots);
|
|
426
582
|
const daemonEnv = resolveDaemonEnv(process.env, existsSync(ENV_FILE) ? readFileSync(ENV_FILE, 'utf-8') : undefined);
|
|
427
583
|
const managedExit = pm2ManagedExitConfig();
|
|
584
|
+
// Node binary every managed process is pinned to (see pm2SafeInterpreter).
|
|
585
|
+
const interpreter = pm2SafeInterpreter();
|
|
428
586
|
const baseApp = {
|
|
429
587
|
script: daemonScript,
|
|
430
588
|
// Pin every managed core process to the Node that invoked this CLI. This
|
|
431
589
|
// keeps GUI/launchd starts independent from PATH and lets Desktop replace
|
|
432
590
|
// an external fleet without also killing unrelated plugin services.
|
|
433
|
-
interpreter
|
|
591
|
+
interpreter,
|
|
434
592
|
cwd: CONFIG_DIR,
|
|
435
593
|
autorestart: true,
|
|
436
594
|
max_restarts: 10,
|
|
437
|
-
restart_delay:
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
// (~13s of churn for 31 bots). Do not use 0 here: PM2 normalizes a signal
|
|
444
|
-
// exit's null code to 0, which would suppress autorestart after SIGKILL.
|
|
595
|
+
restart_delay: PM2_DAEMON_RESTART_DELAY_MS,
|
|
596
|
+
// PM2 maps signal-only death to exit_code=0 before applying
|
|
597
|
+
// stop_exit_codes. Zero cannot be a graceful sentinel: SIGKILL/OOM during
|
|
598
|
+
// a prepared Riff drain would otherwise suppress autorestart and look safe
|
|
599
|
+
// to delete. Only shutdown()'s fully committed success exits the reserved
|
|
600
|
+
// non-zero code. All signal deaths and ordinary failures still restart.
|
|
445
601
|
stop_exit_codes: managedExit.stopExitCodes,
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
// the daemon's graceful-drain budget so it never force-kills mid-shutdown.
|
|
450
|
-
kill_timeout: 3500,
|
|
602
|
+
// Keep the supervisor outside every bounded Riff prepare/commit/refusal
|
|
603
|
+
// handshake so PM2 cannot SIGKILL a correct daemon mid-ACK.
|
|
604
|
+
kill_timeout: PM2_DAEMON_KILL_TIMEOUT_MS,
|
|
451
605
|
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
|
452
606
|
merge_logs: true,
|
|
453
607
|
node_args: [
|
|
@@ -512,13 +666,13 @@ function ecosystemConfig(activationAppId) {
|
|
|
512
666
|
apps.push({
|
|
513
667
|
name: 'botmux-dashboard',
|
|
514
668
|
script: join(PKG_ROOT, 'dist', 'dashboard.js'),
|
|
515
|
-
interpreter
|
|
669
|
+
interpreter,
|
|
516
670
|
cwd: PKG_ROOT,
|
|
517
671
|
autorestart: true,
|
|
518
672
|
max_restarts: 10,
|
|
519
|
-
restart_delay:
|
|
520
|
-
//
|
|
521
|
-
//
|
|
673
|
+
restart_delay: PM2_DAEMON_RESTART_DELAY_MS,
|
|
674
|
+
// Dashboard receives the same managed sentinel env and exits through
|
|
675
|
+
// gracefulProcessExitCode(), so its stop policy must match the daemon's.
|
|
522
676
|
stop_exit_codes: managedExit.stopExitCodes,
|
|
523
677
|
kill_timeout: 3500,
|
|
524
678
|
error_file: join(LOG_DIR, 'dashboard-error.log'),
|
|
@@ -1499,7 +1653,7 @@ async function cmdSetupScripted(argv) {
|
|
|
1499
1653
|
});
|
|
1500
1654
|
return;
|
|
1501
1655
|
}
|
|
1502
|
-
const live = ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
|
|
1656
|
+
const live = await ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
|
|
1503
1657
|
const next = live.ok ? 'live' : (live.reason === 'fleet_down' ? 'botmux start' : 'botmux restart');
|
|
1504
1658
|
if (cmd.json) {
|
|
1505
1659
|
console.log(JSON.stringify({
|
|
@@ -1746,7 +1900,7 @@ async function cmdSetupScripted(argv) {
|
|
|
1746
1900
|
return;
|
|
1747
1901
|
}
|
|
1748
1902
|
// daemon 在跑就直接把新 bot 那一个进程拉起来,免整组 botmux restart。
|
|
1749
|
-
const live = ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
|
|
1903
|
+
const live = await ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
|
|
1750
1904
|
const next = live.ok ? 'live' : (live.reason === 'fleet_down' ? 'botmux start' : 'botmux restart');
|
|
1751
1905
|
if (cmd.json) {
|
|
1752
1906
|
console.log(JSON.stringify({
|
|
@@ -2068,7 +2222,7 @@ async function cmdSetup() {
|
|
|
2068
2222
|
console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
|
|
2069
2223
|
console.log(` 配置文件: ${BOTS_JSON_FILE}`);
|
|
2070
2224
|
await finishOpenPlatformSetup(newBot.larkAppId, botBrand(newBot), { reuseOnly: hasSetupWebSession(newBot) });
|
|
2071
|
-
printAddBotLiveHint(newBot.larkAppId);
|
|
2225
|
+
await printAddBotLiveHint(newBot.larkAppId);
|
|
2072
2226
|
return;
|
|
2073
2227
|
}
|
|
2074
2228
|
}
|
|
@@ -2122,7 +2276,7 @@ async function cmdSetup() {
|
|
|
2122
2276
|
console.log(` 配置文件: ${BOTS_JSON_FILE}`);
|
|
2123
2277
|
console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
|
|
2124
2278
|
await finishOpenPlatformSetup(newBot.larkAppId, botBrand(newBot), { reuseOnly: hasSetupWebSession(newBot) });
|
|
2125
|
-
printAddBotLiveHint(newBot.larkAppId);
|
|
2279
|
+
await printAddBotLiveHint(newBot.larkAppId);
|
|
2126
2280
|
}
|
|
2127
2281
|
else {
|
|
2128
2282
|
// --- Fresh install ---
|
|
@@ -2140,50 +2294,19 @@ async function cmdSetup() {
|
|
|
2140
2294
|
* the daemon, but the error gets buried in pm2 logs and the user sees
|
|
2141
2295
|
* silence.
|
|
2142
2296
|
*
|
|
2143
|
-
* Detects two cases and
|
|
2144
|
-
* 1. pm2 god daemon's running binary is deleted →
|
|
2297
|
+
* Detects two cases and aborts with a clear message:
|
|
2298
|
+
* 1. pm2 god daemon's running binary is deleted → fail closed; an automatic
|
|
2299
|
+
* kill could bypass a managed daemon's Riff shutdown protocol
|
|
2145
2300
|
* 2. This package is installed under an nvm Node version that no longer
|
|
2146
2301
|
* exists on disk → abort with reinstall instructions
|
|
2147
2302
|
*/
|
|
2148
|
-
function preflightNodeSanity() {
|
|
2149
|
-
//
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
pm2Pid
|
|
2155
|
-
}
|
|
2156
|
-
catch { /* ignore */ }
|
|
2157
|
-
if (pm2Pid) {
|
|
2158
|
-
let pm2Alive = false;
|
|
2159
|
-
try {
|
|
2160
|
-
process.kill(pm2Pid, 0);
|
|
2161
|
-
pm2Alive = true;
|
|
2162
|
-
}
|
|
2163
|
-
catch { /* not alive */ }
|
|
2164
|
-
if (pm2Alive && process.platform === 'linux') {
|
|
2165
|
-
// On Linux, /proc/<pid>/exe is a symlink to the running executable.
|
|
2166
|
-
// readlink includes a " (deleted)" suffix when the on-disk file is gone.
|
|
2167
|
-
try {
|
|
2168
|
-
const exe = readlinkSync(`/proc/${pm2Pid}/exe`);
|
|
2169
|
-
const cleanPath = exe.replace(/ \(deleted\)$/, '');
|
|
2170
|
-
const exeDeleted = exe.endsWith(' (deleted)') || !existsSync(cleanPath);
|
|
2171
|
-
if (exeDeleted) {
|
|
2172
|
-
console.warn(`⚠️ pm2 god daemon (pid ${pm2Pid}) 使用的 Node 二进制已失效: ${cleanPath}`);
|
|
2173
|
-
console.warn(` 自动杀掉 pm2 god 以便用当前 Node 重启...`);
|
|
2174
|
-
try {
|
|
2175
|
-
runPm2(['kill'], false, PM2_HOME, 10_000);
|
|
2176
|
-
}
|
|
2177
|
-
catch {
|
|
2178
|
-
try {
|
|
2179
|
-
process.kill(pm2Pid, 'SIGKILL');
|
|
2180
|
-
}
|
|
2181
|
-
catch { /* ignore */ }
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
|
-
catch { /* /proc not readable, skip */ }
|
|
2186
|
-
}
|
|
2303
|
+
function preflightNodeSanity(home = PM2_HOME) {
|
|
2304
|
+
// `pm2.pid` is only a cache. Inspect every God actually enumerated for this
|
|
2305
|
+
// PM2_HOME and refuse rather than guessing which generation is authoritative.
|
|
2306
|
+
const actualGodPids = listPm2GodDaemonPids(home);
|
|
2307
|
+
if (process.platform === 'linux') {
|
|
2308
|
+
for (const pm2Pid of actualGodPids) {
|
|
2309
|
+
assertLinuxPm2GodExecutableUsable(pm2Pid);
|
|
2187
2310
|
}
|
|
2188
2311
|
}
|
|
2189
2312
|
// Case 2: botmux installed under a dead nvm Node version.
|
|
@@ -2212,8 +2335,6 @@ async function cmdStart() {
|
|
|
2212
2335
|
process.exit(1);
|
|
2213
2336
|
}
|
|
2214
2337
|
ensureConfigDir();
|
|
2215
|
-
killDuplicatePm2GodDaemons();
|
|
2216
|
-
preflightNodeSanity();
|
|
2217
2338
|
await ensureSystemDependencies();
|
|
2218
2339
|
// 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
|
|
2219
2340
|
// 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
|
|
@@ -2249,9 +2370,51 @@ async function cmdStart() {
|
|
|
2249
2370
|
process.exit(1);
|
|
2250
2371
|
}
|
|
2251
2372
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2373
|
+
await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
|
|
2374
|
+
await withFileLock(BOTS_JSON_FILE, async () => {
|
|
2375
|
+
const lockedBots = loadBotsJson();
|
|
2376
|
+
if (JSON.stringify(lockedBots) !== JSON.stringify(botsForCheck)) {
|
|
2377
|
+
throw new Error('[start] bots.json changed during credential preflight; retry with the new configuration');
|
|
2378
|
+
}
|
|
2379
|
+
assertNoDuplicatePm2GodDaemons();
|
|
2380
|
+
preflightNodeSanity();
|
|
2381
|
+
cleanupLegacyPm2();
|
|
2382
|
+
const currentProjection = readVerifiedBotmuxPm2Projection('start');
|
|
2383
|
+
assertNoUnregisteredLiveDaemonDescriptors('start', currentProjection);
|
|
2384
|
+
assertCanonicalUniquePm2Rows('start', currentProjection);
|
|
2385
|
+
const configuredNames = configuredCoreProcessNames(lockedBots);
|
|
2386
|
+
const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
|
|
2387
|
+
const cfg = ecosystemConfig(lockedBots);
|
|
2388
|
+
const liveEntries = currentProjection.filter(isLivePm2Entry);
|
|
2389
|
+
if (liveEntries.length > 0) {
|
|
2390
|
+
try {
|
|
2391
|
+
readAndAssertConfiguredFleetOnline('start-idempotent-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
catch (error) {
|
|
2395
|
+
throw new Error(`[start] refusing PM2 start while a partial/live core fleet exists `
|
|
2396
|
+
+ `(${liveEntries.map(entry => `${entry.name}:${entry.pid}`).join(', ')}): `
|
|
2397
|
+
+ `${error instanceof Error ? error.message : String(error)}; `
|
|
2398
|
+
+ 'use start-bot only for an exact one-missing-bot fleet, or restart');
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
const unprovenDormant = currentProjection.filter(entry => !isFleetEntryProvenFreeOfAutorestartTimer(entry));
|
|
2402
|
+
if (unprovenDormant.length > 0) {
|
|
2403
|
+
throw new Error(`[start] refusing PM2 start: dormant row(s) may still have a restart timer `
|
|
2404
|
+
+ `(${unprovenDormant.map(entry => `${entry.name}:${entry.status ?? 'unknown'}`).join(', ')})`);
|
|
2405
|
+
}
|
|
2406
|
+
runBoundedPm2StartTransaction('start', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
|
|
2407
|
+
start: timeoutMs => {
|
|
2408
|
+
assertBotsConfigSnapshotUnchanged('start', lockedBots);
|
|
2409
|
+
assertNoDuplicatePm2GodDaemons();
|
|
2410
|
+
preflightNodeSanity();
|
|
2411
|
+
runPm2(['start', cfg], true, PM2_HOME, timeoutMs);
|
|
2412
|
+
},
|
|
2413
|
+
verifyFresh: timeoutMs => readAndAssertConfiguredFleetOnline('start-after-launch', configuredNames, PM2_HOME, timeoutMs),
|
|
2414
|
+
rollback: () => rollbackPm2StartAttempt('start', currentProjection, configuredNames),
|
|
2415
|
+
});
|
|
2416
|
+
}, { maxWaitMs: 5_000 });
|
|
2417
|
+
}, { maxWaitMs: 5_000 });
|
|
2255
2418
|
await reconcilePluginServicesForCli(undefined, { autoOnly: true });
|
|
2256
2419
|
const bots = loadBotsJson();
|
|
2257
2420
|
const count = bots.length || 1;
|
|
@@ -2301,98 +2464,511 @@ function sleepSyncMs(ms) {
|
|
|
2301
2464
|
function isBotmuxCoreProcessName(name) {
|
|
2302
2465
|
return name === PM2_NAME || (name.startsWith(`${PM2_NAME}-`) && !name.startsWith(`${PM2_NAME}-plugin-`));
|
|
2303
2466
|
}
|
|
2304
|
-
function
|
|
2305
|
-
|
|
2467
|
+
function isBotmuxDaemonProcessName(name) {
|
|
2468
|
+
return isBotmuxCoreProcessName(name) && name !== 'botmux-dashboard';
|
|
2469
|
+
}
|
|
2470
|
+
function toBotmuxPm2ProcessEntry(app) {
|
|
2471
|
+
const rawStopExitCodes = app?.pm2_env?.stop_exit_codes;
|
|
2472
|
+
const pmId = parseCanonicalPm2Id(app);
|
|
2473
|
+
const exitCode = parsePm2Integer(app?.pm2_env?.exit_code);
|
|
2474
|
+
return {
|
|
2475
|
+
name: String(app.name),
|
|
2476
|
+
...(pmId !== undefined ? { pmId } : {}),
|
|
2477
|
+
pid: Number(app.pid) || 0,
|
|
2478
|
+
online: app?.pm2_env?.status === 'online',
|
|
2479
|
+
status: String(app?.pm2_env?.status ?? 'unknown'),
|
|
2480
|
+
autorestart: app?.pm2_env?.autorestart,
|
|
2481
|
+
stopExitCodes: normalizeRawPm2StopExitCodes(rawStopExitCodes),
|
|
2482
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
function readVerifiedBotmuxPm2Projection(operation, home = PM2_HOME, timeoutMs = 10_000) {
|
|
2306
2486
|
try {
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
.
|
|
2310
|
-
.map(a => ({ name: String(a.name), pid: Number(a.pid) || 0, online: a?.pm2_env?.status === 'online' }));
|
|
2487
|
+
return parsePm2JlistOutputStrict(pm2Capture(['jlist'], home, timeoutMs))
|
|
2488
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
2489
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
2311
2490
|
}
|
|
2312
|
-
catch (
|
|
2313
|
-
|
|
2314
|
-
|
|
2491
|
+
catch (err) {
|
|
2492
|
+
throw new Error(`[${operation}] pm2 jlist failed; refusing an unverified PM2 mutation: `
|
|
2493
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2315
2494
|
}
|
|
2316
|
-
|
|
2495
|
+
}
|
|
2496
|
+
function assertNoUnregisteredLiveDaemonDescriptors(operation, projections) {
|
|
2497
|
+
assertNoUnregisteredLiveDaemonDescriptorsIn(operation, projections, join(resolveDataDir(), 'dashboard-daemons'));
|
|
2498
|
+
}
|
|
2499
|
+
function duplicatePm2CoreNames(entries) {
|
|
2500
|
+
return [...new Set(entries.map(entry => entry.name))]
|
|
2501
|
+
.filter(name => entries.filter(entry => entry.name === name).length > 1);
|
|
2502
|
+
}
|
|
2503
|
+
function isLivePm2Entry(entry) {
|
|
2504
|
+
if (!Number.isInteger(entry.pid) || entry.pid <= 1)
|
|
2505
|
+
return false;
|
|
2506
|
+
try {
|
|
2507
|
+
process.kill(entry.pid, 0);
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
catch {
|
|
2511
|
+
return false;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
function configuredCoreProcessNames(bots = loadBotsJson(), activationAppId) {
|
|
2515
|
+
const names = [];
|
|
2516
|
+
bots.forEach((bot, index) => {
|
|
2517
|
+
const appId = typeof bot?.larkAppId === 'string' ? bot.larkAppId : '';
|
|
2518
|
+
const starting = bot?.activationStarting;
|
|
2519
|
+
const committed = bot?.activationCommitted;
|
|
2520
|
+
const deactivating = bot?.activationDeactivating;
|
|
2521
|
+
const conflicting = starting !== undefined && committed !== undefined;
|
|
2522
|
+
const marker = starting ?? committed;
|
|
2523
|
+
const validMarker = (marker
|
|
2524
|
+
&& typeof marker === 'object'
|
|
2525
|
+
&& !Array.isArray(marker)
|
|
2526
|
+
&& marker.appId === appId
|
|
2527
|
+
&& typeof marker.jobId === 'string'
|
|
2528
|
+
&& marker.jobId);
|
|
2529
|
+
if (bot?.activationPending === true
|
|
2530
|
+
|| deactivating !== undefined
|
|
2531
|
+
|| conflicting
|
|
2532
|
+
|| ((starting !== undefined || committed !== undefined)
|
|
2533
|
+
&& (!validMarker || activationAppId !== appId)))
|
|
2534
|
+
return;
|
|
2535
|
+
names.push(botProcessName(bot, index, PM2_NAME));
|
|
2536
|
+
});
|
|
2537
|
+
names.push('botmux-dashboard');
|
|
2538
|
+
return names;
|
|
2539
|
+
}
|
|
2540
|
+
function assertBotsConfigSnapshotUnchanged(operation, snapshot) {
|
|
2541
|
+
if (JSON.stringify(loadBotsJson()) === JSON.stringify(snapshot))
|
|
2317
2542
|
return;
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2543
|
+
throw new Error(`[${operation}] bots.json generation changed before PM2 start; no launch attempted`);
|
|
2544
|
+
}
|
|
2545
|
+
function readAndAssertConfiguredFleetOnline(operation, configuredNames, home = PM2_HOME, timeoutMs = PM2_START_VERIFY_MIN_TIMEOUT_MS) {
|
|
2546
|
+
const deadline = Date.now() + Math.max(1, Math.floor(timeoutMs));
|
|
2547
|
+
let lastError;
|
|
2548
|
+
while (Date.now() < deadline) {
|
|
2549
|
+
try {
|
|
2550
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2551
|
+
const projection = readVerifiedBotmuxPm2Projection(operation, home, remainingMs);
|
|
2552
|
+
assertNoUnregisteredLiveDaemonDescriptors(operation, projection);
|
|
2553
|
+
assertConfiguredPm2FleetReady(operation, projection, configuredNames, pid => { try {
|
|
2554
|
+
process.kill(pid, 0);
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
catch {
|
|
2558
|
+
return false;
|
|
2559
|
+
} }, readyEntries => {
|
|
2560
|
+
const daemonEntries = readyEntries.filter(entry => isBotmuxDaemonProcessName(entry.name));
|
|
2561
|
+
assertDaemonPm2GracefulExitPolicy(`${operation}-handler-ready-pm2-policy`, daemonEntries);
|
|
2562
|
+
const attested = assertPm2DaemonShutdownCapabilitiesIn(`${operation}-handler-ready`, daemonEntries.map(entry => ({ name: entry.name, pid: entry.pid })), join(resolveDataDir(), 'dashboard-daemons'));
|
|
2563
|
+
assertExactAttestedDaemonSet(`${operation}-handler-ready`, daemonEntries, attested.map(entry => entry.pid), pid => { try {
|
|
2564
|
+
process.kill(pid, 0);
|
|
2565
|
+
return true;
|
|
2566
|
+
}
|
|
2567
|
+
catch {
|
|
2568
|
+
return false;
|
|
2569
|
+
} });
|
|
2570
|
+
for (const target of attested) {
|
|
2571
|
+
if (readSupervisorProcessStartIdentity(target.pid) !== target.processStartIdentity) {
|
|
2572
|
+
throw new Error(`[${operation}-handler-ready] daemon generation changed after capability scan: `
|
|
2573
|
+
+ `${target.name}/${target.pid}`);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (readyEntries
|
|
2577
|
+
.filter(entry => !isBotmuxDaemonProcessName(entry.name))
|
|
2578
|
+
.some(entry => !isLivePm2Entry(entry))) {
|
|
2579
|
+
throw new Error(`[${operation}] dashboard exited during handler-ready verification`);
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
return projection;
|
|
2583
|
+
}
|
|
2584
|
+
catch (error) {
|
|
2585
|
+
lastError = error;
|
|
2586
|
+
const remainingMs = deadline - Date.now();
|
|
2587
|
+
if (remainingMs <= 0)
|
|
2588
|
+
break;
|
|
2589
|
+
sleepSyncMs(Math.min(100, remainingMs));
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
throw new Error(`[${operation}] configured fleet never reached PM2-online plus handler-ready capability `
|
|
2593
|
+
+ `within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
2594
|
+
}
|
|
2595
|
+
function assertCanonicalUniquePm2Rows(operation, entries) {
|
|
2596
|
+
const duplicateNames = duplicatePm2CoreNames(entries);
|
|
2597
|
+
const missingIds = entries.filter(entry => !Number.isSafeInteger(entry.pmId) || entry.pmId < 0);
|
|
2598
|
+
const duplicateIds = [...new Set(entries
|
|
2599
|
+
.map(entry => entry.pmId)
|
|
2600
|
+
.filter((id) => Number.isSafeInteger(id)))]
|
|
2601
|
+
.filter(id => entries.filter(entry => entry.pmId === id).length > 1);
|
|
2602
|
+
const duplicateLivePids = [...new Set(entries
|
|
2603
|
+
.map(entry => entry.pid)
|
|
2604
|
+
.filter(pid => Number.isSafeInteger(pid) && pid > 1))]
|
|
2605
|
+
.filter(pid => entries.filter(entry => entry.pid === pid).length > 1);
|
|
2606
|
+
if (duplicateNames.length === 0
|
|
2607
|
+
&& missingIds.length === 0
|
|
2608
|
+
&& duplicateIds.length === 0
|
|
2609
|
+
&& duplicateLivePids.length === 0)
|
|
2610
|
+
return;
|
|
2611
|
+
throw new Error(`[${operation}] refusing PM2 mutation: canonical registry identity is ambiguous`
|
|
2612
|
+
+ (duplicateNames.length > 0 ? ` (duplicate names: ${duplicateNames.join(', ')})` : '')
|
|
2613
|
+
+ (duplicateIds.length > 0 ? ` (duplicate pm_id: ${duplicateIds.join(', ')})` : '')
|
|
2614
|
+
+ (duplicateLivePids.length > 0
|
|
2615
|
+
? ` (duplicate positive pid: ${duplicateLivePids.join(', ')})`
|
|
2616
|
+
: '')
|
|
2617
|
+
+ (missingIds.length > 0
|
|
2618
|
+
? ` (missing pm_id: ${missingIds.map(entry => entry.name).join(', ')})`
|
|
2619
|
+
: ''));
|
|
2620
|
+
}
|
|
2621
|
+
function exactQuiescentRowsForMutation(operation, originals, fresh) {
|
|
2622
|
+
const exact = [];
|
|
2623
|
+
for (const original of originals) {
|
|
2624
|
+
const rows = fresh.filter(entry => entry.name === original.name);
|
|
2625
|
+
if (rows.length === 0)
|
|
2626
|
+
continue;
|
|
2627
|
+
if (rows.length !== 1 || rows[0].pmId !== original.pmId) {
|
|
2628
|
+
throw new Error(`[${operation}] refusing PM2 mutation: registry row ${original.name} was recreated or duplicated`);
|
|
2629
|
+
}
|
|
2630
|
+
if (isLivePm2Entry(rows[0])) {
|
|
2631
|
+
throw new Error(`[${operation}] refusing PM2 mutation: live generation appeared for `
|
|
2632
|
+
+ `${original.name}:${rows[0].pid}`);
|
|
2633
|
+
}
|
|
2634
|
+
if (!isFleetEntryProvenFreeOfAutorestartTimer(rows[0])) {
|
|
2635
|
+
throw new Error(`[${operation}] refusing PM2 mutation: ${original.name} may still publish a successor`);
|
|
2636
|
+
}
|
|
2637
|
+
exact.push(rows[0]);
|
|
2638
|
+
}
|
|
2639
|
+
return exact;
|
|
2640
|
+
}
|
|
2641
|
+
function revalidateExactQuiescentRowBeforeMutation(operation, original, allOriginals, home = PM2_HOME, additionalDescriptorAuthority = []) {
|
|
2642
|
+
assertNoDuplicatePm2GodDaemons(home);
|
|
2643
|
+
const fresh = readVerifiedBotmuxPm2Projection(operation, home);
|
|
2644
|
+
assertNoUnregisteredLiveDaemonDescriptors(operation, [...fresh, ...additionalDescriptorAuthority]);
|
|
2645
|
+
assertCanonicalUniquePm2Rows(operation, fresh);
|
|
2646
|
+
return exactQuiescentRowsForMutation(operation, allOriginals, fresh)
|
|
2647
|
+
.find(entry => entry.name === original.name);
|
|
2648
|
+
}
|
|
2649
|
+
function signalAndAwaitBotmuxProcesses(entries, operation, home = PM2_HOME, additionalDescriptorAuthority = []) {
|
|
2650
|
+
assertCanonicalUniquePm2Rows(operation, entries);
|
|
2651
|
+
const processNameByPid = new Map();
|
|
2652
|
+
const processEntryByPid = new Map();
|
|
2653
|
+
const processStartByPid = new Map();
|
|
2654
|
+
const rememberProcessIdentity = (identityOperation, entry) => {
|
|
2655
|
+
if (entry.pid <= 1)
|
|
2656
|
+
return;
|
|
2657
|
+
const identity = readSupervisorProcessStartIdentity(entry.pid);
|
|
2658
|
+
if (!identity) {
|
|
2659
|
+
if (!isLivePm2Entry(entry))
|
|
2660
|
+
return;
|
|
2661
|
+
throw new Error(`[${identityOperation}] cannot bind ${entry.name}/${entry.pid} to a process-start identity`);
|
|
2662
|
+
}
|
|
2663
|
+
processNameByPid.set(entry.pid, entry.name);
|
|
2664
|
+
processEntryByPid.set(entry.pid, entry);
|
|
2665
|
+
processStartByPid.set(entry.pid, identity);
|
|
2666
|
+
};
|
|
2667
|
+
for (const entry of entries)
|
|
2668
|
+
rememberProcessIdentity(`${operation}-initial-identity`, entry);
|
|
2669
|
+
const assertShutdownCapability = (capabilityOperation, targets) => {
|
|
2670
|
+
const daemonTargets = targets.filter(entry => isBotmuxDaemonProcessName(entry.name));
|
|
2671
|
+
assertDaemonPm2GracefulExitPolicy(capabilityOperation, daemonTargets);
|
|
2672
|
+
return assertPm2DaemonShutdownCapabilitiesIn(capabilityOperation, daemonTargets.map(entry => ({ name: entry.name, pid: entry.pid })), join(resolveDataDir(), 'dashboard-daemons'));
|
|
2673
|
+
};
|
|
2674
|
+
assertShutdownCapability(`${operation}-shutdown-capability-preflight`, entries.filter(entry => entry.online && entry.pid > 1));
|
|
2675
|
+
const list = (timeoutMs) => {
|
|
2676
|
+
const projection = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home, Math.min(10_000, Math.max(1, Math.floor(timeoutMs)))))
|
|
2677
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
2678
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
2679
|
+
assertNoUnregisteredLiveDaemonDescriptors(`${operation}-successor-projection`, [...projection, ...additionalDescriptorAuthority]);
|
|
2680
|
+
assertCanonicalUniquePm2Rows(`${operation}-successor-projection`, projection);
|
|
2681
|
+
for (const entry of projection) {
|
|
2682
|
+
rememberProcessIdentity(`${operation}-successor-identity`, entry);
|
|
2683
|
+
}
|
|
2684
|
+
return projection;
|
|
2685
|
+
};
|
|
2686
|
+
const shutdownRequestFailures = [];
|
|
2687
|
+
const recordShutdownFailure = (name, pid, error) => {
|
|
2688
|
+
shutdownRequestFailures.push(`${name}/${pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2689
|
+
};
|
|
2690
|
+
const signalDashboardResidual = (name, pid) => {
|
|
2691
|
+
const expectedStart = processStartByPid.get(pid);
|
|
2692
|
+
const currentStart = readSupervisorProcessStartIdentity(pid);
|
|
2693
|
+
if (!currentStart)
|
|
2694
|
+
return;
|
|
2695
|
+
if (!expectedStart || currentStart !== expectedStart) {
|
|
2696
|
+
recordShutdownFailure(name, pid, 'dashboard process generation changed');
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2330
2699
|
try {
|
|
2331
2700
|
process.kill(pid, 'SIGTERM');
|
|
2332
2701
|
}
|
|
2333
|
-
catch {
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
const deadline = Date.now() + 5_000;
|
|
2338
|
-
let alive = pids.slice();
|
|
2339
|
-
while (alive.length > 0 && Date.now() < deadline) {
|
|
2340
|
-
sleepSyncMs(50);
|
|
2341
|
-
alive = alive.filter(pid => { try {
|
|
2342
|
-
process.kill(pid, 0);
|
|
2343
|
-
return true;
|
|
2702
|
+
catch (error) {
|
|
2703
|
+
if (error?.code !== 'ESRCH') {
|
|
2704
|
+
recordShutdownFailure(name, pid, error);
|
|
2705
|
+
}
|
|
2344
2706
|
}
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2707
|
+
};
|
|
2708
|
+
try {
|
|
2709
|
+
signalAndAwaitFleet(entries, operation, FLEET_DAEMON_EXIT_WAIT_MS, {
|
|
2710
|
+
signal: pid => {
|
|
2711
|
+
const name = processNameByPid.get(pid);
|
|
2712
|
+
if (!name) {
|
|
2713
|
+
throw new Error(`[${operation}] refusing signal for unmapped PM2 daemon pid ${pid}`);
|
|
2714
|
+
}
|
|
2715
|
+
if (isBotmuxDaemonProcessName(name)) {
|
|
2716
|
+
try {
|
|
2717
|
+
const successor = processEntryByPid.get(pid);
|
|
2718
|
+
if (!successor) {
|
|
2719
|
+
recordShutdownFailure(name, pid, 'successor PM2 policy projection is missing');
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const authorized = assertShutdownCapability(`${operation}-successor-immediately-before-request`, [successor]);
|
|
2723
|
+
const target = authorized.find(entry => entry.pid === pid);
|
|
2724
|
+
if (!target) {
|
|
2725
|
+
recordShutdownFailure(name, pid, 'daemon exited before exact IPC attestation');
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
requestAttestedDaemonShutdown(target, loadDaemonIpcSecret());
|
|
2729
|
+
}
|
|
2730
|
+
catch (error) {
|
|
2731
|
+
recordShutdownFailure(name, pid, error);
|
|
2732
|
+
}
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
signalDashboardResidual(name, pid);
|
|
2736
|
+
},
|
|
2737
|
+
signalInitial: targets => {
|
|
2738
|
+
let authorized;
|
|
2739
|
+
try {
|
|
2740
|
+
authorized = assertShutdownCapability(`${operation}-initial-immediately-before-batch-request`, targets);
|
|
2741
|
+
}
|
|
2742
|
+
catch (error) {
|
|
2743
|
+
for (const target of targets.filter(entry => isBotmuxDaemonProcessName(entry.name))) {
|
|
2744
|
+
recordShutdownFailure(target.name, target.pid, error);
|
|
2745
|
+
}
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
const expectedDaemonTargets = targets
|
|
2749
|
+
.filter(entry => isBotmuxDaemonProcessName(entry.name));
|
|
2750
|
+
const authorizedPids = new Set(authorized.map(entry => entry.pid));
|
|
2751
|
+
for (const target of expectedDaemonTargets) {
|
|
2752
|
+
if (!authorizedPids.has(target.pid)) {
|
|
2753
|
+
recordShutdownFailure(target.name, target.pid, 'daemon exited before initial exact IPC attestation');
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
let attempts;
|
|
2757
|
+
try {
|
|
2758
|
+
attempts = requestAttestedDaemonShutdownBatch(authorized, loadDaemonIpcSecret());
|
|
2759
|
+
}
|
|
2760
|
+
catch (error) {
|
|
2761
|
+
attempts = authorized.map(target => ({ target, ok: false, error: String(error) }));
|
|
2762
|
+
}
|
|
2763
|
+
for (const attempt of attempts) {
|
|
2764
|
+
if (!attempt.ok) {
|
|
2765
|
+
recordShutdownFailure(attempt.target.name, attempt.target.pid, attempt.error ?? 'supervisor shutdown request refused');
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
for (const target of targets.filter(entry => !isBotmuxDaemonProcessName(entry.name))) {
|
|
2769
|
+
signalDashboardResidual(target.name, target.pid);
|
|
2770
|
+
}
|
|
2771
|
+
},
|
|
2772
|
+
assertSignalAuthorityComplete: () => {
|
|
2773
|
+
if (shutdownRequestFailures.length > 0) {
|
|
2774
|
+
throw new Error(shutdownRequestFailures.join('; '));
|
|
2775
|
+
}
|
|
2776
|
+
},
|
|
2777
|
+
isAlive: pid => { try {
|
|
2778
|
+
process.kill(pid, 0);
|
|
2779
|
+
return true;
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
return false;
|
|
2783
|
+
} },
|
|
2784
|
+
now: () => Date.now(),
|
|
2785
|
+
sleep: sleepSyncMs,
|
|
2786
|
+
startOffline: (offlineEntries, timeoutMs) => {
|
|
2787
|
+
runExactPm2Starts(offlineEntries, home, Math.min(10_000, timeoutMs));
|
|
2788
|
+
},
|
|
2789
|
+
list,
|
|
2790
|
+
successorSettleMs: FLEET_SUCCESSOR_SETTLE_MS,
|
|
2791
|
+
});
|
|
2348
2792
|
}
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2793
|
+
catch (error) {
|
|
2794
|
+
if (shutdownRequestFailures.length === 0)
|
|
2795
|
+
throw error;
|
|
2796
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; `
|
|
2797
|
+
+ `shutdown request refusal(s): ${shutdownRequestFailures.join('; ')}`);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function rollbackPm2StartAttempt(operation, before, candidateNames, home = PM2_HOME) {
|
|
2801
|
+
const candidateSet = new Set(candidateNames);
|
|
2802
|
+
reconcileLatePm2StartPublication(operation, PM2_START_LATE_PUBLICATION_SETTLE_MS, FLEET_DAEMON_EXIT_WAIT_MS + PM2_START_LATE_PUBLICATION_SETTLE_MS, {
|
|
2803
|
+
now: () => Date.now(),
|
|
2804
|
+
sleep: sleepSyncMs,
|
|
2805
|
+
reconcileOnce: () => {
|
|
2806
|
+
assertNoDuplicatePm2GodDaemons(home);
|
|
2807
|
+
const fresh = readVerifiedBotmuxPm2Projection(`${operation}-rollback-read`, home);
|
|
2808
|
+
assertNoUnregisteredLiveDaemonDescriptors(`${operation}-rollback-read`, fresh);
|
|
2809
|
+
assertCanonicalUniquePm2Rows(`${operation}-rollback-read`, fresh);
|
|
2810
|
+
const attemptedRows = fresh.filter(entry => candidateSet.has(entry.name));
|
|
2811
|
+
for (const row of attemptedRows) {
|
|
2812
|
+
const priorRows = before.filter(entry => entry.name === row.name);
|
|
2813
|
+
if (priorRows.length > 1
|
|
2814
|
+
|| (priorRows.length === 1 && priorRows[0].pmId !== row.pmId)
|
|
2815
|
+
|| (priorRows.length === 1 && isLivePm2Entry(priorRows[0]))) {
|
|
2816
|
+
throw new Error(`[${operation}] cannot prove ownership of partial-launch row ${row.name}/${row.pmId}`);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
const rowsNeedingCompensation = attemptedRows.filter(row => {
|
|
2820
|
+
const prior = before.find(entry => entry.name === row.name);
|
|
2821
|
+
if (!prior)
|
|
2822
|
+
return true;
|
|
2823
|
+
return isLivePm2Entry(row) || !isFleetEntryProvenFreeOfAutorestartTimer(row);
|
|
2824
|
+
});
|
|
2825
|
+
if (rowsNeedingCompensation.length > 0) {
|
|
2826
|
+
const shutdownRows = rowsNeedingCompensation.map(entry => {
|
|
2827
|
+
if (isLivePm2Entry(entry))
|
|
2828
|
+
return { ...entry, online: true };
|
|
2829
|
+
if (isFleetEntryProvenFreeOfAutorestartTimer(entry))
|
|
2830
|
+
return entry;
|
|
2831
|
+
return { ...entry, online: false, status: 'stopped', autorestart: false };
|
|
2832
|
+
});
|
|
2833
|
+
signalAndAwaitBotmuxProcesses(shutdownRows, 'stop', home);
|
|
2834
|
+
for (const original of rowsNeedingCompensation) {
|
|
2835
|
+
const exact = revalidateExactQuiescentRowBeforeMutation(`${operation}-rollback-before-mutation`, original, rowsNeedingCompensation, home);
|
|
2836
|
+
if (!exact)
|
|
2837
|
+
continue;
|
|
2838
|
+
const existedBefore = before.some(entry => entry.name === original.name && entry.pmId === original.pmId);
|
|
2839
|
+
runPm2([existedBefore ? 'stop' : 'delete', String(exact.pmId)], false, home, 10_000);
|
|
2840
|
+
}
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2843
|
+
const restored = candidateNames.every(name => {
|
|
2844
|
+
const prior = before.find(entry => entry.name === name);
|
|
2845
|
+
const rows = attemptedRows.filter(entry => entry.name === name);
|
|
2846
|
+
if (!prior)
|
|
2847
|
+
return rows.length === 0;
|
|
2848
|
+
return rows.length === 1
|
|
2849
|
+
&& rows[0].pmId === prior.pmId
|
|
2850
|
+
&& !isLivePm2Entry(rows[0])
|
|
2851
|
+
&& isFleetEntryProvenFreeOfAutorestartTimer(rows[0]);
|
|
2852
|
+
});
|
|
2853
|
+
if (!restored) {
|
|
2854
|
+
throw new Error(`[${operation}] rollback could not prove the pre-start registry shape`);
|
|
2855
|
+
}
|
|
2856
|
+
return true;
|
|
2857
|
+
},
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
function deleteAllBotmuxProcesses(home = PM2_HOME, additionalDescriptorAuthority = []) {
|
|
2861
|
+
assertNoDuplicatePm2GodDaemons(home);
|
|
2862
|
+
let entries;
|
|
2354
2863
|
try {
|
|
2355
|
-
|
|
2356
|
-
|
|
2864
|
+
entries = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home))
|
|
2865
|
+
.filter(a => a && isBotmuxCoreProcessName(String(a.name)))
|
|
2866
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
2357
2867
|
}
|
|
2358
2868
|
catch (e) {
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
// per-name loop that try/catches each name independently.
|
|
2362
|
-
console.error(`[restart] batched pm2 delete failed, falling back to per-name: ${e instanceof Error ? e.message : e}`);
|
|
2869
|
+
throw new Error(`[restart] pm2 jlist failed; refusing to start a second fleet without a safe shutdown view: `
|
|
2870
|
+
+ `${e instanceof Error ? e.message : e}`);
|
|
2363
2871
|
}
|
|
2364
|
-
|
|
2872
|
+
assertNoUnregisteredLiveDaemonDescriptors('restart', [...entries, ...additionalDescriptorAuthority]);
|
|
2873
|
+
assertCanonicalUniquePm2Rows('restart', entries);
|
|
2874
|
+
if (entries.length === 0)
|
|
2875
|
+
return;
|
|
2876
|
+
const names = entries.map(e => e.name);
|
|
2877
|
+
signalAndAwaitBotmuxProcesses(entries, 'restart', home, additionalDescriptorAuthority);
|
|
2878
|
+
const deleteErrors = [];
|
|
2879
|
+
for (const entry of entries) {
|
|
2365
2880
|
try {
|
|
2366
|
-
|
|
2881
|
+
const exact = revalidateExactQuiescentRowBeforeMutation('restart-before-delete', entry, entries, home, additionalDescriptorAuthority);
|
|
2882
|
+
if (!exact)
|
|
2883
|
+
continue;
|
|
2884
|
+
runPm2(['delete', String(exact.pmId)], false, home, 10_000);
|
|
2367
2885
|
}
|
|
2368
2886
|
catch (e) {
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
console.error(`[restart] pm2 delete ${name} failed: ${e instanceof Error ? e.message : e}`);
|
|
2887
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
2888
|
+
deleteErrors.push(`${entry.name}: ${message}`);
|
|
2889
|
+
console.error(`[restart] pm2 delete ${entry.name}/${entry.pmId} failed: ${message}`);
|
|
2373
2890
|
}
|
|
2374
2891
|
}
|
|
2375
|
-
|
|
2376
|
-
function killPm2GodDaemon(home = PM2_HOME) {
|
|
2892
|
+
let remaining;
|
|
2377
2893
|
try {
|
|
2378
|
-
|
|
2379
|
-
|
|
2894
|
+
const targetNames = new Set(names);
|
|
2895
|
+
const freshProjection = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home))
|
|
2896
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
2897
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
2898
|
+
assertNoUnregisteredLiveDaemonDescriptors('restart-after-delete', [...freshProjection, ...additionalDescriptorAuthority]);
|
|
2899
|
+
exactQuiescentRowsForMutation('restart-after-delete', entries, freshProjection);
|
|
2900
|
+
remaining = freshProjection
|
|
2901
|
+
.filter(entry => targetNames.has(entry.name))
|
|
2902
|
+
.map(entry => entry.name);
|
|
2380
2903
|
}
|
|
2381
|
-
catch {
|
|
2382
|
-
|
|
2904
|
+
catch (err) {
|
|
2905
|
+
throw new Error(`[restart] PM2 delete verification failed; refusing to report a clean fleet: `
|
|
2906
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2383
2907
|
}
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
}
|
|
2388
|
-
catch { /* already gone */ }
|
|
2908
|
+
if (remaining.length > 0) {
|
|
2909
|
+
throw new Error(`[restart] PM2 delete left registry entries: ${[...new Set(remaining)].join(', ')}`
|
|
2910
|
+
+ (deleteErrors.length > 0 ? ` (${deleteErrors.join('; ')})` : ''));
|
|
2389
2911
|
}
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Explicit first-upgrade escape hatch for a live fleet that predates the
|
|
2915
|
+
* authenticated shutdown protocol. This intentionally bypasses descriptor
|
|
2916
|
+
* capability attestation, but only behind a named flag plus --yes. Every PM2
|
|
2917
|
+
* delete is still bound to the exact name + pm_id + PID + process birth read
|
|
2918
|
+
* before the first mutation and revalidated immediately before that mutation.
|
|
2919
|
+
*/
|
|
2920
|
+
function bootstrapDeleteAllBotmuxProcesses(operation, home = PM2_HOME) {
|
|
2921
|
+
assertNoDuplicatePm2GodDaemons(home);
|
|
2922
|
+
const readProjection = (phase) => {
|
|
2923
|
+
const apps = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home));
|
|
2924
|
+
const projection = (Array.isArray(apps) ? apps : [])
|
|
2925
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
2926
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
2927
|
+
assertCanonicalUniquePm2Rows(`${operation}-bootstrap-${phase}`, projection);
|
|
2928
|
+
return projection;
|
|
2929
|
+
};
|
|
2930
|
+
const entries = readProjection('initial');
|
|
2931
|
+
const identities = new Map();
|
|
2932
|
+
for (const entry of entries) {
|
|
2933
|
+
if (!Number.isInteger(entry.pmId)) {
|
|
2934
|
+
throw new Error(`[${operation}] bootstrap refused: ${entry.name} has no canonical PM2 id`);
|
|
2935
|
+
}
|
|
2936
|
+
if (entry.pid > 1 && isLivePm2Entry(entry)) {
|
|
2937
|
+
const identity = readSupervisorProcessStartIdentity(entry.pid);
|
|
2938
|
+
if (!identity) {
|
|
2939
|
+
throw new Error(`[${operation}] bootstrap refused: cannot bind ${entry.name}/${entry.pid} to a process birth`);
|
|
2940
|
+
}
|
|
2941
|
+
identities.set(entry.pmId, identity);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
for (const original of entries) {
|
|
2945
|
+
const fresh = readProjection(`before-delete-${original.pmId}`);
|
|
2946
|
+
const exact = fresh.filter(entry => entry.pmId === original.pmId);
|
|
2947
|
+
if (exact.length !== 1 || exact[0].name !== original.name) {
|
|
2948
|
+
throw new Error(`[${operation}] bootstrap refused: PM2 row ${original.name}/${original.pmId} changed before delete`);
|
|
2949
|
+
}
|
|
2950
|
+
const current = exact[0];
|
|
2951
|
+
const expectedBirth = identities.get(original.pmId);
|
|
2952
|
+
if (expectedBirth) {
|
|
2953
|
+
const currentBirth = current.pid > 1
|
|
2954
|
+
? readSupervisorProcessStartIdentity(current.pid)
|
|
2955
|
+
: undefined;
|
|
2956
|
+
if (current.pid !== original.pid
|
|
2957
|
+
|| !isLivePm2Entry(current)
|
|
2958
|
+
|| currentBirth !== expectedBirth) {
|
|
2959
|
+
throw new Error(`[${operation}] bootstrap refused: process generation changed for `
|
|
2960
|
+
+ `${original.name}/${original.pmId}`);
|
|
2961
|
+
}
|
|
2394
2962
|
}
|
|
2395
|
-
|
|
2963
|
+
else if (isLivePm2Entry(current)) {
|
|
2964
|
+
throw new Error(`[${operation}] bootstrap refused: dormant PM2 row ${original.name}/${original.pmId} became live`);
|
|
2965
|
+
}
|
|
2966
|
+
runPm2(['delete', String(current.pmId)], false, home, FLEET_DAEMON_EXIT_WAIT_MS);
|
|
2967
|
+
}
|
|
2968
|
+
const remaining = readProjection('after-delete');
|
|
2969
|
+
if (remaining.length > 0) {
|
|
2970
|
+
throw new Error(`[${operation}] bootstrap retirement incomplete: `
|
|
2971
|
+
+ remaining.map(entry => `${entry.name}/${entry.pmId}`).join(', '));
|
|
2396
2972
|
}
|
|
2397
2973
|
}
|
|
2398
2974
|
/**
|
|
@@ -2402,57 +2978,108 @@ function killPm2GodDaemon(home = PM2_HOME) {
|
|
|
2402
2978
|
* truth. Only touches processes named `botmux` or `botmux-*` — the user's
|
|
2403
2979
|
* unrelated pm2 apps are left untouched. No-op on fresh installs.
|
|
2404
2980
|
*/
|
|
2405
|
-
function cleanupLegacyPm2() {
|
|
2981
|
+
function cleanupLegacyPm2(bootstrapOperation) {
|
|
2406
2982
|
const legacyHome = join(homedir(), '.pm2');
|
|
2407
2983
|
if (legacyHome === PM2_HOME)
|
|
2408
2984
|
return false;
|
|
2409
|
-
const
|
|
2410
|
-
if (
|
|
2411
|
-
return false;
|
|
2412
|
-
let legacyPid = 0;
|
|
2413
|
-
try {
|
|
2414
|
-
legacyPid = parseInt(readFileSync(legacyPidFile, 'utf-8').trim(), 10);
|
|
2415
|
-
}
|
|
2416
|
-
catch {
|
|
2417
|
-
return false;
|
|
2418
|
-
}
|
|
2419
|
-
if (!legacyPid)
|
|
2420
|
-
return false;
|
|
2421
|
-
// If the legacy daemon isn't alive anymore there's nothing to clean.
|
|
2422
|
-
try {
|
|
2423
|
-
process.kill(legacyPid, 0);
|
|
2424
|
-
}
|
|
2425
|
-
catch {
|
|
2985
|
+
const legacyGodPids = listPm2GodDaemonPids(legacyHome);
|
|
2986
|
+
if (legacyGodPids.length === 0)
|
|
2426
2987
|
return false;
|
|
2988
|
+
assertNoDuplicatePm2GodDaemons(legacyHome);
|
|
2989
|
+
preflightNodeSanity(legacyHome);
|
|
2990
|
+
assertNoDuplicatePm2GodDaemons(legacyHome);
|
|
2991
|
+
if (bootstrapOperation)
|
|
2992
|
+
bootstrapDeleteAllBotmuxProcesses(bootstrapOperation, legacyHome);
|
|
2993
|
+
else {
|
|
2994
|
+
const currentProjection = readVerifiedBotmuxPm2Projection('legacy-cleanup-authority');
|
|
2995
|
+
deleteAllBotmuxProcesses(legacyHome, currentProjection);
|
|
2427
2996
|
}
|
|
2428
|
-
deleteAllBotmuxProcesses(legacyHome);
|
|
2429
2997
|
return true;
|
|
2430
2998
|
}
|
|
2431
2999
|
async function cmdStop() {
|
|
2432
3000
|
const includePluginServices = process.argv.includes('--with-plugin');
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
3001
|
+
const bootstrapShutdownProtocol = process.argv.includes('--bootstrap-shutdown-protocol');
|
|
3002
|
+
const bootstrapConfirmed = process.argv.includes('--yes');
|
|
3003
|
+
if (bootstrapShutdownProtocol && !bootstrapConfirmed) {
|
|
3004
|
+
throw new Error('[stop] --bootstrap-shutdown-protocol requires --yes after confirming every Session/Riff workload is idle');
|
|
3005
|
+
}
|
|
3006
|
+
ensureConfigDir();
|
|
3007
|
+
await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
|
|
3008
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3009
|
+
cleanupLegacyPm2(bootstrapShutdownProtocol ? 'stop' : undefined);
|
|
3010
|
+
if (bootstrapShutdownProtocol) {
|
|
3011
|
+
bootstrapDeleteAllBotmuxProcesses('stop');
|
|
3012
|
+
cleanupStaleDaemonDescriptors();
|
|
3013
|
+
if (includePluginServices) {
|
|
3014
|
+
await stopPluginServicesForCli(undefined, { autoOnly: true });
|
|
3015
|
+
}
|
|
3016
|
+
console.log('daemon 已通过一次性 shutdown-protocol bootstrap 安全边界停止。');
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
let entries;
|
|
3020
|
+
try {
|
|
3021
|
+
entries = parsePm2JlistOutputStrict(pm2Capture(['jlist']))
|
|
3022
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
3023
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
3024
|
+
}
|
|
3025
|
+
catch (err) {
|
|
3026
|
+
throw new Error(`[stop] pm2 jlist failed; refusing an unverified stop: `
|
|
3027
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
3028
|
+
}
|
|
3029
|
+
assertNoUnregisteredLiveDaemonDescriptors('stop', entries);
|
|
3030
|
+
assertCanonicalUniquePm2Rows('stop', entries);
|
|
3031
|
+
if (entries.length === 0) {
|
|
3032
|
+
cleanupStaleDaemonDescriptors();
|
|
3033
|
+
if (includePluginServices) {
|
|
3034
|
+
await stopPluginServicesForCli(undefined, { autoOnly: true });
|
|
2446
3035
|
}
|
|
3036
|
+
console.log('daemon 未在运行。');
|
|
3037
|
+
return;
|
|
2447
3038
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
3039
|
+
signalAndAwaitBotmuxProcesses(entries, 'stop');
|
|
3040
|
+
const beforeStop = readVerifiedBotmuxPm2Projection('stop-before-registry-mutation');
|
|
3041
|
+
assertNoUnregisteredLiveDaemonDescriptors('stop-before-registry-mutation', beforeStop);
|
|
3042
|
+
exactQuiescentRowsForMutation('stop-before-registry-mutation', entries, beforeStop);
|
|
3043
|
+
const stopErrors = [];
|
|
3044
|
+
for (const entry of entries) {
|
|
3045
|
+
try {
|
|
3046
|
+
const exact = revalidateExactQuiescentRowBeforeMutation('stop-immediately-before-registry-mutation', entry, entries);
|
|
3047
|
+
if (!exact)
|
|
3048
|
+
continue;
|
|
3049
|
+
runPm2(['stop', String(exact.pmId)], false, PM2_HOME, 10_000);
|
|
3050
|
+
}
|
|
3051
|
+
catch (err) {
|
|
3052
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3053
|
+
stopErrors.push(`${entry.name}: ${message}`);
|
|
3054
|
+
console.error(`[stop] pm2 stop ${entry.name} failed: ${message}`);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
let nonStoppedResidual;
|
|
3058
|
+
try {
|
|
3059
|
+
const targetNames = new Set(entries.map(entry => entry.name));
|
|
3060
|
+
const freshProjection = parsePm2JlistOutputStrict(pm2Capture(['jlist']))
|
|
3061
|
+
.filter(app => app && isBotmuxCoreProcessName(String(app.name)))
|
|
3062
|
+
.map(toBotmuxPm2ProcessEntry);
|
|
3063
|
+
assertNoUnregisteredLiveDaemonDescriptors('stop-after-registry-mutation', freshProjection);
|
|
3064
|
+
exactQuiescentRowsForMutation('stop-after-registry-mutation', entries, freshProjection);
|
|
3065
|
+
nonStoppedResidual = freshProjection
|
|
3066
|
+
.filter(entry => targetNames.has(entry.name) && entry.status !== 'stopped')
|
|
3067
|
+
.map(entry => `${entry.name}:${entry.status ?? 'unknown'}`);
|
|
3068
|
+
}
|
|
3069
|
+
catch (err) {
|
|
3070
|
+
throw new Error(`[stop] PM2 stop verification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3071
|
+
}
|
|
3072
|
+
if (stopErrors.length > 0 || nonStoppedResidual.length > 0) {
|
|
3073
|
+
throw new Error(`[stop] PM2 registry mutation incomplete`
|
|
3074
|
+
+ (nonStoppedResidual.length > 0
|
|
3075
|
+
? ` (not stopped: ${[...new Set(nonStoppedResidual)].join(', ')})`
|
|
3076
|
+
: '')
|
|
3077
|
+
+ (stopErrors.length > 0 ? ` (errors: ${stopErrors.join('; ')})` : ''));
|
|
3078
|
+
}
|
|
3079
|
+
cleanupStaleDaemonDescriptors();
|
|
3080
|
+
if (includePluginServices)
|
|
3081
|
+
await stopPluginServicesForCli(undefined, { autoOnly: true });
|
|
3082
|
+
}, { maxWaitMs: 5_000 });
|
|
2456
3083
|
}
|
|
2457
3084
|
async function cmdRestart() {
|
|
2458
3085
|
const restartLeaseId = process.env.BOTMUX_RESTART_LEASE_ID;
|
|
@@ -2475,70 +3102,157 @@ async function cmdRestart() {
|
|
|
2475
3102
|
process.exit(1);
|
|
2476
3103
|
}
|
|
2477
3104
|
ensureConfigDir();
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
3105
|
+
await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
|
|
3106
|
+
const includePm2 = process.argv.includes('--include-pm2');
|
|
3107
|
+
const includePluginServices = process.argv.includes('--with-plugin');
|
|
3108
|
+
const bootstrapShutdownProtocol = process.argv.includes('--bootstrap-shutdown-protocol');
|
|
3109
|
+
const bootstrapConfirmed = process.argv.includes('--yes');
|
|
3110
|
+
if (bootstrapShutdownProtocol && !bootstrapConfirmed) {
|
|
3111
|
+
throw new Error('[restart] --bootstrap-shutdown-protocol requires --yes after confirming every Session/Riff workload is idle');
|
|
3112
|
+
}
|
|
3113
|
+
if (bootstrapShutdownProtocol && includePm2) {
|
|
3114
|
+
throw new Error('[restart] --bootstrap-shutdown-protocol cannot be combined with --include-pm2');
|
|
3115
|
+
}
|
|
3116
|
+
if (includePm2) {
|
|
3117
|
+
assertIncludePm2RestartAdmission(listPm2GodDaemonPids());
|
|
3118
|
+
}
|
|
3119
|
+
const restartIntentDir = resolveDataDir();
|
|
3120
|
+
let stagedRestartIntent = null;
|
|
3121
|
+
try {
|
|
3122
|
+
stagedRestartIntent = consumeRestartIntentTo(restartIntentDir, Date.now());
|
|
3123
|
+
}
|
|
3124
|
+
catch { /* intent reporting is best-effort */ }
|
|
3125
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3126
|
+
preflightNodeSanity();
|
|
3127
|
+
await ensureSystemDependencies();
|
|
3128
|
+
cleanupLegacyPm2(bootstrapShutdownProtocol ? 'restart' : undefined);
|
|
3129
|
+
if (bootstrapShutdownProtocol)
|
|
3130
|
+
bootstrapDeleteAllBotmuxProcesses('restart');
|
|
3131
|
+
else
|
|
3132
|
+
deleteAllBotmuxProcesses();
|
|
3133
|
+
if (includePluginServices)
|
|
3134
|
+
await stopPluginServicesForCli(undefined, { autoOnly: true });
|
|
3135
|
+
cleanupStaleDaemonDescriptors();
|
|
3136
|
+
const retiredProjection = readVerifiedBotmuxPm2Projection('restart-start');
|
|
3137
|
+
assertNoUnregisteredLiveDaemonDescriptors('restart-start', retiredProjection);
|
|
3138
|
+
if (retiredProjection.length > 0) {
|
|
3139
|
+
throw new Error(`[restart-start] new PM2 core row(s) appeared after verified retirement: `
|
|
3140
|
+
+ retiredProjection.map(entry => `${entry.name}:${entry.pid}`).join(', '));
|
|
3141
|
+
}
|
|
3142
|
+
await withFileLock(BOTS_JSON_FILE, async () => {
|
|
3143
|
+
const restartBots = loadBotsJson();
|
|
3144
|
+
const cfg = ecosystemConfig(restartBots);
|
|
3145
|
+
const restartAttemptId = randomBytes(16).toString('hex');
|
|
3146
|
+
let restartIntentPrepared = false;
|
|
3147
|
+
try {
|
|
3148
|
+
const now = Date.now();
|
|
3149
|
+
writeRestartAttemptIntentTo(restartIntentDir, stagedRestartIntent ?? { kind: 'manual', at: new Date(now).toISOString() }, now, restartAttemptId);
|
|
3150
|
+
restartIntentPrepared = true;
|
|
3151
|
+
}
|
|
3152
|
+
catch { /* breadcrumb is best-effort */ }
|
|
3153
|
+
try {
|
|
3154
|
+
const configuredNames = configuredCoreProcessNames(restartBots);
|
|
3155
|
+
const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
|
|
3156
|
+
runBoundedPm2StartTransaction('restart-start', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
|
|
3157
|
+
start: timeoutMs => {
|
|
3158
|
+
assertBotsConfigSnapshotUnchanged('restart-start', restartBots);
|
|
3159
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3160
|
+
preflightNodeSanity();
|
|
3161
|
+
runPm2(['start', cfg], true, PM2_HOME, timeoutMs);
|
|
3162
|
+
},
|
|
3163
|
+
verifyFresh: timeoutMs => readAndAssertConfiguredFleetOnline('restart-after-launch', configuredNames, PM2_HOME, timeoutMs),
|
|
3164
|
+
rollback: () => rollbackPm2StartAttempt('restart-start', retiredProjection, configuredNames),
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
catch (err) {
|
|
3168
|
+
try {
|
|
3169
|
+
removeRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
|
|
3170
|
+
}
|
|
3171
|
+
catch { /* best-effort */ }
|
|
3172
|
+
throw err;
|
|
3173
|
+
}
|
|
3174
|
+
if (restartIntentPrepared) {
|
|
3175
|
+
let committed = false;
|
|
3176
|
+
try {
|
|
3177
|
+
committed = commitRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
|
|
3178
|
+
}
|
|
3179
|
+
catch { /* best-effort after verified healthy fleet */ }
|
|
3180
|
+
if (!committed) {
|
|
3181
|
+
try {
|
|
3182
|
+
removeRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
|
|
3183
|
+
}
|
|
3184
|
+
catch { /* best-effort */ }
|
|
3185
|
+
console.warn('⚠️ daemon 已完整启动,但重启摘要凭据未能提交;本次不会发送重启摘要。');
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}, { maxWaitMs: 5_000 });
|
|
3189
|
+
await reconcilePluginServicesForCli(undefined, { autoOnly: true });
|
|
3190
|
+
if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
|
|
3191
|
+
console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
|
|
3192
|
+
}
|
|
3193
|
+
await printDashboardHintWithRetry();
|
|
3194
|
+
}, { maxWaitMs: 5_000 });
|
|
2513
3195
|
}
|
|
2514
3196
|
/** Observe botmux PM2 rows. Unknown state is never represented as absence. */
|
|
2515
3197
|
function listBotmuxPm2Apps() {
|
|
2516
3198
|
return inspectBotmuxPm2Apps(() => parsePm2JlistOutput(pm2Capture(['jlist'])));
|
|
2517
3199
|
}
|
|
2518
|
-
function
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
3200
|
+
function retireExactBotmuxProcess(operation, target, fullProjection) {
|
|
3201
|
+
const peers = fullProjection.filter(entry => entry.name !== target.name);
|
|
3202
|
+
signalAndAwaitBotmuxProcesses([target], 'stop', PM2_HOME, peers);
|
|
3203
|
+
const exact = revalidateExactQuiescentRowBeforeMutation(`${operation}-before-delete`, target, [target], PM2_HOME, peers);
|
|
3204
|
+
if (exact) {
|
|
3205
|
+
runPm2(['delete', String(exact.pmId)], false, PM2_HOME, 10_000);
|
|
2523
3206
|
}
|
|
2524
|
-
const
|
|
2525
|
-
|
|
2526
|
-
if (
|
|
2527
|
-
|
|
3207
|
+
const fresh = readVerifiedBotmuxPm2Projection(`${operation}-after-delete`);
|
|
3208
|
+
assertNoUnregisteredLiveDaemonDescriptors(`${operation}-after-delete`, fresh);
|
|
3209
|
+
if (fresh.some(entry => entry.name === target.name)) {
|
|
3210
|
+
throw new Error(`[${operation}] PM2 row ${target.name} is still present after exact delete`);
|
|
2528
3211
|
}
|
|
2529
|
-
|
|
2530
|
-
|
|
3212
|
+
}
|
|
3213
|
+
async function ensureBotDaemonStopped(appId, _opts = {}) {
|
|
3214
|
+
ensureConfigDir();
|
|
3215
|
+
try {
|
|
3216
|
+
return await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => (withFileLock(BOTS_JSON_FILE, async () => {
|
|
3217
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3218
|
+
preflightNodeSanity();
|
|
3219
|
+
cleanupLegacyPm2();
|
|
3220
|
+
const bots = loadBotsJson();
|
|
3221
|
+
const index = bots.findIndex(b => b?.larkAppId === appId);
|
|
3222
|
+
if (index < 0) {
|
|
3223
|
+
return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
|
|
3224
|
+
}
|
|
3225
|
+
const processName = botProcessName(bots[index], index, PM2_NAME);
|
|
3226
|
+
const inspection = listBotmuxPm2Apps();
|
|
3227
|
+
if (!inspection.ok) {
|
|
3228
|
+
return { ok: false, reason: 'pm2_error', message: inspection.message };
|
|
3229
|
+
}
|
|
3230
|
+
const named = inspection.apps.filter(app => app.name === processName);
|
|
3231
|
+
if (named.length > 0 && !named.every(app => isExactPm2BotActivationReceipt(app, processName, index, appId))) {
|
|
3232
|
+
return {
|
|
3233
|
+
ok: false,
|
|
3234
|
+
reason: 'pm2_error',
|
|
3235
|
+
message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
3238
|
+
const projection = readVerifiedBotmuxPm2Projection('stop-bot');
|
|
3239
|
+
assertNoUnregisteredLiveDaemonDescriptors('stop-bot', projection);
|
|
3240
|
+
assertCanonicalUniquePm2Rows('stop-bot', projection);
|
|
3241
|
+
const target = projection.find(entry => entry.name === processName);
|
|
3242
|
+
if (!target) {
|
|
3243
|
+
return { ok: true, state: 'already-stopped', processName };
|
|
3244
|
+
}
|
|
3245
|
+
retireExactBotmuxProcess('stop-bot', target, projection);
|
|
3246
|
+
return { ok: true, state: 'stopped', processName };
|
|
3247
|
+
}, { maxWaitMs: 5_000 })), { maxWaitMs: 5_000 });
|
|
3248
|
+
}
|
|
3249
|
+
catch (err) {
|
|
2531
3250
|
return {
|
|
2532
3251
|
ok: false,
|
|
2533
3252
|
reason: 'pm2_error',
|
|
2534
|
-
message:
|
|
3253
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2535
3254
|
};
|
|
2536
3255
|
}
|
|
2537
|
-
return stopExactPm2Process(processName, listBotmuxPm2Apps, exactName => {
|
|
2538
|
-
// Delete only this exact PM2 entry. The later start-bot command recreates
|
|
2539
|
-
// it from the current ecosystem config after activation is acknowledged.
|
|
2540
|
-
runPm2(['delete', exactName], !opts.quiet);
|
|
2541
|
-
});
|
|
2542
3256
|
}
|
|
2543
3257
|
/**
|
|
2544
3258
|
* Bring a SINGLE bot's daemon online without touching any other bot's process.
|
|
@@ -2559,99 +3273,126 @@ function ensureBotDaemonStopped(appId, opts = {}) {
|
|
|
2559
3273
|
* NOT start a lone bot; that case belongs to `botmux start`, which brings up the
|
|
2560
3274
|
* entire ecosystem (all bots + dashboard).
|
|
2561
3275
|
*/
|
|
2562
|
-
function ensureBotDaemonStarted(appId, opts = {}) {
|
|
2563
|
-
|
|
2564
|
-
const index = bots.findIndex(b => b?.larkAppId === appId);
|
|
2565
|
-
if (index < 0) {
|
|
2566
|
-
return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
|
|
2567
|
-
}
|
|
2568
|
-
const bot = bots[index];
|
|
2569
|
-
if (bot?.activationPending === true) {
|
|
2570
|
-
return { ok: false, reason: 'not_ready', message: `appId ${appId} is still activation pending` };
|
|
2571
|
-
}
|
|
2572
|
-
const activationStarting = bot?.activationStarting;
|
|
2573
|
-
const activationCommitted = bot?.activationCommitted;
|
|
2574
|
-
const activationDeactivating = bot?.activationDeactivating;
|
|
2575
|
-
if (activationDeactivating !== undefined) {
|
|
2576
|
-
return { ok: false, reason: 'not_ready', message: `appId ${appId} is still deactivating` };
|
|
2577
|
-
}
|
|
2578
|
-
if (activationStarting !== undefined && activationCommitted !== undefined) {
|
|
2579
|
-
return { ok: false, reason: 'not_ready', message: `appId ${appId} has conflicting activation markers` };
|
|
2580
|
-
}
|
|
2581
|
-
const activationMarker = activationStarting ?? activationCommitted;
|
|
2582
|
-
const activationJobId = (activationMarker
|
|
2583
|
-
&& typeof activationMarker === 'object'
|
|
2584
|
-
&& !Array.isArray(activationMarker)
|
|
2585
|
-
&& activationMarker.appId === appId
|
|
2586
|
-
&& typeof activationMarker.jobId === 'string'
|
|
2587
|
-
&& activationMarker.jobId)
|
|
2588
|
-
? activationMarker.jobId
|
|
2589
|
-
: undefined;
|
|
2590
|
-
if (activationMarker !== undefined && !activationJobId) {
|
|
2591
|
-
return { ok: false, reason: 'not_ready', message: `appId ${appId} has an invalid activation marker` };
|
|
2592
|
-
}
|
|
2593
|
-
const processName = botProcessName(bot, index, PM2_NAME);
|
|
2594
|
-
const running = listBotmuxPm2Apps();
|
|
2595
|
-
if (!running.ok) {
|
|
2596
|
-
return { ok: false, reason: 'pm2_error', message: running.message };
|
|
2597
|
-
}
|
|
2598
|
-
if (running.apps.length === 0) {
|
|
2599
|
-
return { ok: false, reason: 'fleet_down', message: 'daemon 未在运行,请先 botmux start' };
|
|
2600
|
-
}
|
|
2601
|
-
const named = running.apps.filter(app => app.name === processName);
|
|
2602
|
-
if (!activationJobId && named.some(app => (isExactPm2BotActivationReceipt(app, processName, index, appId) && app.online))) {
|
|
2603
|
-
return { ok: true, state: 'already-online', processName };
|
|
2604
|
-
}
|
|
2605
|
-
if (activationJobId && named.length > 0) {
|
|
2606
|
-
const disposition = managedActivationPm2Disposition(named, processName, index, appId, activationJobId);
|
|
2607
|
-
if (disposition === 'acknowledged') {
|
|
2608
|
-
return { ok: true, state: 'already-online', processName };
|
|
2609
|
-
}
|
|
2610
|
-
if (disposition === 'identity_mismatch') {
|
|
2611
|
-
return {
|
|
2612
|
-
ok: false,
|
|
2613
|
-
reason: 'pm2_error',
|
|
2614
|
-
message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
|
|
2615
|
-
};
|
|
2616
|
-
}
|
|
2617
|
-
}
|
|
2618
|
-
else if (named.length > 0) {
|
|
2619
|
-
return {
|
|
2620
|
-
ok: false,
|
|
2621
|
-
reason: 'pm2_error',
|
|
2622
|
-
message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
|
|
2623
|
-
};
|
|
2624
|
-
}
|
|
2625
|
-
if (activationJobId && named.length > 0) {
|
|
2626
|
-
// A same App/index process without this durable receipt can be an old
|
|
2627
|
-
// daemon already consuming Feishu traffic. Delete it and prove absence
|
|
2628
|
-
// before the gated activation daemon is allowed to become the PM2 ACK.
|
|
2629
|
-
const stopped = stopExactPm2Process(processName, listBotmuxPm2Apps, exactName => runPm2(['delete', exactName], !opts.quiet));
|
|
2630
|
-
if (!stopped.ok) {
|
|
2631
|
-
return { ok: false, reason: 'pm2_error', message: stopped.message };
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
const cfg = ecosystemConfig(appId);
|
|
3276
|
+
async function ensureBotDaemonStarted(appId, opts = {}) {
|
|
3277
|
+
ensureConfigDir();
|
|
2635
3278
|
try {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
3279
|
+
return await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => (withFileLock(BOTS_JSON_FILE, async () => {
|
|
3280
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3281
|
+
preflightNodeSanity();
|
|
3282
|
+
cleanupLegacyPm2();
|
|
3283
|
+
const bots = loadBotsJson();
|
|
3284
|
+
const index = bots.findIndex(b => b?.larkAppId === appId);
|
|
3285
|
+
if (index < 0) {
|
|
3286
|
+
return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
|
|
3287
|
+
}
|
|
3288
|
+
const bot = bots[index];
|
|
3289
|
+
if (bot?.activationPending === true) {
|
|
3290
|
+
return { ok: false, reason: 'not_ready', message: `appId ${appId} is still activation pending` };
|
|
3291
|
+
}
|
|
3292
|
+
const activationStarting = bot?.activationStarting;
|
|
3293
|
+
const activationCommitted = bot?.activationCommitted;
|
|
3294
|
+
const activationDeactivating = bot?.activationDeactivating;
|
|
3295
|
+
if (activationDeactivating !== undefined) {
|
|
3296
|
+
return { ok: false, reason: 'not_ready', message: `appId ${appId} is still deactivating` };
|
|
3297
|
+
}
|
|
3298
|
+
if (activationStarting !== undefined && activationCommitted !== undefined) {
|
|
3299
|
+
return { ok: false, reason: 'not_ready', message: `appId ${appId} has conflicting activation markers` };
|
|
3300
|
+
}
|
|
3301
|
+
const activationMarker = activationStarting ?? activationCommitted;
|
|
3302
|
+
const activationJobId = (activationMarker
|
|
3303
|
+
&& typeof activationMarker === 'object'
|
|
3304
|
+
&& !Array.isArray(activationMarker)
|
|
3305
|
+
&& activationMarker.appId === appId
|
|
3306
|
+
&& typeof activationMarker.jobId === 'string'
|
|
3307
|
+
&& activationMarker.jobId)
|
|
3308
|
+
? String(activationMarker.jobId)
|
|
3309
|
+
: undefined;
|
|
3310
|
+
if (activationMarker !== undefined && !activationJobId) {
|
|
3311
|
+
return { ok: false, reason: 'not_ready', message: `appId ${appId} has an invalid activation marker` };
|
|
3312
|
+
}
|
|
3313
|
+
const processName = botProcessName(bot, index, PM2_NAME);
|
|
3314
|
+
const configuredNames = configuredCoreProcessNames(bots, activationJobId ? appId : undefined);
|
|
3315
|
+
const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
|
|
3316
|
+
const inspection = listBotmuxPm2Apps();
|
|
3317
|
+
if (!inspection.ok) {
|
|
3318
|
+
return { ok: false, reason: 'pm2_error', message: inspection.message };
|
|
3319
|
+
}
|
|
3320
|
+
let projection = readVerifiedBotmuxPm2Projection('start-bot');
|
|
3321
|
+
assertNoUnregisteredLiveDaemonDescriptors('start-bot', projection);
|
|
3322
|
+
assertCanonicalUniquePm2Rows('start-bot', projection);
|
|
3323
|
+
const namedInspection = inspection.apps.filter(app => app.name === processName);
|
|
3324
|
+
if (activationJobId && namedInspection.length > 0) {
|
|
3325
|
+
const disposition = managedActivationPm2Disposition(namedInspection, processName, index, appId, activationJobId);
|
|
3326
|
+
if (disposition === 'identity_mismatch') {
|
|
3327
|
+
return {
|
|
3328
|
+
ok: false,
|
|
3329
|
+
reason: 'pm2_error',
|
|
3330
|
+
message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
if (disposition === 'acknowledged') {
|
|
3334
|
+
readAndAssertConfiguredFleetOnline('start-bot-already-online-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
|
|
3335
|
+
return { ok: true, state: 'already-online', processName };
|
|
3336
|
+
}
|
|
3337
|
+
const target = projection.find(entry => entry.name === processName);
|
|
3338
|
+
if (!target) {
|
|
3339
|
+
throw new Error(`[start-bot] PM2 identity view disagrees about ${processName}`);
|
|
3340
|
+
}
|
|
3341
|
+
retireExactBotmuxProcess('start-bot-replace', target, projection);
|
|
3342
|
+
projection = readVerifiedBotmuxPm2Projection('start-bot-after-replace');
|
|
3343
|
+
}
|
|
3344
|
+
else if (namedInspection.length > 0) {
|
|
3345
|
+
if (!namedInspection.every(app => isExactPm2BotActivationReceipt(app, processName, index, appId))) {
|
|
3346
|
+
return {
|
|
3347
|
+
ok: false,
|
|
3348
|
+
reason: 'pm2_error',
|
|
3349
|
+
message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
const admission = classifyStartBotFleetAdmission('start-bot', projection, configuredNames, processName, pid => { try {
|
|
3354
|
+
process.kill(pid, 0);
|
|
3355
|
+
return true;
|
|
3356
|
+
}
|
|
3357
|
+
catch {
|
|
3358
|
+
return false;
|
|
3359
|
+
} });
|
|
3360
|
+
if (admission.state === 'already-online') {
|
|
3361
|
+
readAndAssertConfiguredFleetOnline('start-bot-already-online-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
|
|
3362
|
+
return { ok: true, state: 'already-online', processName };
|
|
3363
|
+
}
|
|
3364
|
+
if (admission.state === 'fleet-down') {
|
|
3365
|
+
return { ok: false, reason: 'fleet_down', message: 'daemon 未在运行,请先 botmux start' };
|
|
3366
|
+
}
|
|
3367
|
+
const beforeStart = projection;
|
|
3368
|
+
const cfg = ecosystemConfig(bots, activationJobId ? appId : undefined);
|
|
3369
|
+
runBoundedPm2StartTransaction('start-bot', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
|
|
3370
|
+
start: timeoutMs => {
|
|
3371
|
+
assertBotsConfigSnapshotUnchanged('start-bot', bots);
|
|
3372
|
+
assertNoDuplicatePm2GodDaemons();
|
|
3373
|
+
preflightNodeSanity();
|
|
3374
|
+
runPm2(['start', cfg, '--only', processName], !opts.quiet, PM2_HOME, timeoutMs);
|
|
3375
|
+
},
|
|
3376
|
+
verifyFresh: timeoutMs => {
|
|
3377
|
+
const fresh = readAndAssertConfiguredFleetOnline('start-bot-after-launch', configuredNames, PM2_HOME, timeoutMs);
|
|
3378
|
+
const acknowledged = listBotmuxPm2Apps();
|
|
3379
|
+
if (!acknowledged.ok || !acknowledged.apps.some(app => (isExactPm2BotActivationReceipt(app, processName, index, appId, activationJobId) && app.online))) {
|
|
3380
|
+
throw new Error(`pm2 start did not acknowledge ${processName} at bots.json slot ${index} / ${appId}`);
|
|
3381
|
+
}
|
|
3382
|
+
return fresh;
|
|
3383
|
+
},
|
|
3384
|
+
rollback: () => rollbackPm2StartAttempt('start-bot', beforeStart, [processName]),
|
|
3385
|
+
});
|
|
3386
|
+
return { ok: true, state: 'started', processName };
|
|
3387
|
+
}, { maxWaitMs: 5_000 })), { maxWaitMs: 5_000 });
|
|
2646
3388
|
}
|
|
2647
|
-
|
|
3389
|
+
catch (err) {
|
|
2648
3390
|
return {
|
|
2649
3391
|
ok: false,
|
|
2650
3392
|
reason: 'pm2_error',
|
|
2651
|
-
message:
|
|
3393
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2652
3394
|
};
|
|
2653
3395
|
}
|
|
2654
|
-
return { ok: true, state: 'started', processName };
|
|
2655
3396
|
}
|
|
2656
3397
|
/**
|
|
2657
3398
|
* `botmux start-bot <larkAppId>` — bring one freshly-added bot online without a
|
|
@@ -2671,7 +3412,7 @@ async function cmdStartBot(argv) {
|
|
|
2671
3412
|
process.exit(1);
|
|
2672
3413
|
}
|
|
2673
3414
|
ensureConfigDir();
|
|
2674
|
-
const r = ensureBotDaemonStarted(appId, { quiet: wantsJson });
|
|
3415
|
+
const r = await ensureBotDaemonStarted(appId, { quiet: wantsJson });
|
|
2675
3416
|
if (wantsJson) {
|
|
2676
3417
|
console.log(JSON.stringify(r, null, 2));
|
|
2677
3418
|
if (!r.ok)
|
|
@@ -2706,7 +3447,7 @@ async function cmdStopBot(argv) {
|
|
|
2706
3447
|
process.exit(1);
|
|
2707
3448
|
}
|
|
2708
3449
|
ensureConfigDir();
|
|
2709
|
-
const r = ensureBotDaemonStopped(appId, { quiet: wantsJson });
|
|
3450
|
+
const r = await ensureBotDaemonStopped(appId, { quiet: wantsJson });
|
|
2710
3451
|
if (wantsJson) {
|
|
2711
3452
|
console.log(JSON.stringify(r, null, 2));
|
|
2712
3453
|
if (!r.ok)
|
|
@@ -2725,8 +3466,8 @@ async function cmdStopBot(argv) {
|
|
|
2725
3466
|
/** Print the post-add "next step" line for interactive setup: auto-start the new
|
|
2726
3467
|
* bot's own daemon when the fleet is up (no fleet-wide restart), else fall back
|
|
2727
3468
|
* to the botmux start / restart hint. */
|
|
2728
|
-
function printAddBotLiveHint(appId) {
|
|
2729
|
-
const live = ensureBotDaemonStarted(appId);
|
|
3469
|
+
async function printAddBotLiveHint(appId) {
|
|
3470
|
+
const live = await ensureBotDaemonStarted(appId);
|
|
2730
3471
|
if (live.ok) {
|
|
2731
3472
|
console.log(`✅ 已自动上线(${live.processName}),无需重启其它机器人。\n`);
|
|
2732
3473
|
}
|
|
@@ -2815,7 +3556,6 @@ function warnIfLegacyBotmuxAlive() {
|
|
|
2815
3556
|
catch { /* ignore */ }
|
|
2816
3557
|
}
|
|
2817
3558
|
function cmdLogs() {
|
|
2818
|
-
killDuplicatePm2GodDaemons();
|
|
2819
3559
|
warnIfLegacyBotmuxAlive();
|
|
2820
3560
|
const lines = process.argv.includes('--lines')
|
|
2821
3561
|
? process.argv[process.argv.indexOf('--lines') + 1] || '50'
|
|
@@ -2854,7 +3594,6 @@ function cmdLogs() {
|
|
|
2854
3594
|
child.on('exit', code => process.exit(code ?? 0));
|
|
2855
3595
|
}
|
|
2856
3596
|
function cmdStatus() {
|
|
2857
|
-
killDuplicatePm2GodDaemons();
|
|
2858
3597
|
warnIfLegacyBotmuxAlive();
|
|
2859
3598
|
runPm2(['status']);
|
|
2860
3599
|
}
|
|
@@ -3028,86 +3767,339 @@ function loadSessions() {
|
|
|
3028
3767
|
if (process.env.BOTMUX_LARK_APP_ID)
|
|
3029
3768
|
loadPerBot(process.env.BOTMUX_LARK_APP_ID);
|
|
3030
3769
|
}
|
|
3031
|
-
//
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3770
|
+
// Deliberately read-only. Older CLIs opportunistically migrated legacy rows
|
|
3771
|
+
// here, which made even `botmux list` a whole-file writer capable of racing a
|
|
3772
|
+
// daemon ledger save. Durable migration belongs to daemon startup under the
|
|
3773
|
+
// session-store lock; this generic discovery path only composes snapshots.
|
|
3774
|
+
return sessions;
|
|
3775
|
+
}
|
|
3776
|
+
function loadSessionFresh(session) {
|
|
3777
|
+
return loadSessions().get(session.sessionId);
|
|
3778
|
+
}
|
|
3779
|
+
/** Save a single session back to its appropriate file based on larkAppId. */
|
|
3780
|
+
function saveSession(session) {
|
|
3781
|
+
const dataDir = resolveDataDir();
|
|
3782
|
+
const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
|
|
3783
|
+
const fp = join(dataDir, fileName);
|
|
3784
|
+
// Read current file, update session, write back
|
|
3785
|
+
let data = {};
|
|
3786
|
+
if (existsSync(fp)) {
|
|
3787
|
+
try {
|
|
3788
|
+
data = JSON.parse(readFileSync(fp, 'utf-8'));
|
|
3789
|
+
}
|
|
3790
|
+
catch { /* start fresh */ }
|
|
3791
|
+
}
|
|
3792
|
+
data[session.sessionId] = session;
|
|
3793
|
+
// Clean up entries where file key doesn't match the entry's sessionId (data
|
|
3794
|
+
// corruption), and strip legacy placeholder-card fields so the file converges
|
|
3795
|
+
// to clean (see stripLegacyPendingCardFields in services/session-store).
|
|
3796
|
+
for (const [key, val] of Object.entries(data)) {
|
|
3797
|
+
if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
|
|
3798
|
+
delete data[key];
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3801
|
+
if (val && typeof val === 'object')
|
|
3802
|
+
stripLegacyPendingCardFields(val);
|
|
3803
|
+
}
|
|
3804
|
+
const tmpFp = fp + '.tmp';
|
|
3805
|
+
writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
|
|
3806
|
+
renameSync(tmpFp, fp);
|
|
3807
|
+
// Remove duplicate from legacy file if session moved to per-bot file (or vice versa)
|
|
3808
|
+
const otherFile = session.larkAppId ? 'sessions.json' : null;
|
|
3809
|
+
if (otherFile) {
|
|
3810
|
+
const otherFp = join(dataDir, otherFile);
|
|
3811
|
+
if (existsSync(otherFp)) {
|
|
3812
|
+
try {
|
|
3813
|
+
const otherData = JSON.parse(readFileSync(otherFp, 'utf-8'));
|
|
3814
|
+
if (otherData[session.sessionId]) {
|
|
3815
|
+
delete otherData[session.sessionId];
|
|
3816
|
+
const otherTmp = otherFp + '.tmp';
|
|
3817
|
+
writeFileSync(otherTmp, JSON.stringify(otherData, null, 2), 'utf-8');
|
|
3818
|
+
renameSync(otherTmp, otherFp);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
catch { /* ignore */ }
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
/** Offline-only narrow session mutation. Callers must prefer the owning daemon
|
|
3826
|
+
* while it is available; rereading the exact row here prevents stale CLI
|
|
3827
|
+
* snapshots from copying FIFO fields during offline maintenance. */
|
|
3828
|
+
function mutateSessionOffline(session, mutate) {
|
|
3829
|
+
const dataDir = resolveDataDir();
|
|
3830
|
+
const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
|
|
3831
|
+
const fp = join(dataDir, fileName);
|
|
3832
|
+
return withFileLockSync(fp, () => {
|
|
3833
|
+
// The owning daemon uses the same session-file lock for every save. Check
|
|
3834
|
+
// absence while holding it so two CLIs cannot race each other and an
|
|
3835
|
+
// already-published daemon cannot be bypassed by the offline fallback.
|
|
3836
|
+
if (session.larkAppId) {
|
|
3837
|
+
try {
|
|
3838
|
+
if (findDaemon(session.larkAppId))
|
|
3839
|
+
return undefined;
|
|
3840
|
+
}
|
|
3841
|
+
catch { /* offline */ }
|
|
3842
|
+
}
|
|
3843
|
+
let data = {};
|
|
3844
|
+
if (existsSync(fp)) {
|
|
3845
|
+
try {
|
|
3846
|
+
data = JSON.parse(readFileSync(fp, 'utf-8'));
|
|
3847
|
+
}
|
|
3848
|
+
catch { /* start fresh */ }
|
|
3849
|
+
}
|
|
3850
|
+
const current = data[session.sessionId];
|
|
3851
|
+
if (!current || !mutate(current))
|
|
3852
|
+
return current;
|
|
3853
|
+
data[session.sessionId] = current;
|
|
3854
|
+
// Clean up entries where file key doesn't match the entry's sessionId
|
|
3855
|
+
// (data corruption), and strip removed placeholder-card fields.
|
|
3856
|
+
for (const [key, val] of Object.entries(data)) {
|
|
3857
|
+
if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
|
|
3858
|
+
delete data[key];
|
|
3859
|
+
continue;
|
|
3860
|
+
}
|
|
3861
|
+
if (val && typeof val === 'object')
|
|
3862
|
+
stripLegacyPendingCardFields(val);
|
|
3863
|
+
}
|
|
3864
|
+
// Recheck immediately before publication. A daemon that appears during
|
|
3865
|
+
// the read/decision phase becomes authoritative; leave the file untouched.
|
|
3866
|
+
if (session.larkAppId) {
|
|
3867
|
+
try {
|
|
3868
|
+
if (findDaemon(session.larkAppId))
|
|
3869
|
+
return undefined;
|
|
3870
|
+
}
|
|
3871
|
+
catch { /* offline */ }
|
|
3872
|
+
}
|
|
3873
|
+
const tmpFp = `${fp}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
3874
|
+
writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
|
|
3875
|
+
renameSync(tmpFp, fp);
|
|
3876
|
+
return current;
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
/** Offline explicit abandon stops the exact Botmux-owned worker, then closes
|
|
3880
|
+
* the newest durable row under the shared session-file lock. Provider-specific
|
|
3881
|
+
* backing cleanup remains owned by the dedicated backend lifecycle changes. */
|
|
3882
|
+
async function abandonSessionOffline(session) {
|
|
3883
|
+
let current = mutateSessionOffline(session, () => false);
|
|
3884
|
+
if (!current)
|
|
3885
|
+
return { ok: false, error: 'owning_daemon_became_available' };
|
|
3886
|
+
const originalPid = adoptedCliPid(current);
|
|
3887
|
+
const ownedWorkerPid = current.pid && current.pid !== originalPid ? current.pid : undefined;
|
|
3888
|
+
if (ownedWorkerPid) {
|
|
3889
|
+
// Narrow the unavoidable descriptor race: do not signal a worker after an
|
|
3890
|
+
// owning daemon has become visible. The locked write below repeats this.
|
|
3891
|
+
if (current.larkAppId) {
|
|
3892
|
+
try {
|
|
3893
|
+
if (findDaemon(current.larkAppId)) {
|
|
3894
|
+
return { ok: false, error: 'owning_daemon_became_available' };
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
catch { /* still offline */ }
|
|
3898
|
+
}
|
|
3899
|
+
if (isProcessAlive(ownedWorkerPid)) {
|
|
3900
|
+
const signalled = killProcess(ownedWorkerPid);
|
|
3901
|
+
if ((!signalled && isProcessAlive(ownedWorkerPid))
|
|
3902
|
+
|| !(await waitForProcessExit(ownedWorkerPid))) {
|
|
3903
|
+
return { ok: false, error: `worker_pid_kill_failed:${ownedWorkerPid}` };
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
// Persist worker-less state without touching FIFO authority. If a new
|
|
3907
|
+
// daemon/generation changed the row while SIGTERM settled, fail closed.
|
|
3908
|
+
let workerCleared = false;
|
|
3909
|
+
const afterStop = mutateSessionOffline(current, latest => {
|
|
3910
|
+
if (latest.pid !== ownedWorkerPid
|
|
3911
|
+
|| isAdoptedSession(latest) !== isAdoptedSession(current))
|
|
3912
|
+
return false;
|
|
3913
|
+
delete latest.pid;
|
|
3914
|
+
workerCleared = true;
|
|
3915
|
+
return true;
|
|
3916
|
+
});
|
|
3917
|
+
if (!afterStop || !workerCleared) {
|
|
3918
|
+
return { ok: false, error: 'session_changed_while_stopping_worker' };
|
|
3919
|
+
}
|
|
3920
|
+
current = afterStop;
|
|
3921
|
+
}
|
|
3922
|
+
else {
|
|
3923
|
+
// Even without a Botmux worker pid, re-read after the first authority check
|
|
3924
|
+
// so the cleanup inputs are the newest locked backend/task lineage.
|
|
3925
|
+
const refreshed = mutateSessionOffline(current, () => false);
|
|
3926
|
+
if (!refreshed)
|
|
3927
|
+
return { ok: false, error: 'owning_daemon_became_available' };
|
|
3928
|
+
current = refreshed;
|
|
3929
|
+
}
|
|
3930
|
+
// Provider-specific backing teardown (no daemon to run killWorker()). Adopted
|
|
3931
|
+
// panes belong to the user and are never touched. This runs BEFORE the durable
|
|
3932
|
+
// close so a ZMX ownership-tag mismatch fails closed: refusing to hide the sole
|
|
3933
|
+
// control row while its CLI is still alive under a different owner.
|
|
3934
|
+
let cleanedBacking;
|
|
3935
|
+
if (!isAdoptedSession(current)) {
|
|
3936
|
+
if (isSuspendableBackendType(current.backendType)) {
|
|
3937
|
+
const target = resolvePersistentBackendTarget(current.backendType, current.sessionId, current.persistentBackendTarget);
|
|
3938
|
+
try {
|
|
3939
|
+
killPersistentBackendTarget(target, current.sessionId);
|
|
3940
|
+
cleanedBacking = `${target.backendType}:${target.sessionName}`;
|
|
3941
|
+
}
|
|
3942
|
+
catch (err) {
|
|
3943
|
+
// ZMX destruction is identity-verified against the complete botmux
|
|
3944
|
+
// session UUID and waits for confirmed disappearance. Swallowing an
|
|
3945
|
+
// inconclusive/mismatched kill would hide the sole control row while
|
|
3946
|
+
// its CLI remains alive. Older mux backends keep their historical
|
|
3947
|
+
// best-effort offline-close compatibility.
|
|
3948
|
+
if (target.backendType === 'zmx') {
|
|
3949
|
+
return {
|
|
3950
|
+
ok: false,
|
|
3951
|
+
error: `ZMX 离线删除未完成:${err instanceof Error ? err.message : String(err)}`,
|
|
3952
|
+
};
|
|
3044
3953
|
}
|
|
3045
|
-
catch { /* */ }
|
|
3046
3954
|
}
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3955
|
+
}
|
|
3956
|
+
else {
|
|
3957
|
+
// Legacy rows without backendType were historically tmux-backed.
|
|
3958
|
+
const tmuxName = `bmx-${current.sessionId.substring(0, 8)}`;
|
|
3959
|
+
try {
|
|
3960
|
+
execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, {
|
|
3961
|
+
stdio: 'ignore',
|
|
3962
|
+
env: tmuxEnv(),
|
|
3963
|
+
});
|
|
3053
3964
|
}
|
|
3965
|
+
catch { /* no tmux session */ }
|
|
3054
3966
|
}
|
|
3055
3967
|
}
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3968
|
+
let applied = false;
|
|
3969
|
+
const published = mutateSessionOffline(current, latest => {
|
|
3970
|
+
if (isAdoptedSession(latest) !== isAdoptedSession(current))
|
|
3971
|
+
return false;
|
|
3972
|
+
if (latest.status === 'closed') {
|
|
3973
|
+
applied = true;
|
|
3974
|
+
return false;
|
|
3975
|
+
}
|
|
3976
|
+
latest.status = 'closed';
|
|
3977
|
+
latest.closedAt = new Date().toISOString();
|
|
3978
|
+
delete latest.codexAppDispatchLedger;
|
|
3979
|
+
delete latest.codexAppGenerationCommits;
|
|
3980
|
+
delete latest.queuedActivationPending;
|
|
3981
|
+
delete latest.queuedActivationTail;
|
|
3982
|
+
delete latest.pendingRepoSetup;
|
|
3983
|
+
applied = true;
|
|
3984
|
+
return true;
|
|
3985
|
+
});
|
|
3986
|
+
if (!published || !applied) {
|
|
3987
|
+
return { ok: false, error: 'session_changed_during_offline_cleanup' };
|
|
3060
3988
|
}
|
|
3061
|
-
return
|
|
3989
|
+
return { ok: true, current: published, ...(cleanedBacking ? { cleanedBacking } : {}) };
|
|
3062
3990
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3991
|
+
function pruneSessionOfflineIfLedgerEmpty(session) {
|
|
3992
|
+
let pruned = false;
|
|
3993
|
+
mutateSessionOffline(session, current => {
|
|
3994
|
+
if (hasProtectedSessionMutationOwnership(current))
|
|
3995
|
+
return false;
|
|
3996
|
+
current.status = 'closed';
|
|
3997
|
+
current.closedAt = new Date().toISOString();
|
|
3998
|
+
delete current.codexAppDispatchLedger;
|
|
3999
|
+
delete current.codexAppGenerationCommits;
|
|
4000
|
+
pruned = true;
|
|
4001
|
+
return true;
|
|
4002
|
+
});
|
|
4003
|
+
return pruned;
|
|
3066
4004
|
}
|
|
3067
|
-
function
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
4005
|
+
function patchSessionWhiteboardOffline(session, whiteboardId) {
|
|
4006
|
+
return !!mutateSessionOffline(session, current => {
|
|
4007
|
+
current.whiteboardId = whiteboardId;
|
|
4008
|
+
return true;
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
4011
|
+
async function postOwningDaemonSessionMutation(session, suffix, body) {
|
|
4012
|
+
if (!session.larkAppId)
|
|
4013
|
+
return 'unavailable';
|
|
4014
|
+
let daemon;
|
|
4015
|
+
try {
|
|
4016
|
+
daemon = findDaemon(session.larkAppId);
|
|
3078
4017
|
}
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
// corruption), and strip legacy placeholder-card fields so the file converges
|
|
3082
|
-
// to clean (see stripLegacyPendingCardFields in services/session-store).
|
|
3083
|
-
for (const [key, val] of Object.entries(data)) {
|
|
3084
|
-
if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
|
|
3085
|
-
delete data[key];
|
|
3086
|
-
continue;
|
|
3087
|
-
}
|
|
3088
|
-
if (val && typeof val === 'object')
|
|
3089
|
-
stripLegacyPendingCardFields(val);
|
|
4018
|
+
catch {
|
|
4019
|
+
return 'unavailable';
|
|
3090
4020
|
}
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
4021
|
+
if (!daemon)
|
|
4022
|
+
return 'unavailable';
|
|
4023
|
+
let secret;
|
|
4024
|
+
try {
|
|
4025
|
+
secret = loadDaemonIpcSecret();
|
|
4026
|
+
}
|
|
4027
|
+
catch {
|
|
4028
|
+
return 'unavailable';
|
|
4029
|
+
}
|
|
4030
|
+
const res = await fetchDaemonIpc(daemon.ipcPort, `/api/sessions/${encodeURIComponent(session.sessionId)}/${suffix}`, {
|
|
4031
|
+
method: 'POST',
|
|
4032
|
+
...(body
|
|
4033
|
+
? {
|
|
4034
|
+
headers: { 'content-type': 'application/json' },
|
|
4035
|
+
body: JSON.stringify(body),
|
|
4036
|
+
}
|
|
4037
|
+
: {}),
|
|
4038
|
+
}, secret);
|
|
4039
|
+
if (suffix === 'prune' && res.status === 409)
|
|
4040
|
+
return 'refused';
|
|
4041
|
+
if (!res.ok) {
|
|
4042
|
+
throw new Error(`owning daemon ${suffix} mutation failed: HTTP ${res.status}`);
|
|
4043
|
+
}
|
|
4044
|
+
return 'applied';
|
|
4045
|
+
}
|
|
4046
|
+
async function abandonSessionAuthoritatively(session, online = listOnlineDaemons()) {
|
|
4047
|
+
// Legacy larkAppId-less rows live in sessions.json; a per-bot daemon writes
|
|
4048
|
+
// only its own sessions-<appId>.json and silently no-ops on close, so keep
|
|
4049
|
+
// them on the offline path (which persists to the legacy file correctly).
|
|
4050
|
+
if (session.larkAppId) {
|
|
4051
|
+
const daemon = online.find(d => d.larkAppId === session.larkAppId);
|
|
4052
|
+
const isCurrentSession = process.env.BOTMUX_SESSION_ID === session.sessionId;
|
|
4053
|
+
const injectedPort = isCurrentSession
|
|
4054
|
+
? resolveDaemonIpcPort(undefined, process.env.BOTMUX_DAEMON_IPC_PORT)
|
|
4055
|
+
: undefined;
|
|
4056
|
+
const ipcPort = daemon?.ipcPort ?? injectedPort;
|
|
4057
|
+
if (ipcPort) {
|
|
3099
4058
|
try {
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
}
|
|
4059
|
+
// Explicit abandon boundary: route through the owning daemon so the
|
|
4060
|
+
// ledger FIFO is cleared atomically with close. postSessionCliIpc carries
|
|
4061
|
+
// the trusted-host HMAC (non-isolated) OR this session's rotating origin
|
|
4062
|
+
// capability (sandboxed/read-isolated) so a sandboxed `delete <other-id>`
|
|
4063
|
+
// stays fail-closed at the daemon's sessionCliIpcAuth check.
|
|
4064
|
+
const res = await postSessionCliIpc(ipcPort, session.sessionId, 'close', {});
|
|
4065
|
+
const body = await res.json().catch(() => ({}));
|
|
4066
|
+
if (res.ok && body.ok)
|
|
4067
|
+
return { ok: true, mode: 'daemon' };
|
|
4068
|
+
// Surface the daemon's own rejection reason (e.g. origin_unproven) and
|
|
4069
|
+
// never fall back to a partial local kill: a fresh descriptor means the
|
|
4070
|
+
// daemon may still hold authoritative in-memory state.
|
|
4071
|
+
return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
|
4072
|
+
}
|
|
4073
|
+
catch (err) {
|
|
4074
|
+
return { ok: false, error: `连接 daemon 失败: ${err instanceof Error ? err.message : String(err)}` };
|
|
3107
4075
|
}
|
|
3108
|
-
catch { /* ignore */ }
|
|
3109
4076
|
}
|
|
3110
4077
|
}
|
|
4078
|
+
const offline = await abandonSessionOffline(session);
|
|
4079
|
+
return offline.ok
|
|
4080
|
+
? {
|
|
4081
|
+
ok: true,
|
|
4082
|
+
mode: 'offline',
|
|
4083
|
+
current: offline.current,
|
|
4084
|
+
...(offline.cleanedBacking ? { cleanedBacking: offline.cleanedBacking } : {}),
|
|
4085
|
+
}
|
|
4086
|
+
: { ok: false, error: offline.error };
|
|
4087
|
+
}
|
|
4088
|
+
async function pruneSessionAuthoritatively(session) {
|
|
4089
|
+
const result = await postOwningDaemonSessionMutation(session, 'prune');
|
|
4090
|
+
if (result === 'applied')
|
|
4091
|
+
return true;
|
|
4092
|
+
if (result === 'refused')
|
|
4093
|
+
return false;
|
|
4094
|
+
return pruneSessionOfflineIfLedgerEmpty(session);
|
|
4095
|
+
}
|
|
4096
|
+
async function patchSessionWhiteboardAuthoritatively(session, whiteboardId) {
|
|
4097
|
+
const result = await postOwningDaemonSessionMutation(session, 'whiteboard', { whiteboardId });
|
|
4098
|
+
if (result === 'applied')
|
|
4099
|
+
return true;
|
|
4100
|
+
if (result === 'refused')
|
|
4101
|
+
return false;
|
|
4102
|
+
return patchSessionWhiteboardOffline(session, whiteboardId);
|
|
3111
4103
|
}
|
|
3112
4104
|
function isProcessAlive(pid) {
|
|
3113
4105
|
try {
|
|
@@ -3127,6 +4119,15 @@ function killProcess(pid) {
|
|
|
3127
4119
|
return false;
|
|
3128
4120
|
}
|
|
3129
4121
|
}
|
|
4122
|
+
async function waitForProcessExit(pid, timeoutMs = 2_000) {
|
|
4123
|
+
const deadline = Date.now() + timeoutMs;
|
|
4124
|
+
while (Date.now() < deadline) {
|
|
4125
|
+
if (!isProcessAlive(pid))
|
|
4126
|
+
return true;
|
|
4127
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
4128
|
+
}
|
|
4129
|
+
return !isProcessAlive(pid);
|
|
4130
|
+
}
|
|
3130
4131
|
function formatDuration(ms) {
|
|
3131
4132
|
const seconds = Math.floor(ms / 1000);
|
|
3132
4133
|
if (seconds < 60)
|
|
@@ -3140,31 +4141,38 @@ function formatDuration(ms) {
|
|
|
3140
4141
|
const days = Math.floor(hours / 24);
|
|
3141
4142
|
return `${days}d${hours % 24}h`;
|
|
3142
4143
|
}
|
|
3143
|
-
/**
|
|
4144
|
+
/**
|
|
4145
|
+
* Get display width of a string in terminal cells.
|
|
4146
|
+
*
|
|
4147
|
+
* Delegates to a cross-terminal conservative width table (see terminal-width.ts):
|
|
4148
|
+
* for any real terminal the width returned is >= what it paints, so the picker's
|
|
4149
|
+
* `layoutWidth <= termWidth` genuinely implies rows never wrap. The previous
|
|
4150
|
+
* inline table only covered CJK/Hangul and under-counted emoji (🤖/🫠 as 1),
|
|
4151
|
+
* letting emoji session titles overflow a row, wrap onto a second physical line,
|
|
4152
|
+
* and push the pinned title off the alt-screen.
|
|
4153
|
+
*
|
|
4154
|
+
* NOTE: cursor-moving control chars (Tab/ESC/C0/C1) are NOT sized here — width
|
|
4155
|
+
* can't express a tab stop. Run text through `sanitizeCellText` first.
|
|
4156
|
+
*/
|
|
3144
4157
|
function displayWidth(str) {
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
else {
|
|
3164
|
-
width += 1;
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
return width;
|
|
4158
|
+
return terminalCellWidth(str);
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Strip everything that would move the cursor or otherwise desync column math
|
|
4162
|
+
* from dynamic text (session titles, working dirs, flash messages) before it is
|
|
4163
|
+
* measured or printed. A raw Tab jumps to the next tab stop and a raw ESC starts
|
|
4164
|
+
* a control sequence — both make a "one physical line" cell silently span more
|
|
4165
|
+
* columns (or lines) than displayWidth accounts for, wrapping the row and
|
|
4166
|
+
* pushing the pinned title off screen. Collapse them all to a single space:
|
|
4167
|
+
* - C0 controls incl. Tab/CR/LF (U+0000–U+001F) and DEL (U+007F);
|
|
4168
|
+
* - C1 controls (U+0080–U+009F);
|
|
4169
|
+
* - stray ESC (U+001B) is covered by the C0 range.
|
|
4170
|
+
* (ANSI colour sequences the picker itself emits are added AFTER sanitizing, so
|
|
4171
|
+
* they are never fed through here.)
|
|
4172
|
+
*/
|
|
4173
|
+
function sanitizeCellText(str) {
|
|
4174
|
+
// eslint-disable-next-line no-control-regex
|
|
4175
|
+
return str.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
|
|
3168
4176
|
}
|
|
3169
4177
|
/** Truncate string to fit within maxWidth display columns, append '…' if truncated. */
|
|
3170
4178
|
function truncate(str, maxWidth) {
|
|
@@ -3530,27 +4538,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3530
4538
|
const b = botConfigs[i];
|
|
3531
4539
|
botLabels.set(b.larkAppId, `bot${i + 1} (${b.cliId ?? 'claude-code'})`);
|
|
3532
4540
|
}
|
|
3533
|
-
|
|
3534
|
-
const termWidth = process.stdout.columns || 100;
|
|
3535
|
-
const PREFIX = 4; // " ❯ " or " "
|
|
3536
|
-
const SEP_W = 3; // " │ "
|
|
3537
|
-
const fixedCols = { id: 10, pid: 8, uptime: 7, status: 7, target: 26 };
|
|
3538
|
-
const botW = multiBot ? 18 : 0;
|
|
3539
|
-
const numSeps = (multiBot ? 8 : 7) - 1; // separators between columns
|
|
3540
|
-
const fixedTotal = PREFIX + fixedCols.id + botW + fixedCols.pid + fixedCols.uptime + fixedCols.status + fixedCols.target + numSeps * SEP_W;
|
|
3541
|
-
const flexTotal = Math.max(20, termWidth - fixedTotal);
|
|
3542
|
-
const titleW = Math.floor(flexTotal * 0.4);
|
|
3543
|
-
const dirW = flexTotal - titleW;
|
|
3544
|
-
const cols = {
|
|
3545
|
-
id: fixedCols.id,
|
|
3546
|
-
...(multiBot ? { bot: botW } : {}),
|
|
3547
|
-
title: titleW,
|
|
3548
|
-
dir: dirW,
|
|
3549
|
-
pid: fixedCols.pid,
|
|
3550
|
-
uptime: fixedCols.uptime,
|
|
3551
|
-
status: fixedCols.status,
|
|
3552
|
-
target: fixedCols.target,
|
|
3553
|
-
};
|
|
4541
|
+
let layout = computeSessionPickerLayout(process.stdout.columns || 100, multiBot);
|
|
3554
4542
|
// Build row data — use shortened paths for TUI
|
|
3555
4543
|
function buildRows() {
|
|
3556
4544
|
return active.map(s => {
|
|
@@ -3559,25 +4547,30 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3559
4547
|
? { probe: 'missing', label: adoptTargetLabel(s) }
|
|
3560
4548
|
: sessionBackingInfo(s, probeSnapshot);
|
|
3561
4549
|
const targetLabel = backing.label;
|
|
3562
|
-
// Build row text with shortened dir
|
|
3563
|
-
const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id);
|
|
3564
|
-
const parts = [id];
|
|
3565
|
-
if (multiBot) {
|
|
3566
|
-
const label = s.larkAppId ? (botLabels.get(s.larkAppId) ?? s.larkAppId.substring(0, 16)) : '-';
|
|
3567
|
-
parts.push(padEndDisplay(truncate(label, cols.bot), cols.bot));
|
|
3568
|
-
}
|
|
3569
|
-
const title = padEndDisplay(truncate((s.title || '(untitled)').replace(/[\r\n]+/g, ' '), cols.title), cols.title);
|
|
3570
|
-
const dir = padEndDisplay(truncate(shortenPath(s.workingDir || '-'), cols.dir), cols.dir);
|
|
3571
4550
|
const displayPid = sessionDisplayPid(s);
|
|
3572
|
-
const pid = displayPid ? String(displayPid).padEnd(cols.pid) : '-'.padEnd(cols.pid);
|
|
3573
|
-
const uptime = formatDuration(Date.now() - new Date(s.createdAt).getTime()).padEnd(cols.uptime);
|
|
3574
4551
|
const alive = isSessionAliveForList(s);
|
|
3575
|
-
const
|
|
3576
|
-
const
|
|
3577
|
-
|
|
4552
|
+
const botLabel = s.larkAppId ? (botLabels.get(s.larkAppId) ?? s.larkAppId.substring(0, 16)) : '-';
|
|
4553
|
+
const cells = {
|
|
4554
|
+
id: s.sessionId.substring(0, 8),
|
|
4555
|
+
bot: botLabel,
|
|
4556
|
+
title: s.title || '(untitled)',
|
|
4557
|
+
dir: shortenPath(s.workingDir || '-'),
|
|
4558
|
+
pid: displayPid ? String(displayPid) : '-',
|
|
4559
|
+
uptime: formatDuration(Date.now() - new Date(s.createdAt).getTime()),
|
|
4560
|
+
status: sessionStatusLabel(s),
|
|
4561
|
+
target: targetLabel,
|
|
4562
|
+
};
|
|
4563
|
+
const text = layout.columns
|
|
4564
|
+
.map(column => {
|
|
4565
|
+
// Sanitize every cell (not just title): a Tab/ESC/control char in any
|
|
4566
|
+
// dynamic field would move the cursor and desync the one-line-per-row math.
|
|
4567
|
+
const value = sanitizeCellText(cells[column.key]);
|
|
4568
|
+
return padEndDisplay(truncate(value, column.width), column.width);
|
|
4569
|
+
})
|
|
4570
|
+
.join(' │ ');
|
|
3578
4571
|
return {
|
|
3579
4572
|
session: s,
|
|
3580
|
-
text
|
|
4573
|
+
text,
|
|
3581
4574
|
alive,
|
|
3582
4575
|
backendTarget: 'target' in backing ? backing.target : undefined,
|
|
3583
4576
|
backingProbe: backing.probe,
|
|
@@ -3594,62 +4587,168 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3594
4587
|
let rows = buildRows();
|
|
3595
4588
|
// Build header (same column layout as rows, no extra prefix in join)
|
|
3596
4589
|
function buildHeader() {
|
|
3597
|
-
const
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
4590
|
+
const labels = {
|
|
4591
|
+
id: 'id',
|
|
4592
|
+
bot: 'bot',
|
|
4593
|
+
title: 'title',
|
|
4594
|
+
dir: 'working dir',
|
|
4595
|
+
pid: 'pid',
|
|
4596
|
+
uptime: 'uptime',
|
|
4597
|
+
status: 'status',
|
|
4598
|
+
target: 'target',
|
|
4599
|
+
};
|
|
4600
|
+
return layout.columns
|
|
4601
|
+
.map(column => padEndDisplay(truncate(labels[column.key], column.width), column.width))
|
|
4602
|
+
.join(' │ ');
|
|
3602
4603
|
}
|
|
3603
|
-
|
|
3604
|
-
|
|
4604
|
+
let header = buildHeader();
|
|
4605
|
+
let separator = '─'.repeat(displayWidth(header));
|
|
3605
4606
|
let cursor = 0;
|
|
4607
|
+
let scrollTop = 0; // index of the first visible row (vertical scroll)
|
|
3606
4608
|
let confirmDelete = false; // true when waiting for y/n confirmation
|
|
3607
|
-
let
|
|
4609
|
+
let flash = null;
|
|
4610
|
+
// Fixed chrome around the scrolling row window: title(2) + header block(3) +
|
|
4611
|
+
// bottom separator(1) + target hint(2) + flash/confirm(2) + hints(2) + a
|
|
4612
|
+
// one-line safety margin so the final newline never scrolls the pinned title
|
|
4613
|
+
// off the alt-screen. Everything else is the row viewport.
|
|
4614
|
+
const CHROME_ROWS = 13;
|
|
4615
|
+
let sepWidth = displayWidth(separator);
|
|
4616
|
+
function rebuildLayout() {
|
|
4617
|
+
layout = computeSessionPickerLayout(process.stdout.columns || 100, multiBot);
|
|
4618
|
+
rows = buildRows();
|
|
4619
|
+
header = buildHeader();
|
|
4620
|
+
separator = '─'.repeat(displayWidth(header));
|
|
4621
|
+
sepWidth = displayWidth(separator);
|
|
4622
|
+
}
|
|
4623
|
+
// Overlay a "N 更多" marker onto a separator line without changing its display
|
|
4624
|
+
// width, so the up/down hidden-row counters cost no extra vertical lines.
|
|
4625
|
+
const sepWithMarker = (marker) => {
|
|
4626
|
+
const label = ` ${marker} `;
|
|
4627
|
+
const lw = displayWidth(label);
|
|
4628
|
+
if (lw + 4 > sepWidth)
|
|
4629
|
+
return separator;
|
|
4630
|
+
return `──${label}${'─'.repeat(sepWidth - 2 - lw)}`;
|
|
4631
|
+
};
|
|
4632
|
+
const fitLine = (text, width) => {
|
|
4633
|
+
if (width <= 0)
|
|
4634
|
+
return '';
|
|
4635
|
+
const singleLine = sanitizeCellText(text);
|
|
4636
|
+
return displayWidth(singleLine) <= width ? singleLine : truncate(singleLine, width);
|
|
4637
|
+
};
|
|
4638
|
+
// The ONLY place footer SGR colour is applied. `text` is treated as untrusted:
|
|
4639
|
+
// it is sanitized (control chars stripped) and width-fitted first, then wrapped
|
|
4640
|
+
// in a fixed whitelist SGR from `style`. No caller-supplied escape can survive,
|
|
4641
|
+
// and there is no fast path that returns a raw/mixed string — closing the two
|
|
4642
|
+
// bypasses where a session's target label / flash message could inject e.g.
|
|
4643
|
+
// `\x1b[2J` (clear screen) into the footer and scroll the pinned title away.
|
|
4644
|
+
const SGR = {
|
|
4645
|
+
error: '\x1b[31m',
|
|
4646
|
+
success: '\x1b[32m',
|
|
4647
|
+
warn: '\x1b[33m',
|
|
4648
|
+
dim: '\x1b[2m',
|
|
4649
|
+
};
|
|
4650
|
+
const styledFooter = (style, text, width) => `${SGR[style]}${fitLine(text, width)}\x1b[0m`;
|
|
4651
|
+
const blankRowPrefix = () => ' '.repeat(layout.prefixWidth);
|
|
4652
|
+
const rowPrefix = (selected) => {
|
|
4653
|
+
if (!selected)
|
|
4654
|
+
return blankRowPrefix();
|
|
4655
|
+
const pointer = '\x1b[36m❯\x1b[0m';
|
|
4656
|
+
if (layout.prefixWidth >= 4)
|
|
4657
|
+
return ` ${pointer} `;
|
|
4658
|
+
if (layout.prefixWidth === 3)
|
|
4659
|
+
return ` ${pointer} `;
|
|
4660
|
+
if (layout.prefixWidth === 2)
|
|
4661
|
+
return `${pointer} `;
|
|
4662
|
+
return layout.prefixWidth === 1 ? pointer : '';
|
|
4663
|
+
};
|
|
4664
|
+
const footerPrefixWidth = () => Math.min(2, layout.termWidth);
|
|
4665
|
+
const footerContentWidth = () => Math.max(0, layout.termWidth - footerPrefixWidth());
|
|
4666
|
+
const footerPrefix = () => ' '.repeat(footerPrefixWidth());
|
|
3608
4667
|
function render() {
|
|
3609
4668
|
process.stdout.write('\x1b[H\x1b[J');
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
4669
|
+
const posLabel = rows.length > 0 ? `${cursor + 1}/${rows.length}` : '0';
|
|
4670
|
+
const titleText = ` botmux sessions (${posLabel})`;
|
|
4671
|
+
if (displayWidth(titleText) <= layout.termWidth) {
|
|
4672
|
+
process.stdout.write(`\x1b[1m botmux sessions\x1b[0m \x1b[2m(${posLabel})\x1b[0m\n\n`);
|
|
4673
|
+
}
|
|
4674
|
+
else {
|
|
4675
|
+
process.stdout.write(`\x1b[1m${fitLine(titleText, layout.termWidth)}\x1b[0m\n\n`);
|
|
4676
|
+
}
|
|
3615
4677
|
if (rows.length === 0) {
|
|
3616
|
-
process.stdout.write(
|
|
3617
|
-
process.stdout.write(
|
|
3618
|
-
process.stdout.write(
|
|
4678
|
+
process.stdout.write(`${blankRowPrefix()}${separator}\n`);
|
|
4679
|
+
process.stdout.write(`${blankRowPrefix()}\x1b[2m${header}\x1b[0m\n`);
|
|
4680
|
+
process.stdout.write(`${blankRowPrefix()}${separator}\n`);
|
|
4681
|
+
process.stdout.write(`\n${blankRowPrefix()}\x1b[2m${fitLine('没有活跃会话', Math.max(0, layout.termWidth - layout.prefixWidth))}\x1b[0m\n`);
|
|
4682
|
+
process.stdout.write(`${blankRowPrefix()}${separator}\n`);
|
|
4683
|
+
process.stdout.write(`\n${footerPrefix()}\x1b[2m${fitLine('q 退出', footerContentWidth())}\x1b[0m\n`);
|
|
3619
4684
|
return;
|
|
3620
4685
|
}
|
|
3621
|
-
|
|
4686
|
+
// Vertical viewport: render only the window of rows that fits the terminal
|
|
4687
|
+
// height, scrolling to keep the cursor visible. Without this a long session
|
|
4688
|
+
// list overflows the alt-screen and pushes the title/header/top rows off it.
|
|
4689
|
+
const win = computeSessionPickerScrollWindow({
|
|
4690
|
+
cursor,
|
|
4691
|
+
scrollTop,
|
|
4692
|
+
rowCount: rows.length,
|
|
4693
|
+
termRows: process.stdout.rows || 24,
|
|
4694
|
+
chromeRows: CHROME_ROWS,
|
|
4695
|
+
});
|
|
4696
|
+
scrollTop = win.scrollTop;
|
|
4697
|
+
const { viewEnd, hiddenAbove, hiddenBelow } = win;
|
|
4698
|
+
// Header + separator — use the same responsive prefix as rows.
|
|
4699
|
+
process.stdout.write(`${blankRowPrefix()}${separator}\n`);
|
|
4700
|
+
process.stdout.write(`${blankRowPrefix()}\x1b[2m${header}\x1b[0m\n`);
|
|
4701
|
+
process.stdout.write(hiddenAbove > 0
|
|
4702
|
+
? `${blankRowPrefix()}\x1b[36m${sepWithMarker(`↑ ${hiddenAbove} 更多`)}\x1b[0m\n`
|
|
4703
|
+
: `${blankRowPrefix()}${separator}\n`);
|
|
4704
|
+
for (let i = scrollTop; i < viewEnd; i++) {
|
|
3622
4705
|
const r = rows[i];
|
|
3623
|
-
const pointer = i === cursor ? '\x1b[36m❯\x1b[0m' : ' ';
|
|
3624
4706
|
if (i === cursor) {
|
|
3625
|
-
process.stdout.write(
|
|
4707
|
+
process.stdout.write(`${rowPrefix(true)}\x1b[7m${r.text}\x1b[0m\n`);
|
|
3626
4708
|
}
|
|
3627
4709
|
else {
|
|
3628
|
-
process.stdout.write(
|
|
4710
|
+
process.stdout.write(`${rowPrefix(false)}${r.text}\n`);
|
|
3629
4711
|
}
|
|
3630
4712
|
}
|
|
3631
|
-
process.stdout.write(
|
|
3632
|
-
|
|
4713
|
+
process.stdout.write(hiddenBelow > 0
|
|
4714
|
+
? `${blankRowPrefix()}\x1b[36m${sepWithMarker(`↓ ${hiddenBelow} 更多`)}\x1b[0m\n`
|
|
4715
|
+
: `${blankRowPrefix()}${separator}\n`);
|
|
4716
|
+
// Footer info. Every dynamic field (targetLabel, backend session name) is
|
|
4717
|
+
// untrusted session metadata, so it only ever reaches the terminal via
|
|
4718
|
+
// styledFooter (sanitize → fit → whitelist SGR). The adopt hint's fixed
|
|
4719
|
+
// suffix is trusted static text, appended after the fitted label.
|
|
3633
4720
|
const selected = rows[cursor];
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
4721
|
+
const width = footerContentWidth();
|
|
4722
|
+
let footerLine;
|
|
4723
|
+
if (selected.isAdopt) {
|
|
4724
|
+
const suffix = ' Enter 已禁用;请直接使用原 tmux/zellij/herdr 客户端。';
|
|
4725
|
+
const labelWidth = Math.max(0, width - displayWidth(suffix));
|
|
4726
|
+
footerLine = `${styledFooter('warn', selected.targetLabel, labelWidth)}\x1b[2m${fitLine(suffix, width)}\x1b[0m`;
|
|
4727
|
+
}
|
|
4728
|
+
else if (selected.canAttach) {
|
|
4729
|
+
footerLine = styledFooter('success', `${selected.attachBackend}: ${selected.backendTarget?.sessionName}`, width);
|
|
4730
|
+
}
|
|
4731
|
+
else {
|
|
4732
|
+
footerLine = styledFooter('dim', `${selected.targetLabel}(不可连接)`, width);
|
|
4733
|
+
}
|
|
4734
|
+
process.stdout.write(`\n${footerPrefix()}${footerLine}\n`);
|
|
3640
4735
|
// Flash message or confirmation prompt
|
|
3641
4736
|
if (confirmDelete) {
|
|
3642
4737
|
const s = selected.session;
|
|
3643
|
-
|
|
4738
|
+
const confirmation = `确认删除 ${s.sessionId.substring(0, 8)} "${truncate(s.title || '', 20)}"? (y/n)`;
|
|
4739
|
+
process.stdout.write(`\n${footerPrefix()}${styledFooter('warn', confirmation, width)}\n`);
|
|
3644
4740
|
}
|
|
3645
|
-
else if (
|
|
3646
|
-
process.stdout.write(`\n
|
|
4741
|
+
else if (flash) {
|
|
4742
|
+
process.stdout.write(`\n${footerPrefix()}${styledFooter(flash.style, flash.text, width)}\n`);
|
|
3647
4743
|
}
|
|
3648
4744
|
else {
|
|
3649
4745
|
process.stdout.write('\n');
|
|
3650
4746
|
}
|
|
3651
4747
|
// Keybinding hints
|
|
3652
|
-
|
|
4748
|
+
const fullHints = `↑/↓ 选择 ⏎ ${selected?.canAttach ? '连接' : '不可连接'} d 删除 q 退出`;
|
|
4749
|
+
const compactHints = '↑/↓ 选择 ⏎ d q';
|
|
4750
|
+
const hints = displayWidth(fullHints) <= width ? fullHints : compactHints;
|
|
4751
|
+
process.stdout.write(`\n${footerPrefix()}${styledFooter('dim', hints, width)}\n`);
|
|
3653
4752
|
}
|
|
3654
4753
|
return new Promise((resolve) => {
|
|
3655
4754
|
process.stdin.setRawMode(true);
|
|
@@ -3658,7 +4757,15 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3658
4757
|
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
3659
4758
|
process.stdout.write('\x1b[?1049h'); // alt screen
|
|
3660
4759
|
render();
|
|
4760
|
+
// Rebuild both axes on resize: rows/header must use the current columns or
|
|
4761
|
+
// stale wide lines wrap and invalidate the vertical one-row-per-session math.
|
|
4762
|
+
const onResize = () => {
|
|
4763
|
+
rebuildLayout();
|
|
4764
|
+
render();
|
|
4765
|
+
};
|
|
4766
|
+
process.stdout.on('resize', onResize);
|
|
3661
4767
|
function cleanup() {
|
|
4768
|
+
process.stdout.off('resize', onResize);
|
|
3662
4769
|
process.stdin.setRawMode(false);
|
|
3663
4770
|
process.stdin.pause();
|
|
3664
4771
|
process.stdout.write('\x1b[?25h'); // show cursor
|
|
@@ -3668,11 +4775,17 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3668
4775
|
async function deleteSession(idx) {
|
|
3669
4776
|
const r = rows[idx];
|
|
3670
4777
|
const s = r.session;
|
|
3671
|
-
|
|
4778
|
+
// Explicit delete is the abandon boundary. Prefer the owning daemon so
|
|
4779
|
+
// its current in-memory FIFO is cleared atomically with close; offline
|
|
4780
|
+
// fallback rereads the exact row and clears the latest ledger/commits.
|
|
4781
|
+
const result = await abandonSessionAuthoritatively(s);
|
|
3672
4782
|
if (!result.ok) {
|
|
3673
|
-
|
|
4783
|
+
flash = { style: 'error', text: `✗ 删除失败: ${result.error}` };
|
|
3674
4784
|
return;
|
|
3675
4785
|
}
|
|
4786
|
+
// A live daemon owns process/pane teardown and has re-resolved the latest
|
|
4787
|
+
// generation. Only the locked offline path may clean up resources, using
|
|
4788
|
+
// the freshly-read row rather than this TUI's stale snapshot.
|
|
3676
4789
|
// Remove from active list and TUI rows
|
|
3677
4790
|
const activeIdx = active.indexOf(s);
|
|
3678
4791
|
if (activeIdx >= 0)
|
|
@@ -3680,9 +4793,12 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3680
4793
|
rows.splice(idx, 1);
|
|
3681
4794
|
if (cursor >= rows.length)
|
|
3682
4795
|
cursor = Math.max(0, rows.length - 1);
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
4796
|
+
// master's flash-object footer; the daemon/offline discriminant on
|
|
4797
|
+
// abandonSessionAuthoritatively is `mode` (see its return type ~3573),
|
|
4798
|
+
// NOT master's `via` — that belongs to a different helper.
|
|
4799
|
+
flash = result.mode === 'daemon'
|
|
4800
|
+
? { style: 'success', text: `✓ 已删除 ${s.sessionId.substring(0, 8)}` }
|
|
4801
|
+
: { style: 'warn', text: `✓ 已离线删除 ${s.sessionId.substring(0, 8)}` };
|
|
3686
4802
|
}
|
|
3687
4803
|
process.stdin.on('data', async (key) => {
|
|
3688
4804
|
if (deleteInFlight)
|
|
@@ -3700,12 +4816,12 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3700
4816
|
}
|
|
3701
4817
|
}
|
|
3702
4818
|
else {
|
|
3703
|
-
|
|
4819
|
+
flash = { style: 'dim', text: '取消删除' };
|
|
3704
4820
|
}
|
|
3705
4821
|
render();
|
|
3706
4822
|
return;
|
|
3707
4823
|
}
|
|
3708
|
-
|
|
4824
|
+
flash = null;
|
|
3709
4825
|
// Ctrl-C or q or Esc
|
|
3710
4826
|
if (key === '\x03' || key === 'q' || key === '\x1b') {
|
|
3711
4827
|
cleanup();
|
|
@@ -3739,19 +4855,19 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3739
4855
|
if (key === '\r' || key === '\n') {
|
|
3740
4856
|
const selected = rows[cursor];
|
|
3741
4857
|
if (selected.isAdopt) {
|
|
3742
|
-
|
|
4858
|
+
flash = { style: 'warn', text: `这是 adopt 会话;botmux 不 attach 用户 pane。目标: ${selected.targetLabel}` };
|
|
3743
4859
|
render();
|
|
3744
4860
|
return;
|
|
3745
4861
|
}
|
|
3746
4862
|
if (!selected.canAttach) {
|
|
3747
|
-
|
|
4863
|
+
flash = { style: 'warn', text: '该会话没有可连接的持久后端' };
|
|
3748
4864
|
render();
|
|
3749
4865
|
return;
|
|
3750
4866
|
}
|
|
3751
4867
|
if (selected.attachBackend === 'zmx') {
|
|
3752
4868
|
const target = selected.backendTarget;
|
|
3753
4869
|
if (!target || target.backendType !== 'zmx') {
|
|
3754
|
-
|
|
4870
|
+
flash = { style: 'error', text: 'ZMX attach target is missing or inconsistent' };
|
|
3755
4871
|
render();
|
|
3756
4872
|
return;
|
|
3757
4873
|
}
|
|
@@ -3760,7 +4876,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3760
4876
|
// cleanup and re-prove it immediately before attach.
|
|
3761
4877
|
const frozen = freezeManagedZmxAttachTarget(target.sessionName, selected.session.sessionId);
|
|
3762
4878
|
if (!frozen.ok) {
|
|
3763
|
-
|
|
4879
|
+
flash = { style: 'error', text: frozen.message };
|
|
3764
4880
|
render();
|
|
3765
4881
|
return;
|
|
3766
4882
|
}
|
|
@@ -3772,7 +4888,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
|
|
|
3772
4888
|
else {
|
|
3773
4889
|
const target = selected.backendTarget;
|
|
3774
4890
|
if (!target || target.backendType !== 'tmux') {
|
|
3775
|
-
|
|
4891
|
+
flash = { style: 'error', text: 'tmux attach target is missing or inconsistent' };
|
|
3776
4892
|
render();
|
|
3777
4893
|
return;
|
|
3778
4894
|
}
|
|
@@ -3863,16 +4979,16 @@ async function cmdList() {
|
|
|
3863
4979
|
const closeNow = async (arr, kind) => {
|
|
3864
4980
|
let closed = 0;
|
|
3865
4981
|
for (const s of arr) {
|
|
3866
|
-
|
|
3867
|
-
if (result.ok) {
|
|
4982
|
+
if (await pruneSessionAuthoritatively(s)) {
|
|
3868
4983
|
closed++;
|
|
3869
4984
|
}
|
|
3870
4985
|
else {
|
|
3871
|
-
//
|
|
3872
|
-
//
|
|
3873
|
-
//
|
|
4986
|
+
// The owning daemon observed a newly unsettled FIFO after this CLI's
|
|
4987
|
+
// liveness snapshot, or still has the row in memory. Keep it visible
|
|
4988
|
+
// instead of abandoning it: mutating only the store here would let the
|
|
4989
|
+
// next message resurrect exactly the session auto-prune claimed to close.
|
|
3874
4990
|
live.push(s);
|
|
3875
|
-
console.warn(`⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}
|
|
4991
|
+
console.warn(`⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:owner 仍持有该会话或存在未结算派发`);
|
|
3876
4992
|
}
|
|
3877
4993
|
}
|
|
3878
4994
|
return closed;
|
|
@@ -3880,9 +4996,9 @@ async function cmdList() {
|
|
|
3880
4996
|
// Scratches: close silently — they were placeholders, not dead sessions.
|
|
3881
4997
|
await closeNow(prunedScratch, 'scratch');
|
|
3882
4998
|
if (pruned.length > 0) {
|
|
3883
|
-
const
|
|
3884
|
-
if (
|
|
3885
|
-
console.log(`已自动清理 ${
|
|
4999
|
+
const prunedCount = await closeNow(pruned, 'real');
|
|
5000
|
+
if (prunedCount > 0) {
|
|
5001
|
+
console.log(`已自动清理 ${prunedCount} 个不可恢复的会话(进程已退出或无可恢复后端)`);
|
|
3886
5002
|
}
|
|
3887
5003
|
}
|
|
3888
5004
|
// Sort by creation time, newest first
|
|
@@ -3962,21 +5078,26 @@ async function cmdDelete() {
|
|
|
3962
5078
|
if (currentSessionId && toDelete.length > 1) {
|
|
3963
5079
|
toDelete.sort((a, b) => Number(a.sessionId === currentSessionId) - Number(b.sessionId === currentSessionId));
|
|
3964
5080
|
}
|
|
3965
|
-
const online = listOnlineDaemons();
|
|
3966
5081
|
let closed = 0;
|
|
3967
5082
|
let offline = 0;
|
|
3968
5083
|
let failed = 0;
|
|
3969
5084
|
for (const s of toDelete) {
|
|
3970
|
-
|
|
5085
|
+
// Explicit abandon boundary: route through the owning daemon so the ledger
|
|
5086
|
+
// FIFO is cleared atomically with close; offline fallback rereads the row
|
|
5087
|
+
// and clears the latest ledger/commits.
|
|
5088
|
+
const result = await abandonSessionAuthoritatively(s);
|
|
3971
5089
|
if (!result.ok) {
|
|
3972
|
-
console.error(`✗ ${s.sessionId.substring(0, 8)} ${s.title}
|
|
5090
|
+
console.error(`✗ ${s.sessionId.substring(0, 8)} ${s.title}${result.error ? `: ${result.error}` : ''}`);
|
|
3973
5091
|
failed++;
|
|
3974
5092
|
continue;
|
|
3975
5093
|
}
|
|
3976
5094
|
closed++;
|
|
3977
|
-
if (result.
|
|
5095
|
+
if (result.mode === 'offline') {
|
|
3978
5096
|
offline++;
|
|
3979
|
-
|
|
5097
|
+
if (result.cleanedBacking)
|
|
5098
|
+
console.log(` killed ${result.cleanedBacking}`);
|
|
5099
|
+
}
|
|
5100
|
+
console.log(`✓ ${s.sessionId.substring(0, 8)} ${s.title}${result.mode === 'offline' ? '(daemon 离线,本地收口)' : ''}`);
|
|
3980
5101
|
}
|
|
3981
5102
|
console.log(`\n已关闭 ${closed} 个会话${offline ? `(${offline} 个离线收口)` : ''}${failed ? `,${failed} 个失败` : ''}`);
|
|
3982
5103
|
if (failed > 0)
|
|
@@ -4104,7 +5225,7 @@ async function postSessionCliIpc(ipcPort, sessionId, route, payload) {
|
|
|
4104
5225
|
catch { /* sandboxed/read-isolated: capability fallback below */ }
|
|
4105
5226
|
}
|
|
4106
5227
|
if (!hostSecret) {
|
|
4107
|
-
const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY);
|
|
5228
|
+
const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID);
|
|
4108
5229
|
if (claim) {
|
|
4109
5230
|
requestBody.originCapability = claim.capability;
|
|
4110
5231
|
if (claim.turnId)
|
|
@@ -4937,7 +6058,10 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
|
|
|
4937
6058
|
默认使用 botmux 内置 Feishu Web QR 登录尝试自动导入权限/redirect/发布版本;可加 --no-open-platform-auto 跳过
|
|
4938
6059
|
start 启动 daemon,并启动 mode=auto 的插件 service
|
|
4939
6060
|
stop 停止 daemon(默认不停止插件 service;--with-plugin 显式停止 mode=auto 的插件 service)
|
|
4940
|
-
restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service
|
|
6061
|
+
restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service)
|
|
6062
|
+
--include-pm2 仅允许“入场时没有 live PM2 God”的干净启动;若已有 live God,整条命令会在 fleet/breadcrumb 零改动处拒绝,且不会信号或重启现存 God
|
|
6063
|
+
首次升级若旧 daemon 缺少 shutdown protocol:先独立确认所有 Session/Riff 工作均 idle,再一次性运行
|
|
6064
|
+
botmux restart --bootstrap-shutdown-protocol --yes;普通 stop/restart 仍保持 fail-closed
|
|
4941
6065
|
logs 查看 daemon 日志(--lines N, --bot <0-based-index|pm2-name|appId>)
|
|
4942
6066
|
status 查看 daemon 状态
|
|
4943
6067
|
upgrade 升级到最新版本(别名:update)
|
|
@@ -5327,8 +6451,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
|
|
|
5327
6451
|
if (!meta && argFlag(rest, '--create')) {
|
|
5328
6452
|
meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
|
|
5329
6453
|
if (ctx.session) {
|
|
6454
|
+
await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
|
|
5330
6455
|
ctx.session.whiteboardId = meta.id;
|
|
5331
|
-
saveSession(ctx.session);
|
|
5332
6456
|
}
|
|
5333
6457
|
}
|
|
5334
6458
|
if (!meta) {
|
|
@@ -5343,8 +6467,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
|
|
|
5343
6467
|
const ctx = currentWhiteboardContext(rest);
|
|
5344
6468
|
const meta = createWhiteboard({ id: argValue(rest, '--id'), title: argValue(rest, '--title'), larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
|
|
5345
6469
|
if (ctx.session && !ctx.session.whiteboardId) {
|
|
6470
|
+
await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
|
|
5346
6471
|
ctx.session.whiteboardId = meta.id;
|
|
5347
|
-
saveSession(ctx.session);
|
|
5348
6472
|
}
|
|
5349
6473
|
console.log(JSON.stringify({ board: meta, path: whiteboardPath(meta.id) }, null, 2));
|
|
5350
6474
|
return;
|
|
@@ -5366,8 +6490,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
|
|
|
5366
6490
|
const meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
|
|
5367
6491
|
id = meta.id;
|
|
5368
6492
|
if (ctx.session) {
|
|
6493
|
+
await patchSessionWhiteboardAuthoritatively(ctx.session, id);
|
|
5369
6494
|
ctx.session.whiteboardId = id;
|
|
5370
|
-
saveSession(ctx.session);
|
|
5371
6495
|
}
|
|
5372
6496
|
}
|
|
5373
6497
|
if (!id) {
|
|
@@ -6059,7 +7183,7 @@ import { renderBrandTemplate } from './im/lark/brand-template.js';
|
|
|
6059
7183
|
import { resolveBrandLabel, resolveUsageDisplay } from './bot-registry.js';
|
|
6060
7184
|
import { config } from './config.js';
|
|
6061
7185
|
import { getSessionUsageSnapshot } from './core/cost-calculator.js';
|
|
6062
|
-
import { resolveQuoteTarget, validateMentionDecision,
|
|
7186
|
+
import { resolveQuoteTarget, validateMentionDecision, mentionBackAmbiguity, mentionBackAmbiguityError, parseAttentionFlag, attentionUsageError, managedVcQuoteError, managedVcCustomCardError, managedVcSendControlError, managedVcSendPayloadError, containsLarkAtTag, } from './services/send-policy.js';
|
|
6063
7187
|
/**
|
|
6064
7188
|
* Sandbox relay mode for `botmux send`. Inside a file-sandbox the CLI cannot
|
|
6065
7189
|
* read bots.json or reach Lark directly (creds are deliberately absent), so we
|
|
@@ -6113,7 +7237,7 @@ async function relaySend(rest, relayDir) {
|
|
|
6113
7237
|
? prepareCardMarkdown(extractCardText(content), process.cwd(), 'filesystem')
|
|
6114
7238
|
: undefined;
|
|
6115
7239
|
const id = randomBytes(8).toString('hex');
|
|
6116
|
-
const originCapability = readManagedOriginCapability(resolveDataDir(), sid, relayDir)?.capability;
|
|
7240
|
+
const originCapability = readManagedOriginCapability(resolveDataDir(), sid, relayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
|
|
6117
7241
|
// Structured request: the daemon-side watcher rebuilds the argv from these
|
|
6118
7242
|
// validated fields (it NEVER executes raw argv — see buildRelayHostArgs).
|
|
6119
7243
|
// Content + attachments are written into the shared outbox as plain
|
|
@@ -6510,11 +7634,98 @@ async function cmdSend(rest) {
|
|
|
6510
7634
|
// central session-capability gate — same hard door every Feishu-touching CLI
|
|
6511
7635
|
// command consults.
|
|
6512
7636
|
assertTurnTransportOrExit('send');
|
|
6513
|
-
//
|
|
6514
|
-
//
|
|
6515
|
-
//
|
|
6516
|
-
//
|
|
6517
|
-
|
|
7637
|
+
// Resolve isolation marker-first. A visible host marker always wins over a
|
|
7638
|
+
// leftover capability. Linux bwrap keeps its host-execution outbox; macOS
|
|
7639
|
+
// read isolation instead challenges the owning daemon and trusts only the
|
|
7640
|
+
// matching host-written read-only proof sidecar. The capability file itself
|
|
7641
|
+
// may survive worker SIGKILL and is never direct-send authority.
|
|
7642
|
+
let sendDataDir = resolveDataDir();
|
|
7643
|
+
let liveMarkerCtx = findLiveAncestorSessionContext(sendDataDir);
|
|
7644
|
+
const relayDir = process.env.BOTMUX_SEND_RELAY;
|
|
7645
|
+
const sessionIdArg = argValue(rest, '--session-id');
|
|
7646
|
+
const inheritedSessionId = process.env.BOTMUX_SESSION_ID?.trim();
|
|
7647
|
+
const inheritedOriginChannelId = process.env.BOTMUX_ORIGIN_CHANNEL_ID?.trim();
|
|
7648
|
+
const isolationSessionCandidates = [...new Set([
|
|
7649
|
+
inheritedSessionId,
|
|
7650
|
+
ancestorCtx?.sessionId,
|
|
7651
|
+
sessionIdArg,
|
|
7652
|
+
].filter((value) => !!value))];
|
|
7653
|
+
const isolationMarkerPresent = isolationSessionCandidates
|
|
7654
|
+
.some(sessionId => !!inheritedOriginChannelId
|
|
7655
|
+
&& hasManagedOriginIsolationMarker(sendDataDir, sessionId, inheritedOriginChannelId));
|
|
7656
|
+
let osUserHomeDir;
|
|
7657
|
+
try {
|
|
7658
|
+
osUserHomeDir = userInfo().homedir;
|
|
7659
|
+
}
|
|
7660
|
+
catch {
|
|
7661
|
+
console.error('botmux send refused: OS account home unavailable for isolation classification');
|
|
7662
|
+
process.exit(2);
|
|
7663
|
+
}
|
|
7664
|
+
if (!osUserHomeDir) {
|
|
7665
|
+
console.error('botmux send refused: OS account home unavailable for isolation classification');
|
|
7666
|
+
process.exit(2);
|
|
7667
|
+
}
|
|
7668
|
+
const kernelReadIsolationDetected = managedOriginLegacyIsolationProbeAccess(osUserHomeDir)
|
|
7669
|
+
=== 'sandbox_denied'
|
|
7670
|
+
|| managedOriginIsolationSentinelAccess(osUserHomeDir) === 'sandbox_denied';
|
|
7671
|
+
const isolatedSendRequired = !relayDir
|
|
7672
|
+
&& (kernelReadIsolationDetected
|
|
7673
|
+
|| process.env.BOTMUX_READ_ISOLATED === '1'
|
|
7674
|
+
|| (!liveMarkerCtx?.sessionId && isolationMarkerPresent));
|
|
7675
|
+
let isolatedBoundSessionId;
|
|
7676
|
+
if (isolatedSendRequired) {
|
|
7677
|
+
if (!inheritedOriginChannelId || !/^[a-f0-9]{64}$/.test(inheritedOriginChannelId)) {
|
|
7678
|
+
console.error('botmux send refused: read-isolated pane authority channel is missing or invalid');
|
|
7679
|
+
process.exit(2);
|
|
7680
|
+
}
|
|
7681
|
+
const locators = isolationSessionCandidates.flatMap(sessionId => {
|
|
7682
|
+
const locator = readManagedOriginRootLocator(osUserHomeDir, sessionId);
|
|
7683
|
+
return locator ? [locator] : [];
|
|
7684
|
+
});
|
|
7685
|
+
if (locators.length !== 1) {
|
|
7686
|
+
console.error('botmux send refused: read-isolated owning data-root locator is missing or ambiguous');
|
|
7687
|
+
process.exit(2);
|
|
7688
|
+
}
|
|
7689
|
+
const locator = locators[0];
|
|
7690
|
+
if ((sessionIdArg && sessionIdArg !== locator.sessionId)
|
|
7691
|
+
|| (inheritedSessionId && inheritedSessionId !== locator.sessionId)) {
|
|
7692
|
+
console.error('botmux send refused: read-isolated session does not match the owning data-root locator');
|
|
7693
|
+
process.exit(2);
|
|
7694
|
+
}
|
|
7695
|
+
isolatedBoundSessionId = locator.sessionId;
|
|
7696
|
+
sendDataDir = locator.dataDir;
|
|
7697
|
+
// A locator is data, not authority. Require the kernel to deny the probe
|
|
7698
|
+
// under the locator-selected *actual* Botmux root. Legacy Seatbelt profiles
|
|
7699
|
+
// already denied this dashboard-secret basename class at their real root;
|
|
7700
|
+
// a child-forged fake root remains readable and is therefore rejected.
|
|
7701
|
+
if (managedOriginDataRootProbeAccess(sendDataDir, locator.sessionId)
|
|
7702
|
+
!== 'sandbox_denied') {
|
|
7703
|
+
console.error('botmux send refused: locator-selected data root is not protected by the active sandbox');
|
|
7704
|
+
process.exit(2);
|
|
7705
|
+
}
|
|
7706
|
+
// All later session/credential/marker reads in this short-lived command
|
|
7707
|
+
// must use the host-bound root, never the child's mutable inherited value.
|
|
7708
|
+
process.env.SESSION_DATA_DIR = sendDataDir;
|
|
7709
|
+
liveMarkerCtx = findLiveAncestorSessionContext(sendDataDir);
|
|
7710
|
+
}
|
|
7711
|
+
const isolatedCapabilityCtx = !isolatedSendRequired && liveMarkerCtx?.sessionId
|
|
7712
|
+
? null
|
|
7713
|
+
: readWorkflowSessionRelayContext({
|
|
7714
|
+
env: process.env,
|
|
7715
|
+
dataDir: sendDataDir,
|
|
7716
|
+
// Keep one marker snapshot for the whole decision. In particular, do
|
|
7717
|
+
// not let resolveSessionContext's protected-capability fallback get
|
|
7718
|
+
// mislabeled as a live process marker.
|
|
7719
|
+
findMarker: () => isolatedSendRequired ? null : liveMarkerCtx,
|
|
7720
|
+
});
|
|
7721
|
+
if (isolatedSendRequired
|
|
7722
|
+
&& (isolatedCapabilityCtx?.sessionId !== isolatedBoundSessionId
|
|
7723
|
+
|| isolatedCapabilityCtx?.originChannelId !== inheritedOriginChannelId)) {
|
|
7724
|
+
console.error('botmux send refused: read-isolated capability is not bound to this session');
|
|
7725
|
+
process.exit(2);
|
|
7726
|
+
}
|
|
7727
|
+
let isolatedAttestationContext;
|
|
7728
|
+
let isolatedManagedOriginCtx;
|
|
6518
7729
|
const trustedHostRelay = process.env.BOTMUX_HOST_RELAY_AUTHORIZED === '1';
|
|
6519
7730
|
const trustedRelayAttemptRaw = Number(process.env.BOTMUX_DISPATCH_ATTEMPT);
|
|
6520
7731
|
const trustedRelayCandidate = trustedHostRelay && process.env.BOTMUX_SESSION_ID
|
|
@@ -6526,15 +7737,59 @@ async function cmdSend(rest) {
|
|
|
6526
7737
|
: undefined,
|
|
6527
7738
|
}
|
|
6528
7739
|
: undefined;
|
|
6529
|
-
const sessionIdArg = argValue(rest, '--session-id');
|
|
6530
7740
|
// Inside bwrap the PID namespace makes host marker traversal impossible.
|
|
6531
7741
|
// The relay watcher therefore binds a short-lived host-issued capability to
|
|
6532
7742
|
// the worker's live turn and performs the authoritative policy check.
|
|
6533
|
-
|
|
6534
|
-
if (relayDir) {
|
|
7743
|
+
if (relayDir && isolatedCapabilityCtx) {
|
|
6535
7744
|
await relaySend(rest, relayDir);
|
|
6536
7745
|
return;
|
|
6537
7746
|
}
|
|
7747
|
+
if (relayDir && !liveMarkerCtx?.sessionId) {
|
|
7748
|
+
// The child may delete or replace its writable outbox capability, while
|
|
7749
|
+
// the immutable default snapshot remains visible. That snapshot is only a
|
|
7750
|
+
// routing hint and can survive worker death; never fall through to direct
|
|
7751
|
+
// Lark send when the live relay token is absent.
|
|
7752
|
+
console.error('botmux send refused: managed host relay capability is stale or missing');
|
|
7753
|
+
process.exit(2);
|
|
7754
|
+
}
|
|
7755
|
+
if (!relayDir && isolatedSendRequired && !isolatedCapabilityCtx) {
|
|
7756
|
+
// Isolation classification must not depend on successfully parsing the
|
|
7757
|
+
// rotating token. A missing/corrupt/revoked capability is an authorization
|
|
7758
|
+
// failure, never permission to downgrade into the ordinary direct path.
|
|
7759
|
+
console.error('botmux send refused: read-isolated managed origin capability is stale, corrupt, or missing');
|
|
7760
|
+
process.exit(2);
|
|
7761
|
+
}
|
|
7762
|
+
if (!relayDir && isolatedCapabilityCtx) {
|
|
7763
|
+
isolatedAttestationContext = {
|
|
7764
|
+
sessionId: isolatedCapabilityCtx.sessionId,
|
|
7765
|
+
channelId: isolatedCapabilityCtx.originChannelId,
|
|
7766
|
+
capability: isolatedCapabilityCtx.capability,
|
|
7767
|
+
dataDir: sendDataDir,
|
|
7768
|
+
...(isolatedCapabilityCtx.larkAppId
|
|
7769
|
+
? { larkAppId: isolatedCapabilityCtx.larkAppId }
|
|
7770
|
+
: {}),
|
|
7771
|
+
...(isolatedCapabilityCtx.ipcPortFallback !== undefined
|
|
7772
|
+
? { ipcPortFallback: isolatedCapabilityCtx.ipcPortFallback }
|
|
7773
|
+
: {}),
|
|
7774
|
+
};
|
|
7775
|
+
try {
|
|
7776
|
+
isolatedManagedOriginCtx = await attestManagedOrigin({
|
|
7777
|
+
context: isolatedAttestationContext,
|
|
7778
|
+
resolveIpcPort: (larkAppId) => {
|
|
7779
|
+
try {
|
|
7780
|
+
return larkAppId ? findDaemon(larkAppId)?.ipcPort : undefined;
|
|
7781
|
+
}
|
|
7782
|
+
catch {
|
|
7783
|
+
return undefined;
|
|
7784
|
+
}
|
|
7785
|
+
},
|
|
7786
|
+
});
|
|
7787
|
+
}
|
|
7788
|
+
catch (err) {
|
|
7789
|
+
console.error(`botmux send refused: ${err instanceof Error ? err.message : String(err)}`);
|
|
7790
|
+
process.exit(2);
|
|
7791
|
+
}
|
|
7792
|
+
}
|
|
6538
7793
|
// Silent is an execution policy, not a prompt suggestion. During a durable
|
|
6539
7794
|
// meeting turn, refuse botmux-mediated sends before either the direct-Lark or
|
|
6540
7795
|
// sandbox-relay path can run. Explicit human IM turns have no dispatchAttempt
|
|
@@ -6549,23 +7804,54 @@ async function cmdSend(rest) {
|
|
|
6549
7804
|
const trustedRelayCtx = trustedRelayCandidate && isTrustedVcMeetingHostRelayParent(trustedHostRelay, trustedRelaySession?.pid, process.ppid)
|
|
6550
7805
|
? trustedRelayCandidate
|
|
6551
7806
|
: undefined;
|
|
7807
|
+
// Keep origin authority independent from the requested destination. In
|
|
7808
|
+
// particular, `--session-id <other>` may select where an ordinary Lark turn
|
|
7809
|
+
// is sent, but it must never replace the session whose durable delivery sink
|
|
7810
|
+
// governs this process. Session identity may fall back to the inherited env
|
|
7811
|
+
// for detached commands; the turn tuple may not. `ancestorCtx` deliberately
|
|
7812
|
+
// adds spawn-time BOTMUX_TURN_ID/ATTEMPT compatibility fallbacks, which can be
|
|
7813
|
+
// stale after a later turn, so only a parent-bound host relay or a fresh
|
|
7814
|
+
// marker/protected-capability tuple can authorize a durable dispatch.
|
|
6552
7815
|
const originSessionId = trustedRelayCtx?.sessionId
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
7816
|
+
?? liveMarkerCtx?.sessionId
|
|
7817
|
+
?? isolatedManagedOriginCtx?.sessionId
|
|
7818
|
+
?? ancestorCtx?.sessionId
|
|
7819
|
+
?? process.env.BOTMUX_SESSION_ID;
|
|
7820
|
+
const authoritativeOriginTurnCtx = trustedRelayCtx
|
|
7821
|
+
? (trustedRelayCtx.turnId ? trustedRelayCtx : undefined)
|
|
7822
|
+
: (liveMarkerCtx?.turnId
|
|
7823
|
+
? liveMarkerCtx
|
|
7824
|
+
: isolatedManagedOriginCtx?.turnId
|
|
7825
|
+
? isolatedManagedOriginCtx
|
|
7826
|
+
: undefined);
|
|
7827
|
+
const originTurnId = authoritativeOriginTurnCtx?.turnId;
|
|
7828
|
+
const originDispatchAttempt = authoritativeOriginTurnCtx?.dispatchAttempt;
|
|
7829
|
+
const originSession = originSessionId
|
|
7830
|
+
? sessionsForOrigin.get(originSessionId)
|
|
7831
|
+
: undefined;
|
|
7832
|
+
const hostRelayRequiresCodexAppLedger = !!trustedRelayCtx
|
|
7833
|
+
&& process.env.BOTMUX_HOST_RELAY_REQUIRES_CODEX_APP_LEDGER === '1';
|
|
7834
|
+
// The live worker authorizes a Codex App relay only while an exact durable
|
|
7835
|
+
// dispatch remains unsettled. Recheck that prerequisite before any other
|
|
7836
|
+
// managed-output policy or provider setup: terminal settlement can win the
|
|
7837
|
+
// race between watcher authorization and this host child loading the store,
|
|
7838
|
+
// and must never downgrade the send into an ordinary Lark message.
|
|
7839
|
+
if ((hostRelayRequiresCodexAppLedger || isolatedManagedOriginCtx?.requiresCodexAppLedger)
|
|
7840
|
+
&& (originSession?.codexAppDispatchLedger?.length ?? 0) === 0) {
|
|
7841
|
+
console.error(`botmux send refused: authorized Codex App origin ${originSessionId}/${originTurnId} is no longer unsettled`);
|
|
7842
|
+
process.exit(2);
|
|
7843
|
+
}
|
|
6556
7844
|
let explicitVcMeetingImOrigin;
|
|
6557
7845
|
let vcMeetingListenerOutputOwner;
|
|
6558
7846
|
let vcMeetingManagedSendOrigin;
|
|
6559
7847
|
let vcMeetingDeliveryReplyOrigin;
|
|
6560
7848
|
if (originSessionId) {
|
|
6561
|
-
const originSession = sessionsForOrigin.get(originSessionId);
|
|
6562
|
-
const originTurnId = trustedRelayCtx?.turnId ?? liveMarkerCtx?.turnId;
|
|
6563
7849
|
const imOrigin = resolveVcMeetingImTurnOrigin(originSession, originTurnId);
|
|
6564
7850
|
const managedOrigin = {
|
|
6565
7851
|
receiverSessionId: originSessionId,
|
|
6566
7852
|
receiverSession: !!originSession?.vcMeetingReceiver,
|
|
6567
7853
|
turnId: originTurnId,
|
|
6568
|
-
dispatchAttempt:
|
|
7854
|
+
dispatchAttempt: originDispatchAttempt,
|
|
6569
7855
|
currentImTurnOrigin: imOrigin,
|
|
6570
7856
|
};
|
|
6571
7857
|
const decision = evaluateVcMeetingManagedSend(resolveDataDir(), managedOrigin);
|
|
@@ -6585,7 +7871,7 @@ async function cmdSend(rest) {
|
|
|
6585
7871
|
}
|
|
6586
7872
|
}
|
|
6587
7873
|
if (decision.kind === 'listener_thread'
|
|
6588
|
-
&&
|
|
7874
|
+
&& originDispatchAttempt === undefined) {
|
|
6589
7875
|
explicitVcMeetingImOrigin = imOrigin;
|
|
6590
7876
|
}
|
|
6591
7877
|
}
|
|
@@ -6728,7 +8014,7 @@ async function cmdSend(rest) {
|
|
|
6728
8014
|
process.exit(1);
|
|
6729
8015
|
}
|
|
6730
8016
|
const sessions = loadSessions();
|
|
6731
|
-
const currentTurnId =
|
|
8017
|
+
const currentTurnId = originTurnId;
|
|
6732
8018
|
let s = sessions.get(sid);
|
|
6733
8019
|
// Riff (remote backend) sandbox: no local daemon/sessions.json/bots.json.
|
|
6734
8020
|
// Fall back to env-var-only mode so `botmux send` works without a daemon.
|
|
@@ -6770,9 +8056,154 @@ async function cmdSend(rest) {
|
|
|
6770
8056
|
assertSessionTransportOrExit({ chatId: s.chatId, larkAppId: s.larkAppId }, 'send');
|
|
6771
8057
|
let deferredMaterializedByThisCommand = false;
|
|
6772
8058
|
let deferredTopicRootMessageIdForOutput;
|
|
6773
|
-
//
|
|
6774
|
-
//
|
|
8059
|
+
// Prefer the exact per-turn reply anchor; the latest single slot is only a
|
|
8060
|
+
// compatibility fallback for sessions persisted before replyTargets.
|
|
6775
8061
|
const turnReplyTarget = pickTurnReplyTarget(s, currentTurnId);
|
|
8062
|
+
const exactOriginDispatch = (() => {
|
|
8063
|
+
const unsettledOriginDispatches = originSession?.codexAppDispatchLedger ?? [];
|
|
8064
|
+
// The live worker authorized this host re-exec against an exact unsettled
|
|
8065
|
+
// Codex App entry. If terminal settlement/revocation won the race before
|
|
8066
|
+
// this child reloaded the store, never degrade into an ordinary Lark send
|
|
8067
|
+
// against mutable session state.
|
|
8068
|
+
if (unsettledOriginDispatches.length === 0) {
|
|
8069
|
+
return undefined;
|
|
8070
|
+
}
|
|
8071
|
+
// A detached/backgrounded command can still inherit the correct session id
|
|
8072
|
+
// but only a spawn-time (and now stale) turn env. If this session has any
|
|
8073
|
+
// unsettled durable output, absence of a fresh marker/capability tuple must
|
|
8074
|
+
// fail closed rather than silently falling through to ordinary Lark IM.
|
|
8075
|
+
if (!originTurnId) {
|
|
8076
|
+
console.error(`botmux send refused: origin session ${originSessionId} has unsettled durable output but no fresh authoritative dispatch identity`);
|
|
8077
|
+
process.exit(2);
|
|
8078
|
+
}
|
|
8079
|
+
const turnMatches = unsettledOriginDispatches
|
|
8080
|
+
.filter(entry => entry.turnId === originTurnId);
|
|
8081
|
+
const exactMatches = originDispatchAttempt === undefined
|
|
8082
|
+
? turnMatches
|
|
8083
|
+
: turnMatches.filter(entry => entry.dispatchAttempt === originDispatchAttempt);
|
|
8084
|
+
// Once a durable turn exists, a supplied attempt must match it exactly.
|
|
8085
|
+
// Falling back to another attempt would let a stale/replayed process select
|
|
8086
|
+
// the wrong sink. With no attempt, multiple entries are equally unsafe.
|
|
8087
|
+
if (exactMatches.length !== 1) {
|
|
8088
|
+
const detail = originDispatchAttempt === undefined
|
|
8089
|
+
? `${turnMatches.length} ledger entries share the turn id`
|
|
8090
|
+
: `${exactMatches.length} ledger entries match attempt ${originDispatchAttempt}`;
|
|
8091
|
+
console.error(`botmux send refused: origin dispatch ${originSessionId}/${originTurnId} is ambiguous (${detail})`);
|
|
8092
|
+
process.exit(2);
|
|
8093
|
+
}
|
|
8094
|
+
return exactMatches[0];
|
|
8095
|
+
})();
|
|
8096
|
+
// Frozen reply routing applies only when the selected destination is the
|
|
8097
|
+
// trusted origin session. A cross-session publish must not accidentally
|
|
8098
|
+
// reuse a coincidentally-equal turn id from the destination ledger.
|
|
8099
|
+
const frozenTurnDispatch = originSessionId === sid
|
|
8100
|
+
? exactOriginDispatch
|
|
8101
|
+
: undefined;
|
|
8102
|
+
const frozenTurnReplyTarget = frozenTurnDispatch?.replyTarget;
|
|
8103
|
+
if (exactOriginDispatch?.deliverySink === 'http_wait'
|
|
8104
|
+
|| exactOriginDispatch?.deliverySink === 'http_async'
|
|
8105
|
+
|| exactOriginDispatch?.deliverySink === 'suppressed') {
|
|
8106
|
+
console.error(`botmux send refused: origin turn ${originTurnId} is bound to the `
|
|
8107
|
+
+ `${exactOriginDispatch.deliverySink} host sink`);
|
|
8108
|
+
process.exit(2);
|
|
8109
|
+
}
|
|
8110
|
+
// A proof is a point-in-time liveness check, not a five-second send lease.
|
|
8111
|
+
// Re-challenge immediately before observable provider effects so lengthy
|
|
8112
|
+
// local parsing/card preparation cannot carry an old capability across a
|
|
8113
|
+
// worker restart, turn rotation, or Codex ledger settlement.
|
|
8114
|
+
const revalidateIsolatedOriginBeforeEffect = async () => {
|
|
8115
|
+
if (!isolatedAttestationContext || !isolatedManagedOriginCtx)
|
|
8116
|
+
return undefined;
|
|
8117
|
+
const fresh = await attestManagedOrigin({
|
|
8118
|
+
context: isolatedAttestationContext,
|
|
8119
|
+
resolveIpcPort: (larkAppId) => {
|
|
8120
|
+
try {
|
|
8121
|
+
return larkAppId ? findDaemon(larkAppId)?.ipcPort : undefined;
|
|
8122
|
+
}
|
|
8123
|
+
catch {
|
|
8124
|
+
return undefined;
|
|
8125
|
+
}
|
|
8126
|
+
},
|
|
8127
|
+
});
|
|
8128
|
+
if (fresh.sessionId !== isolatedManagedOriginCtx.sessionId
|
|
8129
|
+
|| fresh.turnId !== isolatedManagedOriginCtx.turnId
|
|
8130
|
+
|| fresh.dispatchAttempt !== isolatedManagedOriginCtx.dispatchAttempt
|
|
8131
|
+
|| fresh.requiresCodexAppLedger !== isolatedManagedOriginCtx.requiresCodexAppLedger) {
|
|
8132
|
+
throw new Error('managed origin changed before provider effect');
|
|
8133
|
+
}
|
|
8134
|
+
const currentOriginSession = loadSessions().get(fresh.sessionId);
|
|
8135
|
+
const ledgerDecision = validateCodexAppManagedSendOrigin(currentOriginSession?.codexAppDispatchLedger, fresh, fresh.requiresCodexAppLedger);
|
|
8136
|
+
if (!ledgerDecision.ok
|
|
8137
|
+
|| ledgerDecision.requiresLedger !== fresh.requiresCodexAppLedger) {
|
|
8138
|
+
throw new Error(`managed origin ledger changed before provider effect${ledgerDecision.ok ? '' : `: ${ledgerDecision.error}`}`);
|
|
8139
|
+
}
|
|
8140
|
+
return fresh;
|
|
8141
|
+
};
|
|
8142
|
+
const fenceIsolatedOriginBeforeEffect = async () => {
|
|
8143
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8144
|
+
};
|
|
8145
|
+
const isolatedHookOrigin = isolatedAttestationContext?.ipcPortFallback
|
|
8146
|
+
&& isolatedManagedOriginCtx
|
|
8147
|
+
? {
|
|
8148
|
+
ipcPort: isolatedAttestationContext.ipcPortFallback,
|
|
8149
|
+
sessionId: isolatedManagedOriginCtx.sessionId,
|
|
8150
|
+
capability: isolatedAttestationContext.capability,
|
|
8151
|
+
turnId: isolatedManagedOriginCtx.turnId,
|
|
8152
|
+
...(isolatedManagedOriginCtx.dispatchAttempt !== undefined
|
|
8153
|
+
? { dispatchAttempt: isolatedManagedOriginCtx.dispatchAttempt }
|
|
8154
|
+
: {}),
|
|
8155
|
+
}
|
|
8156
|
+
: undefined;
|
|
8157
|
+
// Outbound hooks are a distinct post-provider effect. Preserve normal hook
|
|
8158
|
+
// behavior, but bind it to a fresh challenge of this command's original
|
|
8159
|
+
// protected claim. The Lark client treats fence failure as hook-only loss so
|
|
8160
|
+
// an already-delivered primary is never reported failed and duplicated.
|
|
8161
|
+
const outboundMessageOptions = (suppressHook = false) => suppressHook
|
|
8162
|
+
? { suppressHook: true }
|
|
8163
|
+
: isolatedAttestationContext
|
|
8164
|
+
? isolatedHookOrigin
|
|
8165
|
+
? {
|
|
8166
|
+
beforeHook: fenceIsolatedOriginBeforeEffect,
|
|
8167
|
+
hookOrigin: isolatedHookOrigin,
|
|
8168
|
+
}
|
|
8169
|
+
: { suppressHook: true }
|
|
8170
|
+
: undefined;
|
|
8171
|
+
// A document-comment turn has exactly one supported observable effect: a
|
|
8172
|
+
// plain text reply to its frozen origin target. Validate the complete shape
|
|
8173
|
+
// before reading stdin/content/card files and before any TTS, upload, or Lark
|
|
8174
|
+
// provider call. In particular, an explicit destination session is not an
|
|
8175
|
+
// escape hatch from the origin sink.
|
|
8176
|
+
const docTarget = originTurnId
|
|
8177
|
+
? originSession?.docCommentTargets?.[originTurnId]
|
|
8178
|
+
: undefined;
|
|
8179
|
+
// Codex App turns are governed exclusively by their exact dispatch ledger:
|
|
8180
|
+
// a settled/missing ledger entry must never fall back to mutable session
|
|
8181
|
+
// state. Other CLI adapters predate that ledger, so their frozen per-turn
|
|
8182
|
+
// docCommentTargets entry remains the authoritative origin sink.
|
|
8183
|
+
const isOriginDocCommentTurn = exactOriginDispatch?.deliverySink === 'doc_comment'
|
|
8184
|
+
|| (!exactOriginDispatch && originSession?.cliId !== 'codex-app' && !!docTarget);
|
|
8185
|
+
if (isOriginDocCommentTurn) {
|
|
8186
|
+
if (!docTarget || !originSession?.larkAppId) {
|
|
8187
|
+
console.error('botmux send refused: this turn is bound to a document comment, but its exact origin target is no longer available');
|
|
8188
|
+
process.exit(2);
|
|
8189
|
+
}
|
|
8190
|
+
if (sid !== originSessionId
|
|
8191
|
+
|| sendTopLevel
|
|
8192
|
+
|| !!overrideChatId
|
|
8193
|
+
|| !!sendInto
|
|
8194
|
+
|| asVoice
|
|
8195
|
+
|| images.length > 0
|
|
8196
|
+
|| files.length > 0
|
|
8197
|
+
|| videoAttachments.length > 0
|
|
8198
|
+
|| videoCovers.length > 0
|
|
8199
|
+
|| customCardRequested
|
|
8200
|
+
|| attention.requested
|
|
8201
|
+
|| explicitQuote !== undefined
|
|
8202
|
+
|| noQuote) {
|
|
8203
|
+
console.error('botmux send refused: a document-comment turn supports only its exact plain-text comment reply');
|
|
8204
|
+
process.exit(2);
|
|
8205
|
+
}
|
|
8206
|
+
}
|
|
6776
8207
|
// Read content from: --content-file > positional arg > stdin
|
|
6777
8208
|
let content = '';
|
|
6778
8209
|
let customCard;
|
|
@@ -6894,8 +8325,12 @@ async function cmdSend(rest) {
|
|
|
6894
8325
|
const targetChatId = overrideChatId ?? s.chatId;
|
|
6895
8326
|
let dir;
|
|
6896
8327
|
try {
|
|
6897
|
-
|
|
8328
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8329
|
+
const out = await synthesizeVoiceOpus(appId, content, {
|
|
8330
|
+
beforeProviderEffect: fenceIsolatedOriginBeforeEffect,
|
|
8331
|
+
});
|
|
6898
8332
|
dir = out.dir;
|
|
8333
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
6899
8334
|
const fileKey = await uploadFile(appId, out.path, { duration: out.durationMs });
|
|
6900
8335
|
const sentAtMs = Date.now();
|
|
6901
8336
|
const proposedOutput = {
|
|
@@ -6918,9 +8353,7 @@ async function cmdSend(rest) {
|
|
|
6918
8353
|
}
|
|
6919
8354
|
else {
|
|
6920
8355
|
revalidateVcMeetingManagedSend();
|
|
6921
|
-
const managedProviderOptions = prepared
|
|
6922
|
-
? { suppressHook: true }
|
|
6923
|
-
: undefined;
|
|
8356
|
+
const managedProviderOptions = outboundMessageOptions(!!prepared);
|
|
6924
8357
|
const deferred = !sendInto && (!overrideChatId || overrideChatId === s.chatId)
|
|
6925
8358
|
? await dispatchDeferredTopicSend({
|
|
6926
8359
|
dataDir: resolveDataDir(),
|
|
@@ -6931,9 +8364,18 @@ async function cmdSend(rest) {
|
|
|
6931
8364
|
content: canonicalOutput.content,
|
|
6932
8365
|
msgType: canonicalOutput.msgType,
|
|
6933
8366
|
uuid: prepared?.providerKey,
|
|
6934
|
-
sendRoot: (body, type, uuid) =>
|
|
6935
|
-
|
|
6936
|
-
|
|
8367
|
+
sendRoot: async (body, type, uuid) => {
|
|
8368
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8369
|
+
return sendMessage(appId, targetChatId, body, type, uuid, undefined, managedProviderOptions);
|
|
8370
|
+
},
|
|
8371
|
+
sendTitleSeed: async (title, uuid) => {
|
|
8372
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8373
|
+
return sendMessage(appId, targetChatId, title, 'text', uuid);
|
|
8374
|
+
},
|
|
8375
|
+
replyRoot: async (root, body, type, uuid) => {
|
|
8376
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8377
|
+
return replyMessage(appId, root, body, type, true, uuid, undefined, managedProviderOptions);
|
|
8378
|
+
},
|
|
6937
8379
|
})
|
|
6938
8380
|
: { handled: false };
|
|
6939
8381
|
if (deferred.handled && deferred.messageId) {
|
|
@@ -6942,17 +8384,20 @@ async function cmdSend(rest) {
|
|
|
6942
8384
|
messageId = deferred.messageId;
|
|
6943
8385
|
}
|
|
6944
8386
|
else {
|
|
6945
|
-
const canonicalTarget =
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
8387
|
+
const canonicalTarget = !sendInto && !sendTopLevel && !overrideChatId && frozenTurnReplyTarget
|
|
8388
|
+
? frozenTurnReplyTarget
|
|
8389
|
+
: resolveSendTarget({
|
|
8390
|
+
into: sendInto,
|
|
8391
|
+
topLevel: sendTopLevel,
|
|
8392
|
+
chatScope: s.scope === 'chat',
|
|
8393
|
+
chatId: canonicalOutput.targetChatId,
|
|
8394
|
+
rootMessageId: s.rootMessageId,
|
|
8395
|
+
replyTargetRootId: turnReplyTarget?.rootMessageId,
|
|
8396
|
+
replyTargetTurnId: turnReplyTarget?.turnId,
|
|
8397
|
+
replyTargetQuoteOnly: turnReplyTarget?.quoteOnly,
|
|
8398
|
+
currentTurnId,
|
|
8399
|
+
});
|
|
8400
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
6956
8401
|
messageId = canonicalTarget.mode === 'plain'
|
|
6957
8402
|
? await sendMessage(appId, canonicalTarget.chatId, canonicalOutput.content, canonicalOutput.msgType, prepared?.providerKey, undefined, managedProviderOptions)
|
|
6958
8403
|
: await replyMessage(appId, canonicalTarget.rootMessageId, canonicalOutput.content, canonicalOutput.msgType, canonicalTarget.mode === 'thread', prepared?.providerKey, undefined, managedProviderOptions);
|
|
@@ -7010,17 +8455,12 @@ async function cmdSend(rest) {
|
|
|
7010
8455
|
return;
|
|
7011
8456
|
}
|
|
7012
8457
|
// ── 文档评论入口分流(/watch-comment / /subscribe-lark-doc)─────────────────
|
|
7013
|
-
//
|
|
7014
|
-
//
|
|
7015
|
-
//
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
if (docTarget && !sendTopLevel && !overrideChatId && !sendInto) {
|
|
7020
|
-
if (customCardRequested) {
|
|
7021
|
-
console.error('botmux send: 文档评论回复不支持 --card-file/--card-json;请改用普通文本,或显式 --top-level/--chat-id 发到飞书群');
|
|
7022
|
-
process.exit(2);
|
|
7023
|
-
}
|
|
8458
|
+
// The authority and payload-shape checks ran before content/provider work.
|
|
8459
|
+
// Use only the trusted origin session here; `--session-id` is a destination
|
|
8460
|
+
// selector for ordinary Lark turns and cannot retarget a document turn.
|
|
8461
|
+
if (isOriginDocCommentTurn) {
|
|
8462
|
+
const exactDocTarget = docTarget;
|
|
8463
|
+
const exactDocSession = originSession;
|
|
7024
8464
|
const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
|
|
7025
8465
|
try {
|
|
7026
8466
|
for (const cfg of loadBotConfigs())
|
|
@@ -7034,14 +8474,13 @@ async function cmdSend(rest) {
|
|
|
7034
8474
|
catch { /* */ }
|
|
7035
8475
|
}
|
|
7036
8476
|
const { replyToDocComment, chunkCommentText, removeCommentReaction } = await import('./im/lark/doc-comment.js');
|
|
7037
|
-
const appId =
|
|
7038
|
-
const loc = localeForBot(appId);
|
|
8477
|
+
const appId = exactDocSession.larkAppId;
|
|
7039
8478
|
try {
|
|
7040
8479
|
// @ 落点:--mention-back → 回 @ 原评论人;--mention <open_id[:name]> → @ 指定人;
|
|
7041
8480
|
// 否则(--no-mention / 无)不 @。文档评论里靠 person 元素渲染 @,仅首块加。
|
|
7042
8481
|
let docMentionOpenId;
|
|
7043
8482
|
if (mentionBack)
|
|
7044
|
-
docMentionOpenId =
|
|
8483
|
+
docMentionOpenId = exactDocTarget.replyToOpenId;
|
|
7045
8484
|
else if (mentionArgs.length > 0) {
|
|
7046
8485
|
const first = mentionArgs[0];
|
|
7047
8486
|
const idx = first.indexOf(':');
|
|
@@ -7050,11 +8489,11 @@ async function cmdSend(rest) {
|
|
|
7050
8489
|
// 嵌套回复到用户那条评论 thread(已挂其下,无需 ↪ 前缀)。
|
|
7051
8490
|
const chunks = chunkCommentText(content);
|
|
7052
8491
|
for (let i = 0; i < chunks.length; i++) {
|
|
7053
|
-
await replyToDocComment(appId, { fileToken:
|
|
8492
|
+
await replyToDocComment(appId, { fileToken: exactDocTarget.fileToken, fileType: exactDocTarget.fileType }, exactDocTarget.commentId, chunks[i], i === 0 ? docMentionOpenId : undefined, { beforeProviderEffect: fenceIsolatedOriginBeforeEffect });
|
|
7054
8493
|
}
|
|
7055
8494
|
// 清理 "Typing" reaction(bot 已回复完毕)。
|
|
7056
|
-
if (
|
|
7057
|
-
await removeCommentReaction(appId, { fileToken:
|
|
8495
|
+
if (exactDocTarget.reactionId && exactDocTarget.replyId) {
|
|
8496
|
+
await removeCommentReaction(appId, { fileToken: exactDocTarget.fileToken, fileType: exactDocTarget.fileType }, exactDocTarget.commentId, exactDocTarget.replyId, exactDocTarget.reactionId, { beforeProviderEffect: fenceIsolatedOriginBeforeEffect });
|
|
7058
8497
|
}
|
|
7059
8498
|
// 写 bridge send marker → 抑制 worker 的 final_output 兜底(否则会再补一条评论)。
|
|
7060
8499
|
try {
|
|
@@ -7063,25 +8502,20 @@ async function cmdSend(rest) {
|
|
|
7063
8502
|
mkdirSync(markerDir, { recursive: true });
|
|
7064
8503
|
const marker = {
|
|
7065
8504
|
sentAtMs: Date.now(),
|
|
7066
|
-
messageId: `doc:${
|
|
8505
|
+
messageId: `doc:${exactDocTarget.commentId}`,
|
|
7067
8506
|
contentLength: content.length,
|
|
7068
8507
|
};
|
|
7069
8508
|
const previewText = buildBridgeSendPreviewText(content);
|
|
7070
8509
|
if (previewText)
|
|
7071
8510
|
marker.previewText = previewText;
|
|
7072
|
-
appendFileSync(join(markerDir, `${
|
|
8511
|
+
appendFileSync(join(markerDir, `${originSessionId}.jsonl`), JSON.stringify(marker) + '\n');
|
|
7073
8512
|
}
|
|
7074
8513
|
catch { /* best-effort:漏记只多一条兜底 */ }
|
|
7075
|
-
//
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
}
|
|
7081
|
-
catch { /* best-effort */ }
|
|
7082
|
-
}
|
|
7083
|
-
console.error(`✓ 已回复文档评论 ${docTarget.commentId.slice(0, 12)}(${chunks.length} 条)`);
|
|
7084
|
-
console.log(JSON.stringify({ success: true, commentId: docTarget.commentId, sessionId: sid, kind: 'doc-comment', chunks: chunks.length }));
|
|
8514
|
+
// Do not write this startup snapshot back after the async provider calls:
|
|
8515
|
+
// the daemon may have advanced the dispatch ledger or accepted another
|
|
8516
|
+
// turn in the meantime. Daemon settlement owns exact target retirement.
|
|
8517
|
+
console.error(`✓ 已回复文档评论 ${exactDocTarget.commentId.slice(0, 12)}(${chunks.length} 条)`);
|
|
8518
|
+
console.log(JSON.stringify({ success: true, commentId: exactDocTarget.commentId, sessionId: originSessionId, kind: 'doc-comment', chunks: chunks.length }));
|
|
7085
8519
|
}
|
|
7086
8520
|
catch (e) {
|
|
7087
8521
|
console.error(`文档评论发送失败:${e?.message ?? e}`);
|
|
@@ -7104,7 +8538,16 @@ async function cmdSend(rest) {
|
|
|
7104
8538
|
}
|
|
7105
8539
|
}
|
|
7106
8540
|
const replyTargetSenderOpenId = explicitVcMeetingImOrigin?.replyTargetSenderOpenId
|
|
7107
|
-
??
|
|
8541
|
+
?? frozenTurnDispatch?.replyTargetSenderOpenId
|
|
8542
|
+
?? turnReplyTarget?.senderOpenId
|
|
8543
|
+
// #750 exact-turn contract: the global latest-slot quote sender may ONLY be
|
|
8544
|
+
// borrowed as a legacy fallback when there is NO currentTurnId (true
|
|
8545
|
+
// legacy/no-turn send). With a turnId, an exact-turn map miss/eviction must
|
|
8546
|
+
// resolve to NO sender — never the global slot, which may have advanced to a
|
|
8547
|
+
// DIFFERENT turn B and would mis-@ B as A's --mention-back (the cross-turn
|
|
8548
|
+
// bug #750 fixed). pickTurnReplyTarget already enforces
|
|
8549
|
+
// quoteTargetId===currentTurnId for its own hit.
|
|
8550
|
+
?? (currentTurnId ? undefined : s.quoteTargetSenderOpenId);
|
|
7108
8551
|
// @ hard-gate (config.send.requireMentionDecision, default on): force the
|
|
7109
8552
|
// model to make an explicit @ decision before sending. --top-level publish
|
|
7110
8553
|
// is exempt. The error text adapts to who is being replied to (人 / bot).
|
|
@@ -7120,12 +8563,8 @@ async function cmdSend(rest) {
|
|
|
7120
8563
|
console.error(mentionGate.error);
|
|
7121
8564
|
process.exit(2);
|
|
7122
8565
|
}
|
|
7123
|
-
// Register bots so the Lark client works.
|
|
7124
|
-
//
|
|
7125
|
-
// "Bot not registered" in this standalone `botmux send` process (a fresh
|
|
7126
|
-
// process with an empty registry) — getGroupStats would then soft-fail to
|
|
7127
|
-
// {999,999} and wrongly block --mention-back even in a true 1v1. registerBot
|
|
7128
|
-
// is idempotent, so the downstream send path reuses these same clients.
|
|
8566
|
+
// Register bots so the downstream Lark client works. registerBot is
|
|
8567
|
+
// idempotent, so all send paths reuse these same clients.
|
|
7129
8568
|
// envPinnedRiffBot is re-registered LAST so a remote env credential is never
|
|
7130
8569
|
// clobbered by a stale bots.json entry for the same app.
|
|
7131
8570
|
const { registerBot, loadBotConfigs, findOncallChatForAnyBot, getBot } = await import('./bot-registry.js');
|
|
@@ -7141,29 +8580,20 @@ async function cmdSend(rest) {
|
|
|
7141
8580
|
}
|
|
7142
8581
|
catch { /* */ }
|
|
7143
8582
|
}
|
|
7144
|
-
//
|
|
7145
|
-
//
|
|
7146
|
-
//
|
|
7147
|
-
//
|
|
7148
|
-
//
|
|
7149
|
-
//
|
|
7150
|
-
//
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
+ '或 --no-mention 不 @。');
|
|
7159
|
-
process.exit(2);
|
|
7160
|
-
}
|
|
7161
|
-
}
|
|
7162
|
-
catch (err) {
|
|
7163
|
-
// getGroupStats already soft-fails to {999,999} (→ block) internally, so
|
|
7164
|
-
// reaching here means the dynamic import itself failed. Fail-closed to a
|
|
7165
|
-
// clear error rather than silently letting a possibly-wrong @ through.
|
|
7166
|
-
console.error(`无法确认会话人数以校验 --mention-back:${err?.message ?? err}。请改用 --mention <ou:Name> 或 --no-mention。`);
|
|
8583
|
+
// Ambiguity gate for --mention-back. --mention-back means "@ back the one
|
|
8584
|
+
// counterpart who triggered this turn"; that is only unambiguous when this
|
|
8585
|
+
// turn's window had a single counterpart. Once 2+ distinct people/bots took
|
|
8586
|
+
// part (a human + a peer bot, two humans, the triggerer plus someone they
|
|
8587
|
+
// @-ed, a type-ahead follow-up from a third party, …), block --mention-back
|
|
8588
|
+
// and hand the model the exact candidates so it can --mention <open_id> the
|
|
8589
|
+
// right one instead of guessing (or mis-@-ing the lone human). Reads the
|
|
8590
|
+
// persisted per-turn participant window — no group-stats round-trip. Explicit
|
|
8591
|
+
// VC turns carry their own single-target origin, so they skip this gate.
|
|
8592
|
+
if (mentionBack && !explicitVcMeetingImOrigin && !sendTopLevel) {
|
|
8593
|
+
const window = collectTurnWindowParticipants(s, currentTurnId);
|
|
8594
|
+
const ambiguity = mentionBackAmbiguity({ chatType: s.chatType, participants: window.participants, incomplete: window.incomplete });
|
|
8595
|
+
if (ambiguity.ambiguous) {
|
|
8596
|
+
console.error(mentionBackAmbiguityError(ambiguity.candidates, ambiguity.incomplete));
|
|
7167
8597
|
process.exit(2);
|
|
7168
8598
|
}
|
|
7169
8599
|
}
|
|
@@ -7198,7 +8628,12 @@ async function cmdSend(rest) {
|
|
|
7198
8628
|
// Compute the actual outbound anchor before the advisory guard. A chat-scope
|
|
7199
8629
|
// sender can still reply into a per-turn topic, so sender scope alone does
|
|
7200
8630
|
// not describe which peer sessions are reachable.
|
|
7201
|
-
|
|
8631
|
+
// #597: a frozen per-turn reply target (from a Codex App dispatch) overrides
|
|
8632
|
+
// the nominal resolveSendTarget in chat scope so a steered/dispatched turn
|
|
8633
|
+
// replies to its own origin, not the session's latest human quote target.
|
|
8634
|
+
const sendTarget = !sendInto && !sendTopLevel && !overrideChatId && frozenTurnReplyTarget
|
|
8635
|
+
? frozenTurnReplyTarget
|
|
8636
|
+
: resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId });
|
|
7202
8637
|
const dataDir = resolveDataDir();
|
|
7203
8638
|
const deferredBinding = !sendInto && (!overrideChatId || overrideChatId === s.chatId)
|
|
7204
8639
|
? readDeferredTopicBinding(dataDir, s.sessionId)
|
|
@@ -7318,7 +8753,9 @@ async function cmdSend(rest) {
|
|
|
7318
8753
|
// Ordinary delivery uses the nominal target. Deferred delivery gets first
|
|
7319
8754
|
// refusal below; the advisory mirrors its existing binding in
|
|
7320
8755
|
// `reachabilityTarget` above so both paths agree about the effective root.
|
|
7321
|
-
|
|
8756
|
+
// (sendTarget itself is defined once above, carrying #597's frozen per-turn
|
|
8757
|
+
// reply-target override.)
|
|
8758
|
+
const dispatchAfterOriginGate = async (content, msgType, uuid, suppressHook) => {
|
|
7322
8759
|
// This closure also carries attachments, so every Lark call re-checks the
|
|
7323
8760
|
// exact durable attempt/member instead of inheriting the early cmd gate.
|
|
7324
8761
|
revalidateVcMeetingManagedSend();
|
|
@@ -7332,12 +8769,21 @@ async function cmdSend(rest) {
|
|
|
7332
8769
|
content,
|
|
7333
8770
|
msgType,
|
|
7334
8771
|
uuid,
|
|
7335
|
-
sendRoot: (body, type, rootUuid) =>
|
|
8772
|
+
sendRoot: async (body, type, rootUuid) => {
|
|
8773
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8774
|
+
return sendMessage(appId, targetChatId, body, type, rootUuid, hookContext, outboundMessageOptions(!!suppressHook));
|
|
8775
|
+
},
|
|
7336
8776
|
// The optional title is the root seed and the actual alert follows as
|
|
7337
8777
|
// its first reply. Do not emit a user outbound hook for presentation-
|
|
7338
8778
|
// only seed text; the alert itself still goes through the hook path.
|
|
7339
|
-
sendTitleSeed: (title, rootUuid) =>
|
|
7340
|
-
|
|
8779
|
+
sendTitleSeed: async (title, rootUuid) => {
|
|
8780
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8781
|
+
return sendMessage(appId, targetChatId, title, 'text', rootUuid);
|
|
8782
|
+
},
|
|
8783
|
+
replyRoot: async (root, body, type, replyUuid) => {
|
|
8784
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8785
|
+
return replyMessage(appId, root, body, type, true, replyUuid, hookContext, outboundMessageOptions(!!suppressHook));
|
|
8786
|
+
},
|
|
7341
8787
|
});
|
|
7342
8788
|
if (deferred.handled && deferred.messageId) {
|
|
7343
8789
|
deferredMaterializedByThisCommand ||= deferred.materializedNow === true;
|
|
@@ -7345,9 +8791,14 @@ async function cmdSend(rest) {
|
|
|
7345
8791
|
return deferred.messageId;
|
|
7346
8792
|
}
|
|
7347
8793
|
}
|
|
8794
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
7348
8795
|
return sendTarget.mode === 'plain'
|
|
7349
|
-
? sendMessage(appId, sendTarget.chatId, content, msgType, uuid, hookContext, suppressHook
|
|
7350
|
-
: replyMessage(appId, sendTarget.rootMessageId, content, msgType, sendTarget.mode === 'thread', uuid, hookContext, suppressHook
|
|
8796
|
+
? await sendMessage(appId, sendTarget.chatId, content, msgType, uuid, hookContext, outboundMessageOptions(!!suppressHook))
|
|
8797
|
+
: await replyMessage(appId, sendTarget.rootMessageId, content, msgType, sendTarget.mode === 'thread', uuid, hookContext, outboundMessageOptions(!!suppressHook));
|
|
8798
|
+
};
|
|
8799
|
+
const dispatch = async (content, msgType, uuid, suppressHook) => {
|
|
8800
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8801
|
+
return dispatchAfterOriginGate(content, msgType, uuid, suppressHook);
|
|
7351
8802
|
};
|
|
7352
8803
|
const recordBridgeSendMarker = (sentAtMs, messageId, sentContent) => {
|
|
7353
8804
|
try {
|
|
@@ -7372,11 +8823,13 @@ async function cmdSend(rest) {
|
|
|
7372
8823
|
// belong to another queued IM turn and is not part of the delivery action.
|
|
7373
8824
|
sessionQuoteTargetId: vcMeetingDeliveryReplyOrigin
|
|
7374
8825
|
? undefined
|
|
7375
|
-
: explicitVcMeetingImOrigin?.larkMessageId
|
|
8826
|
+
: explicitVcMeetingImOrigin?.larkMessageId
|
|
8827
|
+
?? frozenTurnDispatch?.quoteTargetId
|
|
8828
|
+
?? s.quoteTargetId,
|
|
7376
8829
|
});
|
|
7377
8830
|
let primaryQuotedId = null;
|
|
7378
8831
|
let vcMeetingListenerReplyReplay = false;
|
|
7379
|
-
const dispatchPrimary = async (content, msgType) => {
|
|
8832
|
+
const dispatchPrimary = async (content, msgType, originAlreadyRevalidated = false) => {
|
|
7380
8833
|
// `dispatchPrimaryMessage` may call replyMessage directly for a quote, so
|
|
7381
8834
|
// fence immediately before preparing/performing that primary effect too.
|
|
7382
8835
|
revalidateVcMeetingManagedSend();
|
|
@@ -7422,16 +8875,28 @@ async function cmdSend(rest) {
|
|
|
7422
8875
|
...(prepared ? { suppressHook: true } : {}),
|
|
7423
8876
|
hookContext,
|
|
7424
8877
|
MessageWithdrawnError,
|
|
8878
|
+
...(isolatedHookOrigin
|
|
8879
|
+
? {
|
|
8880
|
+
beforeHook: fenceIsolatedOriginBeforeEffect,
|
|
8881
|
+
hookOrigin: isolatedHookOrigin,
|
|
8882
|
+
}
|
|
8883
|
+
: {}),
|
|
7425
8884
|
// Explicit VC IM --into is rejected above, so an unquoted first send
|
|
7426
8885
|
// retains the normal chat-scope dispatch semantics. On a mismatch the
|
|
7427
8886
|
// frozen canonical target remains authoritative.
|
|
7428
8887
|
dispatch: prepared?.outputMismatch
|
|
7429
|
-
? (body, type, uuid, suppressHook) => {
|
|
8888
|
+
? async (body, type, uuid, suppressHook) => {
|
|
7430
8889
|
revalidateVcMeetingManagedSend();
|
|
7431
|
-
return sendMessage(appId, canonicalOutput.targetChatId, body, type, uuid, hookContext, suppressHook
|
|
8890
|
+
return sendMessage(appId, canonicalOutput.targetChatId, body, type, uuid, hookContext, outboundMessageOptions(!!suppressHook));
|
|
7432
8891
|
}
|
|
7433
|
-
:
|
|
7434
|
-
|
|
8892
|
+
: dispatchAfterOriginGate,
|
|
8893
|
+
beforeEffect: originAlreadyRevalidated
|
|
8894
|
+
? undefined
|
|
8895
|
+
: fenceIsolatedOriginBeforeEffect,
|
|
8896
|
+
beforeQuoteFallback: async () => {
|
|
8897
|
+
revalidateVcMeetingManagedSend();
|
|
8898
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8899
|
+
},
|
|
7435
8900
|
onQuoteWithdrawn: (id) => {
|
|
7436
8901
|
console.error(`引用目标 ${id} 已撤回,改为普通发送`);
|
|
7437
8902
|
},
|
|
@@ -7477,8 +8942,10 @@ async function cmdSend(rest) {
|
|
|
7477
8942
|
// managed side-effect gate above.
|
|
7478
8943
|
const imageKeys = [];
|
|
7479
8944
|
if (images.length > 0) {
|
|
7480
|
-
const
|
|
7481
|
-
|
|
8945
|
+
for (const imagePath of images) {
|
|
8946
|
+
await revalidateIsolatedOriginBeforeEffect();
|
|
8947
|
+
imageKeys.push(await uploadImage(appId, imagePath));
|
|
8948
|
+
}
|
|
7482
8949
|
}
|
|
7483
8950
|
// Auto-detect @BotName in text and inject as mentions, using the sender
|
|
7484
8951
|
// app's cross-ref file for per-app-scoped open_ids. Without this, a plain
|
|
@@ -7574,13 +9041,20 @@ async function cmdSend(rest) {
|
|
|
7574
9041
|
// --no-mention 显式不 @ 任何人 → 连 footer 的"发送给/cc"寻址 <at> 也清空,
|
|
7575
9042
|
// 否则 footer 仍会 @ 人,与 --no-mention 语义和"未@任何人"输出自相矛盾
|
|
7576
9043
|
// (Codex review P2)。--top-level 同样无特定收件人。
|
|
9044
|
+
const frozenFooterAddressingSource = explicitVcMeetingImOrigin?.replyTargetSenderOpenId
|
|
9045
|
+
? { ...s, lastCallerOpenId: explicitVcMeetingImOrigin.replyTargetSenderOpenId }
|
|
9046
|
+
: frozenTurnDispatch?.replyTargetSenderOpenId
|
|
9047
|
+
? {
|
|
9048
|
+
...s,
|
|
9049
|
+
lastCallerOpenId: frozenTurnDispatch.replyTargetSenderOpenId,
|
|
9050
|
+
lastCallerIsBot: frozenTurnDispatch.replyTargetSenderIsBot,
|
|
9051
|
+
}
|
|
9052
|
+
: s;
|
|
7577
9053
|
const footerAddressing = (sendTopLevel || noMention)
|
|
7578
9054
|
? { sendTo: undefined, cc: [] }
|
|
7579
|
-
: buildFooterAddressing(
|
|
7580
|
-
? { ...s, lastCallerOpenId: explicitVcMeetingImOrigin.replyTargetSenderOpenId }
|
|
7581
|
-
: s, {
|
|
9055
|
+
: buildFooterAddressing(frozenFooterAddressingSource, {
|
|
7582
9056
|
isOncall: !!oncallEntry,
|
|
7583
|
-
isSubstitute: turnReplyTarget?.turnId === currentTurnId && turnReplyTarget?.substitute === true,
|
|
9057
|
+
isSubstitute: isChatScope && turnReplyTarget?.turnId === currentTurnId && turnReplyTarget?.substitute === true,
|
|
7584
9058
|
hasExplicitBotMention: explicitKnownBotMention,
|
|
7585
9059
|
knownBotOpenIds,
|
|
7586
9060
|
});
|
|
@@ -7635,8 +9109,9 @@ async function cmdSend(rest) {
|
|
|
7635
9109
|
const videoResult = await sendVideoAttachments({
|
|
7636
9110
|
uploadFile,
|
|
7637
9111
|
uploadImage,
|
|
7638
|
-
dispatch,
|
|
7639
|
-
primaryDispatch: dispatchPrimary,
|
|
9112
|
+
dispatch: dispatchAfterOriginGate,
|
|
9113
|
+
primaryDispatch: (body, type) => dispatchPrimary(body, type, true),
|
|
9114
|
+
beforeEffect: fenceIsolatedOriginBeforeEffect,
|
|
7640
9115
|
...(vcMeetingManagedSendOrigin ? { maxMessages: 1 } : {}),
|
|
7641
9116
|
}, appId, videoAttachments);
|
|
7642
9117
|
failedVideoAttachments = videoResult.failed;
|
|
@@ -7772,8 +9247,8 @@ async function cmdSend(rest) {
|
|
|
7772
9247
|
// message above is the primary and failures before any media is sent still
|
|
7773
9248
|
// surface as command failure.
|
|
7774
9249
|
if (!pureVideoSend && !vcMeetingListenerReplyReplay) {
|
|
7775
|
-
({ failed: failedAttachments } = await sendFileAttachments({ uploadFile, dispatch }, appId, files));
|
|
7776
|
-
const videoResult = await sendVideoAttachments({ uploadFile, uploadImage, dispatch }, appId, videoAttachments);
|
|
9250
|
+
({ failed: failedAttachments } = await sendFileAttachments({ uploadFile, dispatch: dispatchAfterOriginGate, beforeEffect: fenceIsolatedOriginBeforeEffect }, appId, files));
|
|
9251
|
+
const videoResult = await sendVideoAttachments({ uploadFile, uploadImage, dispatch: dispatchAfterOriginGate, beforeEffect: fenceIsolatedOriginBeforeEffect }, appId, videoAttachments);
|
|
7777
9252
|
failedVideoAttachments = videoResult.failed;
|
|
7778
9253
|
}
|
|
7779
9254
|
for (const f of failedAttachments) {
|
|
@@ -7791,6 +9266,14 @@ async function cmdSend(rest) {
|
|
|
7791
9266
|
? `@${mentions.map(m => m.name || m.open_id).join(',')}`
|
|
7792
9267
|
: '未@任何人';
|
|
7793
9268
|
console.error(`✓ 已发送 ${messageId} | ${primaryQuotedId ? `引用 ${primaryQuotedId}` : '未引用'} | ${atSummary}`);
|
|
9269
|
+
// Sentinel guidance is surfaced HERE — in the send-success output the model
|
|
9270
|
+
// reads back — rather than only in the injected system prompt. A model only
|
|
9271
|
+
// learns about BOTMUX_NOTHING_TO_SEND after it has actually sent, so it
|
|
9272
|
+
// cannot use the sentinel to end a turn where it did work but forgot to send
|
|
9273
|
+
// (the ghosting shape). The injected prompt keeps a one-line sentinel note
|
|
9274
|
+
// only for the genuine never-send silence case (message addressed to another
|
|
9275
|
+
// bot). See services/bridge-fallback-gate.ts for the matching strip-and-forward gate.
|
|
9276
|
+
console.error(t('ai.send.after_success_hint', undefined, localeForBot(appId)));
|
|
7794
9277
|
// --attention: message is already delivered above; now flip the dashboard
|
|
7795
9278
|
// needs-you state via the daemon (botmux send is direct-to-Lark, so the
|
|
7796
9279
|
// daemon-held ds.agentAttention must be set out-of-band). Best-effort: a
|
|
@@ -7800,10 +9283,24 @@ async function cmdSend(rest) {
|
|
|
7800
9283
|
let attentionError;
|
|
7801
9284
|
if (attention.requested) {
|
|
7802
9285
|
try {
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
9286
|
+
// Raising attention is a second externally visible effect after the
|
|
9287
|
+
// awaited Lark send. Bind it to a fresh proof of the same original
|
|
9288
|
+
// turn instead of re-reading a stable path that may now belong to a
|
|
9289
|
+
// successor worker generation.
|
|
9290
|
+
const freshIsolatedOrigin = await revalidateIsolatedOriginBeforeEffect();
|
|
9291
|
+
const attentionOrigin = freshIsolatedOrigin ?? liveMarkerCtx;
|
|
9292
|
+
const daemon = freshIsolatedOrigin ? undefined : findDaemon(appId);
|
|
9293
|
+
const attentionPort = freshIsolatedOrigin
|
|
9294
|
+
? isolatedAttestationContext?.ipcPortFallback
|
|
9295
|
+
: daemon?.ipcPort;
|
|
9296
|
+
if (!attentionPort) {
|
|
9297
|
+
throw new Error(freshIsolatedOrigin
|
|
9298
|
+
? '受保护的 managed-origin claim 未提供 owning daemon 端口'
|
|
9299
|
+
: `找不到 daemon (larkAppId=${appId})`);
|
|
9300
|
+
}
|
|
9301
|
+
const originCapability = freshIsolatedOrigin
|
|
9302
|
+
? isolatedAttestationContext?.capability
|
|
9303
|
+
: readManagedOriginCapability(resolveDataDir(), sid, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
|
|
7807
9304
|
const request = {
|
|
7808
9305
|
method: 'POST',
|
|
7809
9306
|
headers: { 'content-type': 'application/json' },
|
|
@@ -7814,8 +9311,8 @@ async function cmdSend(rest) {
|
|
|
7814
9311
|
kind: attention.kind,
|
|
7815
9312
|
reason: text.trim(),
|
|
7816
9313
|
originCapability,
|
|
7817
|
-
originTurnId:
|
|
7818
|
-
originDispatchAttempt:
|
|
9314
|
+
originTurnId: attentionOrigin?.turnId,
|
|
9315
|
+
originDispatchAttempt: attentionOrigin?.dispatchAttempt,
|
|
7819
9316
|
}),
|
|
7820
9317
|
};
|
|
7821
9318
|
let secret;
|
|
@@ -7824,8 +9321,8 @@ async function cmdSend(rest) {
|
|
|
7824
9321
|
}
|
|
7825
9322
|
catch { /* Seatbelt/read-isolated CLI */ }
|
|
7826
9323
|
const res = secret
|
|
7827
|
-
? await fetchDaemonIpc(
|
|
7828
|
-
: await fetch(`http://127.0.0.1:${
|
|
9324
|
+
? await fetchDaemonIpc(attentionPort, '/api/attention', request, secret)
|
|
9325
|
+
: await fetch(`http://127.0.0.1:${attentionPort}/api/attention`, request);
|
|
7829
9326
|
if (!res.ok)
|
|
7830
9327
|
throw new Error(`daemon HTTP ${res.status}`);
|
|
7831
9328
|
attentionRaised = true;
|
|
@@ -8914,16 +10411,20 @@ botmux create-group — 用一组机器人新建飞书群
|
|
|
8914
10411
|
// the daemon dies). See /tmp/botmux-ask.md (or design memory).
|
|
8915
10412
|
/**
|
|
8916
10413
|
* postAsk: 找到 daemon → POST /api/asks → 返回 AskResult。
|
|
8917
|
-
* 连接失败 / HTTP 错误时抛出带 exitCode 属性的 Error:
|
|
8918
|
-
* - exitCode=3:daemon 不可达或 HTTP
|
|
10414
|
+
* 连接失败 / HTTP 错误时抛出带 `exitCode` + `retryable` 属性的 Error:
|
|
10415
|
+
* - exitCode=3:daemon 不可达或 HTTP 错误(保持向后兼容)
|
|
10416
|
+
* - retryable=true:仅当 daemon 不可达 / 网络失败 / 明确的 transient HTTP
|
|
10417
|
+
* (502/503/504,含 daemon 启动尚未就绪) 时。这些正是"daemon 重启中"的信号,
|
|
10418
|
+
* runHook 会重连重试。确定性 4xx(bad body / capability 拒绝 / unsupported)
|
|
10419
|
+
* 与非 JSON 是 retryable=false —— 重试 24h 也不会变,应立即 passthrough。
|
|
8919
10420
|
*/
|
|
8920
10421
|
async function postAsk(body) {
|
|
10422
|
+
const mkErr = (message, retryable) => Object.assign(new Error(message), { exitCode: 3, retryable });
|
|
8921
10423
|
const larkAppId = body.larkAppId;
|
|
8922
10424
|
const daemon = findDaemon(larkAppId);
|
|
8923
10425
|
if (!daemon) {
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
throw err;
|
|
10426
|
+
// No daemon record → it's (re)starting or momentarily gone → retryable.
|
|
10427
|
+
throw mkErr(`botmux ask: 找不到 daemon (larkAppId=${larkAppId})。daemon 已停?exit 3.`, true);
|
|
8927
10428
|
}
|
|
8928
10429
|
let res;
|
|
8929
10430
|
try {
|
|
@@ -8932,7 +10433,7 @@ async function postAsk(body) {
|
|
|
8932
10433
|
const sessionId = typeof requestBody.sessionId === 'string'
|
|
8933
10434
|
? requestBody.sessionId
|
|
8934
10435
|
: undefined;
|
|
8935
|
-
const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY);
|
|
10436
|
+
const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID);
|
|
8936
10437
|
if (claim)
|
|
8937
10438
|
requestBody.originCapability = claim.capability;
|
|
8938
10439
|
}
|
|
@@ -8955,10 +10456,9 @@ async function postAsk(body) {
|
|
|
8955
10456
|
: await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/asks`, init);
|
|
8956
10457
|
}
|
|
8957
10458
|
catch (fetchErr) {
|
|
10459
|
+
// Socket refused / reset / timeout → daemon is down or restarting → retryable.
|
|
8958
10460
|
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
8959
|
-
|
|
8960
|
-
err.exitCode = 3;
|
|
8961
|
-
throw err;
|
|
10461
|
+
throw mkErr(`botmux ask: 无法连接 daemon (port=${daemon.ipcPort}): ${msg}`, true);
|
|
8962
10462
|
}
|
|
8963
10463
|
if (!res.ok) {
|
|
8964
10464
|
let errBody = '';
|
|
@@ -8966,17 +10466,19 @@ async function postAsk(body) {
|
|
|
8966
10466
|
errBody = (await res.text()).slice(0, 200);
|
|
8967
10467
|
}
|
|
8968
10468
|
catch { /* */ }
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
10469
|
+
// Only transient server states are retryable. A deterministic 4xx (bad
|
|
10470
|
+
// body, capability denied, unsupported chat) will fail identically forever;
|
|
10471
|
+
// 502/503/504 mean the daemon is up but not ready (startup window) → retry.
|
|
10472
|
+
// Shared pure classifier (unit-tested directly — codex P1-3 seam).
|
|
10473
|
+
const retryable = isRetryableAskHttpStatus(res.status);
|
|
10474
|
+
throw mkErr(`botmux ask: daemon HTTP ${res.status}: ${errBody}`, retryable);
|
|
8972
10475
|
}
|
|
8973
10476
|
try {
|
|
8974
10477
|
return (await res.json());
|
|
8975
10478
|
}
|
|
8976
10479
|
catch (jsonErr) {
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
throw err;
|
|
10480
|
+
// A malformed body is not something a retry fixes.
|
|
10481
|
+
throw mkErr(`botmux ask: daemon 返回非 JSON: ${jsonErr}`, false);
|
|
8980
10482
|
}
|
|
8981
10483
|
}
|
|
8982
10484
|
async function cmdAsk(sub, rest) {
|
|
@@ -9034,7 +10536,7 @@ async function cmdAsk(sub, rest) {
|
|
|
9034
10536
|
const askSessionId = process.env.BOTMUX_SESSION_ID;
|
|
9035
10537
|
const liveAskOrigin = resolveSessionContext(resolveDataDir(), askSessionId);
|
|
9036
10538
|
const askRelayDir = process.env.BOTMUX_SEND_RELAY;
|
|
9037
|
-
const askOriginCapability = readManagedOriginCapability(resolveDataDir(), askSessionId, askRelayDir)?.capability;
|
|
10539
|
+
const askOriginCapability = readManagedOriginCapability(resolveDataDir(), askSessionId, askRelayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
|
|
9038
10540
|
const body = {
|
|
9039
10541
|
sessionId: askSessionId,
|
|
9040
10542
|
chatId: process.env.BOTMUX_CHAT_ID,
|
|
@@ -9043,6 +10545,10 @@ async function cmdAsk(sub, rest) {
|
|
|
9043
10545
|
options,
|
|
9044
10546
|
prompt,
|
|
9045
10547
|
timeoutMs,
|
|
10548
|
+
// Explicit `botmux ask buttons` has no reconnecting claimant (the CLI exits
|
|
10549
|
+
// on daemon restart), so mark it non-hook: the broker won't persist/handoff
|
|
10550
|
+
// it and can never confuse it with a hook ask's card (codex P1-4/P1-3).
|
|
10551
|
+
originKind: 'explicit',
|
|
9046
10552
|
...(liveAskOrigin?.turnId ? { originTurnId: liveAskOrigin.turnId } : {}),
|
|
9047
10553
|
...(liveAskOrigin?.dispatchAttempt !== undefined
|
|
9048
10554
|
? { originDispatchAttempt: liveAskOrigin.dispatchAttempt }
|
|
@@ -9163,8 +10669,14 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
|
|
|
9163
10669
|
routeLarkAppId = adopt.larkAppId;
|
|
9164
10670
|
routeRoot = adopt.rootMessageId;
|
|
9165
10671
|
}
|
|
9166
|
-
// 解析 timeoutMs:默认
|
|
9167
|
-
|
|
10672
|
+
// 解析 timeoutMs:默认 ~24h,可由 BOTMUX_ASK_TIMEOUT_MS 覆盖。
|
|
10673
|
+
// 为什么这么长:ask 超时不是良性兜底——broker settle 成 `timedOut` 会让 hook
|
|
10674
|
+
// 落到 passthrough,Claude 转而渲染原生 picker,而此后飞书回调已无通道把答案
|
|
10675
|
+
// 送回(picker 挂死、答案不生效)。所以默认值对齐 hook 安装侧的进程超时上限
|
|
10676
|
+
// (settings.json 里的 86400s),让 broker 不会 *早于* hook 进程本身超时;
|
|
10677
|
+
// 既避免"人回复慢→picker 卡死",又保留一个有限的进程级兜底(永不超时会让一次
|
|
10678
|
+
// CLI turn 无限阻塞,是更糟的失败)。
|
|
10679
|
+
const DEFAULT_TIMEOUT_MS = 86_400_000; // 24h — 对齐 hook 安装侧 timeout:86400s
|
|
9168
10680
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
9169
10681
|
const timeoutEnv = env.BOTMUX_ASK_TIMEOUT_MS;
|
|
9170
10682
|
if (timeoutEnv) {
|
|
@@ -9173,6 +10685,11 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
|
|
|
9173
10685
|
timeoutMs = parsed_timeout;
|
|
9174
10686
|
}
|
|
9175
10687
|
}
|
|
10688
|
+
// Per-invocation identity: generated ONCE here (outside the retry loop) and
|
|
10689
|
+
// reused across every reconnect POST, so a re-POST after a daemon restart
|
|
10690
|
+
// re-attaches to the same restored ask instead of posting a duplicate card.
|
|
10691
|
+
// originKind='hook' namespaces it away from an explicit `botmux ask buttons`.
|
|
10692
|
+
const requestId = randomUUID();
|
|
9176
10693
|
const body = {
|
|
9177
10694
|
sessionId: routeSessionId,
|
|
9178
10695
|
chatId: routeChatId,
|
|
@@ -9180,14 +10697,47 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
|
|
|
9180
10697
|
rootMessageId: routeRoot,
|
|
9181
10698
|
questions: parsed.questions,
|
|
9182
10699
|
timeoutMs,
|
|
10700
|
+
requestId,
|
|
10701
|
+
originKind: 'hook',
|
|
9183
10702
|
};
|
|
10703
|
+
// Post the ask, RETRYING across a daemon restart. The daemon holds pending
|
|
10704
|
+
// asks in memory only, so a restart between "card posted" and "user clicked"
|
|
10705
|
+
// drops the ask; historically postAskFn then threw (daemon unreachable) and
|
|
10706
|
+
// we fell straight to passthrough → the CLI rendered its native picker with no
|
|
10707
|
+
// way to receive the answer. Instead: while the daemon is unreachable (and
|
|
10708
|
+
// only then — an answered/timedOut/invalidated result returns normally), keep
|
|
10709
|
+
// reconnecting until the ask's own deadline. The daemon restores the pending
|
|
10710
|
+
// ask from disk on boot and re-attaches this reconnecting request to it by a
|
|
10711
|
+
// stable key, so the SAME card resolves through the normal hook directive.
|
|
10712
|
+
// Blocking here keeps Claude spinning; the native picker never renders.
|
|
10713
|
+
const deadline = Date.now() + timeoutMs;
|
|
9184
10714
|
let result;
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
10715
|
+
let attempt = 0;
|
|
10716
|
+
while (true) {
|
|
10717
|
+
try {
|
|
10718
|
+
result = await postAskFn(body);
|
|
10719
|
+
break;
|
|
10720
|
+
}
|
|
10721
|
+
catch (err) {
|
|
10722
|
+
// Retry ONLY genuinely transient failures (daemon unreachable / network /
|
|
10723
|
+
// 502-503-504 startup-not-ready — see postAsk's `retryable`). That is the
|
|
10724
|
+
// restart-in-progress case. A deterministic error (4xx bad body /
|
|
10725
|
+
// capability / unsupported, non-JSON) has retryable=false → passthrough
|
|
10726
|
+
// immediately rather than spin for 24h. A non-coded throw is also treated
|
|
10727
|
+
// as non-retryable.
|
|
10728
|
+
const retryable = err?.retryable === true;
|
|
10729
|
+
if (!retryable || Date.now() >= deadline) {
|
|
10730
|
+
return { stdout: adapter.passthrough(payload) };
|
|
10731
|
+
}
|
|
10732
|
+
attempt++;
|
|
10733
|
+
// Backoff: quick first reconnects (daemon usually returns in a few
|
|
10734
|
+
// seconds), capped at 5s. Never sleep past the deadline.
|
|
10735
|
+
const backoff = Math.min(5_000, 500 * attempt);
|
|
10736
|
+
const remaining = deadline - Date.now();
|
|
10737
|
+
if (remaining <= 0)
|
|
10738
|
+
return { stdout: adapter.passthrough(payload) };
|
|
10739
|
+
await new Promise((r) => setTimeout(r, Math.min(backoff, remaining)));
|
|
10740
|
+
}
|
|
9191
10741
|
}
|
|
9192
10742
|
if (result.kind === 'answered') {
|
|
9193
10743
|
return { stdout: adapter.formatAnswer(result.answers, parsed, result.comment) };
|
|
@@ -9280,7 +10830,7 @@ async function cmdSessionReady() {
|
|
|
9280
10830
|
if (ipcPort) {
|
|
9281
10831
|
try {
|
|
9282
10832
|
const relayDir = process.env.BOTMUX_SEND_RELAY;
|
|
9283
|
-
const originCapability = readManagedOriginCapability(resolveDataDir(), sessionId, relayDir)?.capability;
|
|
10833
|
+
const originCapability = readManagedOriginCapability(resolveDataDir(), sessionId, relayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
|
|
9284
10834
|
const liveOrigin = resolveSessionContext(resolveDataDir(), sessionId);
|
|
9285
10835
|
const envAttempt = Number(process.env.BOTMUX_DISPATCH_ATTEMPT);
|
|
9286
10836
|
const originTurnId = liveOrigin?.turnId ?? process.env.BOTMUX_TURN_ID;
|
|
@@ -10430,6 +11980,9 @@ if (LARK_FACING_COMMANDS.has(command) && managedOriginHasNoTransport()) {
|
|
|
10430
11980
|
process.exit(2);
|
|
10431
11981
|
}
|
|
10432
11982
|
switch (command) {
|
|
11983
|
+
case '__pm2-start-exact':
|
|
11984
|
+
await cmdInternalPm2StartExact(process.argv.slice(3));
|
|
11985
|
+
break;
|
|
10433
11986
|
case '--version':
|
|
10434
11987
|
case '-v':
|
|
10435
11988
|
console.log(getVersion());
|