botmux 3.7.1 → 3.8.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/capabilities.d.ts +17 -0
- package/dist/adapters/backend/capabilities.d.ts.map +1 -0
- package/dist/adapters/backend/capabilities.js +22 -0
- package/dist/adapters/backend/capabilities.js.map +1 -0
- package/dist/adapters/backend/critical-control-key.d.ts +19 -0
- package/dist/adapters/backend/critical-control-key.d.ts.map +1 -0
- package/dist/adapters/backend/critical-control-key.js +34 -0
- package/dist/adapters/backend/critical-control-key.js.map +1 -0
- package/dist/adapters/backend/herdr-backend.d.ts +9 -0
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +23 -4
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +3 -2
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +61 -6
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
- package/dist/adapters/backend/session-backend-selector.js +173 -9
- package/dist/adapters/backend/session-backend-selector.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +10 -3
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +25 -8
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +3 -2
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +45 -4
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js +11 -0
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +3 -2
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zmx-backend.d.ts +286 -0
- package/dist/adapters/backend/zmx-backend.d.ts.map +1 -0
- package/dist/adapters/backend/zmx-backend.js +2396 -0
- package/dist/adapters/backend/zmx-backend.js.map +1 -0
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +3 -0
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +5 -1
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +26 -1
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/fs-policy.d.ts +79 -1
- package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
- package/dist/adapters/cli/fs-policy.js +290 -13
- package/dist/adapters/cli/fs-policy.js.map +1 -1
- package/dist/adapters/cli/grok.d.ts.map +1 -1
- package/dist/adapters/cli/grok.js +4 -1
- package/dist/adapters/cli/grok.js.map +1 -1
- package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
- package/dist/adapters/cli/oh-my-pi.js +5 -3
- package/dist/adapters/cli/oh-my-pi.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +7 -4
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +52 -35
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/strict-input-handle.d.ts +13 -0
- package/dist/adapters/cli/strict-input-handle.d.ts.map +1 -0
- package/dist/adapters/cli/strict-input-handle.js +39 -0
- package/dist/adapters/cli/strict-input-handle.js.map +1 -0
- package/dist/adapters/cli/traex.d.ts.map +1 -1
- package/dist/adapters/cli/traex.js +6 -4
- package/dist/adapters/cli/traex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +16 -0
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +137 -8
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +298 -12
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -1
- package/dist/cli/session-list-liveness.js +1 -1
- package/dist/cli/zmx-managed-attach.d.ts +41 -0
- package/dist/cli/zmx-managed-attach.d.ts.map +1 -0
- package/dist/cli/zmx-managed-attach.js +72 -0
- package/dist/cli/zmx-managed-attach.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +780 -131
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +90 -2
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/codex-rpc-engine.d.ts.map +1 -1
- package/dist/codex-rpc-engine.js +22 -6
- package/dist/codex-rpc-engine.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -1
- package/dist/config.js.map +1 -1
- package/dist/core/ask-broker.d.ts +22 -1
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +12 -5
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/botmux-wrapper.d.ts +18 -0
- package/dist/core/botmux-wrapper.d.ts.map +1 -1
- package/dist/core/botmux-wrapper.js +25 -1
- package/dist/core/botmux-wrapper.js.map +1 -1
- package/dist/core/chat-rename.d.ts +27 -0
- package/dist/core/chat-rename.d.ts.map +1 -0
- package/dist/core/chat-rename.js +53 -0
- package/dist/core/chat-rename.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +160 -12
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +25 -1
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +102 -4
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +16 -2
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +611 -62
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts +22 -1
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js +61 -0
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/dashboard-url.d.ts +6 -0
- package/dist/core/dashboard-url.d.ts.map +1 -1
- package/dist/core/dashboard-url.js +11 -1
- package/dist/core/dashboard-url.js.map +1 -1
- package/dist/core/device-isolation-daemon.d.ts +28 -6
- package/dist/core/device-isolation-daemon.d.ts.map +1 -1
- package/dist/core/device-isolation-daemon.js +96 -13
- package/dist/core/device-isolation-daemon.js.map +1 -1
- package/dist/core/host-overload-alert.d.ts +75 -0
- package/dist/core/host-overload-alert.d.ts.map +1 -1
- package/dist/core/host-overload-alert.js +103 -0
- package/dist/core/host-overload-alert.js.map +1 -1
- package/dist/core/idle-worker-sweeper.js +1 -1
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +3 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +9 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +10 -0
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/persistent-backend.d.ts +32 -4
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +75 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +2 -2
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/runtime.d.ts +3 -0
- package/dist/core/plugins/runtime.d.ts.map +1 -1
- package/dist/core/plugins/runtime.js.map +1 -1
- package/dist/core/plugins/service-manager.d.ts +4 -1
- package/dist/core/plugins/service-manager.d.ts.map +1 -1
- package/dist/core/plugins/service-manager.js +11 -6
- package/dist/core/plugins/service-manager.js.map +1 -1
- package/dist/core/role-library.d.ts +48 -1
- package/dist/core/role-library.d.ts.map +1 -1
- package/dist/core/role-library.js +111 -3
- package/dist/core/role-library.js.map +1 -1
- package/dist/core/role-resolver.d.ts +14 -2
- package/dist/core/role-resolver.d.ts.map +1 -1
- package/dist/core/role-resolver.js +47 -4
- package/dist/core/role-resolver.js.map +1 -1
- package/dist/core/session-activity.d.ts +14 -0
- package/dist/core/session-activity.d.ts.map +1 -1
- package/dist/core/session-activity.js +46 -1
- package/dist/core/session-activity.js.map +1 -1
- package/dist/core/session-board.d.ts +5 -2
- package/dist/core/session-board.d.ts.map +1 -1
- package/dist/core/session-board.js +5 -2
- package/dist/core/session-board.js.map +1 -1
- package/dist/core/session-liveness.d.ts +6 -5
- package/dist/core/session-liveness.d.ts.map +1 -1
- package/dist/core/session-liveness.js +34 -13
- package/dist/core/session-liveness.js.map +1 -1
- package/dist/core/session-manager.d.ts +3 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +397 -103
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +11 -2
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +50 -11
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-message-preview.d.ts +20 -0
- package/dist/core/session-message-preview.d.ts.map +1 -0
- package/dist/core/session-message-preview.js +188 -0
- package/dist/core/session-message-preview.js.map +1 -0
- package/dist/core/session-title.d.ts +5 -1
- package/dist/core/session-title.d.ts.map +1 -1
- package/dist/core/session-title.js +20 -3
- package/dist/core/session-title.js.map +1 -1
- package/dist/core/startup-commands.d.ts +1 -1
- package/dist/core/startup-commands.js +1 -1
- package/dist/core/terminal-url.d.ts.map +1 -1
- package/dist/core/terminal-url.js +3 -2
- package/dist/core/terminal-url.js.map +1 -1
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +75 -12
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +23 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-ipc.d.ts +11 -0
- package/dist/core/worker-ipc.d.ts.map +1 -0
- package/dist/core/worker-ipc.js +26 -0
- package/dist/core/worker-ipc.js.map +1 -0
- package/dist/core/worker-pool.d.ts +179 -29
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2118 -331
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +132 -3
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +1327 -449
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/auth.d.ts +2 -0
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +9 -0
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +4 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +5 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/federation-api.d.ts.map +1 -1
- package/dist/dashboard/federation-api.js +4 -0
- package/dist/dashboard/federation-api.js.map +1 -1
- package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
- package/dist/dashboard/federation-spoke-api.js +32 -5
- package/dist/dashboard/federation-spoke-api.js.map +1 -1
- package/dist/dashboard/public-redact.d.ts +8 -2
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +34 -7
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +10 -2
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +35 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +88 -1
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +0 -1
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +15 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +212 -58
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +3 -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/bot-onboarding.d.ts +2 -1
- package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/web/bot-onboarding.js.map +1 -1
- package/dist/dashboard/web/clipboard.d.ts +2 -0
- package/dist/dashboard/web/clipboard.d.ts.map +1 -0
- package/dist/dashboard/web/clipboard.js +42 -0
- package/dist/dashboard/web/clipboard.js.map +1 -0
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +12 -8
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/dashboard-components.d.ts +17 -16
- package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-components.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +7 -3
- 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 +205 -12
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -1
- package/dist/dashboard/web/icon-tooltip.js +16 -1
- package/dist/dashboard/web/icon-tooltip.js.map +1 -1
- package/dist/dashboard/web/listener-filters.d.ts +23 -0
- package/dist/dashboard/web/listener-filters.d.ts.map +1 -0
- package/dist/dashboard/web/listener-filters.js +36 -0
- package/dist/dashboard/web/listener-filters.js.map +1 -0
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
- package/dist/dashboard/web/plugin-page.js.map +1 -1
- package/dist/dashboard/web/preview-markdown.d.ts +5 -0
- package/dist/dashboard/web/preview-markdown.d.ts.map +1 -0
- package/dist/dashboard/web/preview-markdown.js +52 -0
- package/dist/dashboard/web/preview-markdown.js.map +1 -0
- package/dist/dashboard/web/roles-page.d.ts.map +1 -1
- package/dist/dashboard/web/roles-page.js +535 -10
- package/dist/dashboard/web/roles-page.js.map +1 -1
- package/dist/dashboard/web/roles.d.ts +77 -0
- package/dist/dashboard/web/roles.d.ts.map +1 -1
- package/dist/dashboard/web/roles.js +98 -0
- package/dist/dashboard/web/roles.js.map +1 -1
- package/dist/dashboard/web/session-terminal.d.ts.map +1 -1
- package/dist/dashboard/web/session-terminal.js +6 -4
- package/dist/dashboard/web/session-terminal.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts +4 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +1 -1
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts +3 -2
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +163 -28
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts +25 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +58 -7
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +28 -3
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +61 -1
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/skills-page.d.ts +6 -5
- package/dist/dashboard/web/skills-page.d.ts.map +1 -1
- package/dist/dashboard/web/skills-page.js +2 -0
- package/dist/dashboard/web/skills-page.js.map +1 -1
- package/dist/dashboard/web/v3-components.d.ts +3 -2
- package/dist/dashboard/web/v3-components.d.ts.map +1 -1
- package/dist/dashboard/web/v3-components.js.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.d.ts +12 -2
- package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.js +0 -9
- package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
- package/dist/dashboard/web/webhook-logs-page.d.ts +2 -1
- package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -1
- package/dist/dashboard/web/webhook-logs-page.js +4 -1
- package/dist/dashboard/web/webhook-logs-page.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-FAPIMQV3.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-QFMH6ITB.js → chunk-5CJMHTT4.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-KQB42ML5.js → chunk-DLZK52JU.js} +14 -12
- package/dist/dashboard-web/chunks/chunk-FDZ7WOBW.js +16 -0
- package/dist/dashboard-web/chunks/{chunk-3QAUJXAS.js → chunk-GTAJV5YC.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-FNPGOZMX.js → chunk-IHNG6KE6.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-FLGXZJTD.js → chunk-VA6QWSGQ.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
- package/dist/dashboard-web/chunks/chunk-ZJVTGU5X.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-LKWRT3GS.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-V6654ICQ.js +8 -0
- package/dist/dashboard-web/chunks/insights-page-237RMFQ5.js +7 -0
- package/dist/dashboard-web/chunks/{monitor-room-7IVIS75W.js → monitor-room-OO5Y4FRQ.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-5SSJRQZF.js → monitoring-page-OUEITM5P.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-EOND3L5D.js → office-page-4KNAHQ4Q.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-NISBVBBJ.js → overview-page-QDP4YK2S.js} +1 -1
- package/dist/dashboard-web/chunks/{plugin-page-JXC5F5YV.js → plugin-page-VEGTCEAS.js} +1 -1
- package/dist/dashboard-web/chunks/roles-page-PGXNZZNP.js +1 -0
- package/dist/dashboard-web/chunks/{schedules-page-AVOOKLUH.js → schedules-page-BJNC5TID.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-M2ZYYCMO.js +6 -0
- package/dist/dashboard-web/chunks/settings-page-IJFZKUUW.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-5MW54PCC.js → skills-page-YRAIRZOQ.js} +3 -3
- package/dist/dashboard-web/chunks/{team-federation-page-2SNIMVXI.js → team-federation-page-DEU27GRU.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-W6SBXXQC.js → v3-page-6KHEW2YL.js} +5 -5
- package/dist/dashboard-web/chunks/{whiteboards-page-STC54KTP.js → whiteboards-page-VDECAALH.js} +1 -1
- package/dist/dashboard-web/style.css +12545 -11432
- package/dist/dashboard.js +215 -36
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +41 -2
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +47 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +71 -19
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +71 -19
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/callback-button-marker.d.ts +34 -0
- package/dist/im/lark/callback-button-marker.d.ts.map +1 -0
- package/dist/im/lark/callback-button-marker.js +99 -0
- package/dist/im/lark/callback-button-marker.js.map +1 -0
- package/dist/im/lark/card-builder.d.ts +46 -10
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +299 -78
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +3 -4
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +380 -148
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +24 -1
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +101 -8
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +6 -1
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +115 -3
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +534 -147
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts +16 -12
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +48 -151
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/grant-pending.d.ts +12 -4
- package/dist/im/lark/grant-pending.d.ts.map +1 -1
- package/dist/im/lark/grant-pending.js +31 -8
- package/dist/im/lark/grant-pending.js.map +1 -1
- package/dist/im/lark/invite-command.d.ts +20 -0
- package/dist/im/lark/invite-command.d.ts.map +1 -0
- package/dist/im/lark/invite-command.js +239 -0
- package/dist/im/lark/invite-command.js.map +1 -0
- package/dist/im/lark/md-card.d.ts +66 -4
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +201 -31
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/mention-targets.d.ts +43 -0
- package/dist/im/lark/mention-targets.d.ts.map +1 -0
- package/dist/im/lark/mention-targets.js +190 -0
- package/dist/im/lark/mention-targets.js.map +1 -0
- package/dist/im/lark/message-parser.d.ts +31 -0
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +305 -32
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/reply-card-footer-signature.d.ts +7 -0
- package/dist/im/lark/reply-card-footer-signature.d.ts.map +1 -0
- package/dist/im/lark/reply-card-footer-signature.js +7 -0
- package/dist/im/lark/reply-card-footer-signature.js.map +1 -0
- package/dist/im/lark/sessions-card.d.ts.map +1 -1
- package/dist/im/lark/sessions-card.js +4 -1
- package/dist/im/lark/sessions-card.js.map +1 -1
- package/dist/im/lark/substitute-command.d.ts.map +1 -1
- package/dist/im/lark/substitute-command.js +8 -0
- package/dist/im/lark/substitute-command.js.map +1 -1
- package/dist/im/lark/topic-root-context.d.ts +35 -0
- package/dist/im/lark/topic-root-context.d.ts.map +1 -0
- package/dist/im/lark/topic-root-context.js +37 -0
- package/dist/im/lark/topic-root-context.js.map +1 -0
- package/dist/im/lark/v3-blocked-card.d.ts.map +1 -1
- package/dist/im/lark/v3-blocked-card.js +2 -1
- package/dist/im/lark/v3-blocked-card.js.map +1 -1
- package/dist/im/lark/v3-gate-card.d.ts.map +1 -1
- package/dist/im/lark/v3-gate-card.js +2 -1
- package/dist/im/lark/v3-gate-card.js.map +1 -1
- package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -1
- package/dist/im/lark/v3-loop-grant-card.js +2 -1
- package/dist/im/lark/v3-loop-grant-card.js.map +1 -1
- package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
- package/dist/im/lark/v3-progress-card.js +2 -1
- package/dist/im/lark/v3-progress-card.js.map +1 -1
- package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -1
- package/dist/im/lark/v3-revisit-grant-card.js +2 -1
- package/dist/im/lark/v3-revisit-grant-card.js.map +1 -1
- package/dist/index-core-only.d.ts +3 -0
- package/dist/index-core-only.d.ts.map +1 -0
- package/dist/index-core-only.js +107 -0
- package/dist/index-core-only.js.map +1 -0
- package/dist/services/async-trigger-state.d.ts +8 -0
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +3 -2
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +10 -2
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +3 -2
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/backend-availability.d.ts +3 -1
- package/dist/services/backend-availability.d.ts.map +1 -1
- package/dist/services/backend-availability.js +14 -1
- package/dist/services/backend-availability.js.map +1 -1
- package/dist/services/backend-type-store.d.ts.map +1 -1
- package/dist/services/backend-type-store.js +1 -1
- package/dist/services/backend-type-store.js.map +1 -1
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +2 -1
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +14 -1
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +65 -1
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +7 -2
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +30 -4
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +9 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +17 -0
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-app-token-usage.d.ts +93 -0
- package/dist/services/codex-app-token-usage.d.ts.map +1 -0
- package/dist/services/codex-app-token-usage.js +190 -0
- package/dist/services/codex-app-token-usage.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +6 -1
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +103 -32
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/federation-roster.d.ts +4 -0
- package/dist/services/federation-roster.d.ts.map +1 -1
- package/dist/services/federation-roster.js +3 -0
- package/dist/services/federation-roster.js.map +1 -1
- package/dist/services/federation-store.d.ts +6 -0
- package/dist/services/federation-store.d.ts.map +1 -1
- package/dist/services/federation-store.js.map +1 -1
- package/dist/services/grant-policy.d.ts +13 -0
- package/dist/services/grant-policy.d.ts.map +1 -0
- package/dist/services/grant-policy.js +40 -0
- package/dist/services/grant-policy.js.map +1 -0
- package/dist/services/grant-store.d.ts +24 -3
- package/dist/services/grant-store.d.ts.map +1 -1
- package/dist/services/grant-store.js +196 -12
- package/dist/services/grant-store.js.map +1 -1
- package/dist/services/groups-store.d.ts +36 -0
- package/dist/services/groups-store.d.ts.map +1 -1
- package/dist/services/groups-store.js +123 -1
- package/dist/services/groups-store.js.map +1 -1
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +8 -2
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/local-cli-opener.d.ts.map +1 -1
- package/dist/services/local-cli-opener.js +32 -0
- package/dist/services/local-cli-opener.js.map +1 -1
- package/dist/services/message-listener-run-preview-store.d.ts +43 -0
- package/dist/services/message-listener-run-preview-store.d.ts.map +1 -0
- package/dist/services/message-listener-run-preview-store.js +152 -0
- package/dist/services/message-listener-run-preview-store.js.map +1 -0
- package/dist/services/message-listener-store.d.ts +26 -0
- package/dist/services/message-listener-store.d.ts.map +1 -0
- package/dist/services/message-listener-store.js +138 -0
- package/dist/services/message-listener-store.js.map +1 -0
- package/dist/services/message-listener.d.ts +112 -0
- package/dist/services/message-listener.d.ts.map +1 -0
- package/dist/services/message-listener.js +391 -0
- package/dist/services/message-listener.js.map +1 -0
- package/dist/services/overload-alert-migration.d.ts +15 -0
- package/dist/services/overload-alert-migration.d.ts.map +1 -0
- package/dist/services/overload-alert-migration.js +115 -0
- package/dist/services/overload-alert-migration.js.map +1 -0
- package/dist/services/send-policy.d.ts +38 -0
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +34 -0
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +12 -1
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +44 -2
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/skill-registry-store.d.ts.map +1 -1
- package/dist/services/skill-registry-store.js +87 -12
- package/dist/services/skill-registry-store.js.map +1 -1
- package/dist/services/substitute-mode-normalize.d.ts +3 -0
- package/dist/services/substitute-mode-normalize.d.ts.map +1 -1
- package/dist/services/substitute-mode-normalize.js +8 -0
- package/dist/services/substitute-mode-normalize.js.map +1 -1
- package/dist/services/substitute-mode-store.d.ts.map +1 -1
- package/dist/services/substitute-mode-store.js +8 -1
- package/dist/services/substitute-mode-store.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts +6 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +145 -29
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/services/trigger-types.d.ts +16 -0
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +6 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/setup/bot-config-editor.js +2 -2
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/setup/ensure-zmx.d.ts +31 -0
- package/dist/setup/ensure-zmx.d.ts.map +1 -0
- package/dist/setup/ensure-zmx.js +161 -0
- package/dist/setup/ensure-zmx.js.map +1 -0
- package/dist/setup/index.d.ts +1 -1
- package/dist/setup/index.js +1 -1
- package/dist/setup/open-platform-automation.d.ts +9 -0
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +15 -2
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/setup-args.d.ts +1 -1
- package/dist/setup/setup-args.d.ts.map +1 -1
- package/dist/setup/setup-args.js +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +32 -0
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/injection-mode.d.ts +6 -2
- package/dist/skills/injection-mode.d.ts.map +1 -1
- package/dist/skills/injection-mode.js +10 -5
- package/dist/skills/injection-mode.js.map +1 -1
- package/dist/types.d.ts +79 -3
- package/dist/types.d.ts.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 +4 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/effort-confirm-dialog.d.ts +40 -0
- package/dist/utils/effort-confirm-dialog.d.ts.map +1 -0
- package/dist/utils/effort-confirm-dialog.js +74 -0
- package/dist/utils/effort-confirm-dialog.js.map +1 -0
- package/dist/utils/pending-input-queue.d.ts +56 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +80 -0
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/render-dimensions.d.ts +8 -0
- package/dist/utils/render-dimensions.d.ts.map +1 -1
- package/dist/utils/render-dimensions.js +10 -0
- package/dist/utils/render-dimensions.js.map +1 -1
- package/dist/utils/stuck-key-guard.d.ts +3 -2
- package/dist/utils/stuck-key-guard.d.ts.map +1 -1
- package/dist/utils/stuck-key-guard.js +3 -3
- package/dist/utils/stuck-key-guard.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +6 -0
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +15 -0
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/utils/tui-input-delivery.d.ts +32 -0
- package/dist/utils/tui-input-delivery.d.ts.map +1 -0
- package/dist/utils/tui-input-delivery.js +53 -0
- package/dist/utils/tui-input-delivery.js.map +1 -0
- package/dist/utils/xml.d.ts +19 -0
- package/dist/utils/xml.d.ts.map +1 -0
- package/dist/utils/xml.js +26 -0
- package/dist/utils/xml.js.map +1 -0
- package/dist/worker.js +1852 -374
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +8 -8
- package/dist/workflows/events/payloads.d.ts +8 -8
- package/dist/workflows/events/schema.d.ts +160 -160
- package/dist/workflows/v3/session-relay-client.js +1 -1
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/dist/workflows/v3/worker-fence.d.ts.map +1 -1
- package/dist/workflows/v3/worker-fence.js +15 -10
- package/dist/workflows/v3/worker-fence.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-UKA7K4JV.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DFLNXQNT.js +0 -1
- package/dist/dashboard-web/chunks/connectors-page-A77YAB5Y.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-RUZNVXQB.js +0 -8
- package/dist/dashboard-web/chunks/insights-page-RF7U7RTA.js +0 -22
- package/dist/dashboard-web/chunks/roles-page-BUMKD7Y4.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-6HLSEAB7.js +0 -2
- package/dist/dashboard-web/chunks/settings-page-MJDZHQ4X.js +0 -2
|
@@ -0,0 +1,2396 @@
|
|
|
1
|
+
import { execFile, execFileSync, spawn, spawnSync, } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, rmdirSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { AmbiguousSubmissionBlockedError, } from './types.js';
|
|
7
|
+
import { zmxEnv, probeZmxFunctional } from '../../setup/ensure-zmx.js';
|
|
8
|
+
import { buildBotmuxEnvAssignments, buildDebugKeepShellScript, resolveUserShell, shellWrapperScript, } from './tmux-backend.js';
|
|
9
|
+
import { resolveBotmuxWrapperBinDir } from '../../core/botmux-wrapper.js';
|
|
10
|
+
import { TERMINAL_CANCEL_COOLDOWN_MS } from './critical-control-key.js';
|
|
11
|
+
import { logger } from '../../utils/logger.js';
|
|
12
|
+
import { atomicWriteFileSync } from '../../utils/atomic-write.js';
|
|
13
|
+
import { fsyncDirectorySyncPortable } from '../../utils/fs-durability.js';
|
|
14
|
+
const EARLY_BUFFER_MAX = 1024 * 1024;
|
|
15
|
+
const HISTORY_TAIL_DEBOUNCE_MS = 50;
|
|
16
|
+
const HISTORY_HOT_POLL_MS = 250;
|
|
17
|
+
// Keep the worst-case safety poll below IdleDetector's 2s quiescence window:
|
|
18
|
+
// otherwise a pure-Unicode burst (which broken upstream tail may not signal)
|
|
19
|
+
// could be observed only after the worker had already declared the turn idle.
|
|
20
|
+
const HISTORY_COLD_POLL_MS = 1250;
|
|
21
|
+
const HISTORY_COLD_JITTER_MAX_MS = 250;
|
|
22
|
+
const HISTORY_STABLE_POLLS_BEFORE_COLD = 3;
|
|
23
|
+
const ZMX_HISTORY_TIMEOUT_MS = 3000;
|
|
24
|
+
const ZMX_HISTORY_SETTLE_TIMEOUT_MS = 4000;
|
|
25
|
+
const TAIL_RECOVERY_DELAY_MAX_MS = 2000;
|
|
26
|
+
const FRESH_READY_TIMEOUT_MS = 5000;
|
|
27
|
+
const FRESH_RELEASE_TIMEOUT_MS = 12_000;
|
|
28
|
+
const FRESH_CLI_PID_TIMEOUT_MS = 3000;
|
|
29
|
+
const FRESH_RELEASE_ACK_TIMEOUT_MS = 3000;
|
|
30
|
+
// ZMX removes history as soon as the PTY root exits. Keep the private launch
|
|
31
|
+
// shell alive briefly after the real CLI finishes so the history-only output
|
|
32
|
+
// path can publish the final bytes before the daemon unlinks the session.
|
|
33
|
+
const ZMX_EXIT_HISTORY_GRACE_SECONDS = 3;
|
|
34
|
+
const TAIL_CONNECT_TIMEOUT_MS = 3000;
|
|
35
|
+
const MANAGED_KILL_TIMEOUT_MS = 5000;
|
|
36
|
+
const ZMX_COMMAND_TIMEOUT_MS = 5000;
|
|
37
|
+
const ZMX_HISTORY_MAX_BYTES = 16 * 1024 * 1024;
|
|
38
|
+
// ZMX's daemon reads one 4096-byte IPC frame at a time and may observe HUP from
|
|
39
|
+
// the short-lived `send` client in the same poll iteration. Keep header+payload
|
|
40
|
+
// comfortably below that boundary so it can parse the complete message before
|
|
41
|
+
// closing the client (`zmx send` has no ACK/drain handshake as of v0.7.0).
|
|
42
|
+
const ZMX_SEND_CHUNK_BYTES = 1024;
|
|
43
|
+
// v0.7.0's daemon queues send input into a 256 KiB pty_write_buf and silently
|
|
44
|
+
// DROPS any payload that would overflow it (daemon-side log only, no ACK, so
|
|
45
|
+
// `zmx send` still exits 0). Reject
|
|
46
|
+
// one-shot payloads well below that ceiling before writing any prefix; adapters
|
|
47
|
+
// that intentionally stream larger input already split and throttle their calls.
|
|
48
|
+
const ZMX_SEND_MAX_BYTES = 64 * 1024;
|
|
49
|
+
const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~');
|
|
50
|
+
const BRACKETED_PASTE_END = '\x1b[201~';
|
|
51
|
+
const BRACKETED_PASTE_END_BYTES = Buffer.from(BRACKETED_PASTE_END);
|
|
52
|
+
const ZMX_TRANSPORT_LABEL = 'botmux.transport';
|
|
53
|
+
const ZMX_TRANSPORT = 'tail-send-v1';
|
|
54
|
+
const ZMX_SESSION_LABEL = 'botmux.session';
|
|
55
|
+
const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid';
|
|
56
|
+
const ZMX_GATE_NONCE_LABEL = 'botmux.gate_nonce';
|
|
57
|
+
const ZMX_GATE_PROCESS_MARKER_PREFIX = 'botmux-zmx-private-release-gate-v1:';
|
|
58
|
+
function bytesMatchAt(bytes, marker, cursor, boundary) {
|
|
59
|
+
if (cursor + marker.length > boundary)
|
|
60
|
+
return false;
|
|
61
|
+
for (let i = 0; i < marker.length; i += 1) {
|
|
62
|
+
if (bytes[cursor + i] !== marker[i])
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
function bracketedPasteOpenStatesAtChunkBoundaries(bytes) {
|
|
68
|
+
const states = [false];
|
|
69
|
+
let openPastes = 0;
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
const shortestMarkerLength = Math.min(BRACKETED_PASTE_START_BYTES.length, BRACKETED_PASTE_END_BYTES.length);
|
|
72
|
+
for (let boundary = Math.min(ZMX_SEND_CHUNK_BYTES, bytes.length); boundary > 0; boundary = Math.min(boundary + ZMX_SEND_CHUNK_BYTES, bytes.length)) {
|
|
73
|
+
while (cursor + shortestMarkerLength <= boundary) {
|
|
74
|
+
if (bytesMatchAt(bytes, BRACKETED_PASTE_START_BYTES, cursor, boundary)) {
|
|
75
|
+
openPastes += 1;
|
|
76
|
+
cursor += BRACKETED_PASTE_START_BYTES.length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (bytesMatchAt(bytes, BRACKETED_PASTE_END_BYTES, cursor, boundary)) {
|
|
80
|
+
if (openPastes > 0)
|
|
81
|
+
openPastes -= 1;
|
|
82
|
+
cursor += BRACKETED_PASTE_END_BYTES.length;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
cursor += 1;
|
|
86
|
+
}
|
|
87
|
+
states.push(openPastes > 0);
|
|
88
|
+
if (boundary === bytes.length)
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
return states;
|
|
92
|
+
}
|
|
93
|
+
const syncSleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
94
|
+
function sleepSync(ms) {
|
|
95
|
+
Atomics.wait(syncSleepCell, 0, 0, ms);
|
|
96
|
+
}
|
|
97
|
+
/** Cross-platform direct-parent probe used only during the private fresh
|
|
98
|
+
* handshake and warm reattach. ZMX itself is Unix-only, so Linux `/proc` plus
|
|
99
|
+
* BSD-compatible `ps` covers its supported hosts without shell interpolation. */
|
|
100
|
+
function readProcessParentPid(pid) {
|
|
101
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
102
|
+
return null;
|
|
103
|
+
if (process.platform === 'linux') {
|
|
104
|
+
try {
|
|
105
|
+
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
106
|
+
const closeParen = raw.lastIndexOf(')');
|
|
107
|
+
if (closeParen < 0)
|
|
108
|
+
return null;
|
|
109
|
+
const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
|
|
110
|
+
const parent = Number(fields[1]);
|
|
111
|
+
return Number.isSafeInteger(parent) && parent > 0 ? parent : null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const ps of ['/usr/bin/ps', '/bin/ps']) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = execFileSync(ps, ['-o', 'ppid=', '-p', String(pid)], {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
122
|
+
timeout: 2000,
|
|
123
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C' },
|
|
124
|
+
}).trim();
|
|
125
|
+
const parent = Number(raw);
|
|
126
|
+
if (Number.isSafeInteger(parent) && parent > 0)
|
|
127
|
+
return parent;
|
|
128
|
+
}
|
|
129
|
+
catch { /* try the other standard BSD/GNU location */ }
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Read one same-user process command line without a shell. The private gate
|
|
134
|
+
* marker is random per launch, so a substring check remains unambiguous on
|
|
135
|
+
* macOS where `ps` cannot preserve argv boundaries. */
|
|
136
|
+
function readProcessCommandLine(pid) {
|
|
137
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
138
|
+
return null;
|
|
139
|
+
if (process.platform === 'linux') {
|
|
140
|
+
try {
|
|
141
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`);
|
|
142
|
+
if (raw.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
return raw.toString('utf8').replace(/\0/g, ' ');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const ps of ['/usr/bin/ps', '/bin/ps']) {
|
|
151
|
+
try {
|
|
152
|
+
const raw = execFileSync(ps, ['-ww', '-o', 'command=', '-p', String(pid)], {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
155
|
+
timeout: 2000,
|
|
156
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C' },
|
|
157
|
+
}).trim();
|
|
158
|
+
if (raw)
|
|
159
|
+
return raw;
|
|
160
|
+
}
|
|
161
|
+
catch { /* try the other standard BSD/GNU location */ }
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/** Convert plain line feeds into terminal-safe CRLF without doubling CRLF. */
|
|
166
|
+
export function normaliseZmxHistory(text) {
|
|
167
|
+
// ZMX history emits LF while tail can expose CRLF, and high-throughput tail
|
|
168
|
+
// chunking can occasionally repeat the CR at a boundary. All represent the
|
|
169
|
+
// same terminal line break in this plain-text transport.
|
|
170
|
+
return text.replace(/\r*\n/g, '\r\n');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Persistent ZMX backend built from three non-leader primitives:
|
|
174
|
+
*
|
|
175
|
+
* - `zmx tail` is a low-latency change/liveness signal (never a byte source);
|
|
176
|
+
* - `zmx send` injects input without taking client leadership or resizing;
|
|
177
|
+
* - `zmx history` is the sole authoritative plain-text screen source.
|
|
178
|
+
*
|
|
179
|
+
* A one-shot `zmx attach ... /bin/sh <private-bootstrap>` is used only as the
|
|
180
|
+
* race-safe create primitive. Its stdin is /dev/null, so the client exits
|
|
181
|
+
* immediately and never remains as a synthetic leader. The private bootstrap
|
|
182
|
+
* does not launch the CLI until botmux has proved ownership, stamped the
|
|
183
|
+
* transport protocol labels and observed the tail client connected.
|
|
184
|
+
*/
|
|
185
|
+
export class ZmxBackend {
|
|
186
|
+
sessionName;
|
|
187
|
+
opts;
|
|
188
|
+
tailProcess = null;
|
|
189
|
+
dataCbs = [];
|
|
190
|
+
screenResyncCbs = [];
|
|
191
|
+
exitCbs = [];
|
|
192
|
+
reattaching;
|
|
193
|
+
intentionalExit = false;
|
|
194
|
+
exited = false;
|
|
195
|
+
state = 'idle';
|
|
196
|
+
tailEpoch = 0;
|
|
197
|
+
reconnectAttempt = 0;
|
|
198
|
+
reconnectTimer = null;
|
|
199
|
+
stableTailTimer = null;
|
|
200
|
+
historyTimer = null;
|
|
201
|
+
historyTimerDueAt = 0;
|
|
202
|
+
historyProcess = null;
|
|
203
|
+
historyGeneration = 0;
|
|
204
|
+
historyCaptureSerial = 0;
|
|
205
|
+
historyInFlight = false;
|
|
206
|
+
historyAgain = false;
|
|
207
|
+
historyAgainActivity = false;
|
|
208
|
+
historyAgainForceResync = false;
|
|
209
|
+
tailActivitySinceCapture = false;
|
|
210
|
+
forceResyncOnNextSnapshot = false;
|
|
211
|
+
stableHistoryPolls = 0;
|
|
212
|
+
snapshotCache = '';
|
|
213
|
+
hasSnapshot = false;
|
|
214
|
+
pendingScreenResyncReplay = false;
|
|
215
|
+
historyColdJitterMs;
|
|
216
|
+
historySettleWaiters = [];
|
|
217
|
+
/** A same-name session failed ownership checks and must never be killed. */
|
|
218
|
+
preserveSessionOnDestroy = false;
|
|
219
|
+
pendingExit = null;
|
|
220
|
+
earlyBuffer = '';
|
|
221
|
+
lastOpts = null;
|
|
222
|
+
/** Frozen ZMX PTY-root PID for the session generation we may control. */
|
|
223
|
+
backingPid = null;
|
|
224
|
+
/** Stable direct child of the PTY root; wrapper resolution may refine cliPid. */
|
|
225
|
+
launchPid = null;
|
|
226
|
+
/** Monotonic generation of compatible, transport-ambiguous text writes. */
|
|
227
|
+
ambiguousTextFailureSequence = 0;
|
|
228
|
+
/** Highest ambiguous text generation confirmed covered by Ctrl+C. */
|
|
229
|
+
cancelledAmbiguousTextFailureSequence = 0;
|
|
230
|
+
/** Highest ambiguous text generation for which Ctrl+C was actually attempted. */
|
|
231
|
+
attemptedAmbiguousTextRecoverySequence = 0;
|
|
232
|
+
/** A recovery Ctrl+C may have landed, so retrying or appending is unsafe. */
|
|
233
|
+
ambiguousTextRecoveryUnconfirmed = false;
|
|
234
|
+
/** Recovery must leave bracketed-paste mode before injecting Ctrl+C. */
|
|
235
|
+
ambiguousTextRecoveryNeedsPasteClose = false;
|
|
236
|
+
/** Worker-level transaction spanning all adapter chunks and its submit key. */
|
|
237
|
+
activeSubmissionFence = null;
|
|
238
|
+
activeSubmissionGeneration = null;
|
|
239
|
+
activeSubmissionTextTouched = false;
|
|
240
|
+
activeSubmissionTransportFailed = false;
|
|
241
|
+
activeSubmissionControlAccepted = false;
|
|
242
|
+
/** Completion-time timestamp: a timed-out Ctrl+C may have landed just before return. */
|
|
243
|
+
lastInjectedCancelAtMs = 0;
|
|
244
|
+
claudeJsonlPath;
|
|
245
|
+
cliPid;
|
|
246
|
+
cliCwd;
|
|
247
|
+
constructor(sessionName, opts = {}) {
|
|
248
|
+
this.sessionName = sessionName;
|
|
249
|
+
this.opts = opts;
|
|
250
|
+
this.reattaching = opts.isReattach ?? false;
|
|
251
|
+
let hash = 0;
|
|
252
|
+
for (const char of sessionName)
|
|
253
|
+
hash = ((hash * 33) + char.charCodeAt(0)) >>> 0;
|
|
254
|
+
this.historyColdJitterMs = hash % (HISTORY_COLD_JITTER_MAX_MS + 1);
|
|
255
|
+
this.restoreAmbiguousRecoveryState();
|
|
256
|
+
}
|
|
257
|
+
static isAvailable() {
|
|
258
|
+
return probeZmxFunctional().ok;
|
|
259
|
+
}
|
|
260
|
+
static sessionName(sessionId) {
|
|
261
|
+
return `bmx-${sessionId.slice(0, 8)}`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Pair the authoritative healthy-name surface (`list --short`) with the full
|
|
265
|
+
* list's `err=` rows. The full command field is not a line protocol (literal
|
|
266
|
+
* newlines in argv spill onto continuation lines), so it must never be the
|
|
267
|
+
* sole source of truth for a healthy session name.
|
|
268
|
+
*/
|
|
269
|
+
static probeSessions(env = zmxEnv()) {
|
|
270
|
+
try {
|
|
271
|
+
const shortOut = execFileSync('zmx', ['list', '--short'], {
|
|
272
|
+
encoding: 'utf-8',
|
|
273
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
274
|
+
timeout: 3000,
|
|
275
|
+
env,
|
|
276
|
+
});
|
|
277
|
+
const out = execFileSync('zmx', ['list'], {
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
280
|
+
timeout: 3000,
|
|
281
|
+
env,
|
|
282
|
+
});
|
|
283
|
+
const short = parseZmxShortList(shortOut);
|
|
284
|
+
const parsed = parseZmxList(out);
|
|
285
|
+
if (short.malformedLines.length > 0 || parsed.malformedLines.length > 0)
|
|
286
|
+
return { ok: false };
|
|
287
|
+
if (short.sessions.length > 0 && parsed.sessions.length + parsed.unhealthySessions.length === 0) {
|
|
288
|
+
return { ok: false };
|
|
289
|
+
}
|
|
290
|
+
const healthy = new Set(short.sessions);
|
|
291
|
+
// A healthy-looking full row absent from --short is ambiguous (the row
|
|
292
|
+
// can have been forged by a multiline cmd field). Preserve it as unknown,
|
|
293
|
+
// never as authoritative existence or absence. Conversely --short wins
|
|
294
|
+
// over a forged err= continuation for a genuinely healthy name.
|
|
295
|
+
const unhealthy = new Set([
|
|
296
|
+
...parsed.unhealthySessions.filter(name => !healthy.has(name)),
|
|
297
|
+
...parsed.sessions.filter(name => !healthy.has(name)),
|
|
298
|
+
]);
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
sessions: short.sessions,
|
|
302
|
+
unhealthySessions: [...unhealthy],
|
|
303
|
+
raw: out,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return { ok: false };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
static hasSession(name, env = zmxEnv()) {
|
|
311
|
+
return ZmxBackend.probeSession(name, env) === 'exists';
|
|
312
|
+
}
|
|
313
|
+
static probeSession(name, env = zmxEnv()) {
|
|
314
|
+
const probe = ZmxBackend.probeSessions(env);
|
|
315
|
+
if (!probe.ok)
|
|
316
|
+
return 'unknown';
|
|
317
|
+
if (probe.sessions.includes(name))
|
|
318
|
+
return 'exists';
|
|
319
|
+
if (probe.unhealthySessions.includes(name))
|
|
320
|
+
return 'unknown';
|
|
321
|
+
return 'missing';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Verify that a name still resolves to the botmux-owned transport for the
|
|
325
|
+
* complete session UUID. The PTY-root PID is sampled on both sides of the label
|
|
326
|
+
* reads so a same-name replacement cannot be mistaken for the process whose
|
|
327
|
+
* labels we just inspected.
|
|
328
|
+
*/
|
|
329
|
+
static probeManagedSession(name, expectedSessionId, env = zmxEnv(), opts = {}) {
|
|
330
|
+
const beforeSnapshot = ZmxBackend.probeSessions(env);
|
|
331
|
+
if (!beforeSnapshot.ok) {
|
|
332
|
+
return { state: 'unknown', reason: `无法读取 ZMX 会话 ${name} 的进程信息` };
|
|
333
|
+
}
|
|
334
|
+
if (!beforeSnapshot.sessions.includes(name)) {
|
|
335
|
+
return beforeSnapshot.unhealthySessions.includes(name)
|
|
336
|
+
? { state: 'unknown', reason: `ZMX 会话 ${name} 当前无响应` }
|
|
337
|
+
: { state: 'missing' };
|
|
338
|
+
}
|
|
339
|
+
const before = sessionDetailsFromSnapshot(beforeSnapshot, name);
|
|
340
|
+
if (!before)
|
|
341
|
+
return { state: 'unknown', reason: `ZMX 会话 ${name} 的进程信息不唯一` };
|
|
342
|
+
let transport;
|
|
343
|
+
let sessionId = '';
|
|
344
|
+
let launchPidRaw;
|
|
345
|
+
let gateNonce;
|
|
346
|
+
try {
|
|
347
|
+
transport = execFileSync('zmx', ['get', name, ZMX_TRANSPORT_LABEL], {
|
|
348
|
+
encoding: 'utf8',
|
|
349
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
350
|
+
timeout: 3000,
|
|
351
|
+
env,
|
|
352
|
+
}).trim();
|
|
353
|
+
if (expectedSessionId) {
|
|
354
|
+
sessionId = execFileSync('zmx', ['get', name, ZMX_SESSION_LABEL], {
|
|
355
|
+
encoding: 'utf8',
|
|
356
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
357
|
+
timeout: 3000,
|
|
358
|
+
env,
|
|
359
|
+
}).trim();
|
|
360
|
+
}
|
|
361
|
+
launchPidRaw = execFileSync('zmx', ['get', name, ZMX_LAUNCH_PID_LABEL], {
|
|
362
|
+
encoding: 'utf8',
|
|
363
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
364
|
+
timeout: 3000,
|
|
365
|
+
env,
|
|
366
|
+
}).trim();
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
return {
|
|
370
|
+
state: 'unknown',
|
|
371
|
+
reason: `无法读取 ZMX 会话 ${name} 的所有权标签:${err instanceof Error ? err.message : String(err)}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
gateNonce = execFileSync('zmx', ['get', name, ZMX_GATE_NONCE_LABEL], {
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
378
|
+
timeout: 3000,
|
|
379
|
+
env,
|
|
380
|
+
}).trim();
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
return {
|
|
384
|
+
state: 'unknown',
|
|
385
|
+
// Sessions created before the gated-generation protocol cannot prove
|
|
386
|
+
// whether their labelled launch child is still waiting for a private
|
|
387
|
+
// release file. Preserve them for one-time manual cleanup rather than
|
|
388
|
+
// reattaching or deleting by name.
|
|
389
|
+
reason: `ZMX 会话 ${name} 缺少启动门禁标签;已保留,请手动关闭旧会话后重试:${err instanceof Error ? err.message : String(err)}`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const afterSnapshot = ZmxBackend.probeSessions(env);
|
|
393
|
+
const after = afterSnapshot.ok ? sessionDetailsFromSnapshot(afterSnapshot, name) : null;
|
|
394
|
+
if (!after || after.pid !== before.pid) {
|
|
395
|
+
return { state: 'unknown', reason: `ZMX 会话 ${name} 在所有权校验期间发生变化` };
|
|
396
|
+
}
|
|
397
|
+
if (transport !== ZMX_TRANSPORT) {
|
|
398
|
+
return {
|
|
399
|
+
state: 'incompatible',
|
|
400
|
+
pid: after.pid,
|
|
401
|
+
clients: after.clients,
|
|
402
|
+
reason: 'transport-label',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (expectedSessionId && sessionId !== expectedSessionId) {
|
|
406
|
+
return {
|
|
407
|
+
state: 'incompatible',
|
|
408
|
+
pid: after.pid,
|
|
409
|
+
clients: after.clients,
|
|
410
|
+
reason: 'session-label',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const launchPid = Number(launchPidRaw);
|
|
414
|
+
if (!Number.isSafeInteger(launchPid)
|
|
415
|
+
|| launchPid <= 0
|
|
416
|
+
|| readProcessParentPid(launchPid) !== after.pid) {
|
|
417
|
+
return {
|
|
418
|
+
state: 'unknown',
|
|
419
|
+
reason: `ZMX 会话 ${name} 的 launch PID 标签无效或已脱离 PTY root`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (!/^[0-9a-f]{32}$/.test(gateNonce)) {
|
|
423
|
+
return {
|
|
424
|
+
state: 'unknown',
|
|
425
|
+
reason: `ZMX 会话 ${name} 的启动门禁标签无效`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (!opts.allowGated) {
|
|
429
|
+
const commandLine = readProcessCommandLine(launchPid);
|
|
430
|
+
if (!commandLine) {
|
|
431
|
+
return {
|
|
432
|
+
state: 'unknown',
|
|
433
|
+
reason: `无法确认 ZMX 会话 ${name} 的 launch 子进程状态`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (commandLine.includes(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${gateNonce}`)) {
|
|
437
|
+
return {
|
|
438
|
+
state: 'unknown',
|
|
439
|
+
reason: `ZMX 会话 ${name} 仍在等待私有启动门禁放行`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { state: 'compatible', pid: after.pid, clients: after.clients };
|
|
444
|
+
}
|
|
445
|
+
/** ZMX has one daemon per session rather than one shared server. */
|
|
446
|
+
static serverState() {
|
|
447
|
+
const probe = ZmxBackend.probeSessions();
|
|
448
|
+
if (!probe.ok)
|
|
449
|
+
return 'unknown';
|
|
450
|
+
if (probe.unhealthySessions.some(s => s.startsWith('bmx-')))
|
|
451
|
+
return 'unknown';
|
|
452
|
+
return probe.sessions.some(s => s.startsWith('bmx-')) ? 'running' : 'down';
|
|
453
|
+
}
|
|
454
|
+
static killSession(name) {
|
|
455
|
+
try {
|
|
456
|
+
execFileSync('zmx', ['kill', name, '--force'], {
|
|
457
|
+
stdio: 'ignore',
|
|
458
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
459
|
+
env: zmxEnv(),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch { /* already gone or daemon unavailable */ }
|
|
463
|
+
}
|
|
464
|
+
/** Kill only a session whose full botmux identity is still authoritative. */
|
|
465
|
+
static killManagedSession(name, expectedSessionId, expectedPid, env = zmxEnv()) {
|
|
466
|
+
const allowGated = expectedPid !== undefined;
|
|
467
|
+
const probe = ZmxBackend.probeManagedSession(name, expectedSessionId, env, { allowGated });
|
|
468
|
+
if (probe.state === 'missing')
|
|
469
|
+
return;
|
|
470
|
+
if (probe.state !== 'compatible') {
|
|
471
|
+
throw new Error(probe.state === 'unknown'
|
|
472
|
+
? probe.reason
|
|
473
|
+
: `ZMX 会话 ${name} 的所有权标签不匹配,已拒绝删除`);
|
|
474
|
+
}
|
|
475
|
+
if (expectedPid !== undefined && probe.pid !== expectedPid) {
|
|
476
|
+
throw new Error(`ZMX 会话 ${name} 的 PTY root PID 已变化,已拒绝删除`);
|
|
477
|
+
}
|
|
478
|
+
execFileSync('zmx', ['kill', name, '--force'], {
|
|
479
|
+
encoding: 'utf8',
|
|
480
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
481
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
482
|
+
env,
|
|
483
|
+
});
|
|
484
|
+
// Successful ZMX kills print `killed session <name>` (and forced stale
|
|
485
|
+
// cleanup also prints a status line). The exit status plus the generation-
|
|
486
|
+
// aware convergence probe below are authoritative, not empty stdout.
|
|
487
|
+
const deadline = Date.now() + MANAGED_KILL_TIMEOUT_MS;
|
|
488
|
+
let lastUnknownReason = null;
|
|
489
|
+
while (Date.now() < deadline) {
|
|
490
|
+
const after = ZmxBackend.probeManagedSession(name, expectedSessionId, env, { allowGated });
|
|
491
|
+
if (after.state === 'missing')
|
|
492
|
+
return;
|
|
493
|
+
// A just-removed socket may briefly remain visible while its control
|
|
494
|
+
// endpoint already rejects get/list. Treat that as convergence, not as a
|
|
495
|
+
// replacement; an incompatible label or changed PID still fails at once.
|
|
496
|
+
if (after.state === 'unknown') {
|
|
497
|
+
lastUnknownReason = after.reason;
|
|
498
|
+
sleepSync(25);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (after.state === 'incompatible' || after.pid !== probe.pid) {
|
|
502
|
+
throw new Error(`ZMX 会话 ${name} 在删除确认期间被同名会话替换`);
|
|
503
|
+
}
|
|
504
|
+
sleepSync(25);
|
|
505
|
+
}
|
|
506
|
+
throw new Error(`ZMX 会话 ${name} 删除确认超时` +
|
|
507
|
+
(lastUnknownReason ? `:${lastUnknownReason}` : ''));
|
|
508
|
+
}
|
|
509
|
+
static listBotmuxSessions() {
|
|
510
|
+
const probe = ZmxBackend.probeSessions();
|
|
511
|
+
return probe.ok ? probe.sessions.filter(s => s.startsWith('bmx-')) : [];
|
|
512
|
+
}
|
|
513
|
+
static listDetails() {
|
|
514
|
+
const probe = ZmxBackend.probeSessions();
|
|
515
|
+
return probe.ok ? probe.raw : '';
|
|
516
|
+
}
|
|
517
|
+
get isReattach() {
|
|
518
|
+
return this.reattaching;
|
|
519
|
+
}
|
|
520
|
+
get lastInjectedCancelAt() {
|
|
521
|
+
return this.lastInjectedCancelAtMs;
|
|
522
|
+
}
|
|
523
|
+
captureAmbiguousSubmissionFence() {
|
|
524
|
+
const fence = this.ambiguousTextFailureSequence;
|
|
525
|
+
if (this.ambiguousTextRecoveryUnconfirmed) {
|
|
526
|
+
throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
|
|
527
|
+
}
|
|
528
|
+
if (this.hasAmbiguousRecoveryDebt()) {
|
|
529
|
+
throw new AmbiguousSubmissionBlockedError('recovery-pending');
|
|
530
|
+
}
|
|
531
|
+
if (this.activeSubmissionGeneration != null) {
|
|
532
|
+
this.markActiveSubmissionUnconfirmed();
|
|
533
|
+
throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
|
|
534
|
+
}
|
|
535
|
+
const generation = this.armAmbiguousTextGeneration();
|
|
536
|
+
if (generation == null) {
|
|
537
|
+
throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
|
|
538
|
+
}
|
|
539
|
+
this.activeSubmissionFence = fence;
|
|
540
|
+
this.activeSubmissionGeneration = generation;
|
|
541
|
+
this.activeSubmissionTextTouched = false;
|
|
542
|
+
this.activeSubmissionTransportFailed = false;
|
|
543
|
+
this.activeSubmissionControlAccepted = false;
|
|
544
|
+
return fence;
|
|
545
|
+
}
|
|
546
|
+
confirmAmbiguousSubmission(fence) {
|
|
547
|
+
if (this.ambiguousTextRecoveryUnconfirmed)
|
|
548
|
+
return 'recovery-unconfirmed';
|
|
549
|
+
if (this.activeSubmissionGeneration != null
|
|
550
|
+
&& this.activeSubmissionFence !== fence) {
|
|
551
|
+
// A stale caller must never commit another logical transaction. The
|
|
552
|
+
// worker mutex makes this unreachable in the normal path, but keeping the
|
|
553
|
+
// backend contract fail-closed prevents a future caller from silently
|
|
554
|
+
// leaving an armed generation writable.
|
|
555
|
+
this.markActiveSubmissionUnconfirmed();
|
|
556
|
+
return 'recovery-unconfirmed';
|
|
557
|
+
}
|
|
558
|
+
if (this.activeSubmissionFence !== fence
|
|
559
|
+
|| this.activeSubmissionGeneration == null) {
|
|
560
|
+
return this.hasAmbiguousRecoveryDebt() ? 'recovery-pending' : undefined;
|
|
561
|
+
}
|
|
562
|
+
if (this.activeSubmissionTransportFailed)
|
|
563
|
+
return 'recovery-pending';
|
|
564
|
+
return this.markAmbiguousGenerationCovered(this.activeSubmissionGeneration)
|
|
565
|
+
? undefined
|
|
566
|
+
: 'recovery-unconfirmed';
|
|
567
|
+
}
|
|
568
|
+
cancelAmbiguousSubmission(fence) {
|
|
569
|
+
if (this.ambiguousTextRecoveryUnconfirmed)
|
|
570
|
+
return 'recovery-unconfirmed';
|
|
571
|
+
if (this.activeSubmissionGeneration != null
|
|
572
|
+
&& this.activeSubmissionFence !== fence) {
|
|
573
|
+
// Do not let a stale cancellation abort a newer transaction or inject
|
|
574
|
+
// Ctrl+C into its composer. Freeze it for explicit fresh-generation
|
|
575
|
+
// recovery instead.
|
|
576
|
+
this.markActiveSubmissionUnconfirmed();
|
|
577
|
+
return 'recovery-unconfirmed';
|
|
578
|
+
}
|
|
579
|
+
if (this.activeSubmissionFence === fence
|
|
580
|
+
&& this.activeSubmissionGeneration != null) {
|
|
581
|
+
if (!this.activeSubmissionTextTouched) {
|
|
582
|
+
return this.markAmbiguousGenerationCovered(this.activeSubmissionGeneration)
|
|
583
|
+
? undefined
|
|
584
|
+
: 'recovery-unconfirmed';
|
|
585
|
+
}
|
|
586
|
+
// Once a control key was accepted, a later adapter exception cannot
|
|
587
|
+
// prove whether that key submitted the prompt. Never inject Ctrl+C into
|
|
588
|
+
// a possibly running turn; freeze until the user inspects/restarts it.
|
|
589
|
+
if (this.activeSubmissionControlAccepted) {
|
|
590
|
+
this.markActiveSubmissionUnconfirmed();
|
|
591
|
+
return 'recovery-unconfirmed';
|
|
592
|
+
}
|
|
593
|
+
return this.abortPartialSend(false);
|
|
594
|
+
}
|
|
595
|
+
const failedThrough = this.ambiguousTextFailureSequence;
|
|
596
|
+
if (failedThrough <= this.cancelledAmbiguousTextFailureSequence)
|
|
597
|
+
return undefined;
|
|
598
|
+
if (failedThrough <= fence)
|
|
599
|
+
return 'recovery-pending';
|
|
600
|
+
return this.abortPartialSend(false);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Space terminal writes themselves so two valid cancellation debts cannot
|
|
604
|
+
* become OMP's double-Ctrl+C exit gesture.
|
|
605
|
+
*/
|
|
606
|
+
waitForInjectedCancelCooldown() {
|
|
607
|
+
if (this.lastInjectedCancelAtMs > 0) {
|
|
608
|
+
const elapsed = Math.max(0, Date.now() - this.lastInjectedCancelAtMs);
|
|
609
|
+
const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - elapsed;
|
|
610
|
+
if (waitMs > 0)
|
|
611
|
+
sleepSync(waitMs);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
hasPendingAmbiguousTextRecovery() {
|
|
615
|
+
return this.ambiguousTextFailureSequence
|
|
616
|
+
> this.cancelledAmbiguousTextFailureSequence;
|
|
617
|
+
}
|
|
618
|
+
activeSubmissionCanWrite() {
|
|
619
|
+
return this.activeSubmissionGeneration != null
|
|
620
|
+
&& !this.activeSubmissionTransportFailed
|
|
621
|
+
&& !this.ambiguousTextRecoveryUnconfirmed;
|
|
622
|
+
}
|
|
623
|
+
hasAmbiguousRecoveryDebt() {
|
|
624
|
+
return this.hasPendingAmbiguousTextRecovery()
|
|
625
|
+
&& !this.activeSubmissionCanWrite();
|
|
626
|
+
}
|
|
627
|
+
composerRecoveryStatePath() {
|
|
628
|
+
if (!this.opts.recoveryStateDir || !this.opts.sessionId)
|
|
629
|
+
return null;
|
|
630
|
+
const key = createHash('sha256').update(this.opts.sessionId).digest('hex');
|
|
631
|
+
return join(this.opts.recoveryStateDir, 'zmx-composer-recovery', `${key}.json`);
|
|
632
|
+
}
|
|
633
|
+
readComposerRecoveryState() {
|
|
634
|
+
const path = this.composerRecoveryStatePath();
|
|
635
|
+
if (!path)
|
|
636
|
+
return 'clean';
|
|
637
|
+
try {
|
|
638
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
639
|
+
if (parsed.version !== 1
|
|
640
|
+
|| parsed.sessionId !== this.opts.sessionId
|
|
641
|
+
|| !['clean', 'pending', 'unconfirmed'].includes(String(parsed.state))) {
|
|
642
|
+
return 'unconfirmed';
|
|
643
|
+
}
|
|
644
|
+
return parsed.state;
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
if (err
|
|
648
|
+
&& typeof err === 'object'
|
|
649
|
+
&& 'code' in err
|
|
650
|
+
&& err.code === 'ENOENT') {
|
|
651
|
+
return 'clean';
|
|
652
|
+
}
|
|
653
|
+
logger.error(`[zmx:${this.sessionName}] composer recovery journal unreadable; ` +
|
|
654
|
+
`failing closed: ${err instanceof Error ? err.message : String(err)}`);
|
|
655
|
+
return 'unconfirmed';
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
writeComposerRecoveryState(state) {
|
|
659
|
+
const path = this.composerRecoveryStatePath();
|
|
660
|
+
if (!path)
|
|
661
|
+
return true;
|
|
662
|
+
try {
|
|
663
|
+
const journalDir = join(this.opts.recoveryStateDir, 'zmx-composer-recovery');
|
|
664
|
+
const created = mkdirSync(journalDir, {
|
|
665
|
+
recursive: true,
|
|
666
|
+
mode: 0o700,
|
|
667
|
+
});
|
|
668
|
+
if (created !== undefined) {
|
|
669
|
+
// fsyncing the journal directory below makes its file entries durable,
|
|
670
|
+
// but a first-ever mkdir is itself an entry in recoveryStateDir. Publish
|
|
671
|
+
// that parent entry before arming the WAL, or a power loss could erase
|
|
672
|
+
// the entire pending journal and make reattach fail open as `clean`.
|
|
673
|
+
try {
|
|
674
|
+
fsyncDirectorySyncPortable(this.opts.recoveryStateDir);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
// Do not leave an empty, never-confirmed directory behind: a later
|
|
678
|
+
// retry would otherwise see mkdir as pre-existing, skip this parent
|
|
679
|
+
// durability barrier, and incorrectly trust it.
|
|
680
|
+
try {
|
|
681
|
+
rmdirSync(journalDir);
|
|
682
|
+
}
|
|
683
|
+
catch { /* fail-closed below */ }
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
atomicWriteFileSync(path, `${JSON.stringify({ version: 1, sessionId: this.opts.sessionId, state })}\n`, {
|
|
688
|
+
mode: 0o600,
|
|
689
|
+
durable: true,
|
|
690
|
+
followTargetSymlink: false,
|
|
691
|
+
});
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
logger.error(`[zmx:${this.sessionName}] unable to persist composer recovery state ` +
|
|
696
|
+
`${state}; failing closed: ${err instanceof Error ? err.message : String(err)}`);
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
restoreAmbiguousRecoveryState() {
|
|
701
|
+
const state = this.readComposerRecoveryState();
|
|
702
|
+
if (state === 'clean')
|
|
703
|
+
return;
|
|
704
|
+
this.ambiguousTextFailureSequence = 1;
|
|
705
|
+
// A new worker cannot reconstruct whether bracketed-paste mode was open
|
|
706
|
+
// or whether a cleanup key landed before the old process died. Restore
|
|
707
|
+
// every non-clean journal entry as sticky poison, never as retryable debt.
|
|
708
|
+
this.attemptedAmbiguousTextRecoverySequence = 1;
|
|
709
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
710
|
+
logger.error(`[zmx:${this.sessionName}] restored ${state} composer recovery state; ` +
|
|
711
|
+
'automatic input remains blocked until a fresh backend generation');
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Write-ahead arm one text transport generation before `zmx send` can touch
|
|
715
|
+
* the PTY. A worker killed after the daemon consumes stdin but before the
|
|
716
|
+
* synchronous client returns will therefore leave `pending` for reattach to
|
|
717
|
+
* restore as poison instead of trusting a stale `clean` marker.
|
|
718
|
+
*/
|
|
719
|
+
armAmbiguousTextGeneration() {
|
|
720
|
+
const generation = this.ambiguousTextFailureSequence + 1;
|
|
721
|
+
if (!this.writeComposerRecoveryState('pending')) {
|
|
722
|
+
// No bytes have been sent, but this worker can no longer uphold the
|
|
723
|
+
// recovery journal contract. Keep it fail-closed until a fresh process.
|
|
724
|
+
this.ambiguousTextFailureSequence = generation;
|
|
725
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
this.ambiguousTextFailureSequence = generation;
|
|
729
|
+
return generation;
|
|
730
|
+
}
|
|
731
|
+
markArmedTextFailure(closeOpenPaste) {
|
|
732
|
+
this.ambiguousTextRecoveryNeedsPasteClose ||= closeOpenPaste;
|
|
733
|
+
if (this.activeSubmissionGeneration != null) {
|
|
734
|
+
this.activeSubmissionTransportFailed = true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
markActiveSubmissionUnconfirmed() {
|
|
738
|
+
// `pending` already survives a failed atomic replacement, so even a failed
|
|
739
|
+
// unconfirmed write remains fail-closed on the next worker.
|
|
740
|
+
this.writeComposerRecoveryState('unconfirmed');
|
|
741
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
742
|
+
}
|
|
743
|
+
/** Record that a recovery Ctrl+C is now transport-ambiguous itself. */
|
|
744
|
+
markAmbiguousRecoveryAttempted() {
|
|
745
|
+
if (!this.writeComposerRecoveryState('unconfirmed')) {
|
|
746
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
const generation = this.ambiguousTextFailureSequence;
|
|
750
|
+
this.attemptedAmbiguousTextRecoverySequence = Math.max(this.attemptedAmbiguousTextRecoverySequence, generation);
|
|
751
|
+
return generation;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Clear only the exact write-ahead generation whose send or recovery was
|
|
755
|
+
* accepted. Persistence is committed before the in-memory covered marker.
|
|
756
|
+
*/
|
|
757
|
+
markAmbiguousGenerationCovered(generation) {
|
|
758
|
+
if (!this.writeComposerRecoveryState('clean')) {
|
|
759
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
this.cancelledAmbiguousTextFailureSequence = Math.max(this.cancelledAmbiguousTextFailureSequence, generation);
|
|
763
|
+
this.ambiguousTextRecoveryUnconfirmed = false;
|
|
764
|
+
if (!this.hasPendingAmbiguousTextRecovery()) {
|
|
765
|
+
this.ambiguousTextRecoveryNeedsPasteClose = false;
|
|
766
|
+
}
|
|
767
|
+
if (this.activeSubmissionGeneration === generation) {
|
|
768
|
+
this.activeSubmissionFence = null;
|
|
769
|
+
this.activeSubmissionGeneration = null;
|
|
770
|
+
this.activeSubmissionTextTouched = false;
|
|
771
|
+
this.activeSubmissionTransportFailed = false;
|
|
772
|
+
this.activeSubmissionControlAccepted = false;
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
resetAmbiguousRecoveryForFreshSession() {
|
|
777
|
+
if (!this.writeComposerRecoveryState('clean')) {
|
|
778
|
+
throw new Error('ZMX composer recovery journal could not be reset');
|
|
779
|
+
}
|
|
780
|
+
this.ambiguousTextFailureSequence = 0;
|
|
781
|
+
this.cancelledAmbiguousTextFailureSequence = 0;
|
|
782
|
+
this.attemptedAmbiguousTextRecoverySequence = 0;
|
|
783
|
+
this.ambiguousTextRecoveryUnconfirmed = false;
|
|
784
|
+
this.ambiguousTextRecoveryNeedsPasteClose = false;
|
|
785
|
+
this.activeSubmissionFence = null;
|
|
786
|
+
this.activeSubmissionGeneration = null;
|
|
787
|
+
this.activeSubmissionTextTouched = false;
|
|
788
|
+
this.activeSubmissionTransportFailed = false;
|
|
789
|
+
this.activeSubmissionControlAccepted = false;
|
|
790
|
+
}
|
|
791
|
+
/** Start the next terminal-side cooldown after this attempt has settled. */
|
|
792
|
+
noteInjectedCancelAttemptSettled() {
|
|
793
|
+
this.lastInjectedCancelAtMs = Date.now();
|
|
794
|
+
}
|
|
795
|
+
spawn(bin, args, opts) {
|
|
796
|
+
const frozenOpts = {
|
|
797
|
+
...opts,
|
|
798
|
+
env: { ...opts.env },
|
|
799
|
+
injectEnv: opts.injectEnv ? { ...opts.injectEnv } : undefined,
|
|
800
|
+
};
|
|
801
|
+
const controlEnv = zmxControlEnv(frozenOpts);
|
|
802
|
+
const probe = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, controlEnv);
|
|
803
|
+
if (probe.state === 'unknown')
|
|
804
|
+
throw new Error(probe.reason);
|
|
805
|
+
if (probe.state === 'incompatible') {
|
|
806
|
+
this.preserveSessionOnDestroy = true;
|
|
807
|
+
throw new Error(probe.reason === 'transport-label'
|
|
808
|
+
? `ZMX 会话 ${this.sessionName} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);已保留,请手动关闭旧会话后重试`
|
|
809
|
+
: `ZMX 会话 ${this.sessionName} 属于另一个完整 botmux session;已保留该会话`);
|
|
810
|
+
}
|
|
811
|
+
if (probe.state === 'compatible') {
|
|
812
|
+
// Reattach/fresh is frozen by worker before plugin, startup-command and
|
|
813
|
+
// isolation decisions. A late same-name winner must not silently turn a
|
|
814
|
+
// fresh launch into an attach to a process created under another policy.
|
|
815
|
+
if (!this.reattaching) {
|
|
816
|
+
this.preserveSessionOnDestroy = true;
|
|
817
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 在启动前已出现;已保留并拒绝改变 fresh 决策`);
|
|
818
|
+
}
|
|
819
|
+
this.backingPid = probe.pid;
|
|
820
|
+
this.lastOpts = frozenOpts;
|
|
821
|
+
this.launchPid = this.readManagedLaunchPid(frozenOpts, probe.pid);
|
|
822
|
+
this.cliPid = this.launchPid;
|
|
823
|
+
}
|
|
824
|
+
else if (this.reattaching) {
|
|
825
|
+
// The daemon selected reattach from an earlier probe. Never turn that
|
|
826
|
+
// stale decision into a new CLI after the backing session disappears.
|
|
827
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 在重连前已消失`);
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
this.lastOpts = frozenOpts;
|
|
831
|
+
}
|
|
832
|
+
logger.debug(`[zmx:${this.sessionName}] spawn ${this.reattaching ? 'reattach' : 'fresh'} ` +
|
|
833
|
+
`bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd}`);
|
|
834
|
+
if (this.reattaching) {
|
|
835
|
+
this.startTail();
|
|
836
|
+
if (!this.waitForTailClient()) {
|
|
837
|
+
this.stopTailAfterLaunchFailure();
|
|
838
|
+
this.preserveSessionOnDestroy = true;
|
|
839
|
+
throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`);
|
|
840
|
+
}
|
|
841
|
+
this.requestHistoryCapture(0, true, true);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
this.createFreshSession(bin, args, opts);
|
|
845
|
+
}
|
|
846
|
+
write(data) {
|
|
847
|
+
if (!this.sendText(data))
|
|
848
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 输入发送失败`);
|
|
849
|
+
}
|
|
850
|
+
sendText(text) {
|
|
851
|
+
return this.sendBytes(Buffer.from(text, 'utf8'), false, 'text');
|
|
852
|
+
}
|
|
853
|
+
sendSpecialKeys(...keys) {
|
|
854
|
+
return this.sendBytes(Buffer.from(keys.map(tmuxKeyToBytes).join(''), 'utf8'), false, 'control');
|
|
855
|
+
}
|
|
856
|
+
pasteText(text) {
|
|
857
|
+
if (!this.sendBytes(Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), true, 'text')) {
|
|
858
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 粘贴发送失败`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* No-op by construction: zmx exposes no leaderless resize primitive. Size is
|
|
863
|
+
* set only by an attached client's TIOCGWINSZ, and `send` deliberately never
|
|
864
|
+
* becomes that client (upstream 8ba312d7). Since `createFreshSession` creates
|
|
865
|
+
* the session with a non-TTY stdio, zmx's `getTerminalSize` fallback applies
|
|
866
|
+
* and every botmux-owned session runs at a fixed 120x24 until a local
|
|
867
|
+
* `zmx attach` takes leadership. The CLI therefore wraps its TUI at 120
|
|
868
|
+
* columns, which is the width reflected in the history screen we relay.
|
|
869
|
+
*/
|
|
870
|
+
resize(_cols, _rows) { }
|
|
871
|
+
onData(cb) {
|
|
872
|
+
this.dataCbs.push(cb);
|
|
873
|
+
if (this.earlyBuffer) {
|
|
874
|
+
const buffered = this.earlyBuffer;
|
|
875
|
+
this.earlyBuffer = '';
|
|
876
|
+
try {
|
|
877
|
+
cb(buffered);
|
|
878
|
+
}
|
|
879
|
+
catch { /* listener failure must not kill transport */ }
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
onScreenResync(cb) {
|
|
883
|
+
this.screenResyncCbs.push(cb);
|
|
884
|
+
if (this.pendingScreenResyncReplay) {
|
|
885
|
+
this.pendingScreenResyncReplay = false;
|
|
886
|
+
try {
|
|
887
|
+
cb(this.snapshotCache);
|
|
888
|
+
}
|
|
889
|
+
catch { /* listener failure must not kill transport */ }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
onExit(cb) {
|
|
893
|
+
this.exitCbs.push(cb);
|
|
894
|
+
if (this.pendingExit) {
|
|
895
|
+
const exit = this.pendingExit;
|
|
896
|
+
try {
|
|
897
|
+
cb(exit.code, exit.signal);
|
|
898
|
+
}
|
|
899
|
+
catch { /* listener failure must not block teardown */ }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
getChildPid() {
|
|
903
|
+
if (this.cliPid)
|
|
904
|
+
return this.cliPid;
|
|
905
|
+
const pid = this.backingPid ?? findSessionPid(this.sessionName);
|
|
906
|
+
if (pid)
|
|
907
|
+
this.cliPid = pid;
|
|
908
|
+
return pid;
|
|
909
|
+
}
|
|
910
|
+
/** Best-effort plain-text terminal snapshot supplied by ZMX history. */
|
|
911
|
+
captureCurrentScreen() {
|
|
912
|
+
return this.hasSnapshot ? this.snapshotCache : '';
|
|
913
|
+
}
|
|
914
|
+
settleCurrentScreen() {
|
|
915
|
+
if (this.exited || this.intentionalExit || !this.lastOpts)
|
|
916
|
+
return Promise.resolve(false);
|
|
917
|
+
// If a capture is already in flight, wait for the dirty-latch follow-up,
|
|
918
|
+
// not the older sample that may have started before the final output.
|
|
919
|
+
const targetSerial = this.historyCaptureSerial + 1;
|
|
920
|
+
return new Promise(resolve => {
|
|
921
|
+
const timer = setTimeout(() => {
|
|
922
|
+
const index = this.historySettleWaiters.findIndex(waiter => waiter.resolve === resolve);
|
|
923
|
+
if (index >= 0)
|
|
924
|
+
this.historySettleWaiters.splice(index, 1);
|
|
925
|
+
resolve(false);
|
|
926
|
+
}, ZMX_HISTORY_SETTLE_TIMEOUT_MS);
|
|
927
|
+
timer.unref?.();
|
|
928
|
+
this.historySettleWaiters.push({ targetSerial, resolve, timer });
|
|
929
|
+
this.requestHistoryCapture(0);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
captureViewport() {
|
|
933
|
+
return this.captureCurrentScreen();
|
|
934
|
+
}
|
|
935
|
+
getPaneSize() {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
isPaneAlive() {
|
|
939
|
+
return this.verifyBackingGeneration('liveness').state === 'compatible';
|
|
940
|
+
}
|
|
941
|
+
/** Detach the read-only observer while leaving the ZMX daemon and CLI alive. */
|
|
942
|
+
kill() {
|
|
943
|
+
if (this.state === 'stopped' || this.state === 'exited')
|
|
944
|
+
return;
|
|
945
|
+
this.intentionalExit = true;
|
|
946
|
+
this.state = 'stopped';
|
|
947
|
+
this.tailEpoch += 1;
|
|
948
|
+
this.clearReconnectTimer();
|
|
949
|
+
this.clearStableTailTimer();
|
|
950
|
+
this.stopHistoryPolling();
|
|
951
|
+
const tail = this.tailProcess;
|
|
952
|
+
this.tailProcess = null;
|
|
953
|
+
try {
|
|
954
|
+
tail?.kill('SIGTERM');
|
|
955
|
+
}
|
|
956
|
+
catch { /* already gone */ }
|
|
957
|
+
}
|
|
958
|
+
destroySession() {
|
|
959
|
+
this.kill();
|
|
960
|
+
if (!(this.opts.ownsSession ?? true) || this.preserveSessionOnDestroy)
|
|
961
|
+
return;
|
|
962
|
+
if (!this.opts.sessionId || !this.lastOpts || this.backingPid == null)
|
|
963
|
+
return;
|
|
964
|
+
try {
|
|
965
|
+
ZmxBackend.killManagedSession(this.sessionName, this.opts.sessionId, this.backingPid, zmxControlEnv(this.lastOpts));
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
this.preserveSessionOnDestroy = true;
|
|
969
|
+
logger.warn(`[zmx:${this.sessionName}] refused unsafe destroy: ` +
|
|
970
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
createFreshSession(bin, args, opts) {
|
|
974
|
+
const launch = createZmxLaunchPayload(bin, args, opts);
|
|
975
|
+
let managedIdentityVerified = false;
|
|
976
|
+
let protocolStampAttempted = false;
|
|
977
|
+
try {
|
|
978
|
+
const result = spawnSync('zmx', buildFreshAttachArgs(this.sessionName, launch.bootstrapPath), {
|
|
979
|
+
cwd: opts.cwd,
|
|
980
|
+
// /dev/null makes this a one-shot create client: it never remains as a
|
|
981
|
+
// fake terminal leader and never controls the backing PTY dimensions.
|
|
982
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
983
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
984
|
+
env: zmxControlEnv(opts),
|
|
985
|
+
});
|
|
986
|
+
const ready = this.waitForFreshReady(launch);
|
|
987
|
+
if (!ready) {
|
|
988
|
+
const details = sessionDetails(this.sessionName, zmxControlEnv(opts));
|
|
989
|
+
if (!details?.command?.includes(launch.bootstrapPath)) {
|
|
990
|
+
this.preserveSessionOnDestroy = true;
|
|
991
|
+
}
|
|
992
|
+
const stderr = result.stderr?.toString('utf8').trim();
|
|
993
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 启动握手超时` + (stderr ? `:${stderr}` : ''));
|
|
994
|
+
}
|
|
995
|
+
const created = sessionDetails(this.sessionName, zmxControlEnv(opts));
|
|
996
|
+
if (!created || !created.command?.includes(launch.bootstrapPath)) {
|
|
997
|
+
this.preserveSessionOnDestroy = true;
|
|
998
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 的 fresh 所有权握手失效;已保留同名会话`);
|
|
999
|
+
}
|
|
1000
|
+
this.backingPid = created.pid;
|
|
1001
|
+
const launchPid = this.waitForFreshLaunchPid(launch);
|
|
1002
|
+
if (launchPid == null) {
|
|
1003
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 未能确认稳定的 CLI launch 子进程`);
|
|
1004
|
+
}
|
|
1005
|
+
this.launchPid = launchPid;
|
|
1006
|
+
this.cliPid = launchPid;
|
|
1007
|
+
// `zmx set` is transport-ambiguous: the daemon may have committed every
|
|
1008
|
+
// label even if the one-shot client times out or reports an error. From
|
|
1009
|
+
// this point onward, failure cleanup must attempt the same exact
|
|
1010
|
+
// UUID/PID-guarded kill used after an explicit verification.
|
|
1011
|
+
protocolStampAttempted = true;
|
|
1012
|
+
this.stampProtocolLabels(opts, launchPid, launch.readyNonce);
|
|
1013
|
+
const managed = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(opts), { allowGated: true });
|
|
1014
|
+
if (managed.state !== 'compatible' || managed.pid !== this.backingPid) {
|
|
1015
|
+
this.preserveSessionOnDestroy = true;
|
|
1016
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 在 release 前未通过完整身份校验`);
|
|
1017
|
+
}
|
|
1018
|
+
managedIdentityVerified = true;
|
|
1019
|
+
this.startTail();
|
|
1020
|
+
if (!this.waitForTailClient()) {
|
|
1021
|
+
throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`);
|
|
1022
|
+
}
|
|
1023
|
+
// Prove the fresh generation's clean journal can be persisted before the
|
|
1024
|
+
// bootstrap is allowed to exec the real CLI. Some adapters bake the first
|
|
1025
|
+
// prompt into argv; releasing first would let that prompt run before a
|
|
1026
|
+
// journal failure tears the session down, making an automatic retry
|
|
1027
|
+
// capable of repeating side effects. The full managed identity and tail
|
|
1028
|
+
// client are already verified above, so the old composer's debt belongs
|
|
1029
|
+
// to a generation that can no longer receive input.
|
|
1030
|
+
this.resetAmbiguousRecoveryForFreshSession();
|
|
1031
|
+
// The bootstrap performs one read after observing the release file. A
|
|
1032
|
+
// direct write has an open-before-content race, so publish a complete
|
|
1033
|
+
// token atomically within the same private directory.
|
|
1034
|
+
writeFileSync(launch.releaseTempPath, `${launch.releaseToken}\n`, { mode: 0o600, flag: 'wx' });
|
|
1035
|
+
renameSync(launch.releaseTempPath, launch.releasePath);
|
|
1036
|
+
if (!this.waitForFreshReleaseConsumption(launch, launchPid)) {
|
|
1037
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 的 CLI release 未被稳定 launch 子进程确认`);
|
|
1038
|
+
}
|
|
1039
|
+
// The bootstrap can now launch the CLI. Polling is required even when
|
|
1040
|
+
// tail emits no bytes: upstream currently drops pure UTF-8 output while
|
|
1041
|
+
// stripping ANSI, whereas history preserves it.
|
|
1042
|
+
this.requestHistoryCapture(0, true, true);
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
this.stopTailAfterLaunchFailure();
|
|
1046
|
+
// Before full identity verification, never kill by name: the bounded
|
|
1047
|
+
// private bootstrap exits on its own and a same-name race winner must be
|
|
1048
|
+
// preserved. Once the complete UUID + frozen PTY-root PID have both been
|
|
1049
|
+
// verified, tear down that exact generation even if release was never
|
|
1050
|
+
// published. Otherwise an automatic retry can mistake the still-gated
|
|
1051
|
+
// bootstrap for a healthy reattach target and lose input in its unread
|
|
1052
|
+
// PTY before the release timeout expires.
|
|
1053
|
+
if ((managedIdentityVerified || protocolStampAttempted)
|
|
1054
|
+
&& this.opts.sessionId
|
|
1055
|
+
&& this.backingPid != null) {
|
|
1056
|
+
try {
|
|
1057
|
+
ZmxBackend.killManagedSession(this.sessionName, this.opts.sessionId, this.backingPid, zmxControlEnv(opts));
|
|
1058
|
+
}
|
|
1059
|
+
catch (killErr) {
|
|
1060
|
+
this.preserveSessionOnDestroy = true;
|
|
1061
|
+
logger.warn(`[zmx:${this.sessionName}] failed to tear down verified fresh launch after handshake error: ` +
|
|
1062
|
+
`${killErr instanceof Error ? killErr.message : String(killErr)}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
// Keep cli-pid available until the exact kill has signalled the PTY-root
|
|
1066
|
+
// bootstrap: its HUP trap uses that file to forward termination to the
|
|
1067
|
+
// gated/exec'd child. Cleanup before the kill could orphan that child.
|
|
1068
|
+
launch.cleanup();
|
|
1069
|
+
this.lastOpts = null;
|
|
1070
|
+
this.backingPid = null;
|
|
1071
|
+
this.launchPid = null;
|
|
1072
|
+
this.cliPid = undefined;
|
|
1073
|
+
throw err;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
waitForFreshReady(launch) {
|
|
1077
|
+
const deadline = Date.now() + FRESH_READY_TIMEOUT_MS;
|
|
1078
|
+
while (Date.now() < deadline) {
|
|
1079
|
+
try {
|
|
1080
|
+
const value = readFileSync(launch.readyPath, 'utf8').trim();
|
|
1081
|
+
if (value === launch.readyNonce)
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
catch { /* bootstrap has not reached the gate yet */ }
|
|
1085
|
+
sleepSync(25);
|
|
1086
|
+
}
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
waitForFreshLaunchPid(launch) {
|
|
1090
|
+
const deadline = Date.now() + FRESH_CLI_PID_TIMEOUT_MS;
|
|
1091
|
+
while (Date.now() < deadline) {
|
|
1092
|
+
try {
|
|
1093
|
+
const pid = Number(readFileSync(launch.cliPidPath, 'utf8').trim());
|
|
1094
|
+
if (Number.isSafeInteger(pid)
|
|
1095
|
+
&& pid > 0
|
|
1096
|
+
&& this.backingPid != null
|
|
1097
|
+
&& readProcessParentPid(pid) === this.backingPid) {
|
|
1098
|
+
return pid;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
catch { /* bootstrap has not published a complete pid yet */ }
|
|
1102
|
+
sleepSync(25);
|
|
1103
|
+
}
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Do not report a fresh spawn until the gate has consumed the atomically
|
|
1108
|
+
* published release token. The original child must remain attached to the
|
|
1109
|
+
* frozen PTY root throughout the wait; otherwise a dead gate can leave a
|
|
1110
|
+
* fully labelled, apparently compatible session that silently drops the
|
|
1111
|
+
* argv-baked first prompt on an automatic reattach.
|
|
1112
|
+
*/
|
|
1113
|
+
waitForFreshReleaseConsumption(launch, expectedLaunchPid) {
|
|
1114
|
+
const deadline = Date.now() + FRESH_RELEASE_ACK_TIMEOUT_MS;
|
|
1115
|
+
while (Date.now() < deadline) {
|
|
1116
|
+
const releasePending = existsSync(launch.releasePath);
|
|
1117
|
+
try {
|
|
1118
|
+
const observedLaunchPid = Number(readFileSync(launch.cliPidPath, 'utf8').trim());
|
|
1119
|
+
if (observedLaunchPid !== expectedLaunchPid
|
|
1120
|
+
|| this.backingPid == null
|
|
1121
|
+
|| readProcessParentPid(observedLaunchPid) !== this.backingPid) {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
if (!releasePending) {
|
|
1129
|
+
const commandLine = readProcessCommandLine(expectedLaunchPid);
|
|
1130
|
+
if (!commandLine)
|
|
1131
|
+
return false;
|
|
1132
|
+
if (!commandLine.includes(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${launch.readyNonce}`)) {
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
sleepSync(25);
|
|
1137
|
+
}
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
1140
|
+
readManagedLaunchPid(opts, expectedBackingPid) {
|
|
1141
|
+
let raw;
|
|
1142
|
+
try {
|
|
1143
|
+
raw = execFileSync('zmx', ['get', this.sessionName, ZMX_LAUNCH_PID_LABEL], {
|
|
1144
|
+
encoding: 'utf8',
|
|
1145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1146
|
+
timeout: 3000,
|
|
1147
|
+
env: zmxControlEnv(opts),
|
|
1148
|
+
}).trim();
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 缺少 launch PID 标签:` +
|
|
1152
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1153
|
+
}
|
|
1154
|
+
const pid = Number(raw);
|
|
1155
|
+
if (!Number.isSafeInteger(pid)
|
|
1156
|
+
|| pid <= 0
|
|
1157
|
+
|| readProcessParentPid(pid) !== expectedBackingPid) {
|
|
1158
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 的 launch PID 标签无效或已脱离 PTY root`);
|
|
1159
|
+
}
|
|
1160
|
+
const after = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(opts));
|
|
1161
|
+
if (after.state !== 'compatible' || after.pid !== expectedBackingPid) {
|
|
1162
|
+
throw new Error(`ZMX 会话 ${this.sessionName} 在 launch PID 校验期间发生变化`);
|
|
1163
|
+
}
|
|
1164
|
+
return pid;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Wait until the session reports at least one connected client.
|
|
1168
|
+
*
|
|
1169
|
+
* Deliberately NOT a differential against a pre-tail baseline. zmx's
|
|
1170
|
+
* `clients=` is an aggregate — `main.zig` reports `clients.items.len - 1`,
|
|
1171
|
+
* subtracting only the `zmx list` connection that asked. A user's
|
|
1172
|
+
* `zmx attach` (which this integration actively encourages), and every
|
|
1173
|
+
* transient `zmx list`/`get`/`history`/`send` botmux itself issues, are all
|
|
1174
|
+
* counted identically to our `tail`. A differential therefore produces false
|
|
1175
|
+
* NEGATIVES: a user detaching inside this window keeps the net delta at 0 and
|
|
1176
|
+
* a perfectly healthy session fails to restore with "tail 未能连接".
|
|
1177
|
+
*
|
|
1178
|
+
* `>= 1` inverts the error direction. The residual false POSITIVE — someone
|
|
1179
|
+
* else holds a client while our tail failed — is cheap and self-correcting:
|
|
1180
|
+
* tail is only a wakeup signal (never a byte source), the authoritative
|
|
1181
|
+
* screen comes from `history` polling which does not need tail at all, and
|
|
1182
|
+
* `scheduleTailRecovery` reconnects a dead observer. Blocking a restore is
|
|
1183
|
+
* the far more expensive mistake.
|
|
1184
|
+
*
|
|
1185
|
+
* Note this cannot instead watch our own child: the wait is synchronous
|
|
1186
|
+
* (`sleepSync`), so the child's 'error'/'close' callbacks cannot run until it
|
|
1187
|
+
* returns. Session identity is still verified below.
|
|
1188
|
+
*/
|
|
1189
|
+
waitForTailClient() {
|
|
1190
|
+
const deadline = Date.now() + TAIL_CONNECT_TIMEOUT_MS;
|
|
1191
|
+
while (Date.now() < deadline) {
|
|
1192
|
+
const details = sessionDetails(this.sessionName, this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv());
|
|
1193
|
+
if (details && this.backingPid != null && details.pid !== this.backingPid) {
|
|
1194
|
+
this.preserveSessionOnDestroy = true;
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
if (details?.clients != null && details.clients >= 1)
|
|
1198
|
+
return true;
|
|
1199
|
+
if (!details && ZmxBackend.probeSession(this.sessionName, this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv()) === 'missing')
|
|
1200
|
+
return false;
|
|
1201
|
+
sleepSync(25);
|
|
1202
|
+
}
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
stampProtocolLabels(opts, launchPid, gateNonce) {
|
|
1206
|
+
const labels = [`${ZMX_TRANSPORT_LABEL}=${ZMX_TRANSPORT}`];
|
|
1207
|
+
if (this.opts.sessionId)
|
|
1208
|
+
labels.push(`${ZMX_SESSION_LABEL}=${this.opts.sessionId}`);
|
|
1209
|
+
labels.push(`${ZMX_LAUNCH_PID_LABEL}=${launchPid}`);
|
|
1210
|
+
labels.push(`${ZMX_GATE_NONCE_LABEL}=${gateNonce}`);
|
|
1211
|
+
const stdout = execFileSync('zmx', ['set', this.sessionName, ...labels], {
|
|
1212
|
+
encoding: 'utf8',
|
|
1213
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1214
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
1215
|
+
env: zmxControlEnv(opts),
|
|
1216
|
+
});
|
|
1217
|
+
if (stdout.trim()) {
|
|
1218
|
+
throw new Error(`ZMX 协议标签写入返回异常:${stdout.trim()}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
verifyBackingIdentity(context) {
|
|
1222
|
+
if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) {
|
|
1223
|
+
return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` };
|
|
1224
|
+
}
|
|
1225
|
+
const wasAlive = this.isBackingProcessAlive();
|
|
1226
|
+
let transport;
|
|
1227
|
+
let sessionId;
|
|
1228
|
+
try {
|
|
1229
|
+
const env = zmxControlEnv(this.lastOpts);
|
|
1230
|
+
transport = execFileSync('zmx', ['get', this.sessionName, ZMX_TRANSPORT_LABEL], {
|
|
1231
|
+
encoding: 'utf8',
|
|
1232
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1233
|
+
timeout: 3000,
|
|
1234
|
+
env,
|
|
1235
|
+
}).trim();
|
|
1236
|
+
sessionId = execFileSync('zmx', ['get', this.sessionName, ZMX_SESSION_LABEL], {
|
|
1237
|
+
encoding: 'utf8',
|
|
1238
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1239
|
+
timeout: 3000,
|
|
1240
|
+
env,
|
|
1241
|
+
}).trim();
|
|
1242
|
+
}
|
|
1243
|
+
catch (err) {
|
|
1244
|
+
if (!wasAlive || !this.isBackingProcessAlive()) {
|
|
1245
|
+
this.fireExit(0, null);
|
|
1246
|
+
return { state: 'missing' };
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
state: 'unknown',
|
|
1250
|
+
reason: `无法读取 ZMX 会话 ${this.sessionName} 的所有权标签:` +
|
|
1251
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (transport !== ZMX_TRANSPORT || sessionId !== this.opts.sessionId) {
|
|
1255
|
+
return this.rejectBackingReplacement(context, '完整 session / transport 标签已变化');
|
|
1256
|
+
}
|
|
1257
|
+
if (!wasAlive || !this.isBackingProcessAlive()) {
|
|
1258
|
+
this.fireExit(0, null);
|
|
1259
|
+
return { state: 'missing' };
|
|
1260
|
+
}
|
|
1261
|
+
return { state: 'compatible', clients: null };
|
|
1262
|
+
}
|
|
1263
|
+
/** Full list sampling is reserved for lifecycle edges that need client count.
|
|
1264
|
+
* Hot send/history paths use target-scoped labels + the frozen PTY-root PID,
|
|
1265
|
+
* so one unrelated unhealthy ZMX socket cannot freeze every session. */
|
|
1266
|
+
verifyBackingIdentityWithClients(context) {
|
|
1267
|
+
if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) {
|
|
1268
|
+
return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` };
|
|
1269
|
+
}
|
|
1270
|
+
const probe = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(this.lastOpts));
|
|
1271
|
+
if (probe.state === 'missing') {
|
|
1272
|
+
this.fireExit(0, null);
|
|
1273
|
+
return probe;
|
|
1274
|
+
}
|
|
1275
|
+
if (probe.state === 'unknown')
|
|
1276
|
+
return probe;
|
|
1277
|
+
if (probe.state === 'incompatible' || probe.pid !== this.backingPid) {
|
|
1278
|
+
const reason = probe.state === 'incompatible'
|
|
1279
|
+
? '完整 session / transport 标签已变化'
|
|
1280
|
+
: `PTY root PID 已从 ${this.backingPid} 变化为 ${probe.pid}`;
|
|
1281
|
+
return this.rejectBackingReplacement(context, reason);
|
|
1282
|
+
}
|
|
1283
|
+
return { state: 'compatible', clients: probe.clients };
|
|
1284
|
+
}
|
|
1285
|
+
/** Fast generation check for high-frequency read-only snapshots. */
|
|
1286
|
+
verifyBackingGeneration(context) {
|
|
1287
|
+
return this.verifyBackingIdentity(context);
|
|
1288
|
+
}
|
|
1289
|
+
isBackingProcessAlive() {
|
|
1290
|
+
if (this.backingPid == null)
|
|
1291
|
+
return false;
|
|
1292
|
+
try {
|
|
1293
|
+
process.kill(this.backingPid, 0);
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
catch (err) {
|
|
1297
|
+
return !!err && typeof err === 'object' && 'code' in err && err.code === 'EPERM';
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
rejectBackingReplacement(context, reason) {
|
|
1301
|
+
this.preserveSessionOnDestroy = true;
|
|
1302
|
+
logger.error(`[zmx:${this.sessionName}] ${context} refused: ${reason}`);
|
|
1303
|
+
this.fireExit(75, null);
|
|
1304
|
+
return { state: 'replaced', reason };
|
|
1305
|
+
}
|
|
1306
|
+
labelsMatchBacking(raw) {
|
|
1307
|
+
const labels = new Map();
|
|
1308
|
+
for (const pair of raw.trim().split(/\s+/)) {
|
|
1309
|
+
if (!pair)
|
|
1310
|
+
continue;
|
|
1311
|
+
const equals = pair.indexOf('=');
|
|
1312
|
+
if (equals <= 0)
|
|
1313
|
+
continue;
|
|
1314
|
+
labels.set(pair.slice(0, equals), pair.slice(equals + 1));
|
|
1315
|
+
}
|
|
1316
|
+
return labels.get(ZMX_TRANSPORT_LABEL) === ZMX_TRANSPORT
|
|
1317
|
+
&& labels.get(ZMX_SESSION_LABEL) === this.opts.sessionId;
|
|
1318
|
+
}
|
|
1319
|
+
readLabelsAsync(generation) {
|
|
1320
|
+
return new Promise((resolve, reject) => {
|
|
1321
|
+
if (!this.lastOpts || generation !== this.historyGeneration) {
|
|
1322
|
+
reject(new Error('history capture generation changed'));
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const child = execFile('zmx', ['get', this.sessionName], {
|
|
1326
|
+
encoding: 'utf8',
|
|
1327
|
+
timeout: ZMX_HISTORY_TIMEOUT_MS,
|
|
1328
|
+
maxBuffer: 64 * 1024,
|
|
1329
|
+
env: zmxControlEnv(this.lastOpts),
|
|
1330
|
+
}, (err, stdout, stderr) => {
|
|
1331
|
+
if (this.historyProcess === child)
|
|
1332
|
+
this.historyProcess = null;
|
|
1333
|
+
if (generation !== this.historyGeneration) {
|
|
1334
|
+
reject(new Error('history capture generation changed'));
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const errorText = stderr?.toString().trim() ?? '';
|
|
1338
|
+
if (err || errorText) {
|
|
1339
|
+
reject(err ?? new Error(errorText));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
resolve(stdout.toString());
|
|
1343
|
+
});
|
|
1344
|
+
this.historyProcess = child;
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
captureHistoryFileAsync(generation) {
|
|
1348
|
+
return new Promise((resolve, reject) => {
|
|
1349
|
+
if (!this.lastOpts || generation !== this.historyGeneration) {
|
|
1350
|
+
reject(new Error('history capture generation changed'));
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
let historyDir;
|
|
1354
|
+
let historyPath;
|
|
1355
|
+
let historyFd;
|
|
1356
|
+
let child = null;
|
|
1357
|
+
let timeout = null;
|
|
1358
|
+
let settled = false;
|
|
1359
|
+
let stderrTail = '';
|
|
1360
|
+
const cleanup = () => {
|
|
1361
|
+
if (timeout)
|
|
1362
|
+
clearTimeout(timeout);
|
|
1363
|
+
timeout = null;
|
|
1364
|
+
if (this.historyProcess === child)
|
|
1365
|
+
this.historyProcess = null;
|
|
1366
|
+
if (historyFd !== undefined) {
|
|
1367
|
+
try {
|
|
1368
|
+
closeSync(historyFd);
|
|
1369
|
+
}
|
|
1370
|
+
catch { /* best effort */ }
|
|
1371
|
+
}
|
|
1372
|
+
if (historyPath) {
|
|
1373
|
+
try {
|
|
1374
|
+
rmSync(historyPath, { force: true });
|
|
1375
|
+
}
|
|
1376
|
+
catch { /* best effort */ }
|
|
1377
|
+
}
|
|
1378
|
+
if (historyDir) {
|
|
1379
|
+
try {
|
|
1380
|
+
rmdirSync(historyDir);
|
|
1381
|
+
}
|
|
1382
|
+
catch { /* best effort */ }
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
const finish = (err) => {
|
|
1386
|
+
if (settled)
|
|
1387
|
+
return;
|
|
1388
|
+
settled = true;
|
|
1389
|
+
try {
|
|
1390
|
+
if (err)
|
|
1391
|
+
throw err;
|
|
1392
|
+
if (generation !== this.historyGeneration || historyFd === undefined) {
|
|
1393
|
+
throw new Error('history capture generation changed');
|
|
1394
|
+
}
|
|
1395
|
+
const size = fstatSync(historyFd).size;
|
|
1396
|
+
const length = Math.min(size, ZMX_HISTORY_MAX_BYTES);
|
|
1397
|
+
const data = Buffer.allocUnsafe(length);
|
|
1398
|
+
let bytesRead = 0;
|
|
1399
|
+
while (bytesRead < length) {
|
|
1400
|
+
const count = readSync(historyFd, data, bytesRead, length - bytesRead, size - length + bytesRead);
|
|
1401
|
+
if (count === 0)
|
|
1402
|
+
break;
|
|
1403
|
+
bytesRead += count;
|
|
1404
|
+
}
|
|
1405
|
+
let bounded = data.subarray(0, bytesRead);
|
|
1406
|
+
if (size > length) {
|
|
1407
|
+
const firstLf = bounded.indexOf(0x0a);
|
|
1408
|
+
if (firstLf >= 0)
|
|
1409
|
+
bounded = bounded.subarray(firstLf + 1);
|
|
1410
|
+
}
|
|
1411
|
+
resolve(normaliseZmxHistory(bounded.toString('utf8')));
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
reject(error);
|
|
1415
|
+
}
|
|
1416
|
+
finally {
|
|
1417
|
+
cleanup();
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
try {
|
|
1421
|
+
historyDir = mkdtempSync(join(tmpdir(), 'botmux-zmx-history-'));
|
|
1422
|
+
chmodSync(historyDir, 0o700);
|
|
1423
|
+
historyPath = join(historyDir, 'history.txt');
|
|
1424
|
+
historyFd = openSync(historyPath, 'wx+', 0o600);
|
|
1425
|
+
// Keep transcript bytes reachable only through the open fd. A private
|
|
1426
|
+
// regular file also avoids `zmx history`'s single-write pipe truncation.
|
|
1427
|
+
rmSync(historyPath);
|
|
1428
|
+
child = spawn('zmx', ['history', this.sessionName], {
|
|
1429
|
+
stdio: ['ignore', historyFd, 'pipe'],
|
|
1430
|
+
env: zmxControlEnv(this.lastOpts),
|
|
1431
|
+
});
|
|
1432
|
+
this.historyProcess = child;
|
|
1433
|
+
child.stderr?.on('data', (chunk) => {
|
|
1434
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-4096);
|
|
1435
|
+
});
|
|
1436
|
+
child.once('error', error => finish(error));
|
|
1437
|
+
child.once('close', (code, signal) => {
|
|
1438
|
+
const stderr = stderrTail.trim();
|
|
1439
|
+
if (code !== 0 || signal || stderr) {
|
|
1440
|
+
finish(new Error(stderr || `zmx history exited status=${code} signal=${signal}`));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
finish();
|
|
1444
|
+
});
|
|
1445
|
+
timeout = setTimeout(() => {
|
|
1446
|
+
try {
|
|
1447
|
+
child?.kill('SIGKILL');
|
|
1448
|
+
}
|
|
1449
|
+
catch { /* already gone */ }
|
|
1450
|
+
finish(new Error(`zmx history timed out after ${ZMX_HISTORY_TIMEOUT_MS}ms`));
|
|
1451
|
+
}, ZMX_HISTORY_TIMEOUT_MS);
|
|
1452
|
+
timeout.unref?.();
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
async readHistorySnapshotAsync(generation) {
|
|
1460
|
+
if (this.exited || this.intentionalExit || !this.lastOpts)
|
|
1461
|
+
return null;
|
|
1462
|
+
try {
|
|
1463
|
+
if (!this.isBackingProcessAlive()) {
|
|
1464
|
+
this.fireExit(0, null);
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
const before = await this.readLabelsAsync(generation);
|
|
1468
|
+
if (!this.labelsMatchBacking(before)) {
|
|
1469
|
+
this.rejectBackingReplacement('history', '完整 session / transport 标签已变化');
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
const snapshot = await this.captureHistoryFileAsync(generation);
|
|
1473
|
+
const after = await this.readLabelsAsync(generation);
|
|
1474
|
+
if (!this.labelsMatchBacking(after) || !this.isBackingProcessAlive()) {
|
|
1475
|
+
if (!this.isBackingProcessAlive())
|
|
1476
|
+
this.fireExit(0, null);
|
|
1477
|
+
else
|
|
1478
|
+
this.rejectBackingReplacement('history completion', '完整 session / transport 标签已变化');
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
return snapshot;
|
|
1482
|
+
}
|
|
1483
|
+
catch (err) {
|
|
1484
|
+
if (generation !== this.historyGeneration || this.intentionalExit || this.exited)
|
|
1485
|
+
return null;
|
|
1486
|
+
logger.warn(`[zmx:${this.sessionName}] history capture failed: ` +
|
|
1487
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1488
|
+
if (!this.isBackingProcessAlive())
|
|
1489
|
+
this.fireExit(0, null);
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
requestHistoryCapture(delayMs, activity = false, forceResync = false) {
|
|
1494
|
+
if (this.exited || this.intentionalExit || !this.lastOpts)
|
|
1495
|
+
return;
|
|
1496
|
+
if (activity) {
|
|
1497
|
+
this.tailActivitySinceCapture = true;
|
|
1498
|
+
this.stableHistoryPolls = 0;
|
|
1499
|
+
}
|
|
1500
|
+
if (forceResync)
|
|
1501
|
+
this.forceResyncOnNextSnapshot = true;
|
|
1502
|
+
if (this.historyInFlight) {
|
|
1503
|
+
this.historyAgain = true;
|
|
1504
|
+
this.historyAgainActivity ||= activity;
|
|
1505
|
+
this.historyAgainForceResync ||= forceResync;
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const dueAt = Date.now() + Math.max(0, delayMs);
|
|
1509
|
+
if (this.historyTimer && this.historyTimerDueAt <= dueAt)
|
|
1510
|
+
return;
|
|
1511
|
+
if (this.historyTimer)
|
|
1512
|
+
clearTimeout(this.historyTimer);
|
|
1513
|
+
this.historyTimerDueAt = dueAt;
|
|
1514
|
+
this.historyTimer = setTimeout(() => {
|
|
1515
|
+
this.historyTimer = null;
|
|
1516
|
+
this.historyTimerDueAt = 0;
|
|
1517
|
+
void this.runHistoryCapture();
|
|
1518
|
+
}, Math.max(0, delayMs));
|
|
1519
|
+
this.historyTimer.unref?.();
|
|
1520
|
+
}
|
|
1521
|
+
async runHistoryCapture() {
|
|
1522
|
+
if (this.historyInFlight || this.exited || this.intentionalExit || !this.lastOpts)
|
|
1523
|
+
return;
|
|
1524
|
+
this.historyInFlight = true;
|
|
1525
|
+
const captureSerial = ++this.historyCaptureSerial;
|
|
1526
|
+
const generation = this.historyGeneration;
|
|
1527
|
+
const activity = this.tailActivitySinceCapture;
|
|
1528
|
+
const forceResync = this.forceResyncOnNextSnapshot;
|
|
1529
|
+
this.tailActivitySinceCapture = false;
|
|
1530
|
+
this.forceResyncOnNextSnapshot = false;
|
|
1531
|
+
let snapshot = null;
|
|
1532
|
+
let captureUsable = false;
|
|
1533
|
+
try {
|
|
1534
|
+
snapshot = await this.readHistorySnapshotAsync(generation);
|
|
1535
|
+
if (snapshot !== null && generation === this.historyGeneration) {
|
|
1536
|
+
captureUsable = this.publishHistorySnapshot(snapshot, activity, forceResync);
|
|
1537
|
+
}
|
|
1538
|
+
else {
|
|
1539
|
+
this.stableHistoryPolls = 0;
|
|
1540
|
+
}
|
|
1541
|
+
if (!captureUsable) {
|
|
1542
|
+
// A failed/ambiguous capture must not consume the wakeup that caused
|
|
1543
|
+
// it. Preserve both obligations for the next usable history sample.
|
|
1544
|
+
this.tailActivitySinceCapture ||= activity;
|
|
1545
|
+
this.forceResyncOnNextSnapshot ||= forceResync;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
finally {
|
|
1549
|
+
if (generation !== this.historyGeneration)
|
|
1550
|
+
return;
|
|
1551
|
+
const again = this.historyAgain;
|
|
1552
|
+
const againActivity = this.historyAgainActivity;
|
|
1553
|
+
const againForceResync = this.historyAgainForceResync;
|
|
1554
|
+
this.historyAgain = false;
|
|
1555
|
+
this.historyAgainActivity = false;
|
|
1556
|
+
this.historyAgainForceResync = false;
|
|
1557
|
+
// A capture dirtied while in flight predates output that arrived after
|
|
1558
|
+
// it started. Settle waiters must remain pending for the latched follow-
|
|
1559
|
+
// up rather than finalizing from this stale-but-otherwise-usable sample.
|
|
1560
|
+
if (!again)
|
|
1561
|
+
this.resolveHistorySettleWaiters(captureSerial, captureUsable);
|
|
1562
|
+
this.historyInFlight = false;
|
|
1563
|
+
if (this.exited || this.intentionalExit || !this.lastOpts)
|
|
1564
|
+
return;
|
|
1565
|
+
if (again) {
|
|
1566
|
+
// Preserve the dirty-latch obligation, but give the single-threaded
|
|
1567
|
+
// daemon a short breathing window before serializing history again.
|
|
1568
|
+
// Continuous tail chunks otherwise turn a long transcript into a
|
|
1569
|
+
// back-to-back history loop that can starve concurrent `send` probes.
|
|
1570
|
+
this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, againActivity, againForceResync);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const nextDelay = this.stableHistoryPolls >= HISTORY_STABLE_POLLS_BEFORE_COLD
|
|
1574
|
+
? HISTORY_COLD_POLL_MS + this.historyColdJitterMs
|
|
1575
|
+
: HISTORY_HOT_POLL_MS;
|
|
1576
|
+
this.requestHistoryCapture(nextDelay);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
publishHistorySnapshot(snapshot, activity, forceResync) {
|
|
1580
|
+
// Current ZMX history failures can exit 0 with empty stdout. Never let an
|
|
1581
|
+
// ambiguous empty capture erase a previously non-empty authoritative view.
|
|
1582
|
+
if (snapshot.length === 0 && this.hasSnapshot && this.snapshotCache.length > 0) {
|
|
1583
|
+
this.stableHistoryPolls = 0;
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
if (!this.hasSnapshot) {
|
|
1587
|
+
this.hasSnapshot = true;
|
|
1588
|
+
this.snapshotCache = snapshot;
|
|
1589
|
+
this.stableHistoryPolls = snapshot.length === 0 ? 1 : 0;
|
|
1590
|
+
if (forceResync)
|
|
1591
|
+
this.emitScreenResync(snapshot);
|
|
1592
|
+
else if (snapshot)
|
|
1593
|
+
this.emitData(snapshot);
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
const previous = this.snapshotCache;
|
|
1597
|
+
if (snapshot === previous) {
|
|
1598
|
+
this.stableHistoryPolls += 1;
|
|
1599
|
+
// Tail can signal a cursor-only redraw that leaves plain history equal.
|
|
1600
|
+
// Rebase derived state so idle detection observes the activity without
|
|
1601
|
+
// trusting any (possibly UTF-8-corrupted) tail payload bytes.
|
|
1602
|
+
if (activity)
|
|
1603
|
+
this.emitScreenResync(snapshot);
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
this.snapshotCache = snapshot;
|
|
1607
|
+
this.stableHistoryPolls = 0;
|
|
1608
|
+
if (!forceResync && snapshot.startsWith(previous)) {
|
|
1609
|
+
const delta = snapshot.slice(previous.length);
|
|
1610
|
+
if (delta)
|
|
1611
|
+
this.emitData(delta);
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
this.emitScreenResync(snapshot);
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
stopHistoryPolling() {
|
|
1618
|
+
this.historyGeneration += 1;
|
|
1619
|
+
if (this.historyTimer)
|
|
1620
|
+
clearTimeout(this.historyTimer);
|
|
1621
|
+
this.historyTimer = null;
|
|
1622
|
+
this.historyTimerDueAt = 0;
|
|
1623
|
+
const child = this.historyProcess;
|
|
1624
|
+
this.historyProcess = null;
|
|
1625
|
+
try {
|
|
1626
|
+
child?.kill('SIGKILL');
|
|
1627
|
+
}
|
|
1628
|
+
catch { /* already gone */ }
|
|
1629
|
+
this.historyInFlight = false;
|
|
1630
|
+
this.historyAgain = false;
|
|
1631
|
+
this.historyAgainActivity = false;
|
|
1632
|
+
this.historyAgainForceResync = false;
|
|
1633
|
+
this.tailActivitySinceCapture = false;
|
|
1634
|
+
this.forceResyncOnNextSnapshot = false;
|
|
1635
|
+
for (const waiter of this.historySettleWaiters.splice(0)) {
|
|
1636
|
+
clearTimeout(waiter.timer);
|
|
1637
|
+
waiter.resolve(false);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
resolveHistorySettleWaiters(captureSerial, success) {
|
|
1641
|
+
for (let i = this.historySettleWaiters.length - 1; i >= 0; i -= 1) {
|
|
1642
|
+
const waiter = this.historySettleWaiters[i];
|
|
1643
|
+
if (waiter.targetSerial > captureSerial)
|
|
1644
|
+
continue;
|
|
1645
|
+
this.historySettleWaiters.splice(i, 1);
|
|
1646
|
+
clearTimeout(waiter.timer);
|
|
1647
|
+
waiter.resolve(success);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
emitScreenResync(snapshot) {
|
|
1651
|
+
if (this.screenResyncCbs.length === 0) {
|
|
1652
|
+
this.pendingScreenResyncReplay = true;
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
for (const cb of this.screenResyncCbs) {
|
|
1656
|
+
try {
|
|
1657
|
+
cb(snapshot);
|
|
1658
|
+
}
|
|
1659
|
+
catch { /* listener failure must not kill recovery */ }
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
sendBytes(bytes, bracketedPaste = false, intent = 'text') {
|
|
1663
|
+
if (bytes.length === 0)
|
|
1664
|
+
return true;
|
|
1665
|
+
if (this.exited || this.intentionalExit || !this.lastOpts)
|
|
1666
|
+
return false;
|
|
1667
|
+
if (bytes.length > ZMX_SEND_MAX_BYTES) {
|
|
1668
|
+
throw new Error(`ZMX 单次输入 ${bytes.length} 字节超过 ${ZMX_SEND_MAX_BYTES} 字节安全上限;` +
|
|
1669
|
+
'当前 send 协议没有 ACK/backpressure,已在写入任何前缀前拒绝');
|
|
1670
|
+
}
|
|
1671
|
+
const isRecoveryCtrlC = intent === 'control'
|
|
1672
|
+
&& bytes.length === 1
|
|
1673
|
+
&& bytes[0] === 0x03;
|
|
1674
|
+
if (this.hasAmbiguousRecoveryDebt()) {
|
|
1675
|
+
if (intent === 'text' || !isRecoveryCtrlC || this.ambiguousTextRecoveryUnconfirmed) {
|
|
1676
|
+
logger.error(`[zmx:${this.sessionName}] input blocked: ambiguous composer recovery is ` +
|
|
1677
|
+
`${this.ambiguousTextRecoveryUnconfirmed ? 'unconfirmed' : 'pending'}`);
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
const ownsTextGeneration = intent === 'text'
|
|
1682
|
+
&& this.activeSubmissionGeneration == null;
|
|
1683
|
+
const textGeneration = intent === 'text'
|
|
1684
|
+
? (this.activeSubmissionGeneration ?? this.armAmbiguousTextGeneration())
|
|
1685
|
+
: 0;
|
|
1686
|
+
if (textGeneration == null)
|
|
1687
|
+
return false;
|
|
1688
|
+
// Arm first, then perform the one complete backing-identity probe nearest
|
|
1689
|
+
// the PTY write. A crash before the probe only leaves a conservative
|
|
1690
|
+
// pending marker; no replacement session can receive the bytes.
|
|
1691
|
+
const identity = this.verifyBackingIdentity('send');
|
|
1692
|
+
if (identity.state !== 'compatible') {
|
|
1693
|
+
if (identity.state === 'unknown') {
|
|
1694
|
+
logger.warn(`[zmx:${this.sessionName}] send blocked: ${identity.reason}`);
|
|
1695
|
+
}
|
|
1696
|
+
if (ownsTextGeneration && textGeneration > 0) {
|
|
1697
|
+
this.markAmbiguousGenerationCovered(textGeneration);
|
|
1698
|
+
}
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
const explicitPasteOpenAtBoundary = bracketedPaste
|
|
1702
|
+
? null
|
|
1703
|
+
: bracketedPasteOpenStatesAtChunkBoundaries(bytes);
|
|
1704
|
+
for (let offset = 0; offset < bytes.length; offset += ZMX_SEND_CHUNK_BYTES) {
|
|
1705
|
+
const chunk = bytes.subarray(offset, Math.min(offset + ZMX_SEND_CHUNK_BYTES, bytes.length));
|
|
1706
|
+
// ZMX strips exactly one trailing LF from piped stdin. Appending our own
|
|
1707
|
+
// framing LF therefore preserves the caller's original bytes exactly,
|
|
1708
|
+
// including an original trailing LF, while keeping secrets out of argv.
|
|
1709
|
+
let input = Buffer.concat([chunk, Buffer.from('\n')]);
|
|
1710
|
+
// A failed send has no PTY-level ACK: the current chunk may or may not
|
|
1711
|
+
// have reached the CLI. Track both possible states. Looking only at the
|
|
1712
|
+
// confirmed prefix misses an opening marker in a first ambiguous frame;
|
|
1713
|
+
// looking only through the current frame misses an opening marker whose
|
|
1714
|
+
// matching close sits in that ambiguous frame.
|
|
1715
|
+
const chunkIndex = offset / ZMX_SEND_CHUNK_BYTES;
|
|
1716
|
+
const mayHaveOpenPasteAfterAmbiguousSend = bracketedPaste
|
|
1717
|
+
|| explicitPasteOpenAtBoundary?.[chunkIndex] === true
|
|
1718
|
+
|| explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true;
|
|
1719
|
+
let injectedCancelAttempt = false;
|
|
1720
|
+
let recoveryGeneration = 0;
|
|
1721
|
+
try {
|
|
1722
|
+
if (intent === 'text' && this.activeSubmissionGeneration != null) {
|
|
1723
|
+
this.activeSubmissionTextTouched = true;
|
|
1724
|
+
}
|
|
1725
|
+
if (intent === 'control' && chunk.includes(0x03)) {
|
|
1726
|
+
this.waitForInjectedCancelCooldown();
|
|
1727
|
+
const postCooldownIdentity = this.verifyBackingIdentity('control-key cooldown');
|
|
1728
|
+
if (postCooldownIdentity.state !== 'compatible') {
|
|
1729
|
+
logger.warn(`[zmx:${this.sessionName}] skipped Ctrl+C after cooldown: ` +
|
|
1730
|
+
`backing identity is ${postCooldownIdentity.state}`);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if (this.hasAmbiguousRecoveryDebt()) {
|
|
1734
|
+
if (this.attemptedAmbiguousTextRecoverySequence
|
|
1735
|
+
>= this.ambiguousTextFailureSequence) {
|
|
1736
|
+
logger.error(`[zmx:${this.sessionName}] refused duplicate ambiguous composer recovery`);
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
const startedRecovery = this.markAmbiguousRecoveryAttempted();
|
|
1740
|
+
if (startedRecovery == null)
|
|
1741
|
+
return false;
|
|
1742
|
+
recoveryGeneration = startedRecovery;
|
|
1743
|
+
if (this.ambiguousTextRecoveryNeedsPasteClose) {
|
|
1744
|
+
input = Buffer.concat([
|
|
1745
|
+
Buffer.from(BRACKETED_PASTE_END),
|
|
1746
|
+
chunk,
|
|
1747
|
+
Buffer.from('\n'),
|
|
1748
|
+
]);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
injectedCancelAttempt = true;
|
|
1752
|
+
}
|
|
1753
|
+
let stdout;
|
|
1754
|
+
try {
|
|
1755
|
+
stdout = execFileSync('zmx', ['send', this.sessionName], {
|
|
1756
|
+
input,
|
|
1757
|
+
encoding: 'utf8',
|
|
1758
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1759
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
1760
|
+
maxBuffer: 1024 * 1024,
|
|
1761
|
+
env: zmxControlEnv(this.lastOpts),
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
finally {
|
|
1765
|
+
if (injectedCancelAttempt)
|
|
1766
|
+
this.noteInjectedCancelAttemptSettled();
|
|
1767
|
+
}
|
|
1768
|
+
// Several ZMX control-plane failures are reported on stdout with exit
|
|
1769
|
+
// status 0. Empty stdout is part of the transport contract.
|
|
1770
|
+
if (stdout.trim()) {
|
|
1771
|
+
if (recoveryGeneration > 0) {
|
|
1772
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1773
|
+
}
|
|
1774
|
+
else if (intent === 'control'
|
|
1775
|
+
&& this.activeSubmissionGeneration != null) {
|
|
1776
|
+
this.markActiveSubmissionUnconfirmed();
|
|
1777
|
+
}
|
|
1778
|
+
logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`);
|
|
1779
|
+
if (intent === 'text') {
|
|
1780
|
+
this.markArmedTextFailure(mayHaveOpenPasteAfterAmbiguousSend);
|
|
1781
|
+
}
|
|
1782
|
+
const probe = this.verifyBackingIdentity('send rejection');
|
|
1783
|
+
const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend;
|
|
1784
|
+
if (closeOpenPaste || offset > 0) {
|
|
1785
|
+
if (probe.state === 'compatible') {
|
|
1786
|
+
this.abortPartialSend(closeOpenPaste);
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery: ` +
|
|
1790
|
+
`backing identity is ${probe.state}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
if (recoveryGeneration > 0) {
|
|
1796
|
+
// ZMX v0.7 reports only control-client acceptance, not a PTY ACK; it
|
|
1797
|
+
// may still drop a byte when its pty_write_buf is full. Keep the
|
|
1798
|
+
// durable state unconfirmed even after empty stdout so later prompts
|
|
1799
|
+
// cannot append to a composer that Ctrl+C may not have cleared.
|
|
1800
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
else if (intent === 'control'
|
|
1804
|
+
&& this.activeSubmissionGeneration != null
|
|
1805
|
+
&& this.activeSubmissionTextTouched) {
|
|
1806
|
+
this.activeSubmissionControlAccepted = true;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
catch (err) {
|
|
1810
|
+
if (recoveryGeneration > 0) {
|
|
1811
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1812
|
+
}
|
|
1813
|
+
else if (intent === 'control'
|
|
1814
|
+
&& this.activeSubmissionGeneration != null) {
|
|
1815
|
+
this.markActiveSubmissionUnconfirmed();
|
|
1816
|
+
}
|
|
1817
|
+
if (intent === 'text') {
|
|
1818
|
+
this.markArmedTextFailure(mayHaveOpenPasteAfterAmbiguousSend);
|
|
1819
|
+
}
|
|
1820
|
+
const probe = this.verifyBackingIdentity('send failure');
|
|
1821
|
+
logger.warn(`[zmx:${this.sessionName}] send failed (${probe.state}): ` +
|
|
1822
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1823
|
+
// Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying
|
|
1824
|
+
// can duplicate a prompt that the daemon already queued.
|
|
1825
|
+
const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend;
|
|
1826
|
+
if (closeOpenPaste || offset > 0) {
|
|
1827
|
+
if (probe.state === 'compatible') {
|
|
1828
|
+
this.abortPartialSend(closeOpenPaste);
|
|
1829
|
+
}
|
|
1830
|
+
else {
|
|
1831
|
+
logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery: ` +
|
|
1832
|
+
`backing identity is ${probe.state}`);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (ownsTextGeneration
|
|
1839
|
+
&& textGeneration > 0
|
|
1840
|
+
&& !this.markAmbiguousGenerationCovered(textGeneration)) {
|
|
1841
|
+
return false;
|
|
1842
|
+
}
|
|
1843
|
+
this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true);
|
|
1844
|
+
return true;
|
|
1845
|
+
}
|
|
1846
|
+
abortPartialSend(closeOpenPaste) {
|
|
1847
|
+
if (this.ambiguousTextRecoveryUnconfirmed)
|
|
1848
|
+
return 'recovery-unconfirmed';
|
|
1849
|
+
if (!this.hasPendingAmbiguousTextRecovery())
|
|
1850
|
+
return undefined;
|
|
1851
|
+
if (!this.lastOpts)
|
|
1852
|
+
return 'recovery-pending';
|
|
1853
|
+
if (this.attemptedAmbiguousTextRecoverySequence
|
|
1854
|
+
>= this.ambiguousTextFailureSequence) {
|
|
1855
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1856
|
+
return 'recovery-unconfirmed';
|
|
1857
|
+
}
|
|
1858
|
+
// A failed multi-frame paste may have delivered the opening marker but not
|
|
1859
|
+
// its close. Best-effort close it first, then cancel the partial composer
|
|
1860
|
+
// so a later retry cannot append to/trivially submit truncated input.
|
|
1861
|
+
const recovery = closeOpenPaste || this.ambiguousTextRecoveryNeedsPasteClose
|
|
1862
|
+
? `${BRACKETED_PASTE_END}\x03`
|
|
1863
|
+
: '\x03';
|
|
1864
|
+
this.waitForInjectedCancelCooldown();
|
|
1865
|
+
const postCooldownIdentity = this.verifyBackingIdentity('partial-send recovery cooldown');
|
|
1866
|
+
if (postCooldownIdentity.state !== 'compatible') {
|
|
1867
|
+
logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery after cooldown: ` +
|
|
1868
|
+
`backing identity is ${postCooldownIdentity.state}`);
|
|
1869
|
+
return 'recovery-pending';
|
|
1870
|
+
}
|
|
1871
|
+
const recoveryGeneration = this.markAmbiguousRecoveryAttempted();
|
|
1872
|
+
if (recoveryGeneration == null)
|
|
1873
|
+
return 'recovery-unconfirmed';
|
|
1874
|
+
try {
|
|
1875
|
+
let stdout;
|
|
1876
|
+
try {
|
|
1877
|
+
stdout = execFileSync('zmx', ['send', this.sessionName], {
|
|
1878
|
+
input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]),
|
|
1879
|
+
encoding: 'utf8',
|
|
1880
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1881
|
+
timeout: ZMX_COMMAND_TIMEOUT_MS,
|
|
1882
|
+
maxBuffer: 1024 * 1024,
|
|
1883
|
+
env: zmxControlEnv(this.lastOpts),
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
finally {
|
|
1887
|
+
// A timeout is ambiguous: the Ctrl+C may have landed at any point
|
|
1888
|
+
// before the command returned, so start downstream cooldown now.
|
|
1889
|
+
this.noteInjectedCancelAttemptSettled();
|
|
1890
|
+
}
|
|
1891
|
+
if (stdout.trim())
|
|
1892
|
+
throw new Error(stdout.trim());
|
|
1893
|
+
// Empty stdout confirms only that the one-shot client was accepted. ZMX
|
|
1894
|
+
// has no daemon→PTY ACK and can silently drop a full-buffer write, so the
|
|
1895
|
+
// recovery remains poisoned until a fresh backend generation.
|
|
1896
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1897
|
+
logger.warn(`[zmx:${this.sessionName}] attempted partial-input cancellation; ` +
|
|
1898
|
+
'PTY delivery remains unconfirmed and automatic input stays blocked');
|
|
1899
|
+
}
|
|
1900
|
+
catch (err) {
|
|
1901
|
+
this.ambiguousTextRecoveryUnconfirmed = true;
|
|
1902
|
+
logger.error(`[zmx:${this.sessionName}] unable to cancel partially delivered input; ` +
|
|
1903
|
+
`manual session recovery may be required: ${err instanceof Error ? err.message : String(err)}`);
|
|
1904
|
+
this.requestHistoryCapture(0, true, true);
|
|
1905
|
+
return 'recovery-unconfirmed';
|
|
1906
|
+
}
|
|
1907
|
+
this.requestHistoryCapture(0, true, true);
|
|
1908
|
+
return 'recovery-unconfirmed';
|
|
1909
|
+
}
|
|
1910
|
+
startTail() {
|
|
1911
|
+
this.clearReconnectTimer();
|
|
1912
|
+
const epoch = ++this.tailEpoch;
|
|
1913
|
+
const child = spawn('zmx', ['tail', this.sessionName], {
|
|
1914
|
+
cwd: this.lastOpts?.cwd,
|
|
1915
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1916
|
+
env: this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(),
|
|
1917
|
+
});
|
|
1918
|
+
this.tailProcess = child;
|
|
1919
|
+
this.state = 'observing';
|
|
1920
|
+
this.clearStableTailTimer();
|
|
1921
|
+
this.stableTailTimer = setTimeout(() => {
|
|
1922
|
+
this.stableTailTimer = null;
|
|
1923
|
+
if (epoch === this.tailEpoch && this.tailProcess === child && this.state === 'observing') {
|
|
1924
|
+
this.reconnectAttempt = 0;
|
|
1925
|
+
}
|
|
1926
|
+
}, 5000);
|
|
1927
|
+
this.stableTailTimer.unref?.();
|
|
1928
|
+
let settled = false;
|
|
1929
|
+
let stderrTail = '';
|
|
1930
|
+
child.stdout?.on('data', (chunk) => {
|
|
1931
|
+
if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited)
|
|
1932
|
+
return;
|
|
1933
|
+
// Drain the stream, but never expose its bytes. Upstream's ANSI stripper
|
|
1934
|
+
// currently deletes UTF-8 and can even consume following ASCII; only the
|
|
1935
|
+
// existence of a chunk is trustworthy enough to wake history capture.
|
|
1936
|
+
if ((typeof chunk === 'string' ? chunk.length : chunk.byteLength) > 0) {
|
|
1937
|
+
this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true);
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
child.stderr?.on('data', (chunk) => {
|
|
1941
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-4096);
|
|
1942
|
+
});
|
|
1943
|
+
const finish = (code, signal, err) => {
|
|
1944
|
+
if (settled)
|
|
1945
|
+
return;
|
|
1946
|
+
settled = true;
|
|
1947
|
+
if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited)
|
|
1948
|
+
return;
|
|
1949
|
+
this.tailProcess = null;
|
|
1950
|
+
this.state = 'recovering';
|
|
1951
|
+
this.clearStableTailTimer();
|
|
1952
|
+
if (err || stderrTail.trim()) {
|
|
1953
|
+
logger.warn(`[zmx:${this.sessionName}] tail ended: ` +
|
|
1954
|
+
`${err?.message ?? stderrTail.trim()}`);
|
|
1955
|
+
}
|
|
1956
|
+
this.scheduleTailRecovery(code, signal);
|
|
1957
|
+
};
|
|
1958
|
+
child.once('error', err => finish(null, null, err));
|
|
1959
|
+
child.once('close', (code, signal) => finish(code, signal));
|
|
1960
|
+
}
|
|
1961
|
+
scheduleTailRecovery(code, signal) {
|
|
1962
|
+
if (this.intentionalExit || this.exited || !this.lastOpts)
|
|
1963
|
+
return;
|
|
1964
|
+
const delay = Math.min(50 * (2 ** this.reconnectAttempt), TAIL_RECOVERY_DELAY_MAX_MS);
|
|
1965
|
+
this.reconnectAttempt += 1;
|
|
1966
|
+
this.clearReconnectTimer();
|
|
1967
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1968
|
+
this.reconnectTimer = null;
|
|
1969
|
+
if (this.intentionalExit || this.exited || !this.lastOpts)
|
|
1970
|
+
return;
|
|
1971
|
+
const probe = this.verifyBackingIdentityWithClients('tail recovery');
|
|
1972
|
+
if (probe.state === 'missing' || probe.state === 'replaced')
|
|
1973
|
+
return;
|
|
1974
|
+
if (probe.state === 'unknown') {
|
|
1975
|
+
this.scheduleTailRecovery(code, signal);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
try {
|
|
1979
|
+
logger.warn(`[zmx:${this.sessionName}] tail observer exited while session lives; reconnecting`);
|
|
1980
|
+
this.startTail();
|
|
1981
|
+
if (!this.waitForTailClient()) {
|
|
1982
|
+
throw new Error('replacement tail did not become a connected client');
|
|
1983
|
+
}
|
|
1984
|
+
this.requestHistoryCapture(0, true, true);
|
|
1985
|
+
}
|
|
1986
|
+
catch (err) {
|
|
1987
|
+
if (this.intentionalExit || this.exited)
|
|
1988
|
+
return;
|
|
1989
|
+
this.stopTailForRecovery();
|
|
1990
|
+
logger.warn(`[zmx:${this.sessionName}] tail restart failed: ` +
|
|
1991
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1992
|
+
this.scheduleTailRecovery(code, signal);
|
|
1993
|
+
}
|
|
1994
|
+
}, delay);
|
|
1995
|
+
this.reconnectTimer.unref?.();
|
|
1996
|
+
}
|
|
1997
|
+
stopTailAfterLaunchFailure() {
|
|
1998
|
+
this.tailEpoch += 1;
|
|
1999
|
+
this.clearStableTailTimer();
|
|
2000
|
+
this.stopHistoryPolling();
|
|
2001
|
+
const tail = this.tailProcess;
|
|
2002
|
+
this.tailProcess = null;
|
|
2003
|
+
this.state = 'idle';
|
|
2004
|
+
try {
|
|
2005
|
+
tail?.kill('SIGTERM');
|
|
2006
|
+
}
|
|
2007
|
+
catch { /* already gone */ }
|
|
2008
|
+
}
|
|
2009
|
+
stopTailForRecovery() {
|
|
2010
|
+
this.tailEpoch += 1;
|
|
2011
|
+
this.clearStableTailTimer();
|
|
2012
|
+
const tail = this.tailProcess;
|
|
2013
|
+
this.tailProcess = null;
|
|
2014
|
+
this.state = 'recovering';
|
|
2015
|
+
try {
|
|
2016
|
+
tail?.kill('SIGTERM');
|
|
2017
|
+
}
|
|
2018
|
+
catch { /* already gone */ }
|
|
2019
|
+
}
|
|
2020
|
+
clearReconnectTimer() {
|
|
2021
|
+
if (!this.reconnectTimer)
|
|
2022
|
+
return;
|
|
2023
|
+
clearTimeout(this.reconnectTimer);
|
|
2024
|
+
this.reconnectTimer = null;
|
|
2025
|
+
}
|
|
2026
|
+
clearStableTailTimer() {
|
|
2027
|
+
if (!this.stableTailTimer)
|
|
2028
|
+
return;
|
|
2029
|
+
clearTimeout(this.stableTailTimer);
|
|
2030
|
+
this.stableTailTimer = null;
|
|
2031
|
+
}
|
|
2032
|
+
emitData(data) {
|
|
2033
|
+
if (this.dataCbs.length === 0) {
|
|
2034
|
+
const next = this.earlyBuffer + data;
|
|
2035
|
+
if (next.length > EARLY_BUFFER_MAX && this.earlyBuffer.length <= EARLY_BUFFER_MAX) {
|
|
2036
|
+
logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest text`);
|
|
2037
|
+
}
|
|
2038
|
+
this.earlyBuffer = next.slice(-EARLY_BUFFER_MAX);
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
for (const cb of this.dataCbs) {
|
|
2042
|
+
try {
|
|
2043
|
+
cb(data);
|
|
2044
|
+
}
|
|
2045
|
+
catch { /* listener failure must not kill transport */ }
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
fireExit(code, signal) {
|
|
2049
|
+
if (this.exited)
|
|
2050
|
+
return;
|
|
2051
|
+
// Once the backing daemon is gone, history is no longer queryable. Never
|
|
2052
|
+
// fall back to tail payload bytes here: upstream may already have deleted
|
|
2053
|
+
// or corrupted their UTF-8 content.
|
|
2054
|
+
this.exited = true;
|
|
2055
|
+
this.state = 'exited';
|
|
2056
|
+
this.tailEpoch += 1;
|
|
2057
|
+
this.clearReconnectTimer();
|
|
2058
|
+
this.clearStableTailTimer();
|
|
2059
|
+
this.stopHistoryPolling();
|
|
2060
|
+
const tail = this.tailProcess;
|
|
2061
|
+
this.tailProcess = null;
|
|
2062
|
+
try {
|
|
2063
|
+
tail?.kill('SIGTERM');
|
|
2064
|
+
}
|
|
2065
|
+
catch { /* already gone */ }
|
|
2066
|
+
this.pendingExit = { code, signal };
|
|
2067
|
+
if (this.exitCbs.length === 0)
|
|
2068
|
+
return;
|
|
2069
|
+
for (const cb of this.exitCbs) {
|
|
2070
|
+
try {
|
|
2071
|
+
cb(code, signal);
|
|
2072
|
+
}
|
|
2073
|
+
catch { /* listener failure must not block teardown */ }
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
export function buildFreshAttachArgs(sessionName, bootstrapPath) {
|
|
2078
|
+
// An existing session ignores this command, which makes attach safer than
|
|
2079
|
+
// `zmx run` (run is an upsert that can inject into a foreign race winner).
|
|
2080
|
+
return ['attach', sessionName, '/bin/sh', bootstrapPath];
|
|
2081
|
+
}
|
|
2082
|
+
function shellSingleQuote(value) {
|
|
2083
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2084
|
+
}
|
|
2085
|
+
/** Render the private bootstrap and payload used by a fresh session. */
|
|
2086
|
+
export function buildZmxLaunchFiles(bin, args, opts, payloadPath, readyPath, readyNonce, releasePath, releaseToken) {
|
|
2087
|
+
const shellSpec = resolveUserShell(process.env, opts.launchShell);
|
|
2088
|
+
const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv)
|
|
2089
|
+
.filter(assignment => !/^ZMX_(?:SESSION|SESSION_PREFIX)=/.test(assignment));
|
|
2090
|
+
const debugKeepShell = process.env.BOTMUX_DEBUG_KEEP_SHELL === '1';
|
|
2091
|
+
const wrapperBinDir = resolveBotmuxWrapperBinDir(opts.env ?? process.env);
|
|
2092
|
+
const wrapped = debugKeepShell
|
|
2093
|
+
? buildDebugKeepShellScript(shellSpec.shell, wrapperBinDir)
|
|
2094
|
+
: shellWrapperScript(wrapperBinDir);
|
|
2095
|
+
const payloadArgv = [opts.cwd, ...envAssignments, bin, ...args];
|
|
2096
|
+
const payload = `set -- ${payloadArgv.map(shellSingleQuote).join(' ')}\n`;
|
|
2097
|
+
const userScript = [
|
|
2098
|
+
'payload=$1',
|
|
2099
|
+
'if [ ! -r "$payload" ]; then printf "[botmux] ZMX launch payload unavailable\\n" >&2; exit 126; fi',
|
|
2100
|
+
'. "$payload" || exit 126',
|
|
2101
|
+
'rm -f -- "$payload"',
|
|
2102
|
+
'unset payload ZMX_SESSION ZMX_SESSION_PREFIX',
|
|
2103
|
+
wrapped,
|
|
2104
|
+
].join('\n');
|
|
2105
|
+
const shellCommand = [
|
|
2106
|
+
shellSingleQuote(shellSpec.shell),
|
|
2107
|
+
...shellSpec.flags.map(shellSingleQuote),
|
|
2108
|
+
'-c', shellSingleQuote(userScript),
|
|
2109
|
+
'_', shellSingleQuote(payloadPath),
|
|
2110
|
+
].join(' ');
|
|
2111
|
+
const launchDir = payloadPath.slice(0, payloadPath.lastIndexOf('/'));
|
|
2112
|
+
const cliPidPath = join(launchDir, 'cli-pid');
|
|
2113
|
+
const gateScript = [
|
|
2114
|
+
'payload_path=$1',
|
|
2115
|
+
'ready_path=$2',
|
|
2116
|
+
'release_path=$3',
|
|
2117
|
+
'cli_pid_path=$4',
|
|
2118
|
+
'ready_nonce=$5',
|
|
2119
|
+
'release_token=$6',
|
|
2120
|
+
'printf \'%s\\n\' "$$" > "$cli_pid_path" || exit 126',
|
|
2121
|
+
'printf \'%s\\n\' "$ready_nonce" > "$ready_path" || exit 126',
|
|
2122
|
+
'attempt=0',
|
|
2123
|
+
`while [ ! -r "$release_path" ] && [ "$attempt" -lt ${Math.ceil(FRESH_RELEASE_TIMEOUT_MS / 100)} ]; do`,
|
|
2124
|
+
' sleep 0.1',
|
|
2125
|
+
' attempt=$((attempt + 1))',
|
|
2126
|
+
'done',
|
|
2127
|
+
'[ -r "$release_path" ] || exit 75',
|
|
2128
|
+
'release_value=',
|
|
2129
|
+
'IFS= read -r release_value < "$release_path" || [ -n "$release_value" ]',
|
|
2130
|
+
'[ "$release_value" = "$release_token" ] || exit 75',
|
|
2131
|
+
'rm -f -- "$ready_path" "$release_path"',
|
|
2132
|
+
'unset ready_path release_path release_value attempt cli_pid_path ready_nonce release_token',
|
|
2133
|
+
'trap - 1 2 15',
|
|
2134
|
+
// Keep the ZMX forkpty slave inherited on fd 0. Reopening `/dev/tty`
|
|
2135
|
+
// produces a descriptor that Darwin kqueue rejects with EINVAL, so
|
|
2136
|
+
// crossterm/mio event readers fail to initialize even though isatty(0)
|
|
2137
|
+
// still reports true. The release-file read above redirects only that one
|
|
2138
|
+
// command and automatically restores the inherited PTY afterwards.
|
|
2139
|
+
`exec ${shellCommand}`,
|
|
2140
|
+
].join('\n');
|
|
2141
|
+
const gateCommand = [
|
|
2142
|
+
'/bin/sh',
|
|
2143
|
+
'-c', shellSingleQuote(gateScript),
|
|
2144
|
+
shellSingleQuote(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${readyNonce}`),
|
|
2145
|
+
'"$payload_path"',
|
|
2146
|
+
'"$ready_path"',
|
|
2147
|
+
'"$release_path"',
|
|
2148
|
+
'"$cli_pid_path"',
|
|
2149
|
+
'"$ready_nonce"',
|
|
2150
|
+
'"$release_token"',
|
|
2151
|
+
].join(' ');
|
|
2152
|
+
const bootstrap = [
|
|
2153
|
+
'#!/bin/sh',
|
|
2154
|
+
'umask 077',
|
|
2155
|
+
'self=$0',
|
|
2156
|
+
'rm -f -- "$self"',
|
|
2157
|
+
'unset self ZMX_SESSION ZMX_SESSION_PREFIX',
|
|
2158
|
+
`payload_path=${shellSingleQuote(payloadPath)}`,
|
|
2159
|
+
`ready_path=${shellSingleQuote(readyPath)}`,
|
|
2160
|
+
`release_path=${shellSingleQuote(releasePath)}`,
|
|
2161
|
+
`cli_pid_path=${shellSingleQuote(cliPidPath)}`,
|
|
2162
|
+
`ready_nonce=${shellSingleQuote(readyNonce)}`,
|
|
2163
|
+
`release_token=${shellSingleQuote(releaseToken)}`,
|
|
2164
|
+
`launch_dir=${shellSingleQuote(launchDir)}`,
|
|
2165
|
+
'cleanup_launch() {',
|
|
2166
|
+
' rm -f -- "$ready_path" "$release_path" "$cli_pid_path" "$payload_path"',
|
|
2167
|
+
' rmdir -- "$launch_dir" 2>/dev/null || true',
|
|
2168
|
+
'}',
|
|
2169
|
+
// Do not exec away the PTY-root bootstrap. ZMX destroys history together
|
|
2170
|
+
// with that root process, so retaining it for a short bounded grace after
|
|
2171
|
+
// the real CLI exits gives the authoritative history poller time to read
|
|
2172
|
+
// final output (including pure Unicode that tail cannot signal reliably).
|
|
2173
|
+
// A foreground gate child keeps the normal SIGINT disposition (POSIX async
|
|
2174
|
+
// jobs may inherit SIGINT ignored), while retaining one stable PID through
|
|
2175
|
+
// gate -> user shell -> env -> CLI exec. The parent validates and labels
|
|
2176
|
+
// that PID before atomically publishing release.
|
|
2177
|
+
'is_direct_child() {',
|
|
2178
|
+
' for ps_bin in /usr/bin/ps /bin/ps; do',
|
|
2179
|
+
' [ -x "$ps_bin" ] || continue',
|
|
2180
|
+
' child_parent=$("$ps_bin" -o ppid= -p "$child_pid" 2>/dev/null) || continue',
|
|
2181
|
+
' [ "$child_parent" -eq "$$" ] 2>/dev/null && return 0',
|
|
2182
|
+
' done',
|
|
2183
|
+
' return 1',
|
|
2184
|
+
'}',
|
|
2185
|
+
'forward_stop() {',
|
|
2186
|
+
' trap - 1 15',
|
|
2187
|
+
' child_pid=',
|
|
2188
|
+
' IFS= read -r child_pid < "$cli_pid_path" 2>/dev/null || true',
|
|
2189
|
+
' if [ -n "$child_pid" ] && is_direct_child; then',
|
|
2190
|
+
' kill -HUP "$child_pid" 2>/dev/null || true',
|
|
2191
|
+
' kill -HUP -- "-$child_pid" 2>/dev/null || true',
|
|
2192
|
+
' sleep 0.15',
|
|
2193
|
+
' if is_direct_child; then',
|
|
2194
|
+
' kill -KILL "$child_pid" 2>/dev/null || true',
|
|
2195
|
+
' kill -KILL -- "-$child_pid" 2>/dev/null || true',
|
|
2196
|
+
' fi',
|
|
2197
|
+
' fi',
|
|
2198
|
+
' cleanup_launch',
|
|
2199
|
+
' exit 75',
|
|
2200
|
+
'}',
|
|
2201
|
+
"trap 'forward_stop' 1 15",
|
|
2202
|
+
"trap ':' 2",
|
|
2203
|
+
gateCommand,
|
|
2204
|
+
'cli_status=$?',
|
|
2205
|
+
// Do not retain a dead PID during the grace period: an external HUP must
|
|
2206
|
+
// never target an unrelated process that reused the numeric pid.
|
|
2207
|
+
'rm -f -- "$cli_pid_path"',
|
|
2208
|
+
`while ! sleep ${ZMX_EXIT_HISTORY_GRACE_SECONDS}; do :; done`,
|
|
2209
|
+
'cleanup_launch',
|
|
2210
|
+
'trap - 1 2 15',
|
|
2211
|
+
'exit "$cli_status"',
|
|
2212
|
+
'',
|
|
2213
|
+
].join('\n');
|
|
2214
|
+
return { bootstrap, payload };
|
|
2215
|
+
}
|
|
2216
|
+
function createZmxLaunchPayload(bin, args, opts) {
|
|
2217
|
+
const dir = mkdtempSync(join(tmpdir(), 'botmux-zmx-launch-'));
|
|
2218
|
+
chmodSync(dir, 0o700);
|
|
2219
|
+
const bootstrapPath = join(dir, 'bootstrap.sh');
|
|
2220
|
+
const payloadPath = join(dir, 'payload.sh');
|
|
2221
|
+
const readyPath = join(dir, 'ready');
|
|
2222
|
+
const releasePath = join(dir, 'release');
|
|
2223
|
+
const releaseTempPath = join(dir, 'release.tmp');
|
|
2224
|
+
const cliPidPath = join(dir, 'cli-pid');
|
|
2225
|
+
const readyNonce = randomBytes(16).toString('hex');
|
|
2226
|
+
const releaseToken = randomBytes(16).toString('hex');
|
|
2227
|
+
const cleanup = () => {
|
|
2228
|
+
for (const path of [readyPath, releasePath, releaseTempPath, cliPidPath, payloadPath, bootstrapPath]) {
|
|
2229
|
+
try {
|
|
2230
|
+
rmSync(path, { force: true });
|
|
2231
|
+
}
|
|
2232
|
+
catch { /* already consumed */ }
|
|
2233
|
+
}
|
|
2234
|
+
try {
|
|
2235
|
+
rmdirSync(dir);
|
|
2236
|
+
}
|
|
2237
|
+
catch { /* live bootstrap still owns the directory */ }
|
|
2238
|
+
};
|
|
2239
|
+
try {
|
|
2240
|
+
const files = buildZmxLaunchFiles(bin, args, opts, payloadPath, readyPath, readyNonce, releasePath, releaseToken);
|
|
2241
|
+
writeFileSync(payloadPath, files.payload, { mode: 0o600, flag: 'wx' });
|
|
2242
|
+
writeFileSync(bootstrapPath, files.bootstrap, { mode: 0o600, flag: 'wx' });
|
|
2243
|
+
return {
|
|
2244
|
+
dir,
|
|
2245
|
+
bootstrapPath,
|
|
2246
|
+
readyPath,
|
|
2247
|
+
readyNonce,
|
|
2248
|
+
releasePath,
|
|
2249
|
+
releaseTempPath,
|
|
2250
|
+
cliPidPath,
|
|
2251
|
+
releaseToken,
|
|
2252
|
+
cleanup,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
catch (err) {
|
|
2256
|
+
cleanup();
|
|
2257
|
+
throw err;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
/** Strip every payload-delivered key from ZMX control subprocesses. */
|
|
2261
|
+
export function zmxControlEnv(opts) {
|
|
2262
|
+
const env = zmxEnv(opts.env);
|
|
2263
|
+
for (const assignment of buildBotmuxEnvAssignments(opts.env, opts.injectEnv)) {
|
|
2264
|
+
const equals = assignment.indexOf('=');
|
|
2265
|
+
if (equals > 0)
|
|
2266
|
+
delete env[assignment.slice(0, equals)];
|
|
2267
|
+
}
|
|
2268
|
+
if (opts.injectEnv) {
|
|
2269
|
+
for (const key of Object.keys(opts.injectEnv))
|
|
2270
|
+
delete env[key];
|
|
2271
|
+
}
|
|
2272
|
+
return env;
|
|
2273
|
+
}
|
|
2274
|
+
export function parseZmxList(output) {
|
|
2275
|
+
const sessions = [];
|
|
2276
|
+
const unhealthySessions = [];
|
|
2277
|
+
const malformedLines = [];
|
|
2278
|
+
let sawRecord = false;
|
|
2279
|
+
for (const line of output.split('\n')) {
|
|
2280
|
+
if (!line.trim())
|
|
2281
|
+
continue;
|
|
2282
|
+
const looksLikeRecord = /^\s*name=[^\t]*\t(?:pid=\d+(?:\t|$)|err=)/.test(line);
|
|
2283
|
+
if (!looksLikeRecord) {
|
|
2284
|
+
// Full-list cmd= is verbatim and may contain literal newlines. Once a
|
|
2285
|
+
// record starts, continuation text is opaque even when it says name=.
|
|
2286
|
+
if (!sawRecord)
|
|
2287
|
+
malformedLines.push(line);
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
sawRecord = true;
|
|
2291
|
+
const row = parseZmxListRow(line);
|
|
2292
|
+
if (!row)
|
|
2293
|
+
malformedLines.push(line);
|
|
2294
|
+
else if (row.state === 'unhealthy')
|
|
2295
|
+
unhealthySessions.push(row.name);
|
|
2296
|
+
else
|
|
2297
|
+
sessions.push(row.name);
|
|
2298
|
+
}
|
|
2299
|
+
return { sessions, unhealthySessions, malformedLines };
|
|
2300
|
+
}
|
|
2301
|
+
export function parseZmxShortList(output) {
|
|
2302
|
+
const sessions = [];
|
|
2303
|
+
const malformedLines = [];
|
|
2304
|
+
const seen = new Set();
|
|
2305
|
+
for (const raw of output.split('\n')) {
|
|
2306
|
+
const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
2307
|
+
if (!name)
|
|
2308
|
+
continue;
|
|
2309
|
+
if (/[\t\x00-\x1f\x7f]/.test(name) || seen.has(name)) {
|
|
2310
|
+
malformedLines.push(raw);
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
seen.add(name);
|
|
2314
|
+
sessions.push(name);
|
|
2315
|
+
}
|
|
2316
|
+
return { sessions, malformedLines };
|
|
2317
|
+
}
|
|
2318
|
+
function parseZmxListRow(line) {
|
|
2319
|
+
const fields = line.replace(/^\s*/, '').split('\t');
|
|
2320
|
+
const nameField = fields[0];
|
|
2321
|
+
const name = nameField?.startsWith('name=') ? nameField.slice('name='.length) : '';
|
|
2322
|
+
const status = fields[1];
|
|
2323
|
+
if (!name || /[\x00-\x1f\x7f]/.test(name) || !status)
|
|
2324
|
+
return null;
|
|
2325
|
+
const pid = status.match(/^pid=(\d+)$/)?.[1];
|
|
2326
|
+
if (pid) {
|
|
2327
|
+
const clients = fields.map(field => field.match(/^clients=(\d+)$/)?.[1]).find(Boolean);
|
|
2328
|
+
const command = fields.find(field => field.startsWith('cmd='))?.slice('cmd='.length);
|
|
2329
|
+
return {
|
|
2330
|
+
name,
|
|
2331
|
+
state: 'healthy',
|
|
2332
|
+
pid: Number(pid),
|
|
2333
|
+
clients: clients === undefined ? undefined : Number(clients),
|
|
2334
|
+
command,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
if (/^err=/.test(status))
|
|
2338
|
+
return { name, state: 'unhealthy' };
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
function sessionDetails(sessionName, env = zmxEnv()) {
|
|
2342
|
+
const probe = ZmxBackend.probeSessions(env);
|
|
2343
|
+
return probe.ok ? sessionDetailsFromSnapshot(probe, sessionName) : null;
|
|
2344
|
+
}
|
|
2345
|
+
function sessionDetailsFromSnapshot(probe, sessionName) {
|
|
2346
|
+
if (!probe.sessions.includes(sessionName))
|
|
2347
|
+
return null;
|
|
2348
|
+
const rows = probe.raw
|
|
2349
|
+
.split('\n')
|
|
2350
|
+
.map(parseZmxListRow)
|
|
2351
|
+
.filter((row) => row?.name === sessionName && row.state === 'healthy' && !!row.pid);
|
|
2352
|
+
if (rows.length !== 1)
|
|
2353
|
+
return null;
|
|
2354
|
+
const row = rows[0];
|
|
2355
|
+
return {
|
|
2356
|
+
name: row.name,
|
|
2357
|
+
pid: row.pid,
|
|
2358
|
+
clients: row.clients ?? null,
|
|
2359
|
+
command: row.command ?? null,
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
export function findSessionPid(sessionName) {
|
|
2363
|
+
return sessionDetails(sessionName)?.pid ?? null;
|
|
2364
|
+
}
|
|
2365
|
+
export function tmuxKeyToBytes(key) {
|
|
2366
|
+
const named = {
|
|
2367
|
+
Enter: '\r',
|
|
2368
|
+
Tab: '\t',
|
|
2369
|
+
Escape: '\x1b',
|
|
2370
|
+
Esc: '\x1b',
|
|
2371
|
+
Space: ' ',
|
|
2372
|
+
BSpace: '\x7f',
|
|
2373
|
+
Backspace: '\x7f',
|
|
2374
|
+
Up: '\x1b[A',
|
|
2375
|
+
Down: '\x1b[B',
|
|
2376
|
+
Right: '\x1b[C',
|
|
2377
|
+
Left: '\x1b[D',
|
|
2378
|
+
Home: '\x1b[H',
|
|
2379
|
+
End: '\x1b[F',
|
|
2380
|
+
PageUp: '\x1b[5~',
|
|
2381
|
+
PPage: '\x1b[5~',
|
|
2382
|
+
PageDown: '\x1b[6~',
|
|
2383
|
+
NPage: '\x1b[6~',
|
|
2384
|
+
'M-Enter': '\x1b\r',
|
|
2385
|
+
};
|
|
2386
|
+
if (key in named)
|
|
2387
|
+
return named[key];
|
|
2388
|
+
const ctrl = key.match(/^C-([A-Za-z])$/);
|
|
2389
|
+
if (ctrl)
|
|
2390
|
+
return String.fromCharCode(ctrl[1].toLowerCase().charCodeAt(0) - 96);
|
|
2391
|
+
const meta = key.match(/^M-(.)$/);
|
|
2392
|
+
if (meta)
|
|
2393
|
+
return `\x1b${meta[1]}`;
|
|
2394
|
+
return key;
|
|
2395
|
+
}
|
|
2396
|
+
//# sourceMappingURL=zmx-backend.js.map
|