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
package/dist/worker.js
CHANGED
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
16
|
import { mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
|
|
17
17
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
18
|
-
import { join, basename, dirname
|
|
18
|
+
import { join, basename, dirname } from 'node:path';
|
|
19
|
+
import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
|
|
19
20
|
import { homedir, tmpdir } from 'node:os';
|
|
20
21
|
import { spawnSync } from 'node:child_process';
|
|
21
22
|
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
|
|
22
|
-
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths } from './adapters/cli/fs-policy.js';
|
|
23
|
-
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
|
|
23
|
+
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
|
|
24
|
+
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
|
|
24
25
|
import { readProcessStartIdentity } from './core/session-marker.js';
|
|
26
|
+
import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js';
|
|
25
27
|
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
|
|
26
28
|
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
|
|
27
29
|
import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
|
|
@@ -31,8 +33,9 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
|
|
|
31
33
|
import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
|
|
32
34
|
import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
|
|
33
35
|
import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
|
|
36
|
+
import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
|
|
34
37
|
import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
|
|
35
|
-
import { mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
|
|
38
|
+
import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
|
|
36
39
|
import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
|
|
37
40
|
import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
|
|
38
41
|
import { sanitizePerBotEnv } from './core/per-bot-env.js';
|
|
@@ -43,7 +46,7 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
|
|
|
43
46
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
44
47
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
45
48
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
46
|
-
import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
|
|
49
|
+
import { loadBotConfigs, resolveBrandLabel, resolveUsageDisplay, } from './bot-registry.js';
|
|
47
50
|
import { readGlobalConfig } from './global-config.js';
|
|
48
51
|
import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
49
52
|
import { readPlatformBinding } from './platform/binding.js';
|
|
@@ -86,23 +89,29 @@ import { delay } from './utils/timing.js';
|
|
|
86
89
|
import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
|
|
87
90
|
import { sessionReadyHookCommand } from './adapters/hook-command.js';
|
|
88
91
|
import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js';
|
|
92
|
+
import { strictInputHandle } from './adapters/cli/strict-input-handle.js';
|
|
89
93
|
import { HerdrBackend } from './adapters/backend/herdr-backend.js';
|
|
90
94
|
import { TmuxBackend } from './adapters/backend/tmux-backend.js';
|
|
91
95
|
import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
|
|
92
96
|
import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js';
|
|
93
97
|
import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.js';
|
|
98
|
+
import { ZmxBackend } from './adapters/backend/zmx-backend.js';
|
|
99
|
+
import { isCriticalInterruptKey, sendCriticalControlKey, } from './adapters/backend/critical-control-key.js';
|
|
100
|
+
import { backendCliCompatibilityError, backendSupportsWebTerminal, } from './adapters/backend/capabilities.js';
|
|
94
101
|
import { zellijEnv } from './setup/ensure-zellij.js';
|
|
95
|
-
import { isObserveBackend } from './adapters/backend/types.js';
|
|
96
|
-
import {
|
|
102
|
+
import { AmbiguousSubmissionBlockedError, isObserveBackend, } from './adapters/backend/types.js';
|
|
103
|
+
import { backendGateUserMessage, backendSandboxCompatibilityError, backendSandboxCompatibilityUserMessage, decideBackendGate, retireSupersededRecordedHerdrTarget, selectSessionBackend, } from './adapters/backend/session-backend-selector.js';
|
|
97
104
|
import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
|
|
98
105
|
import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
|
|
99
106
|
import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
|
|
100
107
|
import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
|
|
101
108
|
import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
|
|
109
|
+
import { probeZmxVersion } from './setup/ensure-zmx.js';
|
|
102
110
|
import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
|
|
103
111
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
104
112
|
import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
|
|
105
113
|
import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
|
|
114
|
+
import { sendTuiKeySequence, submitTuiTextInput } from './utils/tui-input-delivery.js';
|
|
106
115
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
107
116
|
import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js';
|
|
108
117
|
import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
|
|
@@ -151,6 +160,8 @@ scrubClaudeSessionMarkerEnv(process.env);
|
|
|
151
160
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
152
161
|
let cliAdapter = null;
|
|
153
162
|
let backend = null;
|
|
163
|
+
let backendScreenRevision = 0;
|
|
164
|
+
let idleScreenSettleTask = null;
|
|
154
165
|
let intentionalRestartBackend = null;
|
|
155
166
|
// Hybrid codex RPC input mode (opt-in per bot, cfg.codexRpcInput). When active,
|
|
156
167
|
// user input is delivered to codex via the app-server JSON-RPC channel
|
|
@@ -215,11 +226,14 @@ function persistentPaneInfo(backendType, sessionId) {
|
|
|
215
226
|
name = HerdrBackend.sessionName(sessionId);
|
|
216
227
|
else if (backendType === 'zellij')
|
|
217
228
|
name = ZellijBackend.sessionName(sessionId);
|
|
229
|
+
else if (backendType === 'zmx')
|
|
230
|
+
name = ZmxBackend.sessionName(sessionId);
|
|
218
231
|
if (!name)
|
|
219
232
|
return null;
|
|
220
233
|
const live = backendType === 'tmux' ? TmuxBackend.hasSession(name)
|
|
221
234
|
: backendType === 'zellij' ? ZellijBackend.hasSession(name)
|
|
222
|
-
:
|
|
235
|
+
: backendType === 'zmx' ? ZmxBackend.probeManagedSession(name, sessionId).state === 'compatible'
|
|
236
|
+
: HerdrBackend.hasSession(name);
|
|
223
237
|
return { name, live };
|
|
224
238
|
}
|
|
225
239
|
function persistentPaneLive(backendType, name) {
|
|
@@ -229,7 +243,26 @@ function persistentPaneLive(backendType, name) {
|
|
|
229
243
|
return ZellijBackend.hasSession(name);
|
|
230
244
|
if (backendType === 'herdr')
|
|
231
245
|
return HerdrBackend.hasSession(name);
|
|
232
|
-
return
|
|
246
|
+
return ZmxBackend.hasSession(name);
|
|
247
|
+
}
|
|
248
|
+
function probeOwnedZmxSession(name, sessionId, expectedPid) {
|
|
249
|
+
const managed = ZmxBackend.probeManagedSession(name, sessionId);
|
|
250
|
+
if (managed.state === 'missing')
|
|
251
|
+
return { probe: 'missing' };
|
|
252
|
+
if (managed.state === 'unknown')
|
|
253
|
+
return { probe: 'unknown', reason: managed.reason };
|
|
254
|
+
if (managed.state === 'incompatible') {
|
|
255
|
+
return {
|
|
256
|
+
probe: 'unknown',
|
|
257
|
+
reason: managed.reason === 'transport-label'
|
|
258
|
+
? `ZMX 会话 ${name} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);请手动关闭旧会话后重试`
|
|
259
|
+
: `ZMX 会话 ${name} 属于另一个完整 botmux session`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (expectedPid !== undefined && managed.pid !== expectedPid) {
|
|
263
|
+
return { probe: 'unknown', reason: `ZMX 会话 ${name} 的 PTY root PID 已变化` };
|
|
264
|
+
}
|
|
265
|
+
return { probe: 'exists', pid: managed.pid };
|
|
233
266
|
}
|
|
234
267
|
/** Kill a persistent session and VERIFY it is gone (bounded retry). killSession
|
|
235
268
|
* swallows exceptions and killPersistentSession returns void, so a plain
|
|
@@ -237,9 +270,9 @@ function persistentPaneLive(backendType, name) {
|
|
|
237
270
|
* pane would then be reattached against the NEW app-server's fresh port,
|
|
238
271
|
* re-triggering the exact P0 freeze. Returns true only when the session is
|
|
239
272
|
* confirmed absent (Codex delta point 1). */
|
|
240
|
-
async function killPersistentSessionVerified(backendType, name) {
|
|
273
|
+
async function killPersistentSessionVerified(backendType, name, sessionId) {
|
|
241
274
|
return killAndVerifyPersistentPane(name, {
|
|
242
|
-
kill: (resolvedName) => killPersistentSession(backendType, resolvedName),
|
|
275
|
+
kill: (resolvedName) => killPersistentSession(backendType, resolvedName, sessionId),
|
|
243
276
|
isLive: (resolvedName) => persistentPaneLive(backendType, resolvedName),
|
|
244
277
|
wait: delay,
|
|
245
278
|
});
|
|
@@ -272,7 +305,7 @@ function codexNativeTitleEnv(cfg) {
|
|
|
272
305
|
...redactChildEnv(process.env),
|
|
273
306
|
...sanitizePerBotEnv(cfg.env),
|
|
274
307
|
};
|
|
275
|
-
env.PATH =
|
|
308
|
+
env.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), env.PATH);
|
|
276
309
|
return env;
|
|
277
310
|
}
|
|
278
311
|
function registerNativeTitleForceClose(forceClose) {
|
|
@@ -415,7 +448,7 @@ async function captureCodexResumeTitleBaseline(threadId, engine) {
|
|
|
415
448
|
...redactChildEnv(process.env),
|
|
416
449
|
...sanitizePerBotEnv(cfg.env),
|
|
417
450
|
};
|
|
418
|
-
env.PATH =
|
|
451
|
+
env.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), env.PATH);
|
|
419
452
|
const abortController = new AbortController();
|
|
420
453
|
nativeSessionTitleSyncAbortControllers.add(abortController);
|
|
421
454
|
try {
|
|
@@ -523,7 +556,7 @@ async function engageCodexRpc(cfg) {
|
|
|
523
556
|
try {
|
|
524
557
|
const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
|
|
525
558
|
const engineEnv = { ...redactChildEnv(process.env) };
|
|
526
|
-
engineEnv.PATH =
|
|
559
|
+
engineEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), engineEnv.PATH);
|
|
527
560
|
engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
|
|
528
561
|
// In Codex/TraeX RPC mode the app-server, not the remote viewer TUI, runs
|
|
529
562
|
// model shell tools. Give that process the same non-secret route binding as
|
|
@@ -557,7 +590,7 @@ async function engageCodexRpc(cfg) {
|
|
|
557
590
|
Object.assign(engineEnv, sanitizePerBotEnv(cfg.env));
|
|
558
591
|
engine = new CodexRpcEngine({
|
|
559
592
|
cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
|
|
560
|
-
model: cfg.model, log: (m) => log(m),
|
|
593
|
+
model: cfg.model, reasoningEffort: cfg.reasoningEffort, log: (m) => log(m),
|
|
561
594
|
appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined,
|
|
562
595
|
onRequestUserInput: cfg.cliId === 'traex'
|
|
563
596
|
? (params) => bridgeTraexUserInput(cfg, params)
|
|
@@ -981,6 +1014,19 @@ let lastSpawnEffectiveCliSessionId;
|
|
|
981
1014
|
let lastSpawnDeferInitialPrompt = false;
|
|
982
1015
|
let lastSpawnQueuedInitialPrompt;
|
|
983
1016
|
let lastSpawnQueuedInitialPromptLogicalContent;
|
|
1017
|
+
/**
|
|
1018
|
+
* True only when {@link shouldArmSpawnArgvInitialPromptBusy} says so: argv-
|
|
1019
|
+
* baked first prompt + SessionStart ready (Grok-class). First markPromptReady
|
|
1020
|
+
* then reports working (not idle). Cleared on first consume. Must stay false
|
|
1021
|
+
* for Riff/queue-after-spawn and for quiescence-only argv adapters.
|
|
1022
|
+
*/
|
|
1023
|
+
let spawnArgvInitialPromptBusy = false;
|
|
1024
|
+
/**
|
|
1025
|
+
* True when spawn baked first prompt into argv (any such adapter, incl. Pi /
|
|
1026
|
+
* Gemini). Card-off reactions need a working→idle edge: Grok uses the busy
|
|
1027
|
+
* arm above; quiescence argv adapters seed working then idle at first ready.
|
|
1028
|
+
*/
|
|
1029
|
+
let spawnArgvNeedsWorkingSeed = false;
|
|
984
1030
|
let idleDetector = null;
|
|
985
1031
|
let isTmuxMode = false;
|
|
986
1032
|
/** True once a crash diagnostic tmux shell (bmx-diag-<sid>) is live. */
|
|
@@ -999,6 +1045,15 @@ let lastCliExitSignal = null;
|
|
|
999
1045
|
* of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
|
|
1000
1046
|
let isPipeMode = false;
|
|
1001
1047
|
let effectiveBackendType = 'pty';
|
|
1048
|
+
/**
|
|
1049
|
+
* Whether a screen snapshot can safely drive state changes or synthetic input.
|
|
1050
|
+
* ZMX exposes full history but not the authoritative current PTY geometry; a
|
|
1051
|
+
* local attach can resize it persistently, making a bounded render include
|
|
1052
|
+
* scrollback above the real viewport. Keep that evidence display-only.
|
|
1053
|
+
*/
|
|
1054
|
+
function backendScreenEvidenceIsAuthoritativeForMutation() {
|
|
1055
|
+
return effectiveBackendType !== 'zmx';
|
|
1056
|
+
}
|
|
1002
1057
|
/** Worker-owned statement about the confinement attached to the CURRENT CLI
|
|
1003
1058
|
* generation. The daemon receives this over private IPC; child-writable PID
|
|
1004
1059
|
* marker files remain diagnostics only. */
|
|
@@ -1080,6 +1135,7 @@ function ensureZellijAttachConfig() {
|
|
|
1080
1135
|
}
|
|
1081
1136
|
let sessionId = '';
|
|
1082
1137
|
let lastInitConfig = null;
|
|
1138
|
+
let closeRequested = false;
|
|
1083
1139
|
/** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
|
|
1084
1140
|
* (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
|
|
1085
1141
|
* 持久化。仅有写权限的 dashboard 视图可见。 */
|
|
@@ -1107,12 +1163,11 @@ let rawInputRestartGate = false;
|
|
|
1107
1163
|
* when the CLI restarts. Consumed inside flushPending right before the first
|
|
1108
1164
|
* user prompt is drained, so the commands always precede it (see runStartupCommands). */
|
|
1109
1165
|
let hasRunStartupCommands = false;
|
|
1110
|
-
/** Per-spawn latch: set
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
* instead. Reset per spawn in spawnCli. */
|
|
1166
|
+
/** Per-spawn latch: set when the launch-failure detector still sees a bare
|
|
1167
|
+
* shell after its bounded settle. While set, no input may reach the pane. A
|
|
1168
|
+
* later PTY prompt may release it, but only after the pane leaf has actually
|
|
1169
|
+
* changed to a non-shell process; otherwise a shell prompt that resembles a
|
|
1170
|
+
* CLI prompt could leak the queued message into zsh/bash. Reset per spawn. */
|
|
1116
1171
|
let bareShellLaunchBlocked = false;
|
|
1117
1172
|
/** Per-spawn one-shot: has the bare-shell launch check already run for this
|
|
1118
1173
|
* spawn? Gates detectBareShellLaunch() to the FIRST flush only (the
|
|
@@ -1272,16 +1327,24 @@ async function sendRawCommandLine(be, content) {
|
|
|
1272
1327
|
const cmd = content.trim();
|
|
1273
1328
|
const typed = cmd.includes(' ') ? cmd : `${cmd} `;
|
|
1274
1329
|
for (const ch of typed) {
|
|
1275
|
-
be.sendText(ch)
|
|
1330
|
+
if (be.sendText(ch) === false) {
|
|
1331
|
+
throw new Error('backend rejected command text input');
|
|
1332
|
+
}
|
|
1276
1333
|
await new Promise(r => setTimeout(r, COCO_SLASH_TYPE_THROTTLE_MS));
|
|
1277
1334
|
}
|
|
1278
1335
|
await new Promise(r => setTimeout(r, 200));
|
|
1279
|
-
be.sendSpecialKeys('Enter')
|
|
1336
|
+
if (be.sendSpecialKeys('Enter') === false) {
|
|
1337
|
+
throw new Error('backend rejected command submit key');
|
|
1338
|
+
}
|
|
1280
1339
|
return;
|
|
1281
1340
|
}
|
|
1282
|
-
be.sendText(content)
|
|
1341
|
+
if (be.sendText(content) === false) {
|
|
1342
|
+
throw new Error('backend rejected command text input');
|
|
1343
|
+
}
|
|
1283
1344
|
await new Promise(r => setTimeout(r, 200));
|
|
1284
|
-
be.sendSpecialKeys('Enter')
|
|
1345
|
+
if (be.sendSpecialKeys('Enter') === false) {
|
|
1346
|
+
throw new Error('backend rejected command submit key');
|
|
1347
|
+
}
|
|
1285
1348
|
}
|
|
1286
1349
|
else {
|
|
1287
1350
|
// PtyBackend has no sendText/sendSpecialKeys, so write the keystrokes
|
|
@@ -1296,20 +1359,36 @@ async function sendRawCommandLine(be, content) {
|
|
|
1296
1359
|
be.write('\r');
|
|
1297
1360
|
}
|
|
1298
1361
|
}
|
|
1299
|
-
/** Serialize
|
|
1300
|
-
* raw_input deliberately keeps its legacy "send while busy"
|
|
1301
|
-
* narrow mutex merely prevents concurrent IPC handlers (or
|
|
1302
|
-
* from splicing keystrokes into one another. */
|
|
1362
|
+
/** Serialize one complete raw command-line transaction (fence -> text -> Enter
|
|
1363
|
+
* -> commit/cancel). raw_input deliberately keeps its legacy "send while busy"
|
|
1364
|
+
* behaviour; this narrow mutex merely prevents concurrent IPC handlers (or
|
|
1365
|
+
* native /rename) from splicing keystrokes into one another. */
|
|
1303
1366
|
let commandLineWriteTail = Promise.resolve();
|
|
1304
1367
|
let commandLineWritesPending = 0;
|
|
1305
|
-
|
|
1368
|
+
class SubmissionWriteError extends Error {
|
|
1369
|
+
recoveryFailureReason;
|
|
1370
|
+
submissionStarted;
|
|
1371
|
+
constructor(message, recoveryFailureReason,
|
|
1372
|
+
/** False only when the logical transaction was rejected before its write
|
|
1373
|
+
* callback ran, so the current input is known not to have reached the PTY. */
|
|
1374
|
+
submissionStarted = true) {
|
|
1375
|
+
super(message);
|
|
1376
|
+
this.recoveryFailureReason = recoveryFailureReason;
|
|
1377
|
+
this.submissionStarted = submissionStarted;
|
|
1378
|
+
this.name = 'SubmissionWriteError';
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
async function sendRawCommandLineWithRecoveryFence(be, content, beforeWrite) {
|
|
1306
1382
|
const previous = commandLineWriteTail;
|
|
1307
1383
|
let release;
|
|
1308
1384
|
commandLineWriteTail = new Promise(resolve => { release = resolve; });
|
|
1309
1385
|
commandLineWritesPending += 1;
|
|
1310
1386
|
await previous;
|
|
1311
1387
|
try {
|
|
1312
|
-
await sendRawCommandLine(be, content);
|
|
1388
|
+
const transaction = await runAmbiguousSubmissionTransaction(be, () => sendRawCommandLine(be, content), undefined, beforeWrite);
|
|
1389
|
+
if (transaction.recoveryFailureReason) {
|
|
1390
|
+
throw new SubmissionWriteError(`backend could not commit the command submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
|
|
1391
|
+
}
|
|
1313
1392
|
}
|
|
1314
1393
|
finally {
|
|
1315
1394
|
commandLineWritesPending -= 1;
|
|
@@ -1359,7 +1438,7 @@ async function runStartupCommands() {
|
|
|
1359
1438
|
if (!backend)
|
|
1360
1439
|
break;
|
|
1361
1440
|
try {
|
|
1362
|
-
await
|
|
1441
|
+
await sendRawCommandLineWithRecoveryFence(backend, cmd);
|
|
1363
1442
|
await awaitPtyQuiescence(STARTUP_CMD_QUIET_MS, STARTUP_CMD_CAP_MS);
|
|
1364
1443
|
log(`Startup command sent: ${cmd}`);
|
|
1365
1444
|
}
|
|
@@ -1467,34 +1546,62 @@ function armSessionRenameIdleTimeout() {
|
|
|
1467
1546
|
* Commands received while /rename owns the TUI are deferred by the IPC handler
|
|
1468
1547
|
* and come through this same function after the prompt returns. */
|
|
1469
1548
|
async function deliverRawInput(msg) {
|
|
1470
|
-
renderer?.markNewTurn();
|
|
1471
|
-
usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
1472
|
-
if (tmuxScrolledHalfPages > 0)
|
|
1473
|
-
exitTmuxScrollMode();
|
|
1474
1549
|
const targetBackend = backend;
|
|
1475
1550
|
if (!targetBackend)
|
|
1476
1551
|
return;
|
|
1477
|
-
// A passthrough is still an accepted input boundary. Rotate (or revoke) the
|
|
1478
|
-
// marker immediately before the literal command reaches the CLI so it can
|
|
1479
|
-
// never inherit a previous human turn. System-generated raw commands omit
|
|
1480
|
-
// turnId and therefore publish an explicitly unattributed marker.
|
|
1481
|
-
currentBotmuxTurnId = msg.turnId;
|
|
1482
|
-
currentBotmuxDispatchAttempt = undefined;
|
|
1483
|
-
currentVcMeetingImTurnOrigin = undefined;
|
|
1484
|
-
writeCliPidMarker();
|
|
1485
|
-
publishSandboxRelayCapability();
|
|
1486
1552
|
let sent = false;
|
|
1553
|
+
let recoveryFailureReason;
|
|
1487
1554
|
try {
|
|
1488
|
-
await
|
|
1555
|
+
await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
|
|
1556
|
+
renderer?.markNewTurn();
|
|
1557
|
+
usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
1558
|
+
if (tmuxScrolledHalfPages > 0)
|
|
1559
|
+
exitTmuxScrollMode();
|
|
1560
|
+
// Rotate (or revoke) attribution only after both submission locks are
|
|
1561
|
+
// held and immediately before the literal command reaches the CLI.
|
|
1562
|
+
// A queued passthrough must not steal output from the preceding turn.
|
|
1563
|
+
currentBotmuxTurnId = msg.turnId;
|
|
1564
|
+
currentBotmuxDispatchAttempt = undefined;
|
|
1565
|
+
currentVcMeetingImTurnOrigin = undefined;
|
|
1566
|
+
writeCliPidMarker();
|
|
1567
|
+
publishSandboxRelayCapability();
|
|
1568
|
+
});
|
|
1489
1569
|
sent = true;
|
|
1490
1570
|
isPromptReady = false;
|
|
1491
1571
|
idleDetector?.reset();
|
|
1492
1572
|
log(`Passthrough slash command: ${msg.content}`);
|
|
1573
|
+
// A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
|
|
1574
|
+
// confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
|
|
1575
|
+
// `/effort` opens a level picker (unknown target) and is left alone.
|
|
1576
|
+
if (isEffortLevelCommand(msg.content))
|
|
1577
|
+
armEffortConfirm();
|
|
1493
1578
|
}
|
|
1494
1579
|
catch (err) {
|
|
1580
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
1581
|
+
? err.recoveryFailureReason
|
|
1582
|
+
: undefined;
|
|
1495
1583
|
// Do not send another queued command against a backend whose write failed.
|
|
1496
1584
|
isPromptReady = false;
|
|
1497
1585
|
log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
|
|
1586
|
+
const failedTurnId = msg.followUpTurnId ?? msg.turnId;
|
|
1587
|
+
if (failedTurnId && !recoveryFailureReason) {
|
|
1588
|
+
emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
|
|
1589
|
+
}
|
|
1590
|
+
const failureMessageKey = msg.followUpContent
|
|
1591
|
+
? (recoveryFailureReason
|
|
1592
|
+
? 'worker.raw_input_failed_recovery'
|
|
1593
|
+
: 'worker.raw_input_failed')
|
|
1594
|
+
: (recoveryFailureReason
|
|
1595
|
+
? 'worker.raw_input_failed_command_only_recovery'
|
|
1596
|
+
: 'worker.raw_input_failed_command_only');
|
|
1597
|
+
send({
|
|
1598
|
+
type: 'user_notify',
|
|
1599
|
+
...(failedTurnId ? { turnId: failedTurnId } : {}),
|
|
1600
|
+
message: t(failureMessageKey, {
|
|
1601
|
+
cliName: cliName(),
|
|
1602
|
+
reason: recoveryFailureReason ?? '',
|
|
1603
|
+
}),
|
|
1604
|
+
});
|
|
1498
1605
|
}
|
|
1499
1606
|
// Follow-up rides on the same IPC and is enqueued only after this command's
|
|
1500
1607
|
// Enter lands. sendToPty also observes commandLineWritesPending, so another
|
|
@@ -1822,8 +1929,15 @@ function createUsageLimitTracker() {
|
|
|
1822
1929
|
}
|
|
1823
1930
|
const usageLimitTracker = createUsageLimitTracker();
|
|
1824
1931
|
function currentUsageLimitSnapshot() {
|
|
1932
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation())
|
|
1933
|
+
return '';
|
|
1825
1934
|
return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
|
|
1826
1935
|
}
|
|
1936
|
+
function classifyScreenUsageLimit(content, status) {
|
|
1937
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation())
|
|
1938
|
+
return { status };
|
|
1939
|
+
return usageLimitTracker.classify(content, status);
|
|
1940
|
+
}
|
|
1827
1941
|
// ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
|
|
1828
1942
|
//
|
|
1829
1943
|
// In bridge mode the daemon adopted an existing CLI session that we do NOT
|
|
@@ -2011,6 +2125,8 @@ function bridgeMarkerPath() {
|
|
|
2011
2125
|
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
2012
2126
|
}
|
|
2013
2127
|
function readSendMarkers() {
|
|
2128
|
+
if (closeRequested)
|
|
2129
|
+
return [];
|
|
2014
2130
|
const path = bridgeMarkerPath();
|
|
2015
2131
|
if (!path || !existsSync(path))
|
|
2016
2132
|
return [];
|
|
@@ -2033,6 +2149,23 @@ function readSendMarkers() {
|
|
|
2033
2149
|
return [];
|
|
2034
2150
|
}
|
|
2035
2151
|
}
|
|
2152
|
+
function explicitReplyMarkerForTurnWindow(turn, nextBoundaryMs, markers, adoptMode) {
|
|
2153
|
+
if (adoptMode || turn.isLocal || turn.markTimeMs === undefined)
|
|
2154
|
+
return undefined;
|
|
2155
|
+
const lower = turn.markTimeMs;
|
|
2156
|
+
const upper = nextBoundaryMs ?? Number.POSITIVE_INFINITY;
|
|
2157
|
+
const inWindow = markers.filter(marker => marker.sentAtMs >= lower && marker.sentAtMs < upper);
|
|
2158
|
+
return inWindow.at(-1);
|
|
2159
|
+
}
|
|
2160
|
+
function notifyExplicitReplyObserved(turnId, marker) {
|
|
2161
|
+
if (!marker)
|
|
2162
|
+
return;
|
|
2163
|
+
send({
|
|
2164
|
+
type: 'explicit_reply_observed',
|
|
2165
|
+
turnId,
|
|
2166
|
+
...(marker.messageId ? { messageId: marker.messageId } : {}),
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2036
2169
|
function submitActivityEvidenceSince(sinceMs) {
|
|
2037
2170
|
if (lastPtyActivityAtMs > sinceMs)
|
|
2038
2171
|
return 'pty-output';
|
|
@@ -3132,9 +3265,11 @@ function emitReadyTurns(opts = {}) {
|
|
|
3132
3265
|
if (assistantText.length === 0)
|
|
3133
3266
|
continue;
|
|
3134
3267
|
const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
|
|
3135
|
-
|
|
3268
|
+
const gateInput = { markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: assistantText };
|
|
3269
|
+
if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
|
|
3136
3270
|
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
|
|
3137
3271
|
log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
|
|
3272
|
+
notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
|
|
3138
3273
|
continue;
|
|
3139
3274
|
}
|
|
3140
3275
|
if (turn.isLocal) {
|
|
@@ -3876,6 +4011,7 @@ function emitReadyCodexTurns() {
|
|
|
3876
4011
|
continue;
|
|
3877
4012
|
if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
|
|
3878
4013
|
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
|
|
4014
|
+
notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
|
|
3879
4015
|
continue;
|
|
3880
4016
|
}
|
|
3881
4017
|
if (turn.isLocal) {
|
|
@@ -4282,6 +4418,11 @@ function startStuckDetector() {
|
|
|
4282
4418
|
stopStuckDetector();
|
|
4283
4419
|
stuckDetector = new StuckDetector(sd.timeoutMs, {
|
|
4284
4420
|
isActuallyStuck: () => {
|
|
4421
|
+
// ZMX history has no authoritative viewport geometry after a local attach
|
|
4422
|
+
// resize. A stale hook-review screen may sit just above the real viewport,
|
|
4423
|
+
// so never raise a key-driving stuck card from this observer.
|
|
4424
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation())
|
|
4425
|
+
return false;
|
|
4285
4426
|
// Scope gate: this PR only handles the Codex PreToolUse hook-review
|
|
4286
4427
|
// screen. Other CLIs (Claude Code, Gemini, ...) must never see the
|
|
4287
4428
|
// Codex-specific t/Enter/Esc card, even if their output happens to
|
|
@@ -4344,6 +4485,7 @@ let lastShotHash = '';
|
|
|
4344
4485
|
let larkAppIdForUpload = '';
|
|
4345
4486
|
let larkAppSecretForUpload = '';
|
|
4346
4487
|
let larkBrandForUpload = 'feishu';
|
|
4488
|
+
let apiOnlyForUpload = false;
|
|
4347
4489
|
function startScreenshotLoop() {
|
|
4348
4490
|
stopScreenshotLoop();
|
|
4349
4491
|
screenshotTimer = setInterval(() => { void captureAndUpload(); }, SCREENSHOT_INTERVAL_MS);
|
|
@@ -4412,6 +4554,10 @@ async function captureAndUpload() {
|
|
|
4412
4554
|
logScreenshotSkip('awaitingFirstPrompt');
|
|
4413
4555
|
return;
|
|
4414
4556
|
}
|
|
4557
|
+
if (apiOnlyForUpload) {
|
|
4558
|
+
logScreenshotSkip('no Feishu transport (apiOnly bot or HTTP virtual session)');
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4415
4561
|
if (!larkAppIdForUpload || !larkAppSecretForUpload) {
|
|
4416
4562
|
logScreenshotSkip('lark credentials missing');
|
|
4417
4563
|
return;
|
|
@@ -4467,7 +4613,7 @@ async function captureAndUpload() {
|
|
|
4467
4613
|
send({
|
|
4468
4614
|
type: 'screenshot_uploaded',
|
|
4469
4615
|
imageKey,
|
|
4470
|
-
...
|
|
4616
|
+
...classifyScreenUsageLimit(usageLimitContent, status),
|
|
4471
4617
|
turnId: currentBotmuxTurnId,
|
|
4472
4618
|
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4473
4619
|
});
|
|
@@ -4507,7 +4653,17 @@ function exitTmuxScrollMode() {
|
|
|
4507
4653
|
catch { /* benign */ }
|
|
4508
4654
|
tmuxScrolledHalfPages = 0;
|
|
4509
4655
|
}
|
|
4510
|
-
function
|
|
4656
|
+
function sendTermActionOnce(target, key) {
|
|
4657
|
+
if ('sendSpecialKeys' in target && TMUX_KEY_MAP[key]) {
|
|
4658
|
+
return target.sendSpecialKeys(TMUX_KEY_MAP[key]);
|
|
4659
|
+
}
|
|
4660
|
+
if (PTY_SEQ_MAP[key]) {
|
|
4661
|
+
target.write(PTY_SEQ_MAP[key]);
|
|
4662
|
+
return true;
|
|
4663
|
+
}
|
|
4664
|
+
return false;
|
|
4665
|
+
}
|
|
4666
|
+
async function handleTermAction(key) {
|
|
4511
4667
|
// riff:没有远端终端可驱动——把控制字符 write 进 RiffBackend 会变成一个内容为
|
|
4512
4668
|
// ANSI 序列的 follow-up 任务(^C 也不会 cancel 任务),必须整体拒绝。
|
|
4513
4669
|
if (effectiveBackendType === 'riff') {
|
|
@@ -4516,10 +4672,11 @@ function handleTermAction(key) {
|
|
|
4516
4672
|
}
|
|
4517
4673
|
if (!backend)
|
|
4518
4674
|
return;
|
|
4675
|
+
const targetBackend = backend;
|
|
4519
4676
|
const isHalfPage = key === 'half_page_up' || key === 'half_page_down';
|
|
4520
4677
|
// Tmux copy-mode scroll (works around alternate-buffer scrollback limitation)
|
|
4521
|
-
if (isHalfPage && 'sendCopyModeCommand' in
|
|
4522
|
-
const tb =
|
|
4678
|
+
if (isHalfPage && 'sendCopyModeCommand' in targetBackend) {
|
|
4679
|
+
const tb = targetBackend;
|
|
4523
4680
|
try {
|
|
4524
4681
|
if (tmuxScrolledHalfPages === 0 && key === 'half_page_up') {
|
|
4525
4682
|
tb.enterCopyMode();
|
|
@@ -4547,11 +4704,49 @@ function handleTermAction(key) {
|
|
|
4547
4704
|
// Any non-scroll key cancels active scroll first so the live view returns.
|
|
4548
4705
|
if (tmuxScrolledHalfPages > 0)
|
|
4549
4706
|
exitTmuxScrollMode();
|
|
4550
|
-
|
|
4551
|
-
|
|
4707
|
+
const criticalInterrupt = isCriticalInterruptKey(key);
|
|
4708
|
+
let delivered = false;
|
|
4709
|
+
try {
|
|
4710
|
+
delivered = await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
|
|
4711
|
+
if (criticalInterrupt) {
|
|
4712
|
+
return sendCriticalControlKey(key, () => {
|
|
4713
|
+
// Never retry an old interrupt into a replacement CLI generation.
|
|
4714
|
+
if (backend !== targetBackend)
|
|
4715
|
+
return true;
|
|
4716
|
+
return sendTermActionOnce(targetBackend, key);
|
|
4717
|
+
});
|
|
4718
|
+
}
|
|
4719
|
+
return sendTermActionOnce(targetBackend, key) !== false;
|
|
4720
|
+
});
|
|
4552
4721
|
}
|
|
4553
|
-
|
|
4554
|
-
backend
|
|
4722
|
+
catch (err) {
|
|
4723
|
+
if (backend === targetBackend) {
|
|
4724
|
+
log(`Term action ${key} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
if (backend !== targetBackend)
|
|
4728
|
+
return;
|
|
4729
|
+
if (!delivered) {
|
|
4730
|
+
log(`Term action ${key} was not delivered${criticalInterrupt ? ' after retry' : ''}`);
|
|
4731
|
+
if (criticalInterrupt) {
|
|
4732
|
+
const recovery = t(effectiveBackendType === 'zmx'
|
|
4733
|
+
? 'worker.interrupt_recovery_zmx'
|
|
4734
|
+
: 'worker.interrupt_recovery_web');
|
|
4735
|
+
send({
|
|
4736
|
+
type: 'user_notify',
|
|
4737
|
+
turnId: currentBotmuxTurnId,
|
|
4738
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4739
|
+
message: t('worker.interrupt_unconfirmed', {
|
|
4740
|
+
key: TMUX_KEY_MAP[key] ?? key,
|
|
4741
|
+
cliName: cliName(),
|
|
4742
|
+
recovery,
|
|
4743
|
+
}),
|
|
4744
|
+
});
|
|
4745
|
+
}
|
|
4746
|
+
// Refresh the card from real output, but do not clear prompt blocking or
|
|
4747
|
+
// claim that the CLI stopped when the key never reached it.
|
|
4748
|
+
scheduleOneShotAfterAction();
|
|
4749
|
+
return;
|
|
4555
4750
|
}
|
|
4556
4751
|
// ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking
|
|
4557
4752
|
// flag here — without this, dismissing an AskUserQuestion dialog via the
|
|
@@ -4576,23 +4771,14 @@ const KEY_TO_ANSI = {
|
|
|
4576
4771
|
* @returns true if all keys were written successfully; false if backend is gone
|
|
4577
4772
|
* or a write threw (caller must NOT send tui_keys_delivered on false).
|
|
4578
4773
|
*/
|
|
4579
|
-
async function
|
|
4580
|
-
if (
|
|
4774
|
+
async function handleTuiKeysDirect(targetBackend, keys, isFinal) {
|
|
4775
|
+
if (keys.length === 0)
|
|
4581
4776
|
return false;
|
|
4582
4777
|
try {
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
b.sendSpecialKeys(key);
|
|
4588
|
-
await new Promise(r => setTimeout(r, 100));
|
|
4589
|
-
}
|
|
4590
|
-
}
|
|
4591
|
-
else {
|
|
4592
|
-
for (const key of keys) {
|
|
4593
|
-
backend.write(KEY_TO_ANSI[key] ?? key);
|
|
4594
|
-
await new Promise(r => setTimeout(r, 100));
|
|
4595
|
-
}
|
|
4778
|
+
const delivered = await sendTuiKeySequence(targetBackend, keys, KEY_TO_ANSI, { isCurrent: () => backend === targetBackend });
|
|
4779
|
+
if (!delivered) {
|
|
4780
|
+
logError('handleTuiKeys write rejected or backend replaced');
|
|
4781
|
+
return false;
|
|
4596
4782
|
}
|
|
4597
4783
|
}
|
|
4598
4784
|
catch (e) {
|
|
@@ -4609,6 +4795,18 @@ async function handleTuiKeys(keys, isFinal) {
|
|
|
4609
4795
|
log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
|
|
4610
4796
|
return true;
|
|
4611
4797
|
}
|
|
4798
|
+
async function handleTuiKeys(keys, isFinal) {
|
|
4799
|
+
if (!backend || keys.length === 0)
|
|
4800
|
+
return false;
|
|
4801
|
+
const targetBackend = backend;
|
|
4802
|
+
try {
|
|
4803
|
+
return await runAfterAmbiguousSubmissionWrites(targetBackend, () => handleTuiKeysDirect(targetBackend, keys, isFinal));
|
|
4804
|
+
}
|
|
4805
|
+
catch (err) {
|
|
4806
|
+
logError(`handleTuiKeys queued write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4807
|
+
return false;
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4612
4810
|
// 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
|
|
4613
4811
|
// 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
|
|
4614
4812
|
// barrier 语义:barrier=true 的注入必须先于本次 pending 用户消息落地。历史上
|
|
@@ -4619,7 +4817,7 @@ async function handleTuiKeys(keys, isFinal) {
|
|
|
4619
4817
|
// 单元——本文件只持有队列状态。
|
|
4620
4818
|
const pendingInjections = [];
|
|
4621
4819
|
let injectionFlushing = false;
|
|
4622
|
-
/** 排队注入一行 TUI 命令:idle(isPromptReady
|
|
4820
|
+
/** 排队注入一行 TUI 命令:idle(isPromptReady)时经串行恢复事务敲入。 */
|
|
4623
4821
|
async function flushPendingInjections() {
|
|
4624
4822
|
// 不跨 restart 边界写入(与 flushPending 同款守卫):destroySession 异步期间
|
|
4625
4823
|
// backend 可能仍指向旧 CLI。
|
|
@@ -4672,7 +4870,7 @@ async function flushPendingInjections() {
|
|
|
4672
4870
|
try {
|
|
4673
4871
|
// Serially:与 startupCommands / raw_input / native-rename 共用同一条
|
|
4674
4872
|
// 字面命令行写入互斥链(text → beat → Enter 窗口不被拼接)。
|
|
4675
|
-
await
|
|
4873
|
+
await sendRawCommandLineWithRecoveryFence(backend, cmd);
|
|
4676
4874
|
await awaitPtyQuiescence(STARTUP_CMD_QUIET_MS, STARTUP_CMD_CAP_MS);
|
|
4677
4875
|
log(`Injected command: ${cmd}`);
|
|
4678
4876
|
}
|
|
@@ -4701,39 +4899,53 @@ async function flushPendingInjections() {
|
|
|
4701
4899
|
*/
|
|
4702
4900
|
async function handleTuiTextInput(keys, text) {
|
|
4703
4901
|
if (!backend || !cliAdapter)
|
|
4704
|
-
return;
|
|
4705
|
-
|
|
4706
|
-
const
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4902
|
+
return false;
|
|
4903
|
+
const targetBackend = backend;
|
|
4904
|
+
const targetAdapter = cliAdapter;
|
|
4905
|
+
const navKeyCount = keys[keys.length - 1] === 'Enter' ? keys.length - 1 : keys.length;
|
|
4906
|
+
log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeyCount} nav keys)`);
|
|
4907
|
+
try {
|
|
4908
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => submitTuiTextInput({
|
|
4909
|
+
target: strictInputHandle(targetBackend),
|
|
4910
|
+
keys,
|
|
4911
|
+
text,
|
|
4912
|
+
keyToAnsi: KEY_TO_ANSI,
|
|
4913
|
+
isCurrent: () => backend === targetBackend && cliAdapter === targetAdapter,
|
|
4914
|
+
writeInput: async (target, content) => {
|
|
4915
|
+
const result = await targetAdapter.writeInput(target, content);
|
|
4916
|
+
if (targetBackend.captureAmbiguousSubmissionFence) {
|
|
4917
|
+
await settleVerifiableSubmissionForJournal(result);
|
|
4918
|
+
}
|
|
4919
|
+
return result;
|
|
4920
|
+
},
|
|
4921
|
+
}), delivered => delivered);
|
|
4922
|
+
if (transaction.recoveryFailureReason) {
|
|
4923
|
+
throw new SubmissionWriteError(`backend could not commit the TUI text submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
|
|
4924
|
+
}
|
|
4925
|
+
if (!transaction.result) {
|
|
4926
|
+
logError('TUI text input was rejected, unconfirmed, or crossed a backend replacement');
|
|
4927
|
+
return false;
|
|
4713
4928
|
}
|
|
4714
4929
|
}
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4930
|
+
catch (err) {
|
|
4931
|
+
logError(`TUI text input write failed: ${err?.message ?? err}`);
|
|
4932
|
+
if (err instanceof SubmissionWriteError && err.recoveryFailureReason) {
|
|
4933
|
+
send({
|
|
4934
|
+
type: 'user_notify',
|
|
4935
|
+
turnId: currentBotmuxTurnId,
|
|
4936
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4937
|
+
message: err.recoveryFailureReason,
|
|
4938
|
+
});
|
|
4719
4939
|
}
|
|
4940
|
+
return false;
|
|
4720
4941
|
}
|
|
4721
|
-
//
|
|
4942
|
+
// Clear blocking only after navigation and adapter submission both succeed.
|
|
4722
4943
|
tuiPromptBlocking = false;
|
|
4723
4944
|
if (isPromptReady) {
|
|
4724
4945
|
isPromptReady = false;
|
|
4725
4946
|
idleDetector?.reset();
|
|
4726
4947
|
}
|
|
4727
|
-
|
|
4728
|
-
await new Promise(r => setTimeout(r, 200));
|
|
4729
|
-
// Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter)
|
|
4730
|
-
log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeys.length} nav keys)`);
|
|
4731
|
-
try {
|
|
4732
|
-
await cliAdapter.writeInput(backend, text);
|
|
4733
|
-
}
|
|
4734
|
-
catch (err) {
|
|
4735
|
-
log(`TUI text input write failed: ${err.message}`);
|
|
4736
|
-
}
|
|
4948
|
+
return true;
|
|
4737
4949
|
}
|
|
4738
4950
|
/**
|
|
4739
4951
|
* Drive CoCo's native AskUserQuestion picker to enter the answer the user picked
|
|
@@ -4760,6 +4972,16 @@ async function handleTuiTextInput(keys, text) {
|
|
|
4760
4972
|
async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
|
|
4761
4973
|
if (!backend)
|
|
4762
4974
|
return;
|
|
4975
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
|
|
4976
|
+
logError('coco_drive_picker: refused because ZMX screen geometry is not authoritative');
|
|
4977
|
+
send({
|
|
4978
|
+
type: 'user_notify',
|
|
4979
|
+
turnId: currentBotmuxTurnId,
|
|
4980
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4981
|
+
message: t('worker.tui_submit_failed', { cliName: cliName() }),
|
|
4982
|
+
});
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4763
4985
|
const snap = () => (lastAnalyzerSnapshot || renderer?.rawSnapshot() || '');
|
|
4764
4986
|
const waitFor = async (re, timeoutMs) => {
|
|
4765
4987
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -4776,43 +4998,89 @@ async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
|
|
|
4776
4998
|
log('coco_drive_picker: picker not detected within 30s — aborting drive');
|
|
4777
4999
|
return;
|
|
4778
5000
|
}
|
|
5001
|
+
if (!backend)
|
|
5002
|
+
return;
|
|
5003
|
+
const targetBackend = backend;
|
|
4779
5004
|
tuiPromptBlocking = true;
|
|
5005
|
+
let failureReported = false;
|
|
5006
|
+
const reportFailure = (detail) => {
|
|
5007
|
+
if (failureReported)
|
|
5008
|
+
return;
|
|
5009
|
+
failureReported = true;
|
|
5010
|
+
const suffix = detail
|
|
5011
|
+
? `: ${detail instanceof Error ? detail.message : String(detail)}`
|
|
5012
|
+
: '';
|
|
5013
|
+
logError(`coco_drive_picker: TUI answer delivery failed${suffix}`);
|
|
5014
|
+
send({
|
|
5015
|
+
type: 'user_notify',
|
|
5016
|
+
turnId: currentBotmuxTurnId,
|
|
5017
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
5018
|
+
message: t('worker.tui_submit_failed', { cliName: cliName() }),
|
|
5019
|
+
});
|
|
5020
|
+
};
|
|
4780
5021
|
if (comment && comment.trim()) {
|
|
4781
5022
|
// Free-text reply: navigate to the first question's "Type something" row,
|
|
4782
5023
|
// type the text, then a single Enter. Single-question submits directly; for
|
|
4783
5024
|
// multi-question this only fills the first question (logged limitation).
|
|
4784
5025
|
log(`coco_drive_picker: free-text answer (${navKeys.length} nav keys)${needsReviewSubmit ? ' [multi-question — partial]' : ''}`);
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
5026
|
+
try {
|
|
5027
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, async () => {
|
|
5028
|
+
const navigated = await sendTuiKeySequence(targetBackend, navKeys, KEY_TO_ANSI, { isCurrent: () => backend === targetBackend });
|
|
5029
|
+
if (!navigated) {
|
|
5030
|
+
throw new Error('backend rejected free-text navigation');
|
|
5031
|
+
}
|
|
5032
|
+
await new Promise(r => setTimeout(r, 150));
|
|
5033
|
+
if (backend !== targetBackend) {
|
|
5034
|
+
throw new Error('backend changed before free-text input');
|
|
5035
|
+
}
|
|
5036
|
+
const input = strictInputHandle(targetBackend);
|
|
5037
|
+
if (typeof input.sendText === 'function')
|
|
5038
|
+
input.sendText(comment);
|
|
5039
|
+
else
|
|
5040
|
+
input.write(comment);
|
|
5041
|
+
await new Promise(r => setTimeout(r, 200));
|
|
5042
|
+
if (backend !== targetBackend) {
|
|
5043
|
+
throw new Error('backend changed before free-text submit');
|
|
5044
|
+
}
|
|
5045
|
+
if (!await handleTuiKeysDirect(targetBackend, ['Enter'], true)) {
|
|
5046
|
+
throw new Error('backend rejected free-text submit key');
|
|
5047
|
+
}
|
|
5048
|
+
});
|
|
5049
|
+
if (transaction.recoveryFailureReason) {
|
|
5050
|
+
throw new SubmissionWriteError(`backend could not commit the free-text submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
|
|
4790
5051
|
}
|
|
4791
5052
|
}
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
5053
|
+
catch (err) {
|
|
5054
|
+
if (err instanceof SubmissionWriteError && err.recoveryFailureReason) {
|
|
5055
|
+
send({
|
|
5056
|
+
type: 'user_notify',
|
|
5057
|
+
turnId: currentBotmuxTurnId,
|
|
5058
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
5059
|
+
message: err.recoveryFailureReason,
|
|
5060
|
+
});
|
|
4796
5061
|
}
|
|
5062
|
+
reportFailure(err);
|
|
4797
5063
|
}
|
|
4798
|
-
await new Promise(r => setTimeout(r, 150));
|
|
4799
|
-
if ('sendText' in backend && b.sendText)
|
|
4800
|
-
b.sendText(comment);
|
|
4801
|
-
else
|
|
4802
|
-
backend.write(comment);
|
|
4803
|
-
await new Promise(r => setTimeout(r, 200));
|
|
4804
|
-
await handleTuiKeys(['Enter'], true); // single Enter submits + clears blocking state
|
|
4805
5064
|
return;
|
|
4806
5065
|
}
|
|
4807
5066
|
// Button selection. Single question: navKeys submit directly (isFinal=true).
|
|
4808
5067
|
// Multi question: navKeys land on Review, then one Enter on "Submit answers".
|
|
4809
5068
|
log(`coco_drive_picker: selection answer (${navKeys.length} keys, review=${needsReviewSubmit})`);
|
|
4810
|
-
await handleTuiKeys(navKeys, !needsReviewSubmit)
|
|
5069
|
+
if (!await handleTuiKeys(navKeys, !needsReviewSubmit)) {
|
|
5070
|
+
reportFailure();
|
|
5071
|
+
return;
|
|
5072
|
+
}
|
|
4811
5073
|
if (needsReviewSubmit) {
|
|
4812
5074
|
const review = await waitFor(/Review your answers|Submit answers/, 8_000);
|
|
4813
5075
|
if (!review)
|
|
4814
5076
|
log('coco_drive_picker: Review screen not detected — submitting anyway');
|
|
4815
|
-
|
|
5077
|
+
if (backend !== targetBackend) {
|
|
5078
|
+
reportFailure();
|
|
5079
|
+
return;
|
|
5080
|
+
}
|
|
5081
|
+
if (!await handleTuiKeys(['Enter'], true)) {
|
|
5082
|
+
reportFailure();
|
|
5083
|
+
}
|
|
4816
5084
|
}
|
|
4817
5085
|
}
|
|
4818
5086
|
// ─── Trust Dialog Detection ──────────────────────────────────────────────────
|
|
@@ -4823,6 +5091,33 @@ async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
|
|
|
4823
5091
|
const TRUST_DIALOG_PATTERN = /Yes, I trust this folder|Yes, continue/;
|
|
4824
5092
|
let trustHandled = false;
|
|
4825
5093
|
const codexUpdateDialogGuard = new CodexUpdateDialogGuard();
|
|
5094
|
+
// Auto-confirm Claude Code's mid-session "Change effort level?" Yes/No dialog.
|
|
5095
|
+
// Armed only by botmux's own `/effort <level>` passthrough (see deliverRawInput)
|
|
5096
|
+
// and disarmed on match, timeout, or CLI respawn — never inspects idle screens.
|
|
5097
|
+
const effortConfirmGuard = new EffortConfirmDialogGuard();
|
|
5098
|
+
let effortConfirmTimer = null;
|
|
5099
|
+
// The dialog appears within a second of the command landing; keep the arm
|
|
5100
|
+
// window short so a later, unrelated screen can never be mistaken for it.
|
|
5101
|
+
const EFFORT_CONFIRM_WINDOW_MS = 8_000;
|
|
5102
|
+
function disarmEffortConfirm() {
|
|
5103
|
+
if (effortConfirmTimer) {
|
|
5104
|
+
clearTimeout(effortConfirmTimer);
|
|
5105
|
+
effortConfirmTimer = null;
|
|
5106
|
+
}
|
|
5107
|
+
effortConfirmGuard.disarm();
|
|
5108
|
+
}
|
|
5109
|
+
/** Arm the effort-confirm guard for a bounded window after a `/effort <level>`
|
|
5110
|
+
* passthrough. Only claude-code sessions we drive show this dialog. */
|
|
5111
|
+
function armEffortConfirm() {
|
|
5112
|
+
if (lastInitConfig?.cliId !== 'claude-code' || lastInitConfig.adoptMode)
|
|
5113
|
+
return;
|
|
5114
|
+
disarmEffortConfirm();
|
|
5115
|
+
effortConfirmGuard.arm();
|
|
5116
|
+
effortConfirmTimer = setTimeout(() => {
|
|
5117
|
+
effortConfirmTimer = null;
|
|
5118
|
+
effortConfirmGuard.disarm();
|
|
5119
|
+
}, EFFORT_CONFIRM_WINDOW_MS);
|
|
5120
|
+
}
|
|
4826
5121
|
/**
|
|
4827
5122
|
* Aiden refuses the Codex `-c check_for_update_on_startup=false` override.
|
|
4828
5123
|
* If that wrapper still exposes the startup update picker, move from its
|
|
@@ -4855,6 +5150,33 @@ function dismissAidenCodexUpdateDialog(data) {
|
|
|
4855
5150
|
}
|
|
4856
5151
|
return true;
|
|
4857
5152
|
}
|
|
5153
|
+
/**
|
|
5154
|
+
* Handle startup-only interactions discovered in visible terminal text.
|
|
5155
|
+
* Both incremental PTY chunks and authoritative observer snapshots pass
|
|
5156
|
+
* through here so reconnecting a live-only backend cannot strand the CLI on a
|
|
5157
|
+
* dialog that appeared while its observer was offline.
|
|
5158
|
+
*/
|
|
5159
|
+
function handleVisibleStartupInteraction(data) {
|
|
5160
|
+
// Aiden strips the Codex config override because the launcher rejects it.
|
|
5161
|
+
// Consume only the known startup update picker and choose its non-upgrade
|
|
5162
|
+
// row; never let its selection marker reach the first-prompt idle detector.
|
|
5163
|
+
if (dismissAidenCodexUpdateDialog(data))
|
|
5164
|
+
return true;
|
|
5165
|
+
if (trustHandled)
|
|
5166
|
+
return false;
|
|
5167
|
+
const stripped = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
5168
|
+
if (!TRUST_DIALOG_PATTERN.test(stripped))
|
|
5169
|
+
return false;
|
|
5170
|
+
trustHandled = true;
|
|
5171
|
+
log('Trust dialog detected, auto-accepting...');
|
|
5172
|
+
if (backend && 'sendSpecialKeys' in backend) {
|
|
5173
|
+
backend.sendSpecialKeys('Enter');
|
|
5174
|
+
}
|
|
5175
|
+
else {
|
|
5176
|
+
backend?.write('\r');
|
|
5177
|
+
}
|
|
5178
|
+
return true;
|
|
5179
|
+
}
|
|
4858
5180
|
// Codex App runner sends botmux control messages as OSC sequences so they do
|
|
4859
5181
|
// not pollute the visible terminal. Strip them before xterm rendering and
|
|
4860
5182
|
// translate them back into worker IPC.
|
|
@@ -4935,10 +5257,18 @@ function handleCodexAppMarker(body) {
|
|
|
4935
5257
|
+ `to botmux turn ${shortCorrelationId(identity.turnId)}`);
|
|
4936
5258
|
}
|
|
4937
5259
|
const dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
4938
|
-
if (startedAtMs !== undefined
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
5260
|
+
if (startedAtMs !== undefined) {
|
|
5261
|
+
const markers = readSendMarkers();
|
|
5262
|
+
const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: marker.content };
|
|
5263
|
+
if (shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false)) {
|
|
5264
|
+
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
5265
|
+
// Symmetric with the legacy final branch: tell observers (e.g. the
|
|
5266
|
+
// message-listener run-preview lifecycle) that this turn's reply was
|
|
5267
|
+
// the model's explicit botmux send, so it stops showing "running".
|
|
5268
|
+
notifyExplicitReplyObserved(identity.turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
|
|
5269
|
+
emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
|
|
5270
|
+
return;
|
|
5271
|
+
}
|
|
4942
5272
|
}
|
|
4943
5273
|
send({
|
|
4944
5274
|
type: 'final_output',
|
|
@@ -4946,6 +5276,7 @@ function handleCodexAppMarker(body) {
|
|
|
4946
5276
|
lastUuid: identity.lastUuid,
|
|
4947
5277
|
turnId: identity.turnId,
|
|
4948
5278
|
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
5279
|
+
...(marker.usage ? { usage: marker.usage } : {}),
|
|
4949
5280
|
});
|
|
4950
5281
|
emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
|
|
4951
5282
|
return;
|
|
@@ -4974,9 +5305,12 @@ function handleCodexAppMarker(body) {
|
|
|
4974
5305
|
// equality for compatibility, but can never select another attempt.
|
|
4975
5306
|
const dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
4976
5307
|
if (startedAtMs !== undefined) {
|
|
4977
|
-
const
|
|
5308
|
+
const markers = readSendMarkers();
|
|
5309
|
+
const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: payload.content };
|
|
5310
|
+
const sentByModel = shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false);
|
|
4978
5311
|
if (sentByModel) {
|
|
4979
5312
|
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
5313
|
+
notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
|
|
4980
5314
|
emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
|
|
4981
5315
|
return;
|
|
4982
5316
|
}
|
|
@@ -5045,10 +5379,210 @@ function maybeReportDeferredTopicMaterialization(data) {
|
|
|
5045
5379
|
}
|
|
5046
5380
|
}
|
|
5047
5381
|
// ─── Prompt Detection ────────────────────────────────────────────────────────
|
|
5382
|
+
function settleBackendScreenBeforeIdle(target, requestedRevision) {
|
|
5383
|
+
if (!target.settleCurrentScreen)
|
|
5384
|
+
return Promise.resolve({ proceed: true, degraded: false });
|
|
5385
|
+
const existing = idleScreenSettleTask?.backend === target ? idleScreenSettleTask : null;
|
|
5386
|
+
// A settle round is reusable only for the same (or a newer) authoritative
|
|
5387
|
+
// screen revision. If another idle edge arrives after more output while the
|
|
5388
|
+
// old barrier is still running, chain one fresh round behind it; otherwise
|
|
5389
|
+
// the later edge could finalize from a sample that began before its output.
|
|
5390
|
+
if (existing && existing.revision >= requestedRevision)
|
|
5391
|
+
return existing.promise;
|
|
5392
|
+
const runFreshSettle = async () => {
|
|
5393
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
5394
|
+
if (backend !== target)
|
|
5395
|
+
return { proceed: false, degraded: false };
|
|
5396
|
+
try {
|
|
5397
|
+
if (await target.settleCurrentScreen())
|
|
5398
|
+
return { proceed: true, degraded: false };
|
|
5399
|
+
}
|
|
5400
|
+
catch (err) {
|
|
5401
|
+
log(`Screen settle attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5402
|
+
}
|
|
5403
|
+
if (attempt < 2)
|
|
5404
|
+
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
|
|
5405
|
+
}
|
|
5406
|
+
// History is an availability enhancement around an already-successful
|
|
5407
|
+
// screen cache. Never leave a completed turn stuck forever when the ZMX
|
|
5408
|
+
// daemon is busy; use the last authoritative snapshot after bounded retry.
|
|
5409
|
+
return { proceed: true, degraded: true };
|
|
5410
|
+
};
|
|
5411
|
+
const promise = (async () => {
|
|
5412
|
+
if (existing)
|
|
5413
|
+
await existing.promise;
|
|
5414
|
+
if (backend !== target)
|
|
5415
|
+
return { proceed: false, degraded: false };
|
|
5416
|
+
return runFreshSettle();
|
|
5417
|
+
})();
|
|
5418
|
+
idleScreenSettleTask = { backend: target, revision: requestedRevision, promise };
|
|
5419
|
+
void promise.finally(() => {
|
|
5420
|
+
if (idleScreenSettleTask?.promise === promise)
|
|
5421
|
+
idleScreenSettleTask = null;
|
|
5422
|
+
});
|
|
5423
|
+
return promise;
|
|
5424
|
+
}
|
|
5425
|
+
/** Submission writes must surface ZMX's explicit false result, while its
|
|
5426
|
+
* best-effort navigation/startup keystrokes keep their non-throwing contract. */
|
|
5427
|
+
function adapterInputHandle(target) {
|
|
5428
|
+
return target instanceof ZmxBackend
|
|
5429
|
+
? strictInputHandle(target)
|
|
5430
|
+
: target;
|
|
5431
|
+
}
|
|
5432
|
+
let ambiguousSubmissionWriteTail = Promise.resolve();
|
|
5433
|
+
/** A logical input that was definitely not written because this exact backend
|
|
5434
|
+
* already carried older composer-recovery debt. Keep it queued, but freeze all
|
|
5435
|
+
* further flushes until a fresh backend generation proves the composer clean. */
|
|
5436
|
+
let ambiguousSubmissionRecoveryHold = null;
|
|
5437
|
+
/** Queue an external control action behind any in-flight ZMX text transaction
|
|
5438
|
+
* without opening a new composer journal. This keeps card quick-actions from
|
|
5439
|
+
* landing between adapter text chunks and their submit key. */
|
|
5440
|
+
async function runAfterAmbiguousSubmissionWrites(target, action) {
|
|
5441
|
+
if (!target.captureAmbiguousSubmissionFence)
|
|
5442
|
+
return await action();
|
|
5443
|
+
const previous = ambiguousSubmissionWriteTail;
|
|
5444
|
+
let release;
|
|
5445
|
+
ambiguousSubmissionWriteTail = new Promise(resolve => { release = resolve; });
|
|
5446
|
+
await previous;
|
|
5447
|
+
try {
|
|
5448
|
+
if (backend !== target) {
|
|
5449
|
+
throw new Error('backend changed before queued terminal action');
|
|
5450
|
+
}
|
|
5451
|
+
return await action();
|
|
5452
|
+
}
|
|
5453
|
+
finally {
|
|
5454
|
+
release();
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
/**
|
|
5458
|
+
* Serialize every logical submission for backends that expose the ambiguity
|
|
5459
|
+
* fence contract. ZMX permits only one durable pending composer transaction at
|
|
5460
|
+
* a time; this lock covers structured adapter writes, adopt writes and raw
|
|
5461
|
+
* commands alike while leaving every other backend's concurrency unchanged.
|
|
5462
|
+
*/
|
|
5463
|
+
async function runAmbiguousSubmissionTransaction(target, write, submissionAccepted = () => true, beforeWrite) {
|
|
5464
|
+
if (!target.captureAmbiguousSubmissionFence) {
|
|
5465
|
+
await beforeWrite?.();
|
|
5466
|
+
return { result: await write() };
|
|
5467
|
+
}
|
|
5468
|
+
const previous = ambiguousSubmissionWriteTail;
|
|
5469
|
+
let release;
|
|
5470
|
+
ambiguousSubmissionWriteTail = new Promise(resolve => { release = resolve; });
|
|
5471
|
+
await previous;
|
|
5472
|
+
let submissionFence;
|
|
5473
|
+
let submissionStarted = false;
|
|
5474
|
+
try {
|
|
5475
|
+
if (backend !== target) {
|
|
5476
|
+
throw new Error('backend changed before logical submission');
|
|
5477
|
+
}
|
|
5478
|
+
submissionFence = captureAmbiguousSubmissionFence(target);
|
|
5479
|
+
await beforeWrite?.();
|
|
5480
|
+
submissionStarted = true;
|
|
5481
|
+
const result = await write();
|
|
5482
|
+
const accepted = await submissionAccepted(result);
|
|
5483
|
+
const recoveryFailureReason = accepted
|
|
5484
|
+
? confirmAmbiguousSubmissionAfterSuccess(target, submissionFence)
|
|
5485
|
+
: cancelAmbiguousSubmissionAfterFailure(target, submissionFence);
|
|
5486
|
+
return { result, recoveryFailureReason };
|
|
5487
|
+
}
|
|
5488
|
+
catch (err) {
|
|
5489
|
+
const recoveryFailureReason = err instanceof AmbiguousSubmissionBlockedError
|
|
5490
|
+
? ambiguousSubmissionRecoveryMessage(err.failure)
|
|
5491
|
+
: cancelAmbiguousSubmissionAfterFailure(target, submissionFence);
|
|
5492
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
5493
|
+
throw new SubmissionWriteError(recoveryFailureReason ? `${detail}; ${recoveryFailureReason}` : detail, recoveryFailureReason, submissionStarted);
|
|
5494
|
+
}
|
|
5495
|
+
finally {
|
|
5496
|
+
release();
|
|
5497
|
+
}
|
|
5498
|
+
}
|
|
5499
|
+
/**
|
|
5500
|
+
* A false adapter result can mean only that its short in-band transcript check
|
|
5501
|
+
* expired; slow startup hooks may append the authoritative record seconds
|
|
5502
|
+
* later. While the ZMX submission mutex and pending journal are still held,
|
|
5503
|
+
* give that recheck the same bounded settle window used by the legacy warning
|
|
5504
|
+
* path. Only a confirmed transcript clears the WAL; a still-missing record is
|
|
5505
|
+
* cancelled/poisoned before any later input can be written.
|
|
5506
|
+
*/
|
|
5507
|
+
async function settleVerifiableSubmissionForJournal(result) {
|
|
5508
|
+
if (result?.submitted !== false)
|
|
5509
|
+
return true;
|
|
5510
|
+
if (result.failureReason || !result.recheck)
|
|
5511
|
+
return false;
|
|
5512
|
+
await new Promise(resolve => setTimeout(resolve, SUBMIT_DEFERRED_RECHECK_MS));
|
|
5513
|
+
try {
|
|
5514
|
+
const recheck = await result.recheck();
|
|
5515
|
+
const submitted = typeof recheck === 'boolean'
|
|
5516
|
+
? recheck
|
|
5517
|
+
: recheck.submitted === true;
|
|
5518
|
+
if (!submitted)
|
|
5519
|
+
return false;
|
|
5520
|
+
result.submitted = true;
|
|
5521
|
+
if (typeof recheck === 'object'
|
|
5522
|
+
&& recheck
|
|
5523
|
+
&& typeof recheck.cliSessionId === 'string') {
|
|
5524
|
+
result.cliSessionId = recheck.cliSessionId;
|
|
5525
|
+
}
|
|
5526
|
+
return true;
|
|
5527
|
+
}
|
|
5528
|
+
catch (err) {
|
|
5529
|
+
log(`Deferred submission journal recheck failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5530
|
+
return false;
|
|
5531
|
+
}
|
|
5532
|
+
}
|
|
5533
|
+
function captureAmbiguousSubmissionFence(target) {
|
|
5534
|
+
try {
|
|
5535
|
+
return target.captureAmbiguousSubmissionFence?.();
|
|
5536
|
+
}
|
|
5537
|
+
catch (err) {
|
|
5538
|
+
log(`Unable to capture ambiguous-submission fence: ${err instanceof Error ? err.message : String(err)}`);
|
|
5539
|
+
throw err;
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
function ambiguousSubmissionRecoveryMessage(failure) {
|
|
5543
|
+
const key = {
|
|
5544
|
+
'recovery-pending': 'worker.zmx_recovery_pending',
|
|
5545
|
+
'recovery-unconfirmed': 'worker.zmx_recovery_unconfirmed',
|
|
5546
|
+
};
|
|
5547
|
+
return t(key[failure]);
|
|
5548
|
+
}
|
|
5549
|
+
function confirmAmbiguousSubmissionAfterSuccess(target, fence) {
|
|
5550
|
+
if (!target || fence === undefined)
|
|
5551
|
+
return undefined;
|
|
5552
|
+
if (backend !== target) {
|
|
5553
|
+
return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
|
|
5554
|
+
}
|
|
5555
|
+
try {
|
|
5556
|
+
const failure = target.confirmAmbiguousSubmission?.(fence);
|
|
5557
|
+
return failure ? ambiguousSubmissionRecoveryMessage(failure) : undefined;
|
|
5558
|
+
}
|
|
5559
|
+
catch (err) {
|
|
5560
|
+
log(`Unable to confirm ambiguous submission: ${err instanceof Error ? err.message : String(err)}`);
|
|
5561
|
+
return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
function cancelAmbiguousSubmissionAfterFailure(target, fence) {
|
|
5565
|
+
if (!target || fence === undefined)
|
|
5566
|
+
return undefined;
|
|
5567
|
+
if (backend !== target) {
|
|
5568
|
+
return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
|
|
5569
|
+
}
|
|
5570
|
+
try {
|
|
5571
|
+
const failure = target.cancelAmbiguousSubmission?.(fence);
|
|
5572
|
+
if (!failure)
|
|
5573
|
+
return undefined;
|
|
5574
|
+
return ambiguousSubmissionRecoveryMessage(failure);
|
|
5575
|
+
}
|
|
5576
|
+
catch (err) {
|
|
5577
|
+
log(`Unable to cancel ambiguous submission: ${err instanceof Error ? err.message : String(err)}`);
|
|
5578
|
+
return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
|
|
5579
|
+
}
|
|
5580
|
+
}
|
|
5048
5581
|
function onPtyData(data) {
|
|
5049
5582
|
data = splitCodexAppControl(data);
|
|
5050
5583
|
if (data.length === 0)
|
|
5051
5584
|
return;
|
|
5585
|
+
backendScreenRevision += 1;
|
|
5052
5586
|
lastPtyActivityAtMs = Date.now();
|
|
5053
5587
|
maybeReportDeferredTopicMaterialization(data);
|
|
5054
5588
|
maybeCaptureKiroSessionId(data);
|
|
@@ -5094,38 +5628,125 @@ function onPtyData(data) {
|
|
|
5094
5628
|
ws.send(data);
|
|
5095
5629
|
}
|
|
5096
5630
|
}
|
|
5097
|
-
|
|
5098
|
-
// Consume only the known startup update picker and choose its non-upgrade
|
|
5099
|
-
// row; never let its selection marker reach the first-prompt idle detector.
|
|
5100
|
-
if (dismissAidenCodexUpdateDialog(data))
|
|
5631
|
+
if (handleVisibleStartupInteraction(data))
|
|
5101
5632
|
return;
|
|
5102
|
-
//
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
}
|
|
5114
|
-
return;
|
|
5633
|
+
// Effort-switch confirm auto-accept. Armed only by our own `/effort <level>`
|
|
5634
|
+
// passthrough, so this never fires on an ordinary screen. The dialog's
|
|
5635
|
+
// default row is "Yes", so Enter confirms — mirroring the trust dialog.
|
|
5636
|
+
if (effortConfirmGuard.isArmed() && effortConfirmGuard.inspect(data) === 'confirm') {
|
|
5637
|
+
disarmEffortConfirm();
|
|
5638
|
+
log('Effort-switch confirm dialog detected, auto-accepting...');
|
|
5639
|
+
if (backend && 'sendSpecialKeys' in backend) {
|
|
5640
|
+
backend.sendSpecialKeys('Enter');
|
|
5641
|
+
}
|
|
5642
|
+
else {
|
|
5643
|
+
backend?.write('\r');
|
|
5115
5644
|
}
|
|
5645
|
+
return;
|
|
5116
5646
|
}
|
|
5117
5647
|
// Track last PTY output time for the ready-gate quiescence settle (see
|
|
5118
5648
|
// settleThenFlush) and delegate idle detection to IdleDetector.
|
|
5119
5649
|
lastPtyOutputAtMs = Date.now();
|
|
5120
5650
|
idleDetector?.feed(data);
|
|
5121
5651
|
}
|
|
5652
|
+
/**
|
|
5653
|
+
* Rebase state derived from a live-only observer after it reconnects. This is
|
|
5654
|
+
* deliberately separate from onPtyData: `snapshot` is authoritative full
|
|
5655
|
+
* state, not an incremental chunk, so appending it would duplicate renderer
|
|
5656
|
+
* and workflow history while still failing to express a reset.
|
|
5657
|
+
*/
|
|
5658
|
+
async function onBackendScreenResync(snapshot) {
|
|
5659
|
+
const revision = ++backendScreenRevision;
|
|
5660
|
+
const observedScreenBackend = backend;
|
|
5661
|
+
const now = Date.now();
|
|
5662
|
+
lastPtyActivityAtMs = now;
|
|
5663
|
+
lastPtyOutputAtMs = now;
|
|
5664
|
+
maybeReportDeferredTopicMaterialization(snapshot);
|
|
5665
|
+
maybeCaptureKiroSessionId(snapshot);
|
|
5666
|
+
// Rebase synchronously before xterm's asynchronous write barrier. If newer
|
|
5667
|
+
// PTY bytes arrive while the snapshot is rendering they append after this
|
|
5668
|
+
// authoritative base rather than being overwritten by a late continuation.
|
|
5669
|
+
idleDetector?.reset();
|
|
5670
|
+
if (isWorkflowWorker() && !workflowFinalOutputSent) {
|
|
5671
|
+
// The append-only PTY replay log has no reset opcode. Appending a full
|
|
5672
|
+
// history snapshot would duplicate everything already recorded; keep its
|
|
5673
|
+
// live-byte semantics and rebase only the bounded final-output transcript.
|
|
5674
|
+
workflowTranscript = snapshot.slice(-WORKFLOW_TRANSCRIPT_MAX);
|
|
5675
|
+
maybeEmitWorkflowTranscriptOutput();
|
|
5676
|
+
}
|
|
5677
|
+
let nextRenderer = null;
|
|
5678
|
+
if (renderer) {
|
|
5679
|
+
const previousRenderer = renderer;
|
|
5680
|
+
nextRenderer = new TerminalRenderer(renderCols, renderRows);
|
|
5681
|
+
renderer = nextRenderer;
|
|
5682
|
+
previousRenderer.dispose();
|
|
5683
|
+
await nextRenderer.writeAndFlush(snapshot);
|
|
5684
|
+
// A second resync, incremental output, backend replacement, or teardown can
|
|
5685
|
+
// win while xterm parses the history. Never inspect or feed the stale
|
|
5686
|
+
// continuation after any such generation change.
|
|
5687
|
+
if (backendScreenRevision !== revision
|
|
5688
|
+
|| backend !== observedScreenBackend
|
|
5689
|
+
|| renderer !== nextRenderer)
|
|
5690
|
+
return;
|
|
5691
|
+
}
|
|
5692
|
+
// ZMX history includes scrollback. Keep a bounded ANSI-rendered projection
|
|
5693
|
+
// for cards and non-mutating diagnostics, never as proof of the authoritative
|
|
5694
|
+
// current viewport: a prior local attach may have changed the real geometry.
|
|
5695
|
+
const visibleSnapshot = nextRenderer?.rawSnapshot() ?? '';
|
|
5696
|
+
lastAnalyzerSnapshot = visibleSnapshot;
|
|
5697
|
+
// ZMX history does not carry the authoritative current PTY dimensions. A
|
|
5698
|
+
// local `zmx attach` can resize the session below our default 120x24 and that
|
|
5699
|
+
// size persists after detach, so even the rendered tail may include rows just
|
|
5700
|
+
// above the real viewport. Never synthesize Enter/Down from a full-history
|
|
5701
|
+
// resync. For the same reason, do not feed history into IdleDetector: an old
|
|
5702
|
+
// ready/completion marker just above the real viewport could otherwise flush
|
|
5703
|
+
// queued input into a CLI that is still busy. Later append-only history deltas
|
|
5704
|
+
// still flow through onPtyData; structured transcript completion remains
|
|
5705
|
+
// authoritative independently of this screen observer.
|
|
5706
|
+
}
|
|
5707
|
+
/** Fire-and-forget bridge for backend callbacks and synchronous seed callers.
|
|
5708
|
+
* Their surrounding try/catch cannot observe a rejected async renderer write,
|
|
5709
|
+
* so terminate every resync promise here instead of leaking an unhandled
|
|
5710
|
+
* rejection into the worker process.
|
|
5711
|
+
*/
|
|
5712
|
+
function scheduleBackendScreenResync(snapshot, source) {
|
|
5713
|
+
void onBackendScreenResync(snapshot).catch((err) => {
|
|
5714
|
+
logError(`${source} screen resync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5715
|
+
});
|
|
5716
|
+
}
|
|
5122
5717
|
function releaseRawInputRestartGate() {
|
|
5123
5718
|
if (!rawInputRestartGate)
|
|
5124
5719
|
return;
|
|
5125
5720
|
rawInputRestartGate = false;
|
|
5126
5721
|
log('Replacement CLI prompt ready — releasing deferred passthrough commands');
|
|
5127
5722
|
}
|
|
5128
|
-
function
|
|
5723
|
+
function readPaneLeafComm(observedBackend = backend) {
|
|
5724
|
+
const pid = observedBackend?.getChildPid?.();
|
|
5725
|
+
return pid ? readComm(pid) : undefined;
|
|
5726
|
+
}
|
|
5727
|
+
/** A slow rcfile can outlive the launch detector's settle window, then finish
|
|
5728
|
+
* normally. Screen readiness alone is not enough to reopen the gate because a
|
|
5729
|
+
* customized shell prompt can resemble a CLI prompt. Require both the PTY
|
|
5730
|
+
* readiness signal and a non-shell pane leaf before releasing queued input. */
|
|
5731
|
+
function recoverBareShellLaunchFromPty(observedBackend) {
|
|
5732
|
+
if (!bareShellLaunchBlocked)
|
|
5733
|
+
return true;
|
|
5734
|
+
if (backend !== observedBackend) {
|
|
5735
|
+
log('Ignoring PTY prompt-ready from a replaced backend generation');
|
|
5736
|
+
return false;
|
|
5737
|
+
}
|
|
5738
|
+
const comm = readPaneLeafComm(observedBackend);
|
|
5739
|
+
if (!comm || isBareShellComm(comm)) {
|
|
5740
|
+
log(`Ignoring PTY prompt-ready while launch pane is still a bare shell (comm=${comm ?? '?'})`);
|
|
5741
|
+
return false;
|
|
5742
|
+
}
|
|
5743
|
+
bareShellLaunchBlocked = false;
|
|
5744
|
+
log(`Late CLI launch recovered: pane leaf comm=${comm}; releasing queued input`);
|
|
5745
|
+
return true;
|
|
5746
|
+
}
|
|
5747
|
+
function markPromptReadyFromPty(observedBackend) {
|
|
5748
|
+
if (!recoverBareShellLaunchFromPty(observedBackend))
|
|
5749
|
+
return;
|
|
5129
5750
|
postSessionStartPromptEvidenceInFlight = true;
|
|
5130
5751
|
try {
|
|
5131
5752
|
markPromptReady();
|
|
@@ -5134,7 +5755,40 @@ function markPromptReadyFromPty() {
|
|
|
5134
5755
|
postSessionStartPromptEvidenceInFlight = false;
|
|
5135
5756
|
}
|
|
5136
5757
|
}
|
|
5758
|
+
/**
|
|
5759
|
+
* Push a coarse screen status to the daemon without waiting for the 2s sampler.
|
|
5760
|
+
* Used so card-off reactions see working→idle even on short turns.
|
|
5761
|
+
*
|
|
5762
|
+
* @param force - When true, send `status` as-is and skip classifyScreenUsageLimit.
|
|
5763
|
+
* Synthetic "working" seeds for card-off reactions MUST force: if the first
|
|
5764
|
+
* screen already shows a rate-limit banner, classify would rewrite working→
|
|
5765
|
+
* limited and the seed collapses to limited→limited (no working edge →
|
|
5766
|
+
* finishTurnReactions never runs). Review: PR #633 third round.
|
|
5767
|
+
*/
|
|
5768
|
+
function publishScreenStatus(status, opts) {
|
|
5769
|
+
if (!renderer)
|
|
5770
|
+
return;
|
|
5771
|
+
const { content } = renderer.snapshot();
|
|
5772
|
+
const statusPayload = opts?.force
|
|
5773
|
+
? { status }
|
|
5774
|
+
: classifyScreenUsageLimit(content, status);
|
|
5775
|
+
send({
|
|
5776
|
+
type: 'screen_update',
|
|
5777
|
+
content,
|
|
5778
|
+
...statusPayload,
|
|
5779
|
+
turnId: currentBotmuxTurnId,
|
|
5780
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
5781
|
+
});
|
|
5782
|
+
}
|
|
5137
5783
|
function markPromptReady() {
|
|
5784
|
+
// Only screen readiness plus a non-shell pane leaf may release a launch that
|
|
5785
|
+
// was previously blocked. Transcript/task callbacks can arrive while the
|
|
5786
|
+
// wrapper is still a shell; accepting one here would set isPromptReady and
|
|
5787
|
+
// make the later real PTY callback return early, stranding queued input.
|
|
5788
|
+
if (bareShellLaunchBlocked) {
|
|
5789
|
+
log('Ignoring non-PTY prompt-ready while bare-shell launch block is active');
|
|
5790
|
+
return;
|
|
5791
|
+
}
|
|
5138
5792
|
if (isPromptReady)
|
|
5139
5793
|
return; // guard against duplicate calls
|
|
5140
5794
|
if (cliRestartInProgress && !replacementSpawnInProgress) {
|
|
@@ -5236,14 +5890,50 @@ function markPromptReady() {
|
|
|
5236
5890
|
log('prompt-ready with no backend installed — deferring restart success receipt');
|
|
5237
5891
|
}
|
|
5238
5892
|
}
|
|
5239
|
-
// Send immediate
|
|
5240
|
-
//
|
|
5241
|
-
//
|
|
5242
|
-
//
|
|
5243
|
-
//
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5893
|
+
// Send an immediate status snapshot so Lark card / card-off reactions track
|
|
5894
|
+
// real work. Skip pure idle when:
|
|
5895
|
+
// - messages are pending — flushPending() will immediately make the CLI
|
|
5896
|
+
// busy (avoids a false "就绪" flash on daemon restart);
|
|
5897
|
+
// - Grok-class argv+SessionStart arming: first ready is pre-execution, so
|
|
5898
|
+
// park as working until assistant_final/fireIdle;
|
|
5899
|
+
// - quiescence argv CLIs (Pi/Gemini/MTR/OpenCode): first ready IS turn end
|
|
5900
|
+
// — seed working then idle so card-off gets working→idle (review P2).
|
|
5901
|
+
const hasPendingWork = pendingMessages.length > 0
|
|
5902
|
+
|| pendingRawInputs.length > 0
|
|
5903
|
+
|| pendingSessionRename !== null
|
|
5904
|
+
|| isFlushing;
|
|
5905
|
+
if (renderer && !hasPendingWork) {
|
|
5906
|
+
if (spawnArgvInitialPromptBusy) {
|
|
5907
|
+
spawnArgvInitialPromptBusy = false;
|
|
5908
|
+
spawnArgvNeedsWorkingSeed = false;
|
|
5909
|
+
// Stay non-ready so the next genuine end-of-turn idle is a real edge.
|
|
5910
|
+
isPromptReady = false;
|
|
5911
|
+
idleDetector?.reset();
|
|
5912
|
+
// force: rate-limit banner must not rewrite synthetic working → limited
|
|
5913
|
+
publishScreenStatus('working', { force: true });
|
|
5914
|
+
log('Spawn argv initial prompt still in flight — reporting working (not idle) so turn reactions can settle later');
|
|
5915
|
+
}
|
|
5916
|
+
else if (spawnArgvNeedsWorkingSeed) {
|
|
5917
|
+
// First ready = true completion for quiescence argv adapters. Seed a
|
|
5918
|
+
// working edge before idle/limited so daemon finishTurnReactions (gated
|
|
5919
|
+
// on working→idle|limited) still flips card-off GoGoGo on cold-start
|
|
5920
|
+
// one-shots — including when the terminal already shows a rate-limit
|
|
5921
|
+
// banner (classify would otherwise collapse both seeds to limited).
|
|
5922
|
+
spawnArgvNeedsWorkingSeed = false;
|
|
5923
|
+
publishScreenStatus('working', { force: true });
|
|
5924
|
+
// Second tick may classify to limited when the banner is visible — that
|
|
5925
|
+
// is fine: gate allows working→limited.
|
|
5926
|
+
publishScreenStatus('idle');
|
|
5927
|
+
log('Argv-baked first prompt completed — seeded working→idle for card-off reactions');
|
|
5928
|
+
}
|
|
5929
|
+
else {
|
|
5930
|
+
publishScreenStatus('idle');
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
else if (hasPendingWork) {
|
|
5934
|
+
// Queued path will flip busy via flushPending; drop argv seed flags.
|
|
5935
|
+
spawnArgvInitialPromptBusy = false;
|
|
5936
|
+
spawnArgvNeedsWorkingSeed = false;
|
|
5247
5937
|
}
|
|
5248
5938
|
// barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
|
|
5249
5939
|
// 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
|
|
@@ -5333,6 +6023,21 @@ function observeCursorCliSessionId(pid, label = 'spawn') {
|
|
|
5333
6023
|
* can defer Claude's jsonl append by 5–15s; a 20s deferred recheck covers
|
|
5334
6024
|
* both without being so long that a true failure goes unsurfaced. */
|
|
5335
6025
|
const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
|
|
6026
|
+
/**
|
|
6027
|
+
* A recovery fence failure means the prompt may already be running. Keep its
|
|
6028
|
+
* bridge mark and durable turn open so a real later final can still resolve it;
|
|
6029
|
+
* only freeze further input and tell the user how to recover. Treating this as
|
|
6030
|
+
* a hard non-submit would drop attribution and could replay a live turn.
|
|
6031
|
+
*/
|
|
6032
|
+
function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
|
|
6033
|
+
log(`Submission recovery remains ambiguous: ${recoveryReason}`);
|
|
6034
|
+
send({
|
|
6035
|
+
type: 'user_notify',
|
|
6036
|
+
turnId: turnIdentity?.turnId ?? currentBotmuxTurnId,
|
|
6037
|
+
dispatchAttempt: turnIdentity?.dispatchAttempt ?? currentBotmuxDispatchAttempt,
|
|
6038
|
+
message: recoveryReason,
|
|
6039
|
+
});
|
|
6040
|
+
}
|
|
5336
6041
|
/** Worker-side handler for `submitted: false`. Defers the user-facing
|
|
5337
6042
|
* warning and runs the adapter-supplied `recheck` closure first; if the
|
|
5338
6043
|
* message has shown up in the transcript by then (slow path, hook delay),
|
|
@@ -5345,11 +6050,11 @@ const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
|
|
|
5345
6050
|
* attempt's mark — leaving it would keep `maybeSwitchBridgeJsonl` doing
|
|
5346
6051
|
* full-directory scans every poll tick for a fingerprint that's permanently
|
|
5347
6052
|
* dead, the 99% CPU bug this whole patch series is fixing. */
|
|
5348
|
-
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity) {
|
|
6053
|
+
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
|
|
5349
6054
|
const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
|
|
5350
|
-
const
|
|
6055
|
+
const emitDurableTerminal = (errorCode) => {
|
|
5351
6056
|
if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
|
|
5352
|
-
emitTurnTerminal(turnIdentity.turnId,
|
|
6057
|
+
emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
|
|
5353
6058
|
}
|
|
5354
6059
|
};
|
|
5355
6060
|
const dropBridgeMark = () => {
|
|
@@ -5371,7 +6076,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5371
6076
|
dropBridgeMark();
|
|
5372
6077
|
const reason = action.kind === 'notify-hard-failure' ? action.reason : failureReason;
|
|
5373
6078
|
log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
|
|
5374
|
-
|
|
6079
|
+
emitDurableTerminal(`submit_impossible:${reason}`);
|
|
5375
6080
|
if (turnIdentity?.dispatchAttempt === undefined) {
|
|
5376
6081
|
send({
|
|
5377
6082
|
type: 'user_notify',
|
|
@@ -5418,7 +6123,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5418
6123
|
case 'suppress-usage-limit':
|
|
5419
6124
|
dropBridgeMark();
|
|
5420
6125
|
log(`Deferred recheck missing but usage limit was detected for this turn — suppressing submit warning. preview="${preview}"`);
|
|
5421
|
-
|
|
6126
|
+
emitDurableTerminal('submit_usage_limit');
|
|
5422
6127
|
return;
|
|
5423
6128
|
case 'suppress-active':
|
|
5424
6129
|
log(`Deferred recheck missing but later ${action.evidence} shows ${cliName()} is active — suppressing submit warning. preview="${preview}"`);
|
|
@@ -5426,7 +6131,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5426
6131
|
// a terminal result. Keep the arbiter closed and re-check until either
|
|
5427
6132
|
// the transcript confirms it or a quiet interval proves it stuck.
|
|
5428
6133
|
if (turnIdentity?.dispatchAttempt !== undefined) {
|
|
5429
|
-
scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, undefined, turnSeq, turnIdentity);
|
|
6134
|
+
scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, undefined, turnSeq, turnIdentity, durableTerminalStatus);
|
|
5430
6135
|
}
|
|
5431
6136
|
return;
|
|
5432
6137
|
case 'notify-hard-failure':
|
|
@@ -5437,12 +6142,19 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5437
6142
|
}
|
|
5438
6143
|
dropBridgeMark();
|
|
5439
6144
|
log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
|
|
5440
|
-
|
|
6145
|
+
emitDurableTerminal('submit_unconfirmed');
|
|
5441
6146
|
if (turnIdentity?.dispatchAttempt === undefined) {
|
|
5442
6147
|
send({
|
|
5443
6148
|
type: 'user_notify',
|
|
5444
6149
|
turnId: turnIdentity?.turnId ?? currentBotmuxTurnId,
|
|
5445
|
-
message: t('
|
|
6150
|
+
message: t(effectiveBackendType === 'zmx'
|
|
6151
|
+
? 'worker.submit_unconfirmed_zmx'
|
|
6152
|
+
: 'worker.submit_unconfirmed', {
|
|
6153
|
+
cliName: cliName(),
|
|
6154
|
+
secs: Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000),
|
|
6155
|
+
transcriptLabel,
|
|
6156
|
+
preview,
|
|
6157
|
+
}),
|
|
5446
6158
|
});
|
|
5447
6159
|
}
|
|
5448
6160
|
}, SUBMIT_DEFERRED_RECHECK_MS);
|
|
@@ -5457,15 +6169,15 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5457
6169
|
* runs, and the pane is left at a bare shell. Typing the multi-line prompt into
|
|
5458
6170
|
* it just yields `zsh: parse error near '\n'` and the user is stuck (the exact
|
|
5459
6171
|
* bug this guards). Instead of typing into the shell we surface ONE actionable
|
|
5460
|
-
* diagnostic and
|
|
6172
|
+
* diagnostic and hold the session so no prompt is mis-typed. A same-shell
|
|
6173
|
+
* verdict is provisional: slow rc startup may still exec the CLI later, in
|
|
6174
|
+
* which case current-generation PTY readiness can release the hold safely.
|
|
5461
6175
|
*
|
|
5462
|
-
* Why this is the right moment
|
|
5463
|
-
*
|
|
5464
|
-
* the
|
|
5465
|
-
*
|
|
5466
|
-
*
|
|
5467
|
-
* and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the
|
|
5468
|
-
* CLI itself, never a shell).
|
|
6176
|
+
* Why this is the right moment: it is the last guard before the first write.
|
|
6177
|
+
* The first-prompt timeout plus bounded settle catches ordinary wrapper jitter,
|
|
6178
|
+
* while the reversible hold covers slower rc startup without ever typing into
|
|
6179
|
+
* a shell. We skip wrapperCli/adopt (their leaf is legitimately a launcher or
|
|
6180
|
+
* observed pane); direct backends self-exclude because their child is the CLI.
|
|
5469
6181
|
*
|
|
5470
6182
|
* Returns true when a persistent bare-shell launch was detected (caller must
|
|
5471
6183
|
* NOT flush). A transient wrapper shell gets a bounded chance to exec the CLI.
|
|
@@ -5484,10 +6196,7 @@ async function detectBareShellLaunch() {
|
|
|
5484
6196
|
bareShellCheckInProgress = true;
|
|
5485
6197
|
let comm;
|
|
5486
6198
|
try {
|
|
5487
|
-
comm = await settleLaunchComm(
|
|
5488
|
-
const pid = backend?.getChildPid?.();
|
|
5489
|
-
return pid ? readComm(pid) : undefined;
|
|
5490
|
-
});
|
|
6199
|
+
comm = await settleLaunchComm(readPaneLeafComm);
|
|
5491
6200
|
}
|
|
5492
6201
|
finally {
|
|
5493
6202
|
bareShellCheckInProgress = false;
|
|
@@ -5504,15 +6213,23 @@ async function detectBareShellLaunch() {
|
|
|
5504
6213
|
return false;
|
|
5505
6214
|
if (!isBareShellComm(comm))
|
|
5506
6215
|
return false; // CLI (rust/go/node) is running — healthy launch
|
|
5507
|
-
//
|
|
5508
|
-
//
|
|
5509
|
-
//
|
|
6216
|
+
// The pane leaf is still a shell. A mismatch is the unmistakable signature
|
|
6217
|
+
// of an rcfile `exec`-trampoline; the same shell is ambiguous because a slow
|
|
6218
|
+
// rcfile may still finish and exec the CLI after this bounded check.
|
|
5510
6219
|
const launchShell = (lastInitConfig?.launchShell || process.env.SHELL || '').trim();
|
|
5511
6220
|
const expectedShell = launchShell ? basename(launchShell) : '';
|
|
5512
6221
|
const trampolined = bareShellLaunchKind(comm, expectedShell) === 'trampoline';
|
|
5513
6222
|
bareShellLaunchBlocked = true;
|
|
5514
|
-
|
|
5515
|
-
|
|
6223
|
+
// Injection-first flushes can enter while isPromptReady is still true. Make
|
|
6224
|
+
// this a real busy/blocked transition so a later PTY-ready event re-enters
|
|
6225
|
+
// markPromptReady instead of being swallowed by its duplicate-ready guard.
|
|
6226
|
+
isPromptReady = false;
|
|
6227
|
+
idleDetector?.reset();
|
|
6228
|
+
log(trampolined
|
|
6229
|
+
? `Bare-shell launch detected: pane leaf comm=${comm}, expected launch shell=${expectedShell || '?'}, ` +
|
|
6230
|
+
`cli=${lastInitConfig?.cliId}; suppressing first-prompt write (rc trampoline)`
|
|
6231
|
+
: `CLI launch still pending: pane leaf comm=${comm}, cli=${lastInitConfig?.cliId}; ` +
|
|
6232
|
+
`holding queued input until PTY readiness confirms a non-shell process`);
|
|
5516
6233
|
const cli = cliName();
|
|
5517
6234
|
let message;
|
|
5518
6235
|
if (trampolined) {
|
|
@@ -5525,17 +6242,16 @@ async function detectBareShellLaunch() {
|
|
|
5525
6242
|
}
|
|
5526
6243
|
else {
|
|
5527
6244
|
message =
|
|
5528
|
-
`⚠️
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
}
|
|
5538
|
-
send({ type: 'user_notify', turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt, message });
|
|
6245
|
+
`⚠️ ${cli} 启动时间较长:pane 里暂时仍是 \`${comm}\`。我没有把消息写进 shell,消息还在队列里;检测到真实输入框后会自动继续投递,无需重发。\n\n` +
|
|
6246
|
+
`仅凭进程仍是 \`${comm}\` 无法判断具体原因,可能只是 rc 文件或机器负载让启动变慢。若长时间没有恢复,请打开 Web 终端查看当前提示,处理后等待自动继续,或使用 \`/restart\` 重启会话。`;
|
|
6247
|
+
}
|
|
6248
|
+
const pendingTurn = pendingMessages[0];
|
|
6249
|
+
send({
|
|
6250
|
+
type: 'user_notify',
|
|
6251
|
+
turnId: pendingTurn?.turnId ?? currentBotmuxTurnId,
|
|
6252
|
+
dispatchAttempt: pendingTurn?.dispatchAttempt ?? currentBotmuxDispatchAttempt,
|
|
6253
|
+
message,
|
|
6254
|
+
});
|
|
5539
6255
|
return true;
|
|
5540
6256
|
}
|
|
5541
6257
|
/**
|
|
@@ -5556,6 +6272,14 @@ async function flushPending() {
|
|
|
5556
6272
|
return; // while loop in active flush will pick up new messages
|
|
5557
6273
|
if (!backend || !cliAdapter)
|
|
5558
6274
|
return;
|
|
6275
|
+
if (ambiguousSubmissionRecoveryHold
|
|
6276
|
+
&& ambiguousSubmissionRecoveryHold.backend !== backend) {
|
|
6277
|
+
// A restart installed a fresh backend generation. The held item stayed at
|
|
6278
|
+
// the queue head and may now be delivered under its original attempt.
|
|
6279
|
+
ambiguousSubmissionRecoveryHold = null;
|
|
6280
|
+
}
|
|
6281
|
+
if (ambiguousSubmissionRecoveryHold?.backend === backend)
|
|
6282
|
+
return;
|
|
5559
6283
|
if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
|
|
5560
6284
|
return; // nothing to flush — keep isPromptReady
|
|
5561
6285
|
if (sessionRenameInFlight)
|
|
@@ -5574,7 +6298,7 @@ async function flushPending() {
|
|
|
5574
6298
|
if (shouldDeferUserFlush(pendingInjections))
|
|
5575
6299
|
return;
|
|
5576
6300
|
if (bareShellLaunchBlocked)
|
|
5577
|
-
return; // launch
|
|
6301
|
+
return; // launch is held at a bare shell — don't type prompts into it
|
|
5578
6302
|
// Screen-idle is not a durable receipt. A permission/AskUser prompt can look
|
|
5579
6303
|
// idle while the logical delivery is unresolved, so no following IM or
|
|
5580
6304
|
// meeting turn may cross this boundary until an explicit terminal releases
|
|
@@ -5647,6 +6371,11 @@ async function flushPending() {
|
|
|
5647
6371
|
if (isPromptReady) {
|
|
5648
6372
|
isPromptReady = false;
|
|
5649
6373
|
idleDetector?.reset();
|
|
6374
|
+
// Immediate working for card-off reaction settle (PR #633 P2): the screen
|
|
6375
|
+
// sampler is 2s — without this a short turn can complete before any
|
|
6376
|
+
// working status is observed, leaving idle→idle and GoGoGo uncleared.
|
|
6377
|
+
// force: same rate-limit rewrite trap as argv seed (review third round).
|
|
6378
|
+
publishScreenStatus('working', { force: true });
|
|
5650
6379
|
}
|
|
5651
6380
|
try {
|
|
5652
6381
|
// Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup
|
|
@@ -5699,7 +6428,7 @@ async function flushPending() {
|
|
|
5699
6428
|
pendingSessionRename = null;
|
|
5700
6429
|
sessionRenameInFlight = true;
|
|
5701
6430
|
try {
|
|
5702
|
-
await
|
|
6431
|
+
await sendRawCommandLineWithRecoveryFence(backend, buildRename(title));
|
|
5703
6432
|
armSessionRenameIdleTimeout();
|
|
5704
6433
|
idleDetector?.reset();
|
|
5705
6434
|
log(`Native session rename command sent (${cliName()}): ${title}`);
|
|
@@ -5722,71 +6451,66 @@ async function flushPending() {
|
|
|
5722
6451
|
if (!item)
|
|
5723
6452
|
break;
|
|
5724
6453
|
const durableWrite = item.dispatchAttempt !== undefined;
|
|
5725
|
-
if (durableWrite)
|
|
5726
|
-
durableTurnInFlight = true;
|
|
5727
|
-
// Track as in-flight until the CLI returns to idle (markPromptReady).
|
|
5728
|
-
// If the CLI exits first, onExit stashes these for re-queue on respawn.
|
|
5729
|
-
// EXCEPT in RPC mode: the app-server (not the pane) owns turn execution, so
|
|
5730
|
-
// once turn/start is sent the turn runs regardless of the pane. Re-queuing
|
|
5731
|
-
// it on a pane restart would RE-SEND an already-accepted turn → double
|
|
5732
|
-
// execution (same class as #443's double-submit). So RPC turns are never
|
|
5733
|
-
// auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
|
|
5734
|
-
// resend manually, and the stable clientUserMessageId lets codex dedupe a
|
|
5735
|
-
// resend (Codex delta P1-1).
|
|
5736
|
-
if (!codexRpcEngine) {
|
|
5737
|
-
inflightInputs.onWrite(item);
|
|
5738
|
-
stuckDetector?.arm();
|
|
5739
|
-
}
|
|
5740
6454
|
const msg = item.content;
|
|
5741
6455
|
const logicalMsg = item.logicalContent ?? msg;
|
|
5742
|
-
|
|
5743
|
-
currentBotmuxDispatchAttempt = item.dispatchAttempt;
|
|
5744
|
-
currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
|
|
5745
|
-
writeCliPidMarker();
|
|
5746
|
-
publishSandboxRelayCapability();
|
|
5747
|
-
const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
5748
|
-
// Bridge fallback: mark immediately before writeInput. Doing it here
|
|
5749
|
-
// (instead of at enqueue time) means markTimeMs anchors to the
|
|
5750
|
-
// moment the message actually starts hitting the PTY — so any
|
|
5751
|
-
// `botmux send` whose sentAtMs lands during turn N's processing
|
|
5752
|
-
// falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
|
|
5753
|
-
// (at IPC arrival) would let a slow-finishing turn N's send leak
|
|
5754
|
-
// into turn N+1's window and falsely suppress its emit.
|
|
6456
|
+
let turnSeq = usageLimitTracker.currentTurn();
|
|
5755
6457
|
let bridgeTurnId;
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
6458
|
+
let normalWritePrepared = false;
|
|
6459
|
+
let submissionPreparationFailed = false;
|
|
6460
|
+
const prepareNormalWrite = () => {
|
|
6461
|
+
if (normalWritePrepared)
|
|
6462
|
+
return;
|
|
6463
|
+
normalWritePrepared = true;
|
|
6464
|
+
renderer?.markNewTurn();
|
|
6465
|
+
currentBotmuxTurnId = item.turnId;
|
|
6466
|
+
currentBotmuxDispatchAttempt = item.dispatchAttempt;
|
|
6467
|
+
currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
|
|
6468
|
+
// Acquire durable HOL ownership only after this turn owns the backend
|
|
6469
|
+
// submission mutex. If an older ZMX recovery debt rejects capture,
|
|
6470
|
+
// this input is known not to have started and must not leave a latch
|
|
6471
|
+
// whose attribution still points at the previous turn.
|
|
6472
|
+
if (durableWrite)
|
|
6473
|
+
durableTurnInFlight = true;
|
|
6474
|
+
// Track only after the backend accepts this logical transaction. If an
|
|
6475
|
+
// older ZMX recovery debt rejects capture, this item never became
|
|
6476
|
+
// in-flight and must not participate in restart replay. RPC mode is
|
|
6477
|
+
// excluded: the app-server owns an accepted turn independently of the
|
|
6478
|
+
// viewer pane, so replaying it after a pane restart could run it twice.
|
|
6479
|
+
if (!codexRpcEngine) {
|
|
6480
|
+
inflightInputs.onWrite(item);
|
|
6481
|
+
stuckDetector?.arm();
|
|
5759
6482
|
}
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
//
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
&& cliAdapter.reliableTurnTerminal === true
|
|
5772
|
-
&& lastInitConfig?.cliId === 'claude-code'
|
|
5773
|
-
&& (!item.turnId || !bridgeTurnId)) {
|
|
5774
|
-
// The advertised Claude contract is transcript-backed. If we cannot
|
|
5775
|
-
// establish the mark before touching the PTY, fail without submitting;
|
|
5776
|
-
// otherwise this real delivery would have no attributable final edge.
|
|
5777
|
-
if (item.turnId) {
|
|
5778
|
-
emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
|
|
6483
|
+
writeCliPidMarker();
|
|
6484
|
+
publishSandboxRelayCapability();
|
|
6485
|
+
turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
6486
|
+
// Anchor the bridge baseline only after this turn owns the ZMX
|
|
6487
|
+
// submission lock, immediately before its literal write.
|
|
6488
|
+
if (claudeBridgeActive) {
|
|
6489
|
+
try {
|
|
6490
|
+
bridgeIngest();
|
|
6491
|
+
}
|
|
6492
|
+
catch { /* best-effort */ }
|
|
6493
|
+
bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
|
|
5779
6494
|
}
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
6495
|
+
else if (codexBridgeActive) {
|
|
6496
|
+
codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
|
|
6497
|
+
}
|
|
6498
|
+
if (durableWrite
|
|
6499
|
+
&& cliAdapter.reliableTurnTerminal === true
|
|
6500
|
+
&& lastInitConfig?.cliId === 'claude-code'
|
|
6501
|
+
&& (!item.turnId || !bridgeTurnId)) {
|
|
6502
|
+
submissionPreparationFailed = true;
|
|
6503
|
+
log('Refused durable Claude submit: transcript terminal bridge is unavailable');
|
|
6504
|
+
throw new Error('terminal bridge unavailable before submission');
|
|
6505
|
+
}
|
|
6506
|
+
if (lastInitConfig?.cliId === 'codex-app') {
|
|
6507
|
+
log(`Writing Codex App input to PTY (flush): `
|
|
6508
|
+
+ `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
|
|
6509
|
+
}
|
|
6510
|
+
else {
|
|
6511
|
+
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
6512
|
+
}
|
|
6513
|
+
};
|
|
5790
6514
|
// Defense in depth: TmuxPipeBackend's send methods no longer throw on a
|
|
5791
6515
|
// dead pane (they fire onExit instead), but writeInput can still throw
|
|
5792
6516
|
// for other reasons (fs errors while resolving the JSONL, a future
|
|
@@ -5794,6 +6518,8 @@ async function flushPending() {
|
|
|
5794
6518
|
// escaping rejection would become an unhandledRejection and crash the
|
|
5795
6519
|
// worker — exactly the failure mode this change is closing. Contain it.
|
|
5796
6520
|
let result;
|
|
6521
|
+
let submissionBackend = null;
|
|
6522
|
+
let recoveryFailureReason;
|
|
5797
6523
|
try {
|
|
5798
6524
|
if (codexRpcEngine) {
|
|
5799
6525
|
// RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
|
|
@@ -5801,20 +6527,73 @@ async function flushPending() {
|
|
|
5801
6527
|
// renders. No tmux paste → the history.jsonl verify/retry/recover
|
|
5802
6528
|
// machinery is bypassed. A throw here falls into the catch below and
|
|
5803
6529
|
// surfaces as a normal submit-failure notice.
|
|
5804
|
-
|
|
6530
|
+
const targetBackend = backend;
|
|
6531
|
+
await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
|
|
6532
|
+
prepareNormalWrite();
|
|
6533
|
+
await codexRpcEngine.sendTurn(msg, item.turnId);
|
|
6534
|
+
});
|
|
5805
6535
|
result = { submitted: true };
|
|
5806
6536
|
}
|
|
5807
6537
|
else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
|
|
5808
|
-
|
|
6538
|
+
const targetBackend = backend;
|
|
6539
|
+
const targetAdapter = cliAdapter;
|
|
6540
|
+
submissionBackend = targetBackend;
|
|
6541
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
6542
|
+
result = transaction.result;
|
|
6543
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
5809
6544
|
}
|
|
5810
6545
|
else {
|
|
5811
|
-
|
|
6546
|
+
const targetBackend = backend;
|
|
6547
|
+
const targetAdapter = cliAdapter;
|
|
6548
|
+
submissionBackend = targetBackend;
|
|
6549
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
6550
|
+
result = transaction.result;
|
|
6551
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
6552
|
+
}
|
|
6553
|
+
if (recoveryFailureReason) {
|
|
6554
|
+
result = { ...result, submitted: false };
|
|
6555
|
+
}
|
|
6556
|
+
// Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
|
|
6557
|
+
// assistant_final → fireIdle. Their busyPattern is often missing for
|
|
6558
|
+
// several seconds after submit (or never matches the current TUI chrome),
|
|
6559
|
+
// so a post-submit "busy marker absent" probe falsely marks prompt ready
|
|
6560
|
+
// and the card-off DONE reaction lands while the turn is still running
|
|
6561
|
+
// (seen live on Grok: GoGoGo → +7s post-submit probe → DONE, then
|
|
6562
|
+
// deferred recheck still saw active PTY output).
|
|
6563
|
+
if (cliAdapter.reliableTurnTerminal !== true) {
|
|
6564
|
+
scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
|
|
5812
6565
|
}
|
|
5813
|
-
scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
|
|
5814
6566
|
}
|
|
5815
6567
|
catch (err) {
|
|
6568
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
6569
|
+
? err.recoveryFailureReason
|
|
6570
|
+
: recoveryFailureReason;
|
|
6571
|
+
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
6572
|
+
&& !!err.recoveryFailureReason
|
|
6573
|
+
&& !err.submissionStarted;
|
|
6574
|
+
if (submissionPreparationFailed) {
|
|
6575
|
+
if (item.turnId) {
|
|
6576
|
+
emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
|
|
6577
|
+
}
|
|
6578
|
+
break;
|
|
6579
|
+
}
|
|
5816
6580
|
log(`writeInput threw: ${err?.message ?? err}`);
|
|
5817
|
-
if (
|
|
6581
|
+
if (blockedBeforeWrite && submissionBackend) {
|
|
6582
|
+
// This exact item is known not to have touched the PTY. Keep its
|
|
6583
|
+
// original durable attempt (and ordinary IM content) queued, but do
|
|
6584
|
+
// not hammer the same sticky journal or burn receiver retry budget.
|
|
6585
|
+
// A user /restart installs a fresh backend and releases the hold.
|
|
6586
|
+
pendingMessages.unshift(item);
|
|
6587
|
+
ambiguousSubmissionRecoveryHold = {
|
|
6588
|
+
backend: submissionBackend,
|
|
6589
|
+
item,
|
|
6590
|
+
};
|
|
6591
|
+
log('Held definitely-unwritten input until ZMX recovery restart');
|
|
6592
|
+
}
|
|
6593
|
+
else if (recoveryFailureReason) {
|
|
6594
|
+
inflightInputs.retire(item);
|
|
6595
|
+
}
|
|
6596
|
+
if (durableWrite && item.turnId && !recoveryFailureReason) {
|
|
5818
6597
|
// A throwing backend cannot prove whether bytes reached the CLI.
|
|
5819
6598
|
// Reconcile as ambiguous (not a definitive failure) so the receiver
|
|
5820
6599
|
// can replay the same frozen delivery behind the action gate.
|
|
@@ -5824,8 +6603,14 @@ async function flushPending() {
|
|
|
5824
6603
|
// nulled `backend` and told the user the CLI exited) — nothing more to
|
|
5825
6604
|
// do. Otherwise surface it as a submit failure so the message isn't
|
|
5826
6605
|
// silently lost.
|
|
5827
|
-
if (backend)
|
|
5828
|
-
|
|
6606
|
+
if (backend) {
|
|
6607
|
+
if (recoveryFailureReason) {
|
|
6608
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
|
|
6609
|
+
}
|
|
6610
|
+
else {
|
|
6611
|
+
scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item, 'ambiguous');
|
|
6612
|
+
}
|
|
6613
|
+
}
|
|
5829
6614
|
break;
|
|
5830
6615
|
}
|
|
5831
6616
|
if (lastInitConfig?.cliId === 'codex-app'
|
|
@@ -5856,7 +6641,16 @@ async function flushPending() {
|
|
|
5856
6641
|
// nulled backend) the user already got a "CLI exited" notice; don't also
|
|
5857
6642
|
// nag that the submit wasn't confirmed.
|
|
5858
6643
|
if (result && result.submitted === false && backend) {
|
|
5859
|
-
|
|
6644
|
+
if (recoveryFailureReason) {
|
|
6645
|
+
inflightInputs.retire(item);
|
|
6646
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
|
|
6647
|
+
// The current item may have landed, so never requeue it. Preserve
|
|
6648
|
+
// every later batch item untouched for an explicit fresh generation.
|
|
6649
|
+
break;
|
|
6650
|
+
}
|
|
6651
|
+
else {
|
|
6652
|
+
scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item, 'failed');
|
|
6653
|
+
}
|
|
5860
6654
|
}
|
|
5861
6655
|
// All structured bridges now drain every pending message in one flush:
|
|
5862
6656
|
// Claude's BridgeTurnQueue handles `attachment(queued_command)` events
|
|
@@ -5953,7 +6747,11 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5953
6747
|
else {
|
|
5954
6748
|
if (!mergedQueued)
|
|
5955
6749
|
log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
|
|
5956
|
-
|
|
6750
|
+
// Same false-idle trap as post-submit (see flushPending): do not let
|
|
6751
|
+
// "busy marker absent" declare ready for transcript-backed CLIs.
|
|
6752
|
+
if (cliAdapter?.reliableTurnTerminal !== true) {
|
|
6753
|
+
scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
|
|
6754
|
+
}
|
|
5957
6755
|
}
|
|
5958
6756
|
return true;
|
|
5959
6757
|
}
|
|
@@ -6025,7 +6823,7 @@ function startScreenUpdates() {
|
|
|
6025
6823
|
}
|
|
6026
6824
|
lastContent = content;
|
|
6027
6825
|
}
|
|
6028
|
-
const usageAware =
|
|
6826
|
+
const usageAware = classifyScreenUsageLimit(content, status);
|
|
6029
6827
|
if (changed || usageAware.status !== lastSentStatus) {
|
|
6030
6828
|
lastSentStatus = usageAware.status;
|
|
6031
6829
|
send({ type: 'screen_update', content, ...usageAware, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
|
|
@@ -6244,7 +7042,12 @@ function seedBackendScreen(source, be) {
|
|
|
6244
7042
|
try {
|
|
6245
7043
|
const initial = be.captureCurrentScreen?.() ?? '';
|
|
6246
7044
|
if (initial.length > 0) {
|
|
6247
|
-
|
|
7045
|
+
if (be instanceof ZmxBackend) {
|
|
7046
|
+
scheduleBackendScreenResync(initial, source);
|
|
7047
|
+
}
|
|
7048
|
+
else {
|
|
7049
|
+
onPtyData(initial);
|
|
7050
|
+
}
|
|
6248
7051
|
if (be instanceof HerdrBackend) {
|
|
6249
7052
|
relayHerdrWebSnapshot(initial);
|
|
6250
7053
|
}
|
|
@@ -6263,6 +7066,10 @@ function busyProbeRegion(content) {
|
|
|
6263
7066
|
return lines.slice(-tailLineCount).join('\n');
|
|
6264
7067
|
}
|
|
6265
7068
|
function probeBusyPatternIdle(source, be) {
|
|
7069
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
|
|
7070
|
+
log(`${source} idle probe skipped: backend screen geometry is not authoritative`);
|
|
7071
|
+
return false;
|
|
7072
|
+
}
|
|
6266
7073
|
try {
|
|
6267
7074
|
const content = captureBackendScreen(be);
|
|
6268
7075
|
if (!content)
|
|
@@ -6270,8 +7077,30 @@ function probeBusyPatternIdle(source, be) {
|
|
|
6270
7077
|
if (cliAdapter?.busyPattern) {
|
|
6271
7078
|
if (cliAdapter.busyPattern.test(busyProbeRegion(content)))
|
|
6272
7079
|
return false;
|
|
6273
|
-
|
|
6274
|
-
|
|
7080
|
+
if (!be.settleCurrentScreen) {
|
|
7081
|
+
log(`${source} idle probe: busy marker absent, marking prompt ready`);
|
|
7082
|
+
markPromptReady();
|
|
7083
|
+
return true;
|
|
7084
|
+
}
|
|
7085
|
+
// A busy-marker probe reads the backend's cached screen. ZMX refreshes
|
|
7086
|
+
// that cache from `history`, so absence in the current sample is not yet
|
|
7087
|
+
// an authoritative turn boundary. Reuse the same revision-keyed settle
|
|
7088
|
+
// fence as IdleDetector before publishing prompt_ready; otherwise this
|
|
7089
|
+
// fallback can finalize a whole turn from a pre-tail/poll snapshot.
|
|
7090
|
+
const revisionBeforeSettle = backendScreenRevision;
|
|
7091
|
+
log(`${source} idle probe: busy marker absent, settling authoritative screen before prompt ready`);
|
|
7092
|
+
void settleBackendScreenBeforeIdle(be, revisionBeforeSettle).then((settle) => {
|
|
7093
|
+
if (!settle.proceed || backend !== be || isPromptReady)
|
|
7094
|
+
return;
|
|
7095
|
+
if (backendScreenRevision !== revisionBeforeSettle) {
|
|
7096
|
+
log(`${source} idle probe: authoritative screen changed during settle; deferring completion`);
|
|
7097
|
+
return;
|
|
7098
|
+
}
|
|
7099
|
+
if (settle.degraded) {
|
|
7100
|
+
log(`${source} idle probe: screen settle degraded; finalizing from the last successful snapshot`);
|
|
7101
|
+
}
|
|
7102
|
+
markPromptReady();
|
|
7103
|
+
});
|
|
6275
7104
|
return true;
|
|
6276
7105
|
}
|
|
6277
7106
|
}
|
|
@@ -6497,16 +7326,26 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6497
7326
|
// backendType trust-but-verify + HARD GATE (PTY 退役): an explicit per-bot
|
|
6498
7327
|
// config (or BACKEND_TYPE env override) bypasses config.ts's default, so the
|
|
6499
7328
|
// worker re-probes the requested persistent backend here. A requested
|
|
6500
|
-
// tmux/herdr/zellij backend that isn't functional NO LONGER silently
|
|
7329
|
+
// tmux/herdr/zellij/zmx backend that isn't functional NO LONGER silently
|
|
6501
7330
|
// degrades to raw PTY — that silent fallback was the root of the "secretly
|
|
6502
7331
|
// running on PTY, then hitting all of PTY's problems" bug class. Instead we
|
|
6503
7332
|
// refuse to spawn and post an actionable card (user_notify) telling the user
|
|
6504
7333
|
// to install the backend, or to explicitly opt into PTY with BACKEND_TYPE=pty.
|
|
6505
7334
|
//
|
|
6506
|
-
// Existing botmux sessions stay authoritative over
|
|
6507
|
-
//
|
|
6508
|
-
//
|
|
7335
|
+
// Existing botmux sessions stay authoritative over a separate capability
|
|
7336
|
+
// probe: a live session reattaches regardless of a transient probe failure
|
|
7337
|
+
// (PR#249), so it is exempt from the gate. tmux/zellij use disposable
|
|
7338
|
+
// sessions; ZMX validates its version plus full-list control plane; Herdr
|
|
7339
|
+
// uses a non-destructive version check.
|
|
6509
7340
|
let effectiveBackend = cfg.backendType;
|
|
7341
|
+
const backendCliError = backendCliCompatibilityError(effectiveBackend, cfg.cliId);
|
|
7342
|
+
if (backendCliError) {
|
|
7343
|
+
throw new Error(`⚠️ ${cfg.cliId} 当前不能使用 ZMX 后端:其完成事件依赖不可见 OSC 控制消息,` +
|
|
7344
|
+
`而 ZMX 的纯文本 history 不保留该通道。请将 backendType 改为 tmux / pty。\n` +
|
|
7345
|
+
`原因:${backendCliError}`);
|
|
7346
|
+
}
|
|
7347
|
+
let resolvedZmxSessionProbe;
|
|
7348
|
+
let resolvedZmxSessionPid;
|
|
6510
7349
|
{
|
|
6511
7350
|
let available = true;
|
|
6512
7351
|
let reason = '';
|
|
@@ -6530,6 +7369,30 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6530
7369
|
reason = 'zellij 功能性探针失败(需 zellij >= 0.44)';
|
|
6531
7370
|
}
|
|
6532
7371
|
}
|
|
7372
|
+
else if (effectiveBackend === 'zmx') {
|
|
7373
|
+
// The local controller version is a protocol requirement even when the
|
|
7374
|
+
// backing session already exists: 0.6 has the old send semantics. Only a
|
|
7375
|
+
// transient functional probe may be exempted for a verified live session.
|
|
7376
|
+
const version = probeZmxVersion();
|
|
7377
|
+
if (!version.ok) {
|
|
7378
|
+
available = false;
|
|
7379
|
+
reason = version.reason;
|
|
7380
|
+
resolvedZmxSessionProbe = 'unknown';
|
|
7381
|
+
}
|
|
7382
|
+
else {
|
|
7383
|
+
const resolved = probeOwnedZmxSession(ZmxBackend.sessionName(cfg.sessionId), cfg.sessionId);
|
|
7384
|
+
resolvedZmxSessionProbe = resolved.probe;
|
|
7385
|
+
resolvedZmxSessionPid = resolved.pid;
|
|
7386
|
+
hasExistingSession = resolved.probe === 'exists';
|
|
7387
|
+
if (resolved.probe === 'unknown') {
|
|
7388
|
+
available = false;
|
|
7389
|
+
reason = resolved.reason ?? 'zmx 会话所有权探针结果不确定';
|
|
7390
|
+
// Do not let decideBackendGate's live-session exemption override an
|
|
7391
|
+
// incompatible/unknown ownership result.
|
|
7392
|
+
hasExistingSession = false;
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
}
|
|
6533
7396
|
else if (effectiveBackend === 'herdr') {
|
|
6534
7397
|
// herdr's isAvailable() is a cheap, non-destructive `herdr --version`
|
|
6535
7398
|
// (not a disposable session probe), so it has no PR#249 false-negative
|
|
@@ -6573,7 +7436,14 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6573
7436
|
BOTMUX_SESSION_ID: cfg.sessionId,
|
|
6574
7437
|
BOTMUX_CHAT_ID: cfg.chatId,
|
|
6575
7438
|
BOTMUX_LARK_APP_ID: cfg.larkAppId,
|
|
7439
|
+
BOTMUX_USAGE_DISPLAY: resolveUsageDisplay(cfg.larkAppId),
|
|
6576
7440
|
};
|
|
7441
|
+
// Core-only capability must survive into the sandboxed CLI: riffModeSession
|
|
7442
|
+
// rebuilds a synthetic BotConfig from env (no bots.json), and would otherwise
|
|
7443
|
+
// drop apiOnly → getBotClient would not throw → `botmux send` could reach
|
|
7444
|
+
// Feishu. Thread the flag so the reconstructed config keeps the boundary.
|
|
7445
|
+
if (cfg.apiOnly)
|
|
7446
|
+
sessionEnv.BOTMUX_API_ONLY = '1';
|
|
6577
7447
|
// Session scope for `botmux send` inside the sandbox. Thread sessions
|
|
6578
7448
|
// anchor on a real om_ message (reply_in_thread); chat-scope sessions use
|
|
6579
7449
|
// the chat id as anchor (sessionAnchorId), which is NOT a message id —
|
|
@@ -6613,6 +7483,19 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6613
7483
|
// Per-bot env (bots.json `env`) takes precedence over session context;
|
|
6614
7484
|
// explicit riff config.env takes precedence over both.
|
|
6615
7485
|
const mergedEnv = { ...sessionEnv, ...sanitizePerBotEnv(cfg.env), ...cfg.backendConfig.env };
|
|
7486
|
+
// Re-freeze the no-transport capability keys AFTER the merge: a stale or
|
|
7487
|
+
// attacker-shaped backendConfig.env / per-bot env merges LAST and would
|
|
7488
|
+
// otherwise override the frozen values, restoring send capability for a
|
|
7489
|
+
// core-only bot or an HTTP virtual session. The host-owned session context
|
|
7490
|
+
// is authoritative here — these keys cannot be overridden from config.
|
|
7491
|
+
const noTransport = cfg.apiOnly === true
|
|
7492
|
+
|| cfg.chatId?.startsWith('http_async_') === true
|
|
7493
|
+
|| cfg.chatId?.startsWith('http_wait_') === true;
|
|
7494
|
+
if (noTransport) {
|
|
7495
|
+
delete mergedEnv.BOTMUX_LARK_APP_SECRET;
|
|
7496
|
+
mergedEnv.BOTMUX_API_ONLY = '1';
|
|
7497
|
+
mergedEnv.BOTMUX_CHAT_ID = cfg.chatId; // host-owned; never from config
|
|
7498
|
+
}
|
|
6616
7499
|
riffBackendConfig = Object.assign({}, cfg.backendConfig, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
|
|
6617
7500
|
// 复用本地仓库+分支:多仓只认会话上的显式 stamp(仓库选择卡多选流按用户
|
|
6618
7501
|
// 顺序写入 cfg.riffRepoDirs,首仓=primary);否则仅对 workingDir 本身做单仓
|
|
@@ -6659,6 +7542,15 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6659
7542
|
}
|
|
6660
7543
|
const sandboxRequested = !riffRemoteBackend
|
|
6661
7544
|
&& (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
|
|
7545
|
+
const backendIsolationGate = backendSandboxCompatibilityError({
|
|
7546
|
+
backendType: effectiveBackendType,
|
|
7547
|
+
fileSandboxRequested: sandboxRequested,
|
|
7548
|
+
// The unified sandbox request above already includes legacy readIsolation.
|
|
7549
|
+
effectiveReadIsolationRequested: false,
|
|
7550
|
+
});
|
|
7551
|
+
if (backendIsolationGate) {
|
|
7552
|
+
throw new Error(backendSandboxCompatibilityUserMessage(backendIsolationGate));
|
|
7553
|
+
}
|
|
6662
7554
|
const fullIsolationCoversCredentials = sandboxRequested;
|
|
6663
7555
|
let credentialMechanismAvailable = true;
|
|
6664
7556
|
let credentialMechanismExecutable;
|
|
@@ -6702,17 +7594,35 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6702
7594
|
?? join(defaultBotmuxHome, 'data');
|
|
6703
7595
|
let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
|
|
6704
7596
|
const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
|
|
7597
|
+
const reuseRecordedHerdrTarget = !sandboxRequested && !hasMcpRuntimeEntries;
|
|
7598
|
+
if (effectiveBackend === 'herdr' && !reuseRecordedHerdrTarget) {
|
|
7599
|
+
// Isolation/MCP incarnations move historical shared-host agents to the
|
|
7600
|
+
// data-root-scoped managed target. Retire the exact old pane before backend
|
|
7601
|
+
// selection mutates the durable stamp or a replacement CLI can spawn.
|
|
7602
|
+
retireSupersededRecordedHerdrTarget({
|
|
7603
|
+
sessionId: cfg.sessionId,
|
|
7604
|
+
ownershipScope: isolationRuntimeDataDir,
|
|
7605
|
+
reuseRecordedHerdrTarget,
|
|
7606
|
+
persistentBackendTarget: cfg.persistentBackendTarget,
|
|
7607
|
+
});
|
|
7608
|
+
}
|
|
6705
7609
|
const selectBackend = () => selectSessionBackend({
|
|
6706
7610
|
sessionId: cfg.sessionId,
|
|
6707
7611
|
backendType: effectiveBackend,
|
|
6708
7612
|
backendConfig: riffBackendConfig,
|
|
7613
|
+
herdrOwnershipScope: isolationRuntimeDataDir,
|
|
6709
7614
|
persistentBackendTarget: cfg.persistentBackendTarget,
|
|
6710
7615
|
// Old builds could place managed agents in a user's shared Herdr session.
|
|
6711
7616
|
// Preserve that recorded target for compatibility unless this incarnation
|
|
6712
7617
|
// requires an isolation/MCP boundary that only a Botmux-owned session can
|
|
6713
7618
|
// safely provide. Fresh tasks use distinct agents in one machine-wide host.
|
|
6714
|
-
reuseRecordedHerdrTarget
|
|
6715
|
-
|
|
7619
|
+
reuseRecordedHerdrTarget,
|
|
7620
|
+
// ZMX reattach vs fresh is frozen here from the probe taken above; the
|
|
7621
|
+
// backend refuses to silently turn a fresh launch into an attach.
|
|
7622
|
+
hasExistingSession: effectiveBackend === 'zmx'
|
|
7623
|
+
? resolvedZmxSessionProbe === 'exists'
|
|
7624
|
+
: undefined,
|
|
7625
|
+
zmxRecoveryStateDir: isolationRuntimeDataDir,
|
|
6716
7626
|
});
|
|
6717
7627
|
let selectedBackend = selectBackend();
|
|
6718
7628
|
isTmuxMode = selectedBackend.isTmuxMode;
|
|
@@ -6792,7 +7702,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6792
7702
|
}
|
|
6793
7703
|
}
|
|
6794
7704
|
// Predict reattach vs fresh BEFORE the resume pre-flight. On a persistent
|
|
6795
|
-
// backend (tmux/herdr/zellij) a daemon restart finds the CLI process still
|
|
7705
|
+
// backend (tmux/herdr/zellij/zmx) a daemon restart finds the CLI process still
|
|
6796
7706
|
// alive in its pane, so the backend will `attach` to the live process and
|
|
6797
7707
|
// IGNORE the bin/args — there is no spawn, and the live process still holds
|
|
6798
7708
|
// the full in-memory conversation. In that case the resume-vs-fresh question
|
|
@@ -6813,9 +7723,27 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6813
7723
|
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
6814
7724
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6815
7725
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
7726
|
+
// ZMX ownership is verified against the frozen PID, not just the name — a
|
|
7727
|
+
// same-named session may belong to the user or to a newer generation.
|
|
7728
|
+
const zmxOwnedProbe = effectiveBackendType === 'zmx'
|
|
7729
|
+
? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid)
|
|
7730
|
+
: undefined;
|
|
7731
|
+
// ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe must
|
|
7732
|
+
// fail closed. Other persistent backends retain the upstream semantics:
|
|
7733
|
+
// their target probe returning unknown is not proof that a pane exists.
|
|
7734
|
+
const paneProbe = zmxOwnedProbe?.probe
|
|
7735
|
+
?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
|
|
7736
|
+
if (effectiveBackendType === 'zmx'
|
|
7737
|
+
&& resolvedZmxSessionProbe !== 'exists'
|
|
7738
|
+
&& paneProbe === 'exists') {
|
|
7739
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
|
|
7740
|
+
'ZMX session appeared after the frozen launch probe');
|
|
7741
|
+
}
|
|
7742
|
+
if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
|
|
7743
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
|
|
7744
|
+
`could not verify existing ${effectiveBackendType} pane`);
|
|
7745
|
+
}
|
|
7746
|
+
const paneLive = paneProbe === 'exists';
|
|
6819
7747
|
if (paneLive) {
|
|
6820
7748
|
let marker = null;
|
|
6821
7749
|
marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
|
|
@@ -6829,42 +7757,108 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6829
7757
|
// Missing/legacy marker → pane predates the current policy and may retain
|
|
6830
7758
|
// obsolete permissions. Kill it before publishing any new capability.
|
|
6831
7759
|
log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
|
|
7760
|
+
// Capture the name before re-selection: `persistentSessionName` is
|
|
7761
|
+
// reassigned from the new selection below and widens back to
|
|
7762
|
+
// `string | undefined`, but the backing name we are tearing down is
|
|
7763
|
+
// this one and does not change.
|
|
7764
|
+
const staleSessionName = persistentSessionName;
|
|
7765
|
+
const stalePersistentTarget = selectedBackend.persistentBackendTarget;
|
|
6832
7766
|
try {
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
7767
|
+
// ZMX keeps its own call here rather than going through the target
|
|
7768
|
+
// helper: only this path holds the frozen PID, which makes the
|
|
7769
|
+
// ownership check stricter than the name+label check.
|
|
7770
|
+
if (effectiveBackendType === 'zmx') {
|
|
7771
|
+
ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
|
|
7772
|
+
}
|
|
7773
|
+
else {
|
|
7774
|
+
if (stalePersistentTarget)
|
|
7775
|
+
killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
|
|
7776
|
+
else
|
|
7777
|
+
killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
|
|
7778
|
+
}
|
|
6845
7779
|
}
|
|
6846
7780
|
catch (e) {
|
|
6847
7781
|
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
|
|
6848
7782
|
}
|
|
7783
|
+
const postKillProbe = effectiveBackendType === 'zmx'
|
|
7784
|
+
? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe
|
|
7785
|
+
: (stalePersistentTarget
|
|
7786
|
+
? probePersistentBackendTarget(stalePersistentTarget)
|
|
7787
|
+
: probePersistentSession(effectiveBackendType, staleSessionName));
|
|
7788
|
+
if (shouldRejectPersistentPostKillProbe(effectiveBackendType, postKillProbe)) {
|
|
7789
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
|
|
7790
|
+
`could not confirm stale ${effectiveBackendType} pane termination`);
|
|
7791
|
+
}
|
|
7792
|
+
if (effectiveBackendType === 'zmx') {
|
|
7793
|
+
resolvedZmxSessionProbe = postKillProbe;
|
|
7794
|
+
resolvedZmxSessionPid = undefined;
|
|
7795
|
+
}
|
|
7796
|
+
// ZMX backend selection consumes the frozen probe. Refresh it before
|
|
7797
|
+
// re-selecting or the replacement keeps isReattach=true for the pane
|
|
7798
|
+
// that this gate just proved was removed.
|
|
7799
|
+
selectedBackend = selectBackend();
|
|
7800
|
+
isTmuxMode = selectedBackend.isTmuxMode;
|
|
7801
|
+
isPipeMode = selectedBackend.isPipeMode;
|
|
7802
|
+
isZellijMode = selectedBackend.isZellijMode;
|
|
7803
|
+
backend = selectedBackend.backend;
|
|
7804
|
+
cliLifetimeNonce++;
|
|
7805
|
+
persistentSessionName = selectedBackend.persistentSessionName;
|
|
6849
7806
|
}
|
|
6850
7807
|
}
|
|
6851
7808
|
}
|
|
6852
7809
|
let willReattachPersistent = selectedBackend.isReattach === true;
|
|
6853
7810
|
if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6854
7811
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
7812
|
+
// ZMX ownership is label/PID-sensitive, so only its inconclusive probe
|
|
7813
|
+
// fails closed. Other backends keep the pre-ZMX target-probe semantics.
|
|
7814
|
+
const paneProbe = effectiveBackendType === 'zmx'
|
|
7815
|
+
? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid).probe
|
|
7816
|
+
: (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
|
|
7817
|
+
if (effectiveBackendType === 'zmx'
|
|
7818
|
+
&& resolvedZmxSessionProbe !== 'exists'
|
|
7819
|
+
&& paneProbe === 'exists') {
|
|
7820
|
+
throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
|
|
7821
|
+
'ZMX session appeared after the frozen launch probe');
|
|
7822
|
+
}
|
|
7823
|
+
if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
|
|
7824
|
+
throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
|
|
7825
|
+
`could not verify existing ${effectiveBackendType} pane`);
|
|
7826
|
+
}
|
|
7827
|
+
if (effectiveBackendType === 'zmx') {
|
|
7828
|
+
resolvedZmxSessionProbe = paneProbe;
|
|
7829
|
+
}
|
|
7830
|
+
if (paneProbe === 'exists') {
|
|
6859
7831
|
// The trusted Gateway host belongs to the worker and cannot survive a
|
|
6860
7832
|
// worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
|
|
6861
7833
|
// fresh relay socket instead of reattaching to a dead connection.
|
|
6862
7834
|
log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
|
|
7835
|
+
const persistentBackendType = effectiveBackendType;
|
|
6863
7836
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6864
|
-
if (
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
7837
|
+
if (effectiveBackendType === 'zmx') {
|
|
7838
|
+
ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
|
|
7839
|
+
}
|
|
7840
|
+
else if (persistentTarget) {
|
|
7841
|
+
killPersistentBackendTarget(persistentTarget, cfg.sessionId);
|
|
7842
|
+
}
|
|
7843
|
+
else {
|
|
7844
|
+
killPersistentSession(persistentBackendType, persistentSessionName, cfg.sessionId);
|
|
7845
|
+
}
|
|
7846
|
+
// Confirm the stale pane is really gone before re-selecting. Re-selection
|
|
7847
|
+
// below decides reattach-vs-fresh from this probe, so an unconfirmed kill
|
|
7848
|
+
// would let the new backend reattach to the pane we just tried to remove.
|
|
7849
|
+
const postKillProbe = effectiveBackendType === 'zmx'
|
|
7850
|
+
? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe
|
|
7851
|
+
: (persistentTarget
|
|
7852
|
+
? probePersistentBackendTarget(persistentTarget)
|
|
7853
|
+
: probePersistentSession(persistentBackendType, persistentSessionName));
|
|
7854
|
+
if (shouldRejectPersistentPostKillProbe(persistentBackendType, postKillProbe)) {
|
|
7855
|
+
throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
|
|
7856
|
+
`could not confirm stale ${effectiveBackendType} pane termination`);
|
|
7857
|
+
}
|
|
7858
|
+
if (effectiveBackendType === 'zmx') {
|
|
7859
|
+
resolvedZmxSessionProbe = postKillProbe;
|
|
7860
|
+
resolvedZmxSessionPid = undefined;
|
|
7861
|
+
}
|
|
6868
7862
|
selectedBackend = selectBackend();
|
|
6869
7863
|
isTmuxMode = selectedBackend.isTmuxMode;
|
|
6870
7864
|
isPipeMode = selectedBackend.isPipeMode;
|
|
@@ -7085,6 +8079,21 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7085
8079
|
preparedInitialPrompt = initialPromptDelivery.argvPrompt;
|
|
7086
8080
|
lastSpawnQueuedInitialPrompt = initialPromptDelivery.queuedContent;
|
|
7087
8081
|
lastSpawnQueuedInitialPromptLogicalContent = initialPromptDelivery.logicalContent;
|
|
8082
|
+
// Argv-baked first prompt tracking (PR #633 P2 / second-round review):
|
|
8083
|
+
// - needsWorkingSeed: any argv CLI (Pi/Gemini/MTR/OpenCode/Grok) so card-off
|
|
8084
|
+
// reactions can form working→idle (seeded at first ready or Grok arm).
|
|
8085
|
+
// - busy arm: only Grok-class SessionStart (first ready ≠ turn end).
|
|
8086
|
+
const argvBakedOpts = {
|
|
8087
|
+
passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
|
|
8088
|
+
preparedInitialPrompt,
|
|
8089
|
+
queuedInitialPrompt: lastSpawnQueuedInitialPrompt,
|
|
8090
|
+
};
|
|
8091
|
+
spawnArgvNeedsWorkingSeed = shouldTrackArgvBakedFirstPrompt(argvBakedOpts);
|
|
8092
|
+
spawnArgvInitialPromptBusy = shouldArmSpawnArgvInitialPromptBusy({
|
|
8093
|
+
...argvBakedOpts,
|
|
8094
|
+
injectsReadyHook: cliAdapter.injectsReadyHook === true,
|
|
8095
|
+
reliableTurnTerminal: cliAdapter.reliableTurnTerminal === true,
|
|
8096
|
+
});
|
|
7088
8097
|
if (deferInitialPrompt && preparedDeferredInput) {
|
|
7089
8098
|
piInitialPromptAdditionalArgs = [...(preparedDeferredInput.additionalArgs ?? [])];
|
|
7090
8099
|
piInitialPromptEnv = { ...(preparedDeferredInput.env ?? {}) };
|
|
@@ -7101,7 +8110,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7101
8110
|
try {
|
|
7102
8111
|
const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
|
|
7103
8112
|
mkdirSync(dirname(credPath), { recursive: true });
|
|
7104
|
-
writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
|
|
8113
|
+
writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand, apiOnly: cfg.apiOnly }), { mode: 0o600 });
|
|
7105
8114
|
}
|
|
7106
8115
|
catch (e) {
|
|
7107
8116
|
log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
|
|
@@ -7132,9 +8141,21 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7132
8141
|
larkAppId: cfg.larkAppId,
|
|
7133
8142
|
locale: cfg.locale,
|
|
7134
8143
|
model: ttadkGateway ? undefined : cfg.model,
|
|
8144
|
+
reasoningEffort: cfg.reasoningEffort,
|
|
7135
8145
|
disableCliBypass: cfg.disableCliBypass === true,
|
|
8146
|
+
// Codex-family hook-trust bypass: global toggle (default ON) so a headless
|
|
8147
|
+
// plain-TUI launch doesn't wedge on codex 0.14x's "Press t to trust" gate.
|
|
8148
|
+
// The adapter further ANDs this with !disableCliBypass. Read live per spawn.
|
|
8149
|
+
bypassHookTrust: config.bypassCodexHookTrust,
|
|
7136
8150
|
skillPluginDir: cfg.skillPluginDir,
|
|
7137
8151
|
readIsolation: willRedirectCliData,
|
|
8152
|
+
// Hybrid Codex RPC input: when engageCodexRpc (which runs BEFORE this spawn)
|
|
8153
|
+
// bound the pane to a botmux-owned app-server thread, these make codex.ts emit
|
|
8154
|
+
// `codex --remote <ws> resume <thread>` (a pure viewer — no bypass flags) instead
|
|
8155
|
+
// of a plain TUI. Dropped by the a0fa71010 sandbox refactor; restored here so the
|
|
8156
|
+
// RPC viewer branch actually triggers and never carries the bypass flags.
|
|
8157
|
+
remoteWsUrl,
|
|
8158
|
+
remoteThreadId,
|
|
7138
8159
|
});
|
|
7139
8160
|
// Pi's deferred long-first-prompt command is implemented by a session-scoped
|
|
7140
8161
|
// extension. Keep its launch args across owned process restarts while the
|
|
@@ -7221,7 +8242,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7221
8242
|
// build can't read bots.json (Seatbelt-denied) → `botmux send` fails "Bot not registered".
|
|
7222
8243
|
// (The tmux backend re-prepends this in its pane script after rcfile load; this covers the
|
|
7223
8244
|
// pty/direct-spawn path, whose child inherits childEnv.PATH directly.)
|
|
7224
|
-
childEnv.PATH =
|
|
8245
|
+
childEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), childEnv.PATH);
|
|
7225
8246
|
// §5 of botmux ask v0.1.7 — `botmux ask buttons` reads these to find the
|
|
7226
8247
|
// daemon socket, route the card back to this thread, and resolve the
|
|
7227
8248
|
// approver allowlist against session.owner. Missing env → exit 2.
|
|
@@ -7245,6 +8266,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7245
8266
|
if (typeof bl === 'string')
|
|
7246
8267
|
childEnv.BOTMUX_BRAND_LABEL = bl;
|
|
7247
8268
|
}
|
|
8269
|
+
childEnv.BOTMUX_USAGE_DISPLAY = resolveUsageDisplay(cfg.larkAppId);
|
|
7248
8270
|
// NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
|
|
7249
8271
|
// written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
|
|
7250
8272
|
// via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
|
|
@@ -7549,7 +8571,38 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7549
8571
|
mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7550
8572
|
}
|
|
7551
8573
|
}
|
|
7552
|
-
|
|
8574
|
+
// No-Lark-transport turn (apiOnly bot OR HTTP virtual chat) has no Feishu
|
|
8575
|
+
// sender identity and no business with the role system — it gets NO role
|
|
8576
|
+
// library grant, and we skip both the resolve and the diagnostic below.
|
|
8577
|
+
// (buildFsPolicy independently re-gates roleLibrarySubtree on larkTransport;
|
|
8578
|
+
// this mirror keeps the worker from resolving/diagnosing a subtree the policy
|
|
8579
|
+
// will discard.)
|
|
8580
|
+
const larkTransportEnabled = !(cfg.apiOnly === true
|
|
8581
|
+
|| cfg.chatId?.startsWith('http_async_') === true
|
|
8582
|
+
|| cfg.chatId?.startsWith('http_wait_') === true);
|
|
8583
|
+
// Own role-library subtree, plus the ONE diagnosable failure mode of keying it
|
|
8584
|
+
// on appId: a deployment that named the per-bot dir something else (the layout
|
|
8585
|
+
// pre-2026-07 runbooks used) gets no rule, and "the role system EPERMs" is
|
|
8586
|
+
// indistinguishable from "sandbox working as intended". Say so out loud instead
|
|
8587
|
+
// — the session still runs, only role switching/creation is unavailable.
|
|
8588
|
+
const roleLibSubtree = larkTransportEnabled
|
|
8589
|
+
? (roleLibrarySubtree(cfg.larkAppId) ?? undefined)
|
|
8590
|
+
: undefined;
|
|
8591
|
+
if (larkTransportEnabled && !roleLibSubtree) {
|
|
8592
|
+
try {
|
|
8593
|
+
// 两种形态都比:配置路径表面在库内、但经中间 symlink 解析到库外时,
|
|
8594
|
+
// 只比 canonical 会漏报(而这恰恰也是 roleLibrarySubtree 返回空的场景)。
|
|
8595
|
+
const lexicalRoot = roleLibraryRoot(), rolesRoot = canonical(lexicalRoot);
|
|
8596
|
+
const under = (root, p) => p === root || p.startsWith(`${root}/`);
|
|
8597
|
+
if (under(rolesRoot, canonical(cfg.workingDir)) || under(lexicalRoot, cfg.workingDir)) {
|
|
8598
|
+
log(`[sandbox] role library dir mismatch: workingDir is under ${rolesRoot} but ${rolesRoot}/${cfg.larkAppId} `
|
|
8599
|
+
+ 'is not a real directory — the role system (list/switch/create roles, post-switch knowledge writes) will '
|
|
8600
|
+
+ 'EPERM in this sandboxed session. Rename the per-bot dir to the appId; see docs/roles/deploy-runbook.md.');
|
|
8601
|
+
}
|
|
8602
|
+
}
|
|
8603
|
+
catch { /* diagnostics only — never block the spawn */ }
|
|
8604
|
+
}
|
|
8605
|
+
const fsPolicyCtx = {
|
|
7553
8606
|
platform: process.platform,
|
|
7554
8607
|
homeDir: sandboxHome,
|
|
7555
8608
|
botmuxHome: canonical(dirname(dataDir)),
|
|
@@ -7558,6 +8611,32 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7558
8611
|
currentAppId: cfg.larkAppId,
|
|
7559
8612
|
sessionId: cfg.sessionId,
|
|
7560
8613
|
botHome: canonical(ownBotHome),
|
|
8614
|
+
// Resolved above (gated on larkTransportEnabled). roleLibrarySubtree() does
|
|
8615
|
+
// the existence + canonicalization + "must be a real dir, not a symlink"
|
|
8616
|
+
// checks itself (deliberately NOT via keepExisting: its realpath would follow
|
|
8617
|
+
// a planted link and hand rw to the target). A library created after spawn
|
|
8618
|
+
// only takes effect for the next session — bwrap cannot bind a nonexistent
|
|
8619
|
+
// source anyway.
|
|
8620
|
+
roleLibrarySubtree: roleLibSubtree,
|
|
8621
|
+
// No-Lark-transport credential profile: apiOnly bot OR HTTP virtual chat
|
|
8622
|
+
// (computed above as larkTransportEnabled). buildFsPolicy suppresses every
|
|
8623
|
+
// Feishu-cred grant + hard-denies bots.json/lark-cli stores so a workingDir=~
|
|
8624
|
+
// grant can't re-expose them, and independently withholds the role-library grant.
|
|
8625
|
+
larkTransportEnabled,
|
|
8626
|
+
// ALWAYS freeze BOTH botmux authority roots for a no-transport turn: the
|
|
8627
|
+
// configured one (`botmuxHome` above = dirname(dataDir)) AND the canonical
|
|
8628
|
+
// default `~/.botmux`. A custom SESSION_DATA_DIR moves the data dir, but the
|
|
8629
|
+
// default root still holds the live `.dashboard-secret` HMAC + bots.json —
|
|
8630
|
+
// denying only one leaves the sibling-daemon escalation open (codex P1).
|
|
8631
|
+
// Deduped inside buildFsPolicy when the two are equal (default layout).
|
|
8632
|
+
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
8633
|
+
// The ACTUAL loaded bots-config path, frozen by the daemon
|
|
8634
|
+
// (getLoadedConfigPath), NOT guessed from BOTS_CONFIG env here. Inside a
|
|
8635
|
+
// frozen root → the parent mask already covers it; OUTSIDE every root →
|
|
8636
|
+
// buildFsPolicy THROWS (fail-closed) rather than silently masking an
|
|
8637
|
+
// arbitrary parent dir (`/tmp`, `/etc`, a project root) and bricking the
|
|
8638
|
+
// core CLI (codex P1). Canonicalized so it shares the roots' namespace.
|
|
8639
|
+
loadedBotsConfigPath: cfg.loadedBotsConfigPath ? canonical(cfg.loadedBotsConfigPath) : undefined,
|
|
7561
8640
|
redirectedCliData: willRedirectCliData,
|
|
7562
8641
|
cliDataPaths: willRedirectCliData ? undefined : keepExisting([
|
|
7563
8642
|
cliAdapter.claudeDataDir,
|
|
@@ -7615,7 +8694,30 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7615
8694
|
writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
|
|
7616
8695
|
? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
|
|
7617
8696
|
: [],
|
|
7618
|
-
}
|
|
8697
|
+
};
|
|
8698
|
+
const policy = (() => {
|
|
8699
|
+
try {
|
|
8700
|
+
return buildFsPolicy(fsPolicyCtx);
|
|
8701
|
+
}
|
|
8702
|
+
catch (err) {
|
|
8703
|
+
// A no-transport layout that cannot be safely confined (external
|
|
8704
|
+
// bots-config, or workingDir that IS a Feishu-authority root) fails the
|
|
8705
|
+
// spawn CLOSED with a diagnostic — never a silent parent-dir mask that
|
|
8706
|
+
// would hide /tmp, /etc, or a project root and brick the core CLI (codex P1).
|
|
8707
|
+
if (err instanceof FsPolicyConfigError) {
|
|
8708
|
+
const msg = `[file-sandbox] refusing to start no-transport session ${cfg.sessionId}: ${err.message}`;
|
|
8709
|
+
log(msg);
|
|
8710
|
+
throw new Error(msg);
|
|
8711
|
+
}
|
|
8712
|
+
throw err;
|
|
8713
|
+
}
|
|
8714
|
+
})();
|
|
8715
|
+
// A no-transport turn drops caller allow paths (extraWrite / readonlyRoots /
|
|
8716
|
+
// user RW+RO) that fell inside a Feishu-authority root. Log the suppression so
|
|
8717
|
+
// it's diagnosable rather than a silent hole (codex).
|
|
8718
|
+
if (policy.suppressedAuthorityPaths?.length) {
|
|
8719
|
+
log(`[file-sandbox] no-transport suppressed ${policy.suppressedAuthorityPaths.length} allow path(s) inside a Feishu-authority root: ${policy.suppressedAuthorityPaths.join(', ')}`);
|
|
8720
|
+
}
|
|
7619
8721
|
// Existence-filter only the ALLOW rules (baseline entries are candidates,
|
|
7620
8722
|
// not guarantees): a non-existent allow path has nothing to expose, and
|
|
7621
8723
|
// bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
|
|
@@ -8184,7 +9286,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8184
9286
|
// we hold the first prompt so a cjadk-style startup selector's ❯ can't eat it.
|
|
8185
9287
|
// shouldArmReadyGate() excludes adopt (pre-existing pane, no fresh hook) AND
|
|
8186
9288
|
// persistent-backend reattach (daemon restart re-attaches an already-running
|
|
8187
|
-
// tmux/zellij/herdr Claude WITHOUT re-running its bin/args → no new
|
|
9289
|
+
// tmux/zellij/herdr/zmx Claude WITHOUT re-running its bin/args → no new
|
|
8188
9290
|
// SessionStart hook → arming would hold the first post-recovery message until
|
|
8189
9291
|
// the timeout).
|
|
8190
9292
|
//
|
|
@@ -8252,6 +9354,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8252
9354
|
// markPromptReady(): that call may synchronously flush a type-ahead turn and
|
|
8253
9355
|
// advance bridge attribution. Both screen-idle and authoritative Herdr status
|
|
8254
9356
|
// must preserve this ordering.
|
|
9357
|
+
const observedBackend = backend;
|
|
8255
9358
|
const drainBridgesThenMarkReady = (evidenceSource) => {
|
|
8256
9359
|
if (bridgeJsonlPath) {
|
|
8257
9360
|
try {
|
|
@@ -8270,7 +9373,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8270
9373
|
}
|
|
8271
9374
|
}
|
|
8272
9375
|
if (evidenceSource === 'screen')
|
|
8273
|
-
markPromptReadyFromPty();
|
|
9376
|
+
markPromptReadyFromPty(observedBackend);
|
|
8274
9377
|
else
|
|
8275
9378
|
markPromptReady();
|
|
8276
9379
|
};
|
|
@@ -8280,13 +9383,32 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8280
9383
|
// quiescence, repeatedly triggering markPromptReady() and duplicate cards.
|
|
8281
9384
|
if (effectiveBackendType !== 'riff') {
|
|
8282
9385
|
idleDetector = new IdleDetector(cliAdapter);
|
|
8283
|
-
idleDetector.onIdle((evidenceSource) => {
|
|
9386
|
+
idleDetector.onIdle(async (evidenceSource) => {
|
|
8284
9387
|
log('Prompt detected (idle)');
|
|
9388
|
+
// Snapshot-only backends (ZMX) must complete one authoritative refresh
|
|
9389
|
+
// before a turn is declared idle, or a final burst lands after finalize.
|
|
9390
|
+
const idleBackend = backend;
|
|
9391
|
+
const revisionBeforeSettle = backendScreenRevision;
|
|
9392
|
+
if (idleBackend?.settleCurrentScreen) {
|
|
9393
|
+
const settle = await settleBackendScreenBeforeIdle(idleBackend, revisionBeforeSettle);
|
|
9394
|
+
if (!settle.proceed || backend !== idleBackend)
|
|
9395
|
+
return;
|
|
9396
|
+
if (backendScreenRevision !== revisionBeforeSettle) {
|
|
9397
|
+
log('Authoritative screen changed during idle settle; deferring completion to the re-armed detector');
|
|
9398
|
+
return;
|
|
9399
|
+
}
|
|
9400
|
+
if (settle.degraded) {
|
|
9401
|
+
log('Screen settle barrier degraded after bounded retries; finalizing from the last successful snapshot');
|
|
9402
|
+
}
|
|
9403
|
+
}
|
|
8285
9404
|
drainBridgesThenMarkReady(evidenceSource);
|
|
8286
9405
|
});
|
|
8287
9406
|
}
|
|
8288
|
-
|
|
8289
|
-
|
|
9407
|
+
observedBackend.onData((data) => {
|
|
9408
|
+
if (backend !== observedBackend)
|
|
9409
|
+
return;
|
|
9410
|
+
onPtyData(data);
|
|
9411
|
+
});
|
|
8290
9412
|
if (observedBackend instanceof HerdrBackend) {
|
|
8291
9413
|
observedBackend.onAgentStatus((status) => {
|
|
8292
9414
|
if (backend !== observedBackend)
|
|
@@ -8301,6 +9423,12 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8301
9423
|
}
|
|
8302
9424
|
});
|
|
8303
9425
|
}
|
|
9426
|
+
backend.onScreenResync?.((snapshot) => {
|
|
9427
|
+
if (observedBackend !== backend)
|
|
9428
|
+
return;
|
|
9429
|
+
log(`${effectiveBackendType} observer recovered — rebasing screen state from history`);
|
|
9430
|
+
scheduleBackendScreenResync(snapshot, `${effectiveBackendType} observer recovery`);
|
|
9431
|
+
});
|
|
8304
9432
|
backend.onAccessUrl?.((url) => {
|
|
8305
9433
|
send({
|
|
8306
9434
|
type: 'riff_access_url',
|
|
@@ -8347,6 +9475,19 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8347
9475
|
log(`Ignored stale backend exit (code: ${code}, signal: ${signal})`);
|
|
8348
9476
|
return;
|
|
8349
9477
|
}
|
|
9478
|
+
const recoveryHeld = ambiguousSubmissionRecoveryHold;
|
|
9479
|
+
const handedOffDurable = handoffQueuedDurableInputsOnBackendExit(pendingMessages, { intentionalRestart });
|
|
9480
|
+
if (handedOffDurable.length > 0) {
|
|
9481
|
+
// onCliExit reconciles every durable receipt owned by this worker
|
|
9482
|
+
// generation to ambiguous, including items still queued behind an
|
|
9483
|
+
// ordinary turn. Drop all of their definitely-unwritten local copies so
|
|
9484
|
+
// a fresh backend cannot execute attempt N while the hub dispatches N+1.
|
|
9485
|
+
// Ordinary IM inputs have no external replay owner and remain queued.
|
|
9486
|
+
if (recoveryHeld && handedOffDurable.includes(recoveryHeld.item)) {
|
|
9487
|
+
ambiguousSubmissionRecoveryHold = null;
|
|
9488
|
+
}
|
|
9489
|
+
log(`Handed ${handedOffDurable.length} queued durable input(s) to daemon replay after CLI exit`);
|
|
9490
|
+
}
|
|
8350
9491
|
stopSessionMcpGatewayHost();
|
|
8351
9492
|
const exitedTurnId = currentBotmuxTurnId;
|
|
8352
9493
|
const exitedDispatchAttempt = currentBotmuxDispatchAttempt;
|
|
@@ -8555,7 +9696,15 @@ function killCli(opts = {}) {
|
|
|
8555
9696
|
submittedCodexAppReplyTurnIds.clear();
|
|
8556
9697
|
pendingCodexAppSteerAckIds.clear();
|
|
8557
9698
|
acknowledgedCodexAppSteers.clear();
|
|
8558
|
-
if (
|
|
9699
|
+
if (opts.preserveSandbox) {
|
|
9700
|
+
// The daemon waits for this worker's detach ACK and exit before forking the
|
|
9701
|
+
// replacement. Drop this worker's cleanup reference and disarm its process-
|
|
9702
|
+
// exit hook so it cannot delete the same-session sandbox tree that the
|
|
9703
|
+
// replacement will reuse.
|
|
9704
|
+
sandboxCleanup = null;
|
|
9705
|
+
sandboxTeardownDone = true;
|
|
9706
|
+
}
|
|
9707
|
+
else if (sandboxCleanup) {
|
|
8559
9708
|
try {
|
|
8560
9709
|
sandboxCleanup();
|
|
8561
9710
|
}
|
|
@@ -8582,6 +9731,7 @@ function killCli(opts = {}) {
|
|
|
8582
9731
|
altBufferActive = false;
|
|
8583
9732
|
trustHandled = false;
|
|
8584
9733
|
codexUpdateDialogGuard.reset();
|
|
9734
|
+
disarmEffortConfirm();
|
|
8585
9735
|
appRunnerControlDecoder.reset();
|
|
8586
9736
|
}
|
|
8587
9737
|
async function restartCliProcess(reason, opts = {}) {
|
|
@@ -9321,6 +10471,106 @@ function _showReadonlyToast(){
|
|
|
9321
10471
|
}
|
|
9322
10472
|
document.getElementById('terminal').addEventListener('contextmenu',function(e){e.preventDefault()});
|
|
9323
10473
|
|
|
10474
|
+
// ── iOS third-party IME fix (keyCode=229 + composed insertText dead-path) ──
|
|
10475
|
+
// Some iOS third-party keyboards (Doubao/豆包 for every char, WeChat/微信 for
|
|
10476
|
+
// spaces & trailing single chars) deliver text as: keydown(keyCode=229) →
|
|
10477
|
+
// input(inputType=insertText, composed=true) with NO composition events. xterm
|
|
10478
|
+
// 5.x drops these:
|
|
10479
|
+
// • _inputEvent bails because its guard is (!ev.composed || !_keyDownSeen) —
|
|
10480
|
+
// here composed=true AND a 229 keydown set _keyDownSeen, so both are false.
|
|
10481
|
+
// • the only fallback (CompositionHelper._handleAnyTextareaChanges) uses a
|
|
10482
|
+
// setTimeout + String.replace(oldValue,'') diff that miscomputes under fast
|
|
10483
|
+
// consecutive input, so characters are silently lost.
|
|
10484
|
+
//
|
|
10485
|
+
// Voice-input correction (Doubao 语音) exposes a SECOND bug: one physical
|
|
10486
|
+
// Backspace fires MANY deleteContentBackward events in the textarea (it wants to
|
|
10487
|
+
// delete a whole run), but xterm emits only ONE \x7f per Backspace keydown — so
|
|
10488
|
+
// the terminal deletes 1 char while the textarea deleted N, leaving N-1 stale
|
|
10489
|
+
// chars that reappear when Doubao then re-inserts the corrected sentence
|
|
10490
|
+
// (the "everything repeats" symptom). We must forward EACH delete event.
|
|
10491
|
+
//
|
|
10492
|
+
// Diagnosed from real-device iOS event traces. Upstream: xterm.js #5835 / #5836.
|
|
10493
|
+
//
|
|
10494
|
+
// Strategy — take over ONLY these dead/lossy paths, nothing else:
|
|
10495
|
+
// 1. attachCustomKeyEventHandler returns false to CLAIM a key so xterm skips
|
|
10496
|
+
// it (no broken 229 fallback; no single-\x7f Backspace) — this is also what
|
|
10497
|
+
// prevents double-emit. We claim: keydown 229 (not composing), AND
|
|
10498
|
+
// Backspace ONLY WHEN the textarea is non-empty (an IME edit is pending).
|
|
10499
|
+
// An empty textarea means a normal terminal Backspace (delete the CLI line)
|
|
10500
|
+
// → we DON'T claim it, so xterm sends its standard \x7f. This is critical:
|
|
10501
|
+
// claiming an empty-textarea Backspace would swallow it (no input event).
|
|
10502
|
+
// 2. our textarea 'input' listener forwards each event: insertText → the data,
|
|
10503
|
+
// each delete* → one \x7f, so N textarea deletes become N terminal deletes.
|
|
10504
|
+
// 3. composition input (WeChat Chinese) is untouched — it already works — so
|
|
10505
|
+
// we never interfere while _composing is true.
|
|
10506
|
+
// term.input(data,true) routes through onData like real typing (NOT paste — it
|
|
10507
|
+
// must not bracketed-paste-wrap per-char input nor clear the textarea), so the
|
|
10508
|
+
// readonly gating in onData still applies.
|
|
10509
|
+
if(hasToken && !/[?&]imefix=0\\b/.test(location.search)){(function(){
|
|
10510
|
+
var _claim=false; // current key is claimed (229 dead-path or IME Backspace)
|
|
10511
|
+
var _composing=false; // a real composition (compositionstart..end) is active
|
|
10512
|
+
var _ta=term.textarea;
|
|
10513
|
+
try{
|
|
10514
|
+
term.attachCustomKeyEventHandler(function(e){
|
|
10515
|
+
if(e.type==='keydown'){
|
|
10516
|
+
// Reset at the start of every keydown: iOS IME synthetic keys often fire
|
|
10517
|
+
// NO keyup, so _claim could otherwise stay stuck on from a prior cycle.
|
|
10518
|
+
// Clearing here (before deciding to claim) guarantees a fresh state each
|
|
10519
|
+
// physical key, closing the "stuck-open → double-emit" window.
|
|
10520
|
+
_claim=false;
|
|
10521
|
+
if(_composing) return true;
|
|
10522
|
+
if(e.keyCode===229){ _claim=true; return false; }
|
|
10523
|
+
// Backspace: claim ONLY when an IME edit is pending (textarea non-empty),
|
|
10524
|
+
// so one physical backspace's N delete events all reach the terminal.
|
|
10525
|
+
// Empty textarea → normal terminal backspace, let xterm send its \x7f.
|
|
10526
|
+
if(e.keyCode===8 && _ta && _ta.value.length>0){ _claim=true; return false; }
|
|
10527
|
+
}
|
|
10528
|
+
return true;
|
|
10529
|
+
});
|
|
10530
|
+
}catch(_e){}
|
|
10531
|
+
if(_ta){
|
|
10532
|
+
// compositionstart/end bound the reliable path; never take over inside it.
|
|
10533
|
+
_ta.addEventListener('compositionstart',function(){_composing=true;_claim=false;},{capture:true,passive:true});
|
|
10534
|
+
_ta.addEventListener('compositionend',function(){_composing=false;_claim=false;},{capture:true,passive:true});
|
|
10535
|
+
// Blur also closes the cycle — another guard against a stuck-open _claim when
|
|
10536
|
+
// the matching keyup never arrives (the textarea can lose focus instead).
|
|
10537
|
+
_ta.addEventListener('blur',function(){_claim=false;},{capture:true,passive:true});
|
|
10538
|
+
// A single claimed keydown can fire MULTIPLE input events, e.g. the
|
|
10539
|
+
// consecutive-space→。 conversion (delete THEN insertText) or a voice-input
|
|
10540
|
+
// correction (many deletes). So DON'T clear _claim per-input — forward every
|
|
10541
|
+
// input in this cycle and only close the cycle on keyup / compositionstart.
|
|
10542
|
+
_ta.addEventListener('input',function(e){
|
|
10543
|
+
if(!_claim||_composing)return; // only inside a claimed cycle
|
|
10544
|
+
var it=e.inputType||'';
|
|
10545
|
+
if(it.indexOf('delete')===0){
|
|
10546
|
+
// Each textarea delete → one terminal backspace, so N deletes (whole-run
|
|
10547
|
+
// erase) map 1:1 instead of collapsing to a single \x7f.
|
|
10548
|
+
try{term.input('\\x7f',true)}catch(_e){}
|
|
10549
|
+
return;
|
|
10550
|
+
}
|
|
10551
|
+
// Forward ONLY inputType==='insertText' — the exact dead path the target
|
|
10552
|
+
// Doubao/WeChat traces take (keydown 229 → insertText, composed=true, no
|
|
10553
|
+
// composition events). This is a strict WHITELIST, not a composed filter:
|
|
10554
|
+
// • e.composed is a shadow-DOM-crossing flag, NOT an "is-IME" marker —
|
|
10555
|
+
// EVERY trusted InputEvent (paste, replacement, drop…) is composed=true.
|
|
10556
|
+
// So gating on composed alone still swallows non-insertText paths.
|
|
10557
|
+
// • insertFromPaste: xterm's own paste handler already sent the text
|
|
10558
|
+
// (stopPropagation but NOT preventDefault), then the default paste
|
|
10559
|
+
// inserts into the textarea and fires insertFromPaste — forwarding it
|
|
10560
|
+
// here too would DOUBLE the paste.
|
|
10561
|
+
// • insertReplacementText: WebKit correction runs ㅎ→하→한 as a REPLACE
|
|
10562
|
+
// stream; appending each token yields "ㅎ하한" instead of "한".
|
|
10563
|
+
// insertText remains gated on composed too: a composed=false insertText is
|
|
10564
|
+
// one xterm's _inputEvent WILL emit itself (its guard passes when
|
|
10565
|
+
// !composed), so — should _claim ever be stuck open — forwarding it here
|
|
10566
|
+
// would double-emit. Whitelist + composed = mutually exclusive with xterm.
|
|
10567
|
+
if(e.data&&e.composed&&it==='insertText'){try{term.input(e.data,true)}catch(_e){}}
|
|
10568
|
+
},{capture:true,passive:true});
|
|
10569
|
+
// Close the cycle on keyup (the claimed keydown's matching keyup).
|
|
10570
|
+
_ta.addEventListener('keyup',function(){_claim=false;},{capture:true,passive:true});
|
|
10571
|
+
}
|
|
10572
|
+
})();}
|
|
10573
|
+
|
|
9324
10574
|
// ── WebSocket ──
|
|
9325
10575
|
var ws_=null,el=document.getElementById('status');
|
|
9326
10576
|
term.onData(function(d){
|
|
@@ -9890,6 +11140,10 @@ function workerIpcPayload(msg) {
|
|
|
9890
11140
|
: msg;
|
|
9891
11141
|
}
|
|
9892
11142
|
function send(msg) {
|
|
11143
|
+
if (closeRequested && msg.type === 'final_output') {
|
|
11144
|
+
log('Dropped final_output after close fence');
|
|
11145
|
+
return;
|
|
11146
|
+
}
|
|
9893
11147
|
const payload = workerIpcPayload(msg);
|
|
9894
11148
|
if (isWorkflowWorker() && payload.type === 'final_output') {
|
|
9895
11149
|
workflowFinalOutputSent = true;
|
|
@@ -9929,6 +11183,27 @@ function sendAndFlush(msg) {
|
|
|
9929
11183
|
}
|
|
9930
11184
|
});
|
|
9931
11185
|
}
|
|
11186
|
+
const TRANSFER_DETACH_ACK_FLUSH_MS = 250;
|
|
11187
|
+
/** Best-effort terminal ACK for transfer. Backend detach has already happened,
|
|
11188
|
+
* so a wedged process.send callback must not keep this old worker alive and
|
|
11189
|
+
* strand the daemon behind its detach fence indefinitely.
|
|
11190
|
+
*/
|
|
11191
|
+
async function flushTransferDetachAck(requestId) {
|
|
11192
|
+
let timeout;
|
|
11193
|
+
try {
|
|
11194
|
+
await Promise.race([
|
|
11195
|
+
sendAndFlush({ type: 'transfer_detached', requestId }),
|
|
11196
|
+
new Promise((resolve) => {
|
|
11197
|
+
timeout = setTimeout(resolve, TRANSFER_DETACH_ACK_FLUSH_MS);
|
|
11198
|
+
timeout.unref?.();
|
|
11199
|
+
}),
|
|
11200
|
+
]);
|
|
11201
|
+
}
|
|
11202
|
+
finally {
|
|
11203
|
+
if (timeout)
|
|
11204
|
+
clearTimeout(timeout);
|
|
11205
|
+
}
|
|
11206
|
+
}
|
|
9932
11207
|
let fatalWorkerErrorPending = false;
|
|
9933
11208
|
/** Surface a fatal (re)launch failure before terminating the worker. This is
|
|
9934
11209
|
* used both during init and after a previously-ready worker tries to recover
|
|
@@ -9993,6 +11268,16 @@ process.on('message', async (raw) => {
|
|
|
9993
11268
|
// Capture credentials for direct image upload from worker
|
|
9994
11269
|
larkAppIdForUpload = msg.larkAppId;
|
|
9995
11270
|
larkAppSecretForUpload = msg.larkAppSecret;
|
|
11271
|
+
// Core-only (apiOnly) bots have no Feishu transport — the worker uploads
|
|
11272
|
+
// screenshots via its OWN client (utils/lark-upload), bypassing the daemon's
|
|
11273
|
+
// bot-level assertLarkTransport gate, so the capability must ride the init
|
|
11274
|
+
// message into this process and hard-disable upload here. Also disable for
|
|
11275
|
+
// a NORMAL bot whose turn runs in an HTTP virtual session (chatId is
|
|
11276
|
+
// http_async_*/http_wait_*): it has real creds so apiOnly is false, but the
|
|
11277
|
+
// synthetic chat has no card to attach a screenshot to.
|
|
11278
|
+
apiOnlyForUpload = msg.apiOnly === true
|
|
11279
|
+
|| msg.chatId?.startsWith('http_async_') === true
|
|
11280
|
+
|| msg.chatId?.startsWith('http_wait_') === true;
|
|
9996
11281
|
// brand 决定截图上传打哪个域(feishu / larksuite)。缺省 feishu。
|
|
9997
11282
|
larkBrandForUpload = msg.brand === 'lark' ? 'lark' : 'feishu';
|
|
9998
11283
|
// Resolve render dimensions BEFORE startScreenUpdates() — the
|
|
@@ -10000,7 +11285,11 @@ process.on('message', async (raw) => {
|
|
|
10000
11285
|
// up-front. Setting them later (after the renderer was built at
|
|
10001
11286
|
// 160x50) wouldn't unwrap content xterm has already buffered, so
|
|
10002
11287
|
// adopt-mode wide-pane content would still come out stair-stepped.
|
|
10003
|
-
const
|
|
11288
|
+
const requestedBackendType = msg.backendType ?? config.daemon.backendType;
|
|
11289
|
+
const dims = resolveRenderDimensions({
|
|
11290
|
+
...msg,
|
|
11291
|
+
backendType: requestedBackendType,
|
|
11292
|
+
});
|
|
10004
11293
|
renderCols = dims.cols;
|
|
10005
11294
|
renderRows = dims.rows;
|
|
10006
11295
|
log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
|
|
@@ -10013,18 +11302,28 @@ process.on('message', async (raw) => {
|
|
|
10013
11302
|
publishSandboxRelayCapability();
|
|
10014
11303
|
}
|
|
10015
11304
|
let port = 0;
|
|
11305
|
+
const webTerminalEnabled = backendSupportsWebTerminal(requestedBackendType);
|
|
10016
11306
|
if (!isWorkflowWorker()) {
|
|
10017
|
-
|
|
11307
|
+
if (webTerminalEnabled) {
|
|
11308
|
+
port = await startWebServer(config.web.workerHost, msg.webPort);
|
|
11309
|
+
}
|
|
11310
|
+
else {
|
|
11311
|
+
log(`Web terminal disabled for ${requestedBackendType} backend (no raw ANSI transport)`);
|
|
11312
|
+
}
|
|
10018
11313
|
startScreenUpdates();
|
|
10019
11314
|
startStuckDetector();
|
|
10020
11315
|
}
|
|
10021
11316
|
else {
|
|
10022
|
-
// Workflow attempts
|
|
10023
|
-
//
|
|
10024
|
-
//
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
11317
|
+
// Workflow attempts expose a read-only web terminal only when the
|
|
11318
|
+
// backend has a raw terminal stream. Keep chat-side features
|
|
11319
|
+
// disabled: no screen cards, no analyzer, no sessionStore writes.
|
|
11320
|
+
if (webTerminalEnabled) {
|
|
11321
|
+
port = await startWebServer(config.web.workerHost, msg.webPort);
|
|
11322
|
+
log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer');
|
|
11323
|
+
}
|
|
11324
|
+
else {
|
|
11325
|
+
log(`Workflow worker mode: web terminal disabled for ${requestedBackendType} backend`);
|
|
11326
|
+
}
|
|
10028
11327
|
}
|
|
10029
11328
|
// Hybrid codex RPC input (opt-in): bind the pane to a botmux-owned
|
|
10030
11329
|
// app-server thread; input flows via JSON-RPC instead of a drop-prone
|
|
@@ -10047,7 +11346,7 @@ process.on('message', async (raw) => {
|
|
|
10047
11346
|
rpcPluginGenerationPrepared = true;
|
|
10048
11347
|
},
|
|
10049
11348
|
engage: () => engageCodexRpc(msg),
|
|
10050
|
-
killVerify: (name) => killPersistentSessionVerified(rpcBackendType, name),
|
|
11349
|
+
killVerify: (name) => killPersistentSessionVerified(rpcBackendType, name, msg.sessionId),
|
|
10051
11350
|
teardownEngine: () => stopCodexRpcEngine(),
|
|
10052
11351
|
log: (m) => log(m),
|
|
10053
11352
|
notify: (m) => send({ type: 'user_notify', message: m, turnId: msg.turnId }),
|
|
@@ -10149,9 +11448,11 @@ process.on('message', async (raw) => {
|
|
|
10149
11448
|
break;
|
|
10150
11449
|
}
|
|
10151
11450
|
case 'message': {
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
11451
|
+
const messageAdoptMode = lastInitConfig?.adoptMode === true;
|
|
11452
|
+
// Adopt IPC handlers can overlap. Delay their turn baseline until the
|
|
11453
|
+
// submission mutex is held so a queued message cannot steal attribution
|
|
11454
|
+
// from the write/verification already in flight.
|
|
11455
|
+
let turnSeq = usageLimitTracker.currentTurn();
|
|
10155
11456
|
if (msg.nativeSessionTitlePrompt
|
|
10156
11457
|
&& msg.nativeSessionTitle
|
|
10157
11458
|
&& lastInitConfig?.cliId === 'codex'
|
|
@@ -10168,7 +11469,7 @@ process.on('message', async (raw) => {
|
|
|
10168
11469
|
void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
|
|
10169
11470
|
}
|
|
10170
11471
|
// Cancel any active tmux copy-mode scroll so user input reaches the CLI.
|
|
10171
|
-
if (tmuxScrolledHalfPages > 0)
|
|
11472
|
+
if (tmuxScrolledHalfPages > 0 && !messageAdoptMode)
|
|
10172
11473
|
exitTmuxScrollMode();
|
|
10173
11474
|
let content = msg.content;
|
|
10174
11475
|
let codexAppInput = msg.codexAppInput;
|
|
@@ -10203,51 +11504,46 @@ process.on('message', async (raw) => {
|
|
|
10203
11504
|
}
|
|
10204
11505
|
}
|
|
10205
11506
|
if (lastInitConfig?.adoptMode) {
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10213
|
-
|
|
10214
|
-
|
|
10215
|
-
|
|
10216
|
-
|
|
10217
|
-
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10222
|
-
|
|
10223
|
-
}
|
|
10224
|
-
catch { /* best effort */ }
|
|
10225
|
-
bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
10226
|
-
}
|
|
10227
|
-
else if (codexBridgeFallbackActive()) {
|
|
10228
|
-
// Codex adopt: same idea, different bridge. ingest first so any
|
|
10229
|
-
// in-flight events from a local-typed prior turn close before
|
|
10230
|
-
// this Lark turn's fingerprint window opens. Mark works even
|
|
10231
|
-
// pre-attach (queue is path-agnostic).
|
|
10232
|
-
if (codexBridgeIsCursor()) {
|
|
10233
|
-
// Cursor may append the current Lark/user line to its transcript
|
|
10234
|
-
// before this IPC message is handled. Mark first so that preexisting
|
|
10235
|
-
// current-line can still fingerprint-match instead of being marked
|
|
10236
|
-
// seen as an unmatched event.
|
|
10237
|
-
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
11507
|
+
let adoptWritePrepared = false;
|
|
11508
|
+
const prepareAdoptWrite = () => {
|
|
11509
|
+
if (adoptWritePrepared)
|
|
11510
|
+
return;
|
|
11511
|
+
adoptWritePrepared = true;
|
|
11512
|
+
renderer?.markNewTurn();
|
|
11513
|
+
turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
11514
|
+
if (tmuxScrolledHalfPages > 0)
|
|
11515
|
+
exitTmuxScrollMode();
|
|
11516
|
+
currentBotmuxTurnId = msg.turnId;
|
|
11517
|
+
currentBotmuxDispatchAttempt = msg.dispatchAttempt;
|
|
11518
|
+
currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
|
|
11519
|
+
writeCliPidMarker();
|
|
11520
|
+
publishSandboxRelayCapability();
|
|
11521
|
+
// Capture the transcript baseline only after this turn owns the
|
|
11522
|
+
// submission lock and immediately before its literal write.
|
|
11523
|
+
if (bridgeJsonlPath) {
|
|
10238
11524
|
try {
|
|
10239
|
-
|
|
11525
|
+
bridgeIngest();
|
|
10240
11526
|
}
|
|
10241
11527
|
catch { /* best effort */ }
|
|
11528
|
+
bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
10242
11529
|
}
|
|
10243
|
-
else {
|
|
10244
|
-
|
|
10245
|
-
|
|
11530
|
+
else if (codexBridgeFallbackActive()) {
|
|
11531
|
+
if (codexBridgeIsCursor()) {
|
|
11532
|
+
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
11533
|
+
try {
|
|
11534
|
+
codexBridgeIngest();
|
|
11535
|
+
}
|
|
11536
|
+
catch { /* best effort */ }
|
|
11537
|
+
}
|
|
11538
|
+
else {
|
|
11539
|
+
try {
|
|
11540
|
+
codexBridgeIngest();
|
|
11541
|
+
}
|
|
11542
|
+
catch { /* best effort */ }
|
|
11543
|
+
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
10246
11544
|
}
|
|
10247
|
-
catch { /* best effort */ }
|
|
10248
|
-
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
10249
11545
|
}
|
|
10250
|
-
}
|
|
11546
|
+
};
|
|
10251
11547
|
// Adopt mode write:
|
|
10252
11548
|
// - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr)
|
|
10253
11549
|
// route through cliAdapter.writeInput so paste+Enter-retry +
|
|
@@ -10262,41 +11558,108 @@ process.on('message', async (raw) => {
|
|
|
10262
11558
|
if (backend) {
|
|
10263
11559
|
if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
|
|
10264
11560
|
// writeInput is async but we're already inside an async
|
|
10265
|
-
// message handler. Errors are best-effort logged;
|
|
10266
|
-
//
|
|
10267
|
-
//
|
|
11561
|
+
// message handler. Errors are best-effort logged; attribution and
|
|
11562
|
+
// bridge baselines are prepared only after the submission lock is
|
|
11563
|
+
// held, immediately before the adapter write.
|
|
11564
|
+
const submissionBackend = backend;
|
|
11565
|
+
const submissionAdapter = cliAdapter;
|
|
11566
|
+
let recoveryFailureReason;
|
|
10268
11567
|
try {
|
|
10269
|
-
const
|
|
11568
|
+
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, () => submissionAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
|
|
11569
|
+
const result = transaction.result;
|
|
11570
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
10270
11571
|
if (result?.cliSessionId) {
|
|
10271
11572
|
persistCliSessionId(result.cliSessionId);
|
|
10272
11573
|
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
10273
11574
|
}
|
|
10274
|
-
if (result
|
|
10275
|
-
|
|
11575
|
+
if (result?.submitted === false || recoveryFailureReason) {
|
|
11576
|
+
if (recoveryFailureReason) {
|
|
11577
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
11578
|
+
}
|
|
11579
|
+
else {
|
|
11580
|
+
scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', undefined, result?.failureReason, turnSeq, msg, 'failed');
|
|
11581
|
+
}
|
|
10276
11582
|
}
|
|
10277
11583
|
else {
|
|
10278
11584
|
acknowledgeTurnInputCommitted(msg.turnId);
|
|
10279
11585
|
}
|
|
10280
11586
|
}
|
|
10281
11587
|
catch (err) {
|
|
11588
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
11589
|
+
? err.recoveryFailureReason
|
|
11590
|
+
: recoveryFailureReason;
|
|
11591
|
+
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
11592
|
+
&& !!err.recoveryFailureReason
|
|
11593
|
+
&& !err.submissionStarted;
|
|
10282
11594
|
log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
11595
|
+
if (msg.turnId
|
|
11596
|
+
&& msg.dispatchAttempt !== undefined
|
|
11597
|
+
&& blockedBeforeWrite) {
|
|
11598
|
+
emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
|
|
11599
|
+
}
|
|
11600
|
+
else if (msg.turnId
|
|
11601
|
+
&& msg.dispatchAttempt !== undefined
|
|
11602
|
+
&& !recoveryFailureReason) {
|
|
11603
|
+
emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
|
|
11604
|
+
}
|
|
11605
|
+
if (backend) {
|
|
11606
|
+
if (recoveryFailureReason) {
|
|
11607
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
11608
|
+
}
|
|
11609
|
+
else {
|
|
11610
|
+
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
|
|
11611
|
+
}
|
|
11612
|
+
}
|
|
10283
11613
|
}
|
|
10284
11614
|
}
|
|
10285
11615
|
else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
10286
|
-
backend
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
11616
|
+
const submissionBackend = backend;
|
|
11617
|
+
let recoveryFailureReason;
|
|
11618
|
+
try {
|
|
11619
|
+
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
|
|
11620
|
+
const input = adapterInputHandle(submissionBackend);
|
|
11621
|
+
input.sendText(content);
|
|
11622
|
+
// Beat between text and Enter so the adopted CLI's input
|
|
11623
|
+
// layer registers the typed chars before submit.
|
|
11624
|
+
await new Promise(r => setTimeout(r, 200));
|
|
11625
|
+
input.sendSpecialKeys('Enter');
|
|
11626
|
+
}, undefined, prepareAdoptWrite);
|
|
11627
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
11628
|
+
if (recoveryFailureReason) {
|
|
11629
|
+
throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
|
|
11630
|
+
}
|
|
11631
|
+
acknowledgeTurnInputCommitted(msg.turnId);
|
|
11632
|
+
}
|
|
11633
|
+
catch (err) {
|
|
11634
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
11635
|
+
? err.recoveryFailureReason
|
|
11636
|
+
: recoveryFailureReason;
|
|
11637
|
+
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
11638
|
+
&& !!err.recoveryFailureReason
|
|
11639
|
+
&& !err.submissionStarted;
|
|
11640
|
+
log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
11641
|
+
if (msg.turnId
|
|
11642
|
+
&& msg.dispatchAttempt !== undefined
|
|
11643
|
+
&& blockedBeforeWrite) {
|
|
11644
|
+
emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
|
|
11645
|
+
}
|
|
11646
|
+
else if (msg.turnId
|
|
11647
|
+
&& msg.dispatchAttempt !== undefined
|
|
11648
|
+
&& !recoveryFailureReason) {
|
|
11649
|
+
emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
|
|
11650
|
+
}
|
|
11651
|
+
if (backend) {
|
|
11652
|
+
if (recoveryFailureReason) {
|
|
11653
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
11654
|
+
}
|
|
11655
|
+
else {
|
|
11656
|
+
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
|
|
11657
|
+
}
|
|
11658
|
+
}
|
|
11659
|
+
}
|
|
10298
11660
|
}
|
|
10299
11661
|
else {
|
|
11662
|
+
prepareAdoptWrite();
|
|
10300
11663
|
backend.write(content + '\r');
|
|
10301
11664
|
acknowledgeTurnInputCommitted(msg.turnId);
|
|
10302
11665
|
}
|
|
@@ -10334,9 +11697,9 @@ process.on('message', async (raw) => {
|
|
|
10334
11697
|
// await settleLaunchComm() 最长 2s 等 wrapper 完成 `exec <cli>`——这段
|
|
10335
11698
|
// await 让出事件循环,而 IPC handler 不串行(见 raw-input-followup-
|
|
10336
11699
|
// atomicity.test.ts),若此刻放行 passthrough 就会打进尚未 exec 的临时
|
|
10337
|
-
// shell。bareShellCheckInProgress
|
|
10338
|
-
//
|
|
10339
|
-
//
|
|
11700
|
+
// shell。bareShellCheckInProgress 覆盖“检查进行中”、bareShellLaunchBlocked
|
|
11701
|
+
// 覆盖“仍停在裸 shell 的安全 hold”两种状态,一并入队;若该进程随后出现
|
|
11702
|
+
// 真实 PTY prompt 且 leaf 已变为非 shell,markPromptReady 会恢复排空。
|
|
10340
11703
|
if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
|
|
10341
11704
|
|| shouldHoldCodexRunnerInput(codexRunnerFreshness)
|
|
10342
11705
|
|| injectionFlushing || shouldDeferUserFlush(pendingInjections)
|
|
@@ -10383,6 +11746,16 @@ process.on('message', async (raw) => {
|
|
|
10383
11746
|
if (msg.updateWorkingDir && lastInitConfig) {
|
|
10384
11747
|
lastInitConfig.workingDir = msg.updateWorkingDir;
|
|
10385
11748
|
}
|
|
11749
|
+
// per-bot env 热更:daemon 发 restart 时捎带 bots.json `env` 的最新值
|
|
11750
|
+
// (live-worker restart 不 refork,没有 init 重发这条通道),respawn 前
|
|
11751
|
+
// 全量覆盖 lastInitConfig.env —— spawnCli 的 sanitizePerBotEnv(cfg.env)
|
|
11752
|
+
// 每次 spawn 都重跑,覆盖即在重启出的 CLI 上生效。undefined=不携带(旧
|
|
11753
|
+
// daemon / 兜底)保持快照;null=dashboard 已清空 → 移除快照。与上面的
|
|
11754
|
+
// cwd merge 一样放在合并守卫之前:被合并的重复 restart 也应带走 env
|
|
11755
|
+
// 更新,pending 的 respawn 展开 {...lastInitConfig} 时自然拿到新值。
|
|
11756
|
+
if (msg.env !== undefined && lastInitConfig) {
|
|
11757
|
+
lastInitConfig.env = msg.env === null ? undefined : msg.env;
|
|
11758
|
+
}
|
|
10386
11759
|
// restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
|
|
10387
11760
|
// 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
|
|
10388
11761
|
// 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
|
|
@@ -10471,6 +11844,21 @@ process.on('message', async (raw) => {
|
|
|
10471
11844
|
}
|
|
10472
11845
|
}
|
|
10473
11846
|
if (removedPending > 0) {
|
|
11847
|
+
if (ambiguousSubmissionRecoveryHold?.backend === backend
|
|
11848
|
+
&& !lastInitConfig?.adoptMode) {
|
|
11849
|
+
// A recovery-held item at the queue head blocked this durable
|
|
11850
|
+
// attempt from ever reaching the PTY. It may be this exact delivery
|
|
11851
|
+
// or an ordinary IM immediately ahead of it; either way, hub replay
|
|
11852
|
+
// must not hit the same poisoned generation. Fence it now, preserve
|
|
11853
|
+
// every other queued item, then admit the next attempt to the fresh
|
|
11854
|
+
// session.
|
|
11855
|
+
ambiguousSubmissionRecoveryHold = null;
|
|
11856
|
+
log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
|
|
11857
|
+
+ `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
|
|
11858
|
+
await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
|
|
11859
|
+
acknowledge('cli_fenced');
|
|
11860
|
+
break;
|
|
11861
|
+
}
|
|
10474
11862
|
log(`Expired ${removedPending} queued durable input(s) turn=${msg.turnId.slice(0, 12)} `
|
|
10475
11863
|
+ `attempt=${msg.dispatchAttempt}`);
|
|
10476
11864
|
acknowledge('queued_removed');
|
|
@@ -10516,6 +11904,18 @@ process.on('message', async (raw) => {
|
|
|
10516
11904
|
// (no stuckNonce) bypass the guard and write keys directly.
|
|
10517
11905
|
let wroteKeys = false;
|
|
10518
11906
|
if (msg.stuckNonce !== undefined && msg.stuckPageType) {
|
|
11907
|
+
if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
|
|
11908
|
+
// Defense in depth: an old card may predate this worker or a backend
|
|
11909
|
+
// switch. A fresh 120x24 history render is still not authoritative
|
|
11910
|
+
// after local attach resize, so expire the action without writing.
|
|
11911
|
+
send({
|
|
11912
|
+
type: 'stuck_warning_expired',
|
|
11913
|
+
nonce: msg.stuckNonce,
|
|
11914
|
+
turnId: currentBotmuxTurnId,
|
|
11915
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
11916
|
+
});
|
|
11917
|
+
break;
|
|
11918
|
+
}
|
|
10519
11919
|
const result = await processStuckWarningTuiKeys({
|
|
10520
11920
|
stuckNonce: msg.stuckNonce,
|
|
10521
11921
|
stuckPageType: msg.stuckPageType,
|
|
@@ -10534,13 +11934,40 @@ process.on('message', async (raw) => {
|
|
|
10534
11934
|
writeKeys: handleTuiKeys,
|
|
10535
11935
|
sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }),
|
|
10536
11936
|
sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }),
|
|
11937
|
+
sendFailed: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_prompt_submit_failed', stuckNonce: nonce, turnId, dispatchAttempt }),
|
|
10537
11938
|
log,
|
|
10538
11939
|
});
|
|
10539
11940
|
wroteKeys = result.wroteKeys;
|
|
10540
11941
|
}
|
|
10541
11942
|
else {
|
|
10542
|
-
await handleTuiKeys(msg.keys, msg.isFinal);
|
|
10543
|
-
|
|
11943
|
+
wroteKeys = await handleTuiKeys(msg.keys, msg.isFinal);
|
|
11944
|
+
if (msg.cardMessageId) {
|
|
11945
|
+
if (!wroteKeys) {
|
|
11946
|
+
send({
|
|
11947
|
+
type: 'tui_prompt_submit_failed',
|
|
11948
|
+
cardMessageId: msg.cardMessageId,
|
|
11949
|
+
turnId: currentBotmuxTurnId,
|
|
11950
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
11951
|
+
});
|
|
11952
|
+
}
|
|
11953
|
+
else if (msg.isFinal) {
|
|
11954
|
+
send({
|
|
11955
|
+
type: 'tui_prompt_resolved',
|
|
11956
|
+
cardMessageId: msg.cardMessageId,
|
|
11957
|
+
selectedText: msg.selectedText,
|
|
11958
|
+
turnId: currentBotmuxTurnId,
|
|
11959
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
11960
|
+
});
|
|
11961
|
+
}
|
|
11962
|
+
}
|
|
11963
|
+
else if (!wroteKeys) {
|
|
11964
|
+
send({
|
|
11965
|
+
type: 'user_notify',
|
|
11966
|
+
turnId: currentBotmuxTurnId,
|
|
11967
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
11968
|
+
message: t('worker.tui_submit_failed', { cliName: cliName() }),
|
|
11969
|
+
});
|
|
11970
|
+
}
|
|
10544
11971
|
}
|
|
10545
11972
|
// Re-arm the stuck detector ONLY when the card-handler explicitly flags
|
|
10546
11973
|
// this as a stuck-warning card's Enter action (advances to the next
|
|
@@ -10561,7 +11988,31 @@ process.on('message', async (raw) => {
|
|
|
10561
11988
|
break;
|
|
10562
11989
|
}
|
|
10563
11990
|
case 'tui_text_input': {
|
|
10564
|
-
handleTuiTextInput(msg.keys, msg.text);
|
|
11991
|
+
const wroteText = await handleTuiTextInput(msg.keys, msg.text);
|
|
11992
|
+
if (msg.cardMessageId) {
|
|
11993
|
+
send(wroteText
|
|
11994
|
+
? {
|
|
11995
|
+
type: 'tui_prompt_resolved',
|
|
11996
|
+
cardMessageId: msg.cardMessageId,
|
|
11997
|
+
selectedText: msg.text,
|
|
11998
|
+
turnId: currentBotmuxTurnId,
|
|
11999
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
12000
|
+
}
|
|
12001
|
+
: {
|
|
12002
|
+
type: 'tui_prompt_submit_failed',
|
|
12003
|
+
cardMessageId: msg.cardMessageId,
|
|
12004
|
+
turnId: currentBotmuxTurnId,
|
|
12005
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
12006
|
+
});
|
|
12007
|
+
}
|
|
12008
|
+
else if (!wroteText) {
|
|
12009
|
+
send({
|
|
12010
|
+
type: 'user_notify',
|
|
12011
|
+
turnId: currentBotmuxTurnId,
|
|
12012
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
12013
|
+
message: t('worker.tui_submit_failed', { cliName: cliName() }),
|
|
12014
|
+
});
|
|
12015
|
+
}
|
|
10565
12016
|
break;
|
|
10566
12017
|
}
|
|
10567
12018
|
case 'coco_drive_picker': {
|
|
@@ -10622,7 +12073,7 @@ process.on('message', async (raw) => {
|
|
|
10622
12073
|
break;
|
|
10623
12074
|
}
|
|
10624
12075
|
case 'term_action': {
|
|
10625
|
-
handleTermAction(msg.key);
|
|
12076
|
+
await handleTermAction(msg.key);
|
|
10626
12077
|
break;
|
|
10627
12078
|
}
|
|
10628
12079
|
case 'refresh_screen': {
|
|
@@ -10639,7 +12090,11 @@ process.on('message', async (raw) => {
|
|
|
10639
12090
|
}
|
|
10640
12091
|
case 'close': {
|
|
10641
12092
|
log('Close requested');
|
|
12093
|
+
closeRequested = true;
|
|
12094
|
+
send({ type: 'session_close_ready', sessionId });
|
|
10642
12095
|
stopScreenshotLoop();
|
|
12096
|
+
stopBridgeWatcher();
|
|
12097
|
+
stopCodexBridge();
|
|
10643
12098
|
// destroySession kills tmux session permanently; kill() only detaches.
|
|
10644
12099
|
// riff 的 destroySession 是异步远端取消——必须有界 await:紧跟着的
|
|
10645
12100
|
// process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
|
|
@@ -10660,6 +12115,20 @@ process.on('message', async (raw) => {
|
|
|
10660
12115
|
cleanup();
|
|
10661
12116
|
process.exit(0);
|
|
10662
12117
|
}
|
|
12118
|
+
case 'detach_for_transfer': {
|
|
12119
|
+
log('Transfer detach requested');
|
|
12120
|
+
stopScreenshotLoop();
|
|
12121
|
+
// Transfer keeps the logical session alive. The daemon starts its
|
|
12122
|
+
// replacement on the new routing anchor only after this worker ACKs the
|
|
12123
|
+
// observer detach and exits. `killCli()` deliberately calls backend.kill()
|
|
12124
|
+
// rather than destroySession(): persistent mux/ZMX sessions and Riff
|
|
12125
|
+
// tasks survive for reattach, while PTY keeps its existing cold-resume
|
|
12126
|
+
// behavior because its kill() owns the child process.
|
|
12127
|
+
killCli({ preserveSandbox: true });
|
|
12128
|
+
cleanup();
|
|
12129
|
+
await flushTransferDetachAck(msg.requestId);
|
|
12130
|
+
process.exit(0);
|
|
12131
|
+
}
|
|
10663
12132
|
case 'suspend': {
|
|
10664
12133
|
log('Suspend requested');
|
|
10665
12134
|
stopScreenshotLoop();
|
|
@@ -10670,7 +12139,7 @@ process.on('message', async (raw) => {
|
|
|
10670
12139
|
// cold-resumes a FRESH CLI on the next message — bmx-<sid> is absent.)
|
|
10671
12140
|
destroyCrashDiagnosticTerminal('suspend');
|
|
10672
12141
|
// Free the CLI's memory, not just the worker's: destroySession kills the
|
|
10673
|
-
// backing tmux/herdr/zellij session AND the CLI process inside it (kill()
|
|
12142
|
+
// backing tmux/herdr/zellij/zmx session AND the CLI process inside it (kill()
|
|
10674
12143
|
// would only detach the pty viewer and leave the CLI running in the
|
|
10675
12144
|
// background — defeating the whole point of a session cap, since the CLI
|
|
10676
12145
|
// is the memory hog). On the next message the session cold-resumes via
|
|
@@ -10819,6 +12288,15 @@ function teardownSandboxBestEffort() {
|
|
|
10819
12288
|
// the guard before any further stdout writes (log() writes to process.stdout).
|
|
10820
12289
|
installStdioEpipeGuard();
|
|
10821
12290
|
process.on('exit', () => {
|
|
12291
|
+
// `zmx tail` can remain blocked with PPID=1 after an abrupt Node exit because
|
|
12292
|
+
// it notices a broken stdout pipe only when new session output arrives.
|
|
12293
|
+
// Detach the observer synchronously; never destroy the persistent CLI here.
|
|
12294
|
+
if (backend instanceof ZmxBackend) {
|
|
12295
|
+
try {
|
|
12296
|
+
backend.kill();
|
|
12297
|
+
}
|
|
12298
|
+
catch { /* best-effort crash teardown */ }
|
|
12299
|
+
}
|
|
10822
12300
|
stopNativeSessionTitleSync();
|
|
10823
12301
|
teardownSandboxBestEffort();
|
|
10824
12302
|
stopCodexRpcEngine();
|