botmux 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/herdr-backend.d.ts +4 -3
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +11 -8
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/pty-backend.d.ts +1 -1
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +4 -1
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts +29 -5
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +302 -31
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +6 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +7 -0
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +4 -1
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +37 -3
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +9 -3
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.js +12 -7
- package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +11 -2
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +35 -2
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +88 -7
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +15 -14
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +64 -26
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +20 -5
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +24 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +110 -5
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/fleet-shutdown.d.ts +47 -0
- package/dist/cli/fleet-shutdown.d.ts.map +1 -0
- package/dist/cli/fleet-shutdown.js +387 -0
- package/dist/cli/fleet-shutdown.js.map +1 -0
- package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
- package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
- package/dist/cli/pm2-descriptor-guard.js +122 -0
- package/dist/cli/pm2-descriptor-guard.js.map +1 -0
- package/dist/cli/pm2-exact-start.d.ts +16 -0
- package/dist/cli/pm2-exact-start.d.ts.map +1 -0
- package/dist/cli/pm2-exact-start.js +63 -0
- package/dist/cli/pm2-exact-start.js.map +1 -0
- package/dist/cli/pm2-god-admission.d.ts +8 -0
- package/dist/cli/pm2-god-admission.d.ts.map +1 -0
- package/dist/cli/pm2-god-admission.js +25 -0
- package/dist/cli/pm2-god-admission.js.map +1 -0
- package/dist/cli/pm2-jlist.d.ts +13 -0
- package/dist/cli/pm2-jlist.d.ts.map +1 -0
- package/dist/cli/pm2-jlist.js +108 -0
- package/dist/cli/pm2-jlist.js.map +1 -0
- package/dist/cli/pm2-preflight.d.ts +10 -0
- package/dist/cli/pm2-preflight.d.ts.map +1 -0
- package/dist/cli/pm2-preflight.js +23 -0
- package/dist/cli/pm2-preflight.js.map +1 -0
- package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
- package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
- package/dist/cli/pm2-shutdown-capability.js +161 -0
- package/dist/cli/pm2-shutdown-capability.js.map +1 -0
- package/dist/cli/pm2-start-transaction.d.ts +55 -0
- package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
- package/dist/cli/pm2-start-transaction.js +220 -0
- package/dist/cli/pm2-start-transaction.js.map +1 -0
- package/dist/cli/send-dispatch.d.ts +12 -0
- package/dist/cli/send-dispatch.d.ts.map +1 -1
- package/dist/cli/send-dispatch.js +31 -4
- package/dist/cli/send-dispatch.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -0
- package/dist/cli/session-list-liveness.d.ts.map +1 -1
- package/dist/cli/session-list-liveness.js +6 -0
- package/dist/cli/session-list-liveness.js.map +1 -1
- package/dist/cli/session-picker-layout.d.ts +26 -0
- package/dist/cli/session-picker-layout.d.ts.map +1 -0
- package/dist/cli/session-picker-layout.js +123 -0
- package/dist/cli/session-picker-layout.js.map +1 -0
- package/dist/cli/session-picker-viewport.d.ts +49 -0
- package/dist/cli/session-picker-viewport.d.ts.map +1 -0
- package/dist/cli/session-picker-viewport.js +46 -0
- package/dist/cli/session-picker-viewport.js.map +1 -0
- package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
- package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
- package/dist/cli/supervisor-shutdown-client.js +146 -0
- package/dist/cli/supervisor-shutdown-client.js.map +1 -0
- package/dist/cli/terminal-width.d.ts +48 -0
- package/dist/cli/terminal-width.d.ts.map +1 -0
- package/dist/cli/terminal-width.js +119 -0
- package/dist/cli/terminal-width.js.map +1 -0
- package/dist/cli/vc-agent.d.ts.map +1 -1
- package/dist/cli/vc-agent.js +1 -1
- package/dist/cli/vc-agent.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2269 -716
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +1630 -125
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/ask-api.d.ts +8 -1
- package/dist/core/ask-api.d.ts.map +1 -1
- package/dist/core/ask-api.js +18 -0
- package/dist/core/ask-api.js.map +1 -1
- package/dist/core/ask-broker.d.ts +20 -0
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +467 -47
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/ask-persist-store.d.ts +126 -0
- package/dist/core/ask-persist-store.d.ts.map +1 -0
- package/dist/core/ask-persist-store.js +188 -0
- package/dist/core/ask-persist-store.js.map +1 -0
- package/dist/core/ask-types.d.ts +80 -0
- package/dist/core/ask-types.d.ts.map +1 -1
- package/dist/core/ask-types.js +62 -0
- package/dist/core/ask-types.js.map +1 -1
- package/dist/core/cli-session-id-publisher.d.ts +20 -0
- package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
- package/dist/core/cli-session-id-publisher.js +21 -0
- package/dist/core/cli-session-id-publisher.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +427 -255
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +13 -0
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +385 -26
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +9 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +842 -288
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
- package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
- package/dist/core/explicit-session-backing-cleanup.js +76 -0
- package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
- package/dist/core/idle-worker-sweeper.d.ts +20 -0
- package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
- package/dist/core/idle-worker-sweeper.js +18 -0
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +10 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +7 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/managed-origin-attestation.d.ts +51 -0
- package/dist/core/managed-origin-attestation.d.ts.map +1 -0
- package/dist/core/managed-origin-attestation.js +208 -0
- package/dist/core/managed-origin-attestation.js.map +1 -0
- package/dist/core/managed-origin-capability.d.ts +62 -3
- package/dist/core/managed-origin-capability.d.ts.map +1 -1
- package/dist/core/managed-origin-capability.js +419 -9
- package/dist/core/managed-origin-capability.js.map +1 -1
- package/dist/core/pending-repo-journal.d.ts +6 -0
- package/dist/core/pending-repo-journal.d.ts.map +1 -0
- package/dist/core/pending-repo-journal.js +85 -0
- package/dist/core/pending-repo-journal.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +6 -1
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +12 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/process-start-identity.d.ts +3 -0
- package/dist/core/process-start-identity.d.ts.map +1 -0
- package/dist/core/process-start-identity.js +62 -0
- package/dist/core/process-start-identity.js.map +1 -0
- package/dist/core/raw-command-writer.d.ts +34 -0
- package/dist/core/raw-command-writer.d.ts.map +1 -0
- package/dist/core/raw-command-writer.js +48 -0
- package/dist/core/raw-command-writer.js.map +1 -0
- package/dist/core/reply-target.d.ts +85 -22
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +267 -32
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
- package/dist/core/resource-monitor/runtime.js +4 -1
- package/dist/core/resource-monitor/runtime.js.map +1 -1
- package/dist/core/restart-report.d.ts +6 -1
- package/dist/core/restart-report.d.ts.map +1 -1
- package/dist/core/restart-report.js +22 -4
- package/dist/core/restart-report.js.map +1 -1
- package/dist/core/riff-shutdown-detach.d.ts +164 -0
- package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
- package/dist/core/riff-shutdown-detach.js +787 -0
- package/dist/core/riff-shutdown-detach.js.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.js +21 -0
- package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/session-manager.d.ts +6 -6
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +1227 -594
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +1 -1
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +2 -2
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-mutation-guard.d.ts +17 -0
- package/dist/core/session-mutation-guard.d.ts.map +1 -0
- package/dist/core/session-mutation-guard.js +33 -0
- package/dist/core/session-mutation-guard.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +27 -0
- package/dist/core/shutdown-budgets.d.ts.map +1 -0
- package/dist/core/shutdown-budgets.js +47 -0
- package/dist/core/shutdown-budgets.js.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.js +10 -0
- package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.js +16 -0
- package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
- package/dist/core/trigger-final-suppression.d.ts +4 -0
- package/dist/core/trigger-final-suppression.d.ts.map +1 -1
- package/dist/core/trigger-final-suppression.js +6 -11
- package/dist/core/trigger-final-suppression.js.map +1 -1
- package/dist/core/trigger-session.d.ts +70 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +923 -145
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +94 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +194 -13
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2597 -547
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +76 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2255 -773
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +20 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +24 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/feed-groups.d.ts +17 -0
- package/dist/dashboard/feed-groups.d.ts.map +1 -0
- package/dist/dashboard/feed-groups.js +75 -0
- package/dist/dashboard/feed-groups.js.map +1 -0
- package/dist/dashboard/session-card-model.d.ts +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +8 -5
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +139 -46
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +40 -3
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +95 -2
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +86 -40
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/kanban-model.js +1 -1
- package/dist/dashboard/web/kanban-model.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts +3 -0
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +2 -0
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +2 -0
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +103 -2
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +3 -2
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +8 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +15 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
- package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
- package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
- package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
- package/dist/dashboard-web/style.css +255 -0
- package/dist/dashboard.js +172 -7
- package/dist/dashboard.js.map +1 -1
- package/dist/desktop/renderer/app.js +21 -0
- package/dist/desktop/renderer/app.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +35 -9
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +35 -9
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/ask-card.d.ts +23 -0
- package/dist/im/lark/ask-card.d.ts.map +1 -1
- package/dist/im/lark/ask-card.js +94 -4
- package/dist/im/lark/ask-card.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +6 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +112 -15
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +496 -310
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +7 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +39 -26
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts +7 -4
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +19 -6
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +16 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +88 -23
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
- package/dist/im/lark/forward-followup-content.js +9 -1
- package/dist/im/lark/forward-followup-content.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +7 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/md-card.d.ts +11 -0
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +46 -0
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +23 -2
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +58 -3
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +2 -1
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +6 -1
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +21 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +23 -1
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +96 -13
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +40 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +124 -24
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +8 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +9 -2
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +1 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +1 -0
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-service-tier.d.ts +2 -0
- package/dist/services/codex-service-tier.d.ts.map +1 -1
- package/dist/services/codex-service-tier.js +1 -0
- package/dist/services/codex-service-tier.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +27 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +340 -2
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/grant-policy.d.ts +3 -0
- package/dist/services/grant-policy.d.ts.map +1 -1
- package/dist/services/grant-policy.js +5 -0
- package/dist/services/grant-policy.js.map +1 -1
- package/dist/services/grant-prefs-store.d.ts +6 -3
- package/dist/services/grant-prefs-store.d.ts.map +1 -1
- package/dist/services/grant-prefs-store.js +42 -12
- package/dist/services/grant-prefs-store.js.map +1 -1
- package/dist/services/hook-runner.d.ts +20 -1
- package/dist/services/hook-runner.d.ts.map +1 -1
- package/dist/services/hook-runner.js +17 -13
- package/dist/services/hook-runner.js.map +1 -1
- package/dist/services/idempotency-store.d.ts +134 -0
- package/dist/services/idempotency-store.d.ts.map +1 -0
- package/dist/services/idempotency-store.js +299 -0
- package/dist/services/idempotency-store.js.map +1 -0
- package/dist/services/jsonl-cursor.d.ts +7 -3
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +55 -31
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +1 -108
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/restart-intent-store.d.ts +22 -2
- package/dist/services/restart-intent-store.d.ts.map +1 -1
- package/dist/services/restart-intent-store.js +160 -18
- package/dist/services/restart-intent-store.js.map +1 -1
- package/dist/services/sandbox-store.d.ts +22 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +37 -8
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/send-policy.d.ts +41 -33
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +39 -40
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +66 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +403 -67
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/submit-notification.d.ts +3 -0
- package/dist/services/submit-notification.d.ts.map +1 -0
- package/dist/services/submit-notification.js +15 -0
- package/dist/services/submit-notification.js.map +1 -0
- package/dist/services/traex-transcript.d.ts +25 -2
- package/dist/services/traex-transcript.d.ts.map +1 -1
- package/dist/services/traex-transcript.js +133 -1
- package/dist/services/traex-transcript.js.map +1 -1
- package/dist/services/trigger-types.d.ts +13 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +39 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/services/voice/index.d.ts +3 -2
- package/dist/services/voice/index.d.ts.map +1 -1
- package/dist/services/voice/index.js +5 -5
- package/dist/services/voice/index.js.map +1 -1
- package/dist/services/voice/openai.d.ts +2 -1
- package/dist/services/voice/openai.d.ts.map +1 -1
- package/dist/services/voice/openai.js +2 -1
- package/dist/services/voice/openai.js.map +1 -1
- package/dist/services/voice/sami.d.ts +5 -2
- package/dist/services/voice/sami.d.ts.map +1 -1
- package/dist/services/voice/sami.js +19 -4
- package/dist/services/voice/sami.js.map +1 -1
- package/dist/setup/bots-store.d.ts.map +1 -1
- package/dist/setup/bots-store.js +10 -4
- package/dist/setup/bots-store.js.map +1 -1
- package/dist/setup/lark-scopes.json +2 -0
- package/dist/setup/open-platform-automation.d.ts +9 -1
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +44 -15
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-visibility.d.ts +42 -0
- package/dist/setup/open-platform-visibility.d.ts.map +1 -0
- package/dist/setup/open-platform-visibility.js +133 -0
- package/dist/setup/open-platform-visibility.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -1
- package/dist/setup/verify-permissions.js +6 -0
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/skills/definitions.js +2 -2
- package/dist/types.d.ts +290 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +1 -0
- package/dist/utils/bot-routing.d.ts.map +1 -1
- package/dist/utils/bot-routing.js +1 -1
- package/dist/utils/bot-routing.js.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +9 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/codex-app-control.d.ts +438 -0
- package/dist/utils/codex-app-control.d.ts.map +1 -0
- package/dist/utils/codex-app-control.js +2336 -0
- package/dist/utils/codex-app-control.js.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.js +149 -0
- package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.js +141 -0
- package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
- package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
- package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-liveness.js +301 -0
- package/dist/utils/codex-app-turn-liveness.js.map +1 -0
- package/dist/utils/idle-detector.d.ts +12 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +19 -0
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/input-gate.d.ts +87 -0
- package/dist/utils/input-gate.d.ts.map +1 -1
- package/dist/utils/input-gate.js +86 -0
- package/dist/utils/input-gate.js.map +1 -1
- package/dist/utils/lark-upload.d.ts.map +1 -1
- package/dist/utils/lark-upload.js +52 -1
- package/dist/utils/lark-upload.js.map +1 -1
- package/dist/utils/pending-input-queue.d.ts +15 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +5 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/user-token.d.ts.map +1 -1
- package/dist/utils/user-token.js +53 -2
- package/dist/utils/user-token.js.map +1 -1
- package/dist/worker-ipc-preload.d.ts +12 -0
- package/dist/worker-ipc-preload.d.ts.map +1 -0
- package/dist/worker-ipc-preload.js +44 -0
- package/dist/worker-ipc-preload.js.map +1 -0
- package/dist/worker.js +2604 -325
- package/dist/worker.js.map +1 -1
- package/dist/workflows/v3/session-relay-client.d.ts +1 -0
- package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
- package/dist/workflows/v3/session-relay-client.js +12 -3
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
- package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/worker.js
CHANGED
|
@@ -12,22 +12,25 @@
|
|
|
12
12
|
* 6. On 'close', kills CLI and exits
|
|
13
13
|
* 7. On 'restart', kills CLI and re-spawns with --resume
|
|
14
14
|
*/
|
|
15
|
-
import { randomBytes } from 'node:crypto';
|
|
16
|
-
import { mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream
|
|
15
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
+
import { chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
|
|
17
17
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
18
18
|
import { join, basename, dirname } from 'node:path';
|
|
19
19
|
import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
|
|
20
|
-
import { homedir, tmpdir } from 'node:os';
|
|
20
|
+
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
|
|
22
|
+
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
|
|
23
23
|
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
|
|
24
24
|
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
|
|
25
|
+
import { finalizeRawCommandDelivery, writeRawCommandLine } from './core/raw-command-writer.js';
|
|
26
|
+
import { publishCliSessionIdToDaemon } from './core/cli-session-id-publisher.js';
|
|
25
27
|
import { readProcessStartIdentity } from './core/session-marker.js';
|
|
26
28
|
import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js';
|
|
27
29
|
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
|
|
28
30
|
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
|
|
29
|
-
import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
|
|
30
|
-
import {
|
|
31
|
+
import { bridgePostText, shouldEmitEmptyCompletedBridgeFallback, shouldEmitFailedBridgeFallback, shouldSuppressBridgeEmit, stripTrailingBridgeSentinelLine } from './services/bridge-fallback-gate.js';
|
|
32
|
+
import { buildSubmitMessagePreview } from './services/submit-notification.js';
|
|
33
|
+
import { decideHardTimeoutAction, decidePostHookPromptEvidence, decideSettleMarkReady, shouldArmPostHookPromptEvidenceFallback, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, POST_HOOK_EVIDENCE_FALLBACK_MS, POST_HOOK_EVIDENCE_RETRY_MS, } from './utils/input-gate.js';
|
|
31
34
|
import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
|
|
32
35
|
import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
|
|
33
36
|
import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
|
|
@@ -36,6 +39,7 @@ import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
|
|
|
36
39
|
import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
|
|
37
40
|
import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
|
|
38
41
|
import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
|
|
42
|
+
import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
|
|
39
43
|
import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
|
|
40
44
|
import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
|
|
41
45
|
import { sanitizePerBotEnv } from './core/per-bot-env.js';
|
|
@@ -58,9 +62,10 @@ import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
|
|
|
58
62
|
import { detectCodexComposerState } from './services/codex-composer-state.js';
|
|
59
63
|
import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
|
|
60
64
|
import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
|
|
61
|
-
import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, findCodexRolloutSetByPid, codexHistorySidIsOwned, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath, scanCodexThreadSettings } from './services/codex-transcript.js';
|
|
65
|
+
import { CODEX_AUTH_ERROR_CODE, CODEX_CONNECTION_ERROR_CODE, CODEX_INVALID_REQUEST_ERROR_CODE, CODEX_RATE_LIMIT_ERROR_CODE, drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, findCodexRolloutSetByPid, codexHistorySidIsOwned, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath, isCodexRateLimitEvent, scanCodexThreadSettings, readLatestCodexRuntime } from './services/codex-transcript.js';
|
|
62
66
|
import { CodexServiceTierTracker, resolveCodexServiceTierSnapshot } from './services/codex-service-tier.js';
|
|
63
|
-
import {
|
|
67
|
+
import { WORKER_IPC_HANDLER_READY_EVENT } from './worker-ipc-preload.js';
|
|
68
|
+
import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid, findTraexRolloutSetByPid, readLatestTraexRuntime, traexHistorySidIsOwned } from './services/traex-transcript.js';
|
|
64
69
|
import { parseTraexUserInputQuestions } from './services/traex-user-input.js';
|
|
65
70
|
import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
|
|
66
71
|
import { currentHermesStateOffset, drainHermesStateDb, resolveHermesStateDbPath } from './services/hermes-transcript.js';
|
|
@@ -76,6 +81,7 @@ import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
|
|
|
76
81
|
import { baselineJsonlCursor } from './services/jsonl-cursor.js';
|
|
77
82
|
import { fileURLToPath } from 'node:url';
|
|
78
83
|
import { createServer as createHttpServer } from 'node:http';
|
|
84
|
+
import { createServer as createNetServer } from 'node:net';
|
|
79
85
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
80
86
|
import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
|
|
81
87
|
import { HerdrWebTerminalBinding } from './utils/herdr-web-terminal-binding.js';
|
|
@@ -127,7 +133,6 @@ import { decideSubmitConfirmationAction } from './services/submit-confirmation.j
|
|
|
127
133
|
import { config, resolveChatBotDiscoveryConfig } from './config.js';
|
|
128
134
|
import * as sessionStore from './services/session-store.js';
|
|
129
135
|
import * as pty from 'node-pty';
|
|
130
|
-
import { createHash } from 'node:crypto';
|
|
131
136
|
import { hasInstalledSessionReadyHook, installHook, } from './adapters/hook-installer.js';
|
|
132
137
|
import { hookCommandFor } from './adapters/hook-command.js';
|
|
133
138
|
import { findOnlineDaemon, parseDaemonIpcPort } from './utils/daemon-discovery.js';
|
|
@@ -135,8 +140,8 @@ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
|
|
|
135
140
|
import { withCodexAppContext } from './utils/codex-app-context.js';
|
|
136
141
|
import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
|
|
137
142
|
import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
|
|
138
|
-
import {
|
|
139
|
-
import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
|
|
143
|
+
import { normalizeCodexAppLifecycleEvent, normalizeFinalUsage, } from './services/codex-app-runner-protocol.js';
|
|
144
|
+
import { hasMatchingManagedOriginCapability, ensureManagedOriginAttestationDirectory, ensureManagedOriginCapabilityLeafSafe, ensureManagedOriginDataRootProbe, ensureManagedOriginIsolationSentinel, ensureManagedOriginRootLocator, managedOriginLegacyIsolationProbeAccess, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginAttestationDirectory, managedOriginCapabilityPath, managedOriginRootLocatorPath, readManagedOriginAuthorityFile, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, sweepManagedOriginAttestationProofs, } from './core/managed-origin-capability.js';
|
|
140
145
|
import { CodexRpcEngine } from './codex-rpc-engine.js';
|
|
141
146
|
// A worker must never trust an INHERITED session-level CLI home pointer
|
|
142
147
|
// (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon
|
|
@@ -153,6 +158,12 @@ import { CodexRpcEngine } from './codex-rpc-engine.js';
|
|
|
153
158
|
// dirs. See SESSION_CLI_HOME_ENV_KEYS for why deleting beats pinning a
|
|
154
159
|
// default (~/.claude relocates Claude's state file → onboarding rerun) and
|
|
155
160
|
// why GROK_HOME is exempt.
|
|
161
|
+
import { applyTrustedCodexAppActivityMarker, applyTrustedCodexAppStateMarker, CODEX_APP_NO_PROGRESS_TIMEOUT_MS, CodexAppFlushPromptReplay, CodexAppReadyAuthority, CodexAppTurnLiveness, } from './utils/codex-app-turn-liveness.js';
|
|
162
|
+
import { CodexAppTurnDispatchQueue } from './utils/codex-app-turn-dispatch.js';
|
|
163
|
+
import { committedCodexAppSequence, validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
|
|
164
|
+
import { CODEX_APP_CONTROL_BOOTSTRAP_ENV, CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS, CodexAppControlFinalAssembler, CodexAppControlLineDecoder, CodexAppControlProofDeadline, CodexAppControlRecordApplicationGate, CodexAppControlReplayWindow, CodexAppControlSequenceFence, activateCodexAppControlIdentity, acquireCodexAppControlOwnerLease, acquireCodexAppPosixOwnerLease, armCodexAppControlStartupTimeout, authenticateCodexAppControlCandidate, bindThenPublishCodexAppControlLocator, cleanupStaleCodexAppControlBootstraps, codexAppSignedStateReadiness, codexAppControlLocatorPath, codexAppPosixControlRoot, codexAppControlStatePath, codexAppWindowsOwnerPipeEndpoint, codexAppWindowsControlRoot, createCodexAppControlBootstrap, encodeCodexAppControlAck, encodeCodexAppControlAccepted, encodeCodexAppControlChallenge, ensureCodexAppControlDirectory, generateCodexAppControlEpoch, generateCodexAppControlChallenge, generateCodexAppPosixSocketEndpoint, generateCodexAppWindowsPipeEndpoint, hardenWindowsCodexAppControlFile, mergeCodexAppControlCandidate, parseCodexAppControlWireRecord, projectCodexAppControlReadinessStatus, readCodexAppControlState, shouldColdStartCodexAppReattach, shouldFailCodexAppControlChannel, verifyCodexAppSignedControlMarker, writeCodexAppControlLocator, writeCodexAppControlState, } from './utils/codex-app-control.js';
|
|
165
|
+
// Never inherit a session-level CLI home from the daemon's launch environment.
|
|
166
|
+
// Each worker re-pins the current bot's home after this scrub.
|
|
156
167
|
scrubSessionCliHomeEnv(process.env);
|
|
157
168
|
// Claude session-identity markers ride the same restart-from-a-session vector
|
|
158
169
|
// and this process's env seeds childEnv AND the tmux client env — a marker
|
|
@@ -649,6 +660,16 @@ async function engageCodexRpc(cfg) {
|
|
|
649
660
|
// terminal are the authoritative recovery surface (Codex P1).
|
|
650
661
|
if (shouldPreMarkFirstTurn(first))
|
|
651
662
|
codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
|
|
663
|
+
if (first === 'accepted' && cfg.queuedActivationToken) {
|
|
664
|
+
// Fresh RPC input bypasses pendingMessages/flushPending entirely. The
|
|
665
|
+
// app-server's accepted turn/start (or positive rollout proof) is the
|
|
666
|
+
// durable submission boundary for the daemon's queued-opening journal.
|
|
667
|
+
send({
|
|
668
|
+
type: 'queued_activation_submitted',
|
|
669
|
+
sessionId: cfg.sessionId,
|
|
670
|
+
activationToken: cfg.queuedActivationToken,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
652
673
|
outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
|
|
653
674
|
}
|
|
654
675
|
codexRpcEngine = engine;
|
|
@@ -733,6 +754,13 @@ function armRpcStartupDialogDismiss() {
|
|
|
733
754
|
};
|
|
734
755
|
rpcDialogDismissTimer = setTimeout(tick, 2500);
|
|
735
756
|
}
|
|
757
|
+
let cliSpawnGeneration = 0;
|
|
758
|
+
class CliSpawnSupersededError extends Error {
|
|
759
|
+
constructor() {
|
|
760
|
+
super('CLI spawn was superseded by a newer lifecycle operation');
|
|
761
|
+
this.name = 'CliSpawnSupersededError';
|
|
762
|
+
}
|
|
763
|
+
}
|
|
736
764
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
737
765
|
let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
|
|
738
766
|
let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
|
|
@@ -740,6 +768,7 @@ let sandboxCleanup = null; // reclaim deny-mask mountpoints + rm the per-session
|
|
|
740
768
|
let sandboxRelayOutbox = null;
|
|
741
769
|
let sandboxRelayCapability = null;
|
|
742
770
|
let readIsolationOriginCapabilityFile = null;
|
|
771
|
+
let readIsolationOriginChannelId = null;
|
|
743
772
|
let sandboxTeardownDone = false; // guards the exit-time best-effort teardown from double-running / running on suspend-for-resume
|
|
744
773
|
let sessionMcpGatewayHost = null;
|
|
745
774
|
/** Counts consecutive in-worker restart cycles (see case 'restart'). Used by
|
|
@@ -1077,6 +1106,13 @@ function backendScreenEvidenceIsAuthoritativeForMutation() {
|
|
|
1077
1106
|
* generation. The daemon receives this over private IPC; child-writable PID
|
|
1078
1107
|
* marker files remain diagnostics only. */
|
|
1079
1108
|
let currentCliCredentialIsolated = false;
|
|
1109
|
+
/** Successful Riff close prepare awaiting durable daemon commit. */
|
|
1110
|
+
let preparedCloseRequestId = null;
|
|
1111
|
+
let closeRequestInFlightId = null;
|
|
1112
|
+
let lastAbortedCloseRequestId = null;
|
|
1113
|
+
/** Graceful daemon-shutdown detach stays alive until lineage commit. */
|
|
1114
|
+
let shutdownDetachRequestId = null;
|
|
1115
|
+
let shutdownDetachPhase = null;
|
|
1080
1116
|
/** pty-under-zellij backend (BACKEND_TYPE=zellij). Behaves like the non-tmux
|
|
1081
1117
|
* pty path for the worker (renderer screenshots, relay web terminal) but owns
|
|
1082
1118
|
* a persistent zellij session that survives daemon restart. */
|
|
@@ -1234,7 +1270,7 @@ const READY_SIGNAL_TIMEOUT_MS = 45_000;
|
|
|
1234
1270
|
const FIRST_PROMPT_TIMEOUT_MS = 15_000;
|
|
1235
1271
|
/** Hard cap for startup screens that outlive the soft fallback. Prevents a
|
|
1236
1272
|
* changed/missing readyPattern from trapping the first queued input forever. */
|
|
1237
|
-
const FIRST_PROMPT_HARD_TIMEOUT_MS =
|
|
1273
|
+
const FIRST_PROMPT_HARD_TIMEOUT_MS = CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS;
|
|
1238
1274
|
/** Epoch ms of the most recent PTY output — used to settle for quiescence
|
|
1239
1275
|
* before the first flush (see settleThenFlush). */
|
|
1240
1276
|
let lastPtyOutputAtMs = 0;
|
|
@@ -1339,53 +1375,19 @@ const COCO_SLASH_TYPE_THROTTLE_MS = 40;
|
|
|
1339
1375
|
* single write + CR. Shared by the `raw_input` IPC handler and runStartupCommands
|
|
1340
1376
|
* so both stay in lockstep. */
|
|
1341
1377
|
async function sendRawCommandLine(be, content) {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
// isn't typed as a literal newline that re-breaks command detection.
|
|
1356
|
-
const cmd = content.trim();
|
|
1357
|
-
const typed = cmd.includes(' ') ? cmd : `${cmd} `;
|
|
1358
|
-
for (const ch of typed) {
|
|
1359
|
-
if (be.sendText(ch) === false) {
|
|
1360
|
-
throw new Error('backend rejected command text input');
|
|
1361
|
-
}
|
|
1362
|
-
await new Promise(r => setTimeout(r, COCO_SLASH_TYPE_THROTTLE_MS));
|
|
1363
|
-
}
|
|
1364
|
-
await new Promise(r => setTimeout(r, 200));
|
|
1365
|
-
if (be.sendSpecialKeys('Enter') === false) {
|
|
1366
|
-
throw new Error('backend rejected command submit key');
|
|
1367
|
-
}
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
if (be.sendText(content) === false) {
|
|
1371
|
-
throw new Error('backend rejected command text input');
|
|
1372
|
-
}
|
|
1373
|
-
await new Promise(r => setTimeout(r, 200));
|
|
1374
|
-
if (be.sendSpecialKeys('Enter') === false) {
|
|
1375
|
-
throw new Error('backend rejected command submit key');
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
else {
|
|
1379
|
-
// PtyBackend has no sendText/sendSpecialKeys, so write the keystrokes
|
|
1380
|
-
// directly — but still beat between the text and the Enter. Writing
|
|
1381
|
-
// `content + '\r'` in one chunk submits before the CLI's slash-command
|
|
1382
|
-
// parser has registered the `/cmd` match, so the command is left
|
|
1383
|
-
// unsent in the input box (observed with `/goal <text>` on a pty
|
|
1384
|
-
// workflow worker: typed but never executed). Mirror the tmux path's
|
|
1385
|
-
// 200ms beat.
|
|
1386
|
-
be.write(content);
|
|
1387
|
-
await new Promise(r => setTimeout(r, 200));
|
|
1388
|
-
be.write('\r');
|
|
1378
|
+
// PR #597 extracted the CoCo/pty keystroke choreography into the shared
|
|
1379
|
+
// writeRawCommandLine helper (unit-tested in raw-command-writer.ts). It
|
|
1380
|
+
// reports a rejected text/Enter write as `false` instead of throwing; master's
|
|
1381
|
+
// recovery-fence transaction detects a failed submission by a thrown error, so
|
|
1382
|
+
// bridge the two: turn an explicit `false` into the same throw the inline
|
|
1383
|
+
// implementation used, letting runAmbiguousSubmissionTransaction cancel the WAL.
|
|
1384
|
+
const accepted = await writeRawCommandLine(be, content, {
|
|
1385
|
+
coco: lastInitConfig?.cliId === 'coco',
|
|
1386
|
+
cocoThrottleMs: COCO_SLASH_TYPE_THROTTLE_MS,
|
|
1387
|
+
submitBeatMs: 200,
|
|
1388
|
+
});
|
|
1389
|
+
if (accepted === false) {
|
|
1390
|
+
throw new Error('backend rejected command text or submit key input');
|
|
1389
1391
|
}
|
|
1390
1392
|
}
|
|
1391
1393
|
/** Serialize one complete raw command-line transaction (fence -> text -> Enter
|
|
@@ -1481,6 +1483,9 @@ async function runStartupCommands() {
|
|
|
1481
1483
|
}
|
|
1482
1484
|
const freshnessInputQueue = new CodexRunnerFreshnessInputQueue(() => codexRunnerFreshness, state => { codexRunnerFreshness = state; });
|
|
1483
1485
|
const pendingMessages = freshnessInputQueue.normal;
|
|
1486
|
+
/** Async init must materialize its opening input before follow-ups may flush
|
|
1487
|
+
* or shutdown may fence the worker generation. */
|
|
1488
|
+
let initPromptMaterialized = false;
|
|
1484
1489
|
/** Ordinary Lark IM turns may be retransmitted by the daemon when the exact
|
|
1485
1490
|
* receipt ACK times out. Fence those retries before any renderer / adapter side
|
|
1486
1491
|
* effects so one Lark message can enter the CLI input queue at most once per
|
|
@@ -1603,9 +1608,9 @@ async function deliverRawInput(msg) {
|
|
|
1603
1608
|
publishSandboxRelayCapability();
|
|
1604
1609
|
});
|
|
1605
1610
|
sent = true;
|
|
1606
|
-
isPromptReady
|
|
1607
|
-
|
|
1608
|
-
|
|
1611
|
+
// The accepted side effects (isPromptReady/idle reset/log) are owned by
|
|
1612
|
+
// finalizeRawCommandDelivery.onAccepted below so the durable-activation
|
|
1613
|
+
// ACK and follow-up enqueue stay in one ordered dispatcher.
|
|
1609
1614
|
// A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
|
|
1610
1615
|
// confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
|
|
1611
1616
|
// `/effort` opens a level picker (unknown target) and is left alone.
|
|
@@ -1639,14 +1644,39 @@ async function deliverRawInput(msg) {
|
|
|
1639
1644
|
}),
|
|
1640
1645
|
});
|
|
1641
1646
|
}
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1647
|
+
finalizeRawCommandDelivery({
|
|
1648
|
+
accepted: sent,
|
|
1649
|
+
durableActivation: !!msg.queuedActivationToken,
|
|
1650
|
+
acknowledgeActivation: !!msg.queuedActivationToken,
|
|
1651
|
+
hasFollowUp: !!msg.followUpContent,
|
|
1652
|
+
onAccepted: () => {
|
|
1653
|
+
isPromptReady = false;
|
|
1654
|
+
idleDetector?.reset();
|
|
1655
|
+
log(`Passthrough slash command: ${msg.content}`);
|
|
1656
|
+
},
|
|
1657
|
+
// Follow-up rides on the same IPC and is enqueued only after this command's
|
|
1658
|
+
// Enter lands. sendToPty also observes commandLineWritesPending, so another
|
|
1659
|
+
// raw command's text -> Enter window cannot be interrupted by the follow-up.
|
|
1660
|
+
onFollowUp: () => {
|
|
1661
|
+
sendToPty(msg.followUpContent, msg.followUpTurnId, {
|
|
1662
|
+
codexAppInput: msg.followUpCodexAppInput,
|
|
1663
|
+
});
|
|
1664
|
+
log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
|
|
1665
|
+
},
|
|
1666
|
+
// Pending-repo raw openings are durable too. ACK only after text + Enter.
|
|
1667
|
+
onActivationAck: () => send({
|
|
1668
|
+
type: 'queued_activation_submitted',
|
|
1669
|
+
sessionId,
|
|
1670
|
+
activationToken: msg.queuedActivationToken,
|
|
1671
|
+
}),
|
|
1672
|
+
onDurableFailure: () => {
|
|
1673
|
+
isPromptReady = false;
|
|
1674
|
+
log(`Durable raw activation write rejected; retiring worker generation`);
|
|
1675
|
+
void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
|
|
1676
|
+
},
|
|
1677
|
+
});
|
|
1678
|
+
if (!sent && !msg.queuedActivationToken) {
|
|
1679
|
+
log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
|
|
1650
1680
|
}
|
|
1651
1681
|
// A pending /rename may have been held by the command-write mutex. It still
|
|
1652
1682
|
// waits for a genuine prompt because isPromptReady was cleared above.
|
|
@@ -1677,19 +1707,6 @@ function publishSandboxRelayCapability(opts = {}) {
|
|
|
1677
1707
|
body: JSON.stringify({ token: capability.token }),
|
|
1678
1708
|
}]
|
|
1679
1709
|
: []),
|
|
1680
|
-
...(readIsolationOriginCapabilityFile && sessionId
|
|
1681
|
-
? [{
|
|
1682
|
-
path: readIsolationOriginCapabilityFile,
|
|
1683
|
-
body: JSON.stringify({
|
|
1684
|
-
sessionId,
|
|
1685
|
-
capability: capability.token,
|
|
1686
|
-
...(capability.turnId ? { turnId: capability.turnId } : {}),
|
|
1687
|
-
...(capability.dispatchAttempt !== undefined
|
|
1688
|
-
? { dispatchAttempt: capability.dispatchAttempt }
|
|
1689
|
-
: {}),
|
|
1690
|
-
}),
|
|
1691
|
-
}]
|
|
1692
|
-
: []),
|
|
1693
1710
|
];
|
|
1694
1711
|
let publishError;
|
|
1695
1712
|
for (const file of files) {
|
|
@@ -1719,6 +1736,9 @@ function publishSandboxRelayCapability(opts = {}) {
|
|
|
1719
1736
|
type: 'managed_turn_origin',
|
|
1720
1737
|
sessionId,
|
|
1721
1738
|
capability: capability.token,
|
|
1739
|
+
...(readIsolationOriginChannelId
|
|
1740
|
+
? { originChannelId: readIsolationOriginChannelId }
|
|
1741
|
+
: {}),
|
|
1722
1742
|
...(capability.turnId ? { turnId: capability.turnId } : {}),
|
|
1723
1743
|
...(capability.dispatchAttempt !== undefined
|
|
1724
1744
|
? { dispatchAttempt: capability.dispatchAttempt }
|
|
@@ -1728,11 +1748,15 @@ function publishSandboxRelayCapability(opts = {}) {
|
|
|
1728
1748
|
return true;
|
|
1729
1749
|
}
|
|
1730
1750
|
function unlinkManagedOriginCapabilityFiles() {
|
|
1751
|
+
// The Linux outbox belongs to this worker generation and can be removed.
|
|
1752
|
+
// The macOS capability belongs to the persistent pane channel. A warm
|
|
1753
|
+
// Node-worker reattach reuses that path, so stale worker teardown must never
|
|
1754
|
+
// unlink the successor worker's freshly published token. Leaving old bytes
|
|
1755
|
+
// is fail-closed against the daemon's exact live origin tuple.
|
|
1731
1756
|
const files = [
|
|
1732
1757
|
sandboxRelayOutbox
|
|
1733
1758
|
? join(sandboxRelayOutbox, RELAY_ORIGIN_CAPABILITY_BASENAME)
|
|
1734
1759
|
: undefined,
|
|
1735
|
-
readIsolationOriginCapabilityFile ?? undefined,
|
|
1736
1760
|
];
|
|
1737
1761
|
for (const file of new Set(files.filter((p) => !!p))) {
|
|
1738
1762
|
try {
|
|
@@ -1741,28 +1765,6 @@ function unlinkManagedOriginCapabilityFiles() {
|
|
|
1741
1765
|
catch { /* absent or teardown racing */ }
|
|
1742
1766
|
}
|
|
1743
1767
|
}
|
|
1744
|
-
/** Read a host-owned isolation marker without following a child-planted
|
|
1745
|
-
* symlink between lookup and open. */
|
|
1746
|
-
function readRegularHostFileNoFollow(filePath) {
|
|
1747
|
-
let fd;
|
|
1748
|
-
try {
|
|
1749
|
-
fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
1750
|
-
if (!fstatSync(fd).isFile())
|
|
1751
|
-
return null;
|
|
1752
|
-
return readFileSync(fd, 'utf8');
|
|
1753
|
-
}
|
|
1754
|
-
catch {
|
|
1755
|
-
return null;
|
|
1756
|
-
}
|
|
1757
|
-
finally {
|
|
1758
|
-
if (fd !== undefined) {
|
|
1759
|
-
try {
|
|
1760
|
-
closeSync(fd);
|
|
1761
|
-
}
|
|
1762
|
-
catch { /* best-effort */ }
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
1768
|
function completeManagedTurnOriginRevocation(revoked, turnId, dispatchAttempt) {
|
|
1767
1769
|
// Clear local authority before queuing daemon IPC. A forked/delayed process
|
|
1768
1770
|
// can otherwise win the small window between terminal publication and
|
|
@@ -1774,6 +1776,9 @@ function completeManagedTurnOriginRevocation(revoked, turnId, dispatchAttempt) {
|
|
|
1774
1776
|
type: 'managed_turn_origin_revoked',
|
|
1775
1777
|
sessionId,
|
|
1776
1778
|
...(revoked ? { capability: revoked.token } : {}),
|
|
1779
|
+
...(readIsolationOriginChannelId
|
|
1780
|
+
? { originChannelId: readIsolationOriginChannelId }
|
|
1781
|
+
: {}),
|
|
1777
1782
|
...(turnId ? { turnId } : {}),
|
|
1778
1783
|
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
1779
1784
|
});
|
|
@@ -1818,7 +1823,18 @@ function authorizeManagedSend(claim) {
|
|
|
1818
1823
|
if (!capability || !claim.capability || claim.capability !== capability.token) {
|
|
1819
1824
|
return { ok: false, error: 'origin_mismatch: relay capability is stale or missing' };
|
|
1820
1825
|
}
|
|
1821
|
-
|
|
1826
|
+
// The daemon owns and rotates this ledger in another process. Never consult
|
|
1827
|
+
// SessionStore's worker-local cache here: it was loaded at init and would
|
|
1828
|
+
// stale-authorize turn N or reject turn N+1 after daemon settlement.
|
|
1829
|
+
const session = sessionStore.getSessionFresh(sessionId);
|
|
1830
|
+
const ledger = session?.codexAppDispatchLedger ?? [];
|
|
1831
|
+
const codexAppManagedOrigin = lastInitConfig?.cliId === 'codex-app'
|
|
1832
|
+
|| session?.cliId === 'codex-app';
|
|
1833
|
+
const codexLedgerDecision = validateCodexAppManagedSendOrigin(ledger, capability, codexAppManagedOrigin);
|
|
1834
|
+
if (!codexLedgerDecision.ok) {
|
|
1835
|
+
return { ok: false, error: `origin_mismatch: ${codexLedgerDecision.error}` };
|
|
1836
|
+
}
|
|
1837
|
+
const requiresCodexAppLedger = codexLedgerDecision.requiresLedger;
|
|
1822
1838
|
const currentImOrigin = currentVcMeetingImTurnOrigin;
|
|
1823
1839
|
const imOrigin = currentImOrigin?.larkMessageId === capability.turnId
|
|
1824
1840
|
&& currentImOrigin?.receiverSessionId === sessionId
|
|
@@ -1839,6 +1855,7 @@ function authorizeManagedSend(claim) {
|
|
|
1839
1855
|
...(capability.dispatchAttempt !== undefined
|
|
1840
1856
|
? { dispatchAttempt: capability.dispatchAttempt }
|
|
1841
1857
|
: {}),
|
|
1858
|
+
...(requiresCodexAppLedger ? { requiresCodexAppLedger: true } : {}),
|
|
1842
1859
|
},
|
|
1843
1860
|
}
|
|
1844
1861
|
: { ok: false, error: `${decision.errorCode}: ${decision.error}` };
|
|
@@ -1893,6 +1910,889 @@ function writeCliPidMarker() {
|
|
|
1893
1910
|
}
|
|
1894
1911
|
}
|
|
1895
1912
|
let lastStructuredBridgeActivityAtMs = 0;
|
|
1913
|
+
const codexAppTurnLiveness = new CodexAppTurnLiveness();
|
|
1914
|
+
const codexAppReadyAuthority = new CodexAppReadyAuthority();
|
|
1915
|
+
const codexAppTurnDispatchQueue = new CodexAppTurnDispatchQueue();
|
|
1916
|
+
let codexAppFallbackTurnSequence = 0;
|
|
1917
|
+
let codexAppRecoveredDispatches = [];
|
|
1918
|
+
let codexAppGenerationCommits = [];
|
|
1919
|
+
const codexAppPendingDaemonAcks = new Map();
|
|
1920
|
+
let codexAppCompletionAwaitingFinal = false;
|
|
1921
|
+
let codexAppControlBootstrapPathForSpawn;
|
|
1922
|
+
let codexAppControlStateValue;
|
|
1923
|
+
let codexAppControlProven = false;
|
|
1924
|
+
/** Authentication proves identity, not app-server readiness. These become true
|
|
1925
|
+
* only after the active generation publishes a valid signed state marker. */
|
|
1926
|
+
let codexAppSignedStateObserved = false;
|
|
1927
|
+
let codexAppInputReady = false;
|
|
1928
|
+
let codexAppControlFatal = false;
|
|
1929
|
+
let codexAppControlPersistentGeneration = false;
|
|
1930
|
+
let codexAppControlDirectoryForSpawn;
|
|
1931
|
+
let codexAppControlSocketPathValue;
|
|
1932
|
+
let codexAppControlSocketDirectory;
|
|
1933
|
+
let codexAppControlLocatorPathValue;
|
|
1934
|
+
let codexAppControlEndpointEpoch;
|
|
1935
|
+
let codexAppControlServer;
|
|
1936
|
+
let codexAppWindowsOwnerLeaseServer;
|
|
1937
|
+
let codexAppWindowsOwnerLeaseSessionId;
|
|
1938
|
+
let codexAppWindowsOwnerLeasePromise;
|
|
1939
|
+
let codexAppPosixOwnerLease;
|
|
1940
|
+
let codexAppPosixOwnerLeaseSessionId;
|
|
1941
|
+
let codexAppPosixOwnerLeasePromise;
|
|
1942
|
+
let codexAppControlActiveSocket;
|
|
1943
|
+
let codexAppControlActiveIdentity;
|
|
1944
|
+
let codexAppFreshCandidateGeneration;
|
|
1945
|
+
let codexAppUnprovenPromptDeferred = false;
|
|
1946
|
+
let codexAppRejectedControlLogged = false;
|
|
1947
|
+
let codexAppMalformedControlLogged = false;
|
|
1948
|
+
let codexAppBootstrapCleanupTimer;
|
|
1949
|
+
const codexAppProofDeadline = new CodexAppControlProofDeadline();
|
|
1950
|
+
let codexAppControlStopping = false;
|
|
1951
|
+
let codexAppControlChannelId = 0;
|
|
1952
|
+
let codexAppControlRotation;
|
|
1953
|
+
const CODEX_APP_CONTROL_ENDPOINT_RETRY_MS = 5_000;
|
|
1954
|
+
const codexAppControlConnections = new Map();
|
|
1955
|
+
const codexAppControlReplayWindow = new CodexAppControlReplayWindow();
|
|
1956
|
+
const codexAppControlRecordApplicationGate = new CodexAppControlRecordApplicationGate();
|
|
1957
|
+
function cleanupCodexAppControlBootstrap() {
|
|
1958
|
+
if (codexAppBootstrapCleanupTimer) {
|
|
1959
|
+
clearTimeout(codexAppBootstrapCleanupTimer);
|
|
1960
|
+
codexAppBootstrapCleanupTimer = undefined;
|
|
1961
|
+
}
|
|
1962
|
+
const path = codexAppControlBootstrapPathForSpawn;
|
|
1963
|
+
codexAppControlBootstrapPathForSpawn = undefined;
|
|
1964
|
+
if (!path)
|
|
1965
|
+
return;
|
|
1966
|
+
try {
|
|
1967
|
+
unlinkSync(path);
|
|
1968
|
+
}
|
|
1969
|
+
catch { /* runner normally unlinks it first */ }
|
|
1970
|
+
}
|
|
1971
|
+
function readPersistedCodexAppControlState(cfg) {
|
|
1972
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
1973
|
+
if (!dataDir && process.platform !== 'win32')
|
|
1974
|
+
return undefined;
|
|
1975
|
+
const statePath = codexAppControlStatePath(dataDir ?? '', cfg.sessionId);
|
|
1976
|
+
if (process.platform === 'win32') {
|
|
1977
|
+
ensureCodexAppControlDirectory(codexAppWindowsControlRoot());
|
|
1978
|
+
ensureCodexAppControlDirectory(dirname(statePath));
|
|
1979
|
+
if (existsSync(statePath))
|
|
1980
|
+
hardenWindowsCodexAppControlFile(statePath);
|
|
1981
|
+
}
|
|
1982
|
+
return readCodexAppControlState(statePath);
|
|
1983
|
+
}
|
|
1984
|
+
function persistCodexAppControlState(cfg, state) {
|
|
1985
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
1986
|
+
if (!dataDir && process.platform !== 'win32') {
|
|
1987
|
+
throw new Error('SESSION_DATA_DIR is required for persistent Codex App control state');
|
|
1988
|
+
}
|
|
1989
|
+
const statePath = codexAppControlStatePath(dataDir ?? '', cfg.sessionId);
|
|
1990
|
+
if (process.platform === 'win32') {
|
|
1991
|
+
ensureCodexAppControlDirectory(codexAppWindowsControlRoot());
|
|
1992
|
+
ensureCodexAppControlDirectory(dirname(statePath));
|
|
1993
|
+
}
|
|
1994
|
+
writeCodexAppControlState(statePath, state);
|
|
1995
|
+
}
|
|
1996
|
+
function stopCodexAppControlChannel(opts = {}) {
|
|
1997
|
+
codexAppControlStopping = true;
|
|
1998
|
+
// Attribution belongs to exactly one worker/control generation. A fresh or
|
|
1999
|
+
// replacement worker may recover only the daemon-frozen active identity
|
|
2000
|
+
// after the old runner proves a warm reattach; stale queued entries must not
|
|
2001
|
+
// cross a stop/restart boundary.
|
|
2002
|
+
if (!opts.preserveDispatchRecovery) {
|
|
2003
|
+
codexAppTurnDispatchQueue.clear();
|
|
2004
|
+
codexAppRecoveredDispatches = [];
|
|
2005
|
+
}
|
|
2006
|
+
for (const pending of codexAppPendingDaemonAcks.values()) {
|
|
2007
|
+
clearTimeout(pending.timer);
|
|
2008
|
+
pending.resolve(false);
|
|
2009
|
+
}
|
|
2010
|
+
codexAppPendingDaemonAcks.clear();
|
|
2011
|
+
codexAppControlChannelId++;
|
|
2012
|
+
codexAppControlRotation = undefined;
|
|
2013
|
+
codexAppProofDeadline.clear();
|
|
2014
|
+
codexAppSignedStateObserved = false;
|
|
2015
|
+
codexAppInputReady = false;
|
|
2016
|
+
for (const connection of codexAppControlConnections.values()) {
|
|
2017
|
+
clearTimeout(connection.authTimer);
|
|
2018
|
+
connection.socket.destroy();
|
|
2019
|
+
}
|
|
2020
|
+
codexAppControlConnections.clear();
|
|
2021
|
+
codexAppControlActiveSocket = undefined;
|
|
2022
|
+
codexAppControlActiveIdentity = undefined;
|
|
2023
|
+
const server = codexAppControlServer;
|
|
2024
|
+
codexAppControlServer = undefined;
|
|
2025
|
+
try {
|
|
2026
|
+
server?.close();
|
|
2027
|
+
}
|
|
2028
|
+
catch { /* worker exit/restart */ }
|
|
2029
|
+
const socketPath = codexAppControlSocketPathValue;
|
|
2030
|
+
// Named pipes are kernel objects, not filesystem entries. Never lstat,
|
|
2031
|
+
// chmod, or unlink them on Windows; closing the server retires the endpoint.
|
|
2032
|
+
if (socketPath && process.platform !== 'win32') {
|
|
2033
|
+
try {
|
|
2034
|
+
const stat = lstatSync(socketPath);
|
|
2035
|
+
const uid = process.geteuid?.() ?? process.getuid?.();
|
|
2036
|
+
if (stat.isSocket() && !stat.isSymbolicLink() && (uid === undefined || stat.uid === uid)) {
|
|
2037
|
+
unlinkSync(socketPath);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
catch { /* absent or already removed */ }
|
|
2041
|
+
}
|
|
2042
|
+
// Do not read-then-unlink the fixed locator here. That is not an
|
|
2043
|
+
// atomic compare-and-delete: a replacement process could publish a new epoch
|
|
2044
|
+
// between those operations. A stale locator is harmless (the random pipe is
|
|
2045
|
+
// closed and the runner validates its independent epoch) and the next owner
|
|
2046
|
+
// overwrites it atomically after binding a fresh endpoint.
|
|
2047
|
+
codexAppControlEndpointEpoch = undefined;
|
|
2048
|
+
}
|
|
2049
|
+
function failCodexAppControlGeneration(reason) {
|
|
2050
|
+
if (codexAppControlFatal)
|
|
2051
|
+
return;
|
|
2052
|
+
codexAppControlFatal = true;
|
|
2053
|
+
log(`Codex App control generation failed closed: ${reason}`);
|
|
2054
|
+
codexAppControlProven = false;
|
|
2055
|
+
codexAppSignedStateObserved = false;
|
|
2056
|
+
codexAppInputReady = false;
|
|
2057
|
+
codexAppTurnLiveness.clear();
|
|
2058
|
+
codexAppReadyAuthority.reset();
|
|
2059
|
+
cleanupCodexAppControlBootstrap();
|
|
2060
|
+
stopCodexAppControlChannel();
|
|
2061
|
+
const cfg = lastInitConfig;
|
|
2062
|
+
if (cfg && effectiveBackendType !== 'pty') {
|
|
2063
|
+
const name = effectiveBackendType === 'tmux'
|
|
2064
|
+
? TmuxBackend.sessionName(cfg.sessionId)
|
|
2065
|
+
: effectiveBackendType === 'zellij'
|
|
2066
|
+
? ZellijBackend.sessionName(cfg.sessionId)
|
|
2067
|
+
: effectiveBackendType === 'herdr'
|
|
2068
|
+
? HerdrBackend.sessionName(cfg.sessionId)
|
|
2069
|
+
: undefined;
|
|
2070
|
+
if (name) {
|
|
2071
|
+
try {
|
|
2072
|
+
killPersistentSession(effectiveBackendType, name);
|
|
2073
|
+
}
|
|
2074
|
+
catch (err) {
|
|
2075
|
+
log(`Failed to kill rejected Codex App generation: ${err?.message ?? err}`);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
try {
|
|
2080
|
+
backend?.kill();
|
|
2081
|
+
}
|
|
2082
|
+
catch { /* process exit is the final fail-close */ }
|
|
2083
|
+
backend = null;
|
|
2084
|
+
queueMicrotask(() => {
|
|
2085
|
+
void sendFatalWorkerErrorAndExit(new Error(reason), undefined, undefined, { hardExit: true });
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
function activateCodexAppControlConnection(connection, identity) {
|
|
2089
|
+
const cfg = lastInitConfig;
|
|
2090
|
+
const state = codexAppControlStateValue;
|
|
2091
|
+
if (!cfg || !state) {
|
|
2092
|
+
connection.socket.destroy();
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
const proofKind = identity.generation === codexAppFreshCandidateGeneration
|
|
2096
|
+
? 'fresh runner'
|
|
2097
|
+
: 'warm reattach';
|
|
2098
|
+
if (proofKind === 'fresh runner'
|
|
2099
|
+
&& codexAppRecoveredDispatches.some(entry => entry.state === 'prepared')) {
|
|
2100
|
+
// A fresh process cannot prove whether the prior generation buffered a
|
|
2101
|
+
// prepared frame. Fail before persisting/announcing this generation or
|
|
2102
|
+
// ACKing authentication; otherwise the daemon may trim recovery state and
|
|
2103
|
+
// observers can briefly treat an unusable runner as active.
|
|
2104
|
+
connection.socket.destroy();
|
|
2105
|
+
failCodexAppControlGeneration('Fresh Codex App runner cannot adopt a recovered prepared dispatch');
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
let active;
|
|
2109
|
+
try {
|
|
2110
|
+
active = activateCodexAppControlIdentity(state, identity.generation);
|
|
2111
|
+
if (codexAppControlPersistentGeneration)
|
|
2112
|
+
persistCodexAppControlState(cfg, active);
|
|
2113
|
+
}
|
|
2114
|
+
catch (err) {
|
|
2115
|
+
failCodexAppControlGeneration(`Could not persist authenticated Codex App generation: ${err?.message ?? err}`);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
codexAppControlStateValue = active;
|
|
2119
|
+
codexAppControlProven = true;
|
|
2120
|
+
codexAppSignedStateObserved = false;
|
|
2121
|
+
codexAppInputReady = false;
|
|
2122
|
+
if (!codexAppProofDeadline.armed) {
|
|
2123
|
+
codexAppProofDeadline.arm(() => {
|
|
2124
|
+
failCodexAppControlGeneration(`Authenticated Codex App runner did not publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
codexAppControlActiveIdentity = identity;
|
|
2128
|
+
codexAppControlReplayWindow.retainOnly(identity.generation);
|
|
2129
|
+
connection.authenticated = true;
|
|
2130
|
+
connection.identity = identity;
|
|
2131
|
+
clearTimeout(connection.authTimer);
|
|
2132
|
+
const previous = codexAppControlActiveSocket;
|
|
2133
|
+
codexAppControlActiveSocket = connection.socket;
|
|
2134
|
+
if (previous && previous !== connection.socket)
|
|
2135
|
+
previous.destroy();
|
|
2136
|
+
cleanupCodexAppControlBootstrap();
|
|
2137
|
+
connection.socket.write(`${encodeCodexAppControlAccepted(cfg.sessionId, identity.generation, connection.challenge, connection.epoch)}\n`);
|
|
2138
|
+
log(`Authenticated Codex App ${proofKind} by Ed25519 challenge proof (generation=${identity.generation.slice(0, 8)})`);
|
|
2139
|
+
send({
|
|
2140
|
+
type: 'codex_app_generation_active',
|
|
2141
|
+
sessionId: cfg.sessionId,
|
|
2142
|
+
generation: identity.generation,
|
|
2143
|
+
fresh: proofKind === 'fresh runner',
|
|
2144
|
+
});
|
|
2145
|
+
if (proofKind === 'fresh runner') {
|
|
2146
|
+
codexAppGenerationCommits = codexAppGenerationCommits.filter(commit => commit.generation === identity.generation);
|
|
2147
|
+
}
|
|
2148
|
+
if (proofKind === 'warm reattach') {
|
|
2149
|
+
codexAppTurnLiveness.beginReattachObservation();
|
|
2150
|
+
}
|
|
2151
|
+
// Authentication is not an input-ready barrier. A warm runner may still
|
|
2152
|
+
// replay old final/state records immediately after auth. Only the later
|
|
2153
|
+
// signed idle record, after recovered-prefix reconciliation, may flush.
|
|
2154
|
+
}
|
|
2155
|
+
async function handleCodexAppControlLine(connection, line) {
|
|
2156
|
+
const record = parseCodexAppControlWireRecord(line);
|
|
2157
|
+
const cfg = lastInitConfig;
|
|
2158
|
+
if (!record || !cfg || record.sessionId !== cfg.sessionId
|
|
2159
|
+
|| connection.channelId !== codexAppControlChannelId
|
|
2160
|
+
|| connection.epoch !== codexAppControlEndpointEpoch) {
|
|
2161
|
+
rejectCodexAppControlMarker('malformed socket');
|
|
2162
|
+
connection.socket.destroy();
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (!connection.authenticated) {
|
|
2166
|
+
if (record.type !== 'auth' || record.challenge !== connection.challenge) {
|
|
2167
|
+
rejectCodexAppControlMarker(record.type);
|
|
2168
|
+
connection.socket.destroy();
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const identity = authenticateCodexAppControlCandidate({
|
|
2172
|
+
state: codexAppControlStateValue,
|
|
2173
|
+
auth: record,
|
|
2174
|
+
sessionId: cfg.sessionId,
|
|
2175
|
+
challenge: connection.challenge,
|
|
2176
|
+
});
|
|
2177
|
+
if (!identity) {
|
|
2178
|
+
rejectCodexAppControlMarker('challenge response');
|
|
2179
|
+
connection.socket.destroy();
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
activateCodexAppControlConnection(connection, identity);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
const identity = connection.identity;
|
|
2186
|
+
if (record.type !== 'marker' || !identity
|
|
2187
|
+
|| connection.socket !== codexAppControlActiveSocket
|
|
2188
|
+
|| record.generation !== identity.generation
|
|
2189
|
+
|| record.challenge !== connection.challenge
|
|
2190
|
+
|| !verifyCodexAppSignedControlMarker(record, identity.publicKey)) {
|
|
2191
|
+
rejectCodexAppControlMarker(record.type === 'marker' ? record.kind : record.type);
|
|
2192
|
+
connection.socket.destroy();
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
if (!connection.sequenceFence.accept(record.seq)) {
|
|
2196
|
+
rejectCodexAppControlMarker('non-contiguous signed sequence');
|
|
2197
|
+
connection.socket.destroy();
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
// A replacement worker may see the runner replay a complete final whose
|
|
2201
|
+
// daemon-side delivery and FIFO pop were committed just before the old
|
|
2202
|
+
// worker died. The per-generation high-water mark is a durable cumulative
|
|
2203
|
+
// ACK boundary: acknowledge each replayed prefix record without assembling
|
|
2204
|
+
// or re-emitting the final.
|
|
2205
|
+
if (committedCodexAppSequence(codexAppGenerationCommits, identity.generation, record.seq)) {
|
|
2206
|
+
connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
// ACK loss is expected around endpoint replacement. The runner re-signs its
|
|
2210
|
+
// unacknowledged record against the fresh challenge; acknowledge that retry
|
|
2211
|
+
// without applying completed/final side effects twice.
|
|
2212
|
+
if (codexAppControlReplayWindow.hasSeen(identity.generation, record.seq)) {
|
|
2213
|
+
connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
const finalResult = connection.finalAssembler.accept(record.kind, record.payload);
|
|
2217
|
+
if (finalResult.status === 'reject') {
|
|
2218
|
+
rejectCodexAppControlMarker(finalResult.reason);
|
|
2219
|
+
connection.socket.destroy();
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
// start/chunk records intentionally remain uncommitted so a replacement
|
|
2223
|
+
// connection can rebuild the complete final. Every record that does apply
|
|
2224
|
+
// worker effects is single-flight by signed generation/sequence until the
|
|
2225
|
+
// replay window below is committed.
|
|
2226
|
+
if (finalResult.status === 'accepted')
|
|
2227
|
+
return;
|
|
2228
|
+
const application = codexAppControlRecordApplicationGate.run(identity.generation, record.seq, () => finalResult.status === 'not-final'
|
|
2229
|
+
? handleTrustedCodexAppMarker(record.kind, record.payload, { generation: identity.generation, seq: record.seq })
|
|
2230
|
+
: handleTrustedCodexAppMarker('final', finalResult.payload, { generation: identity.generation, seq: record.seq }));
|
|
2231
|
+
let applied;
|
|
2232
|
+
try {
|
|
2233
|
+
applied = await application;
|
|
2234
|
+
}
|
|
2235
|
+
catch (err) {
|
|
2236
|
+
codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
|
|
2237
|
+
throw err;
|
|
2238
|
+
}
|
|
2239
|
+
if (!applied) {
|
|
2240
|
+
codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
|
|
2241
|
+
// Do not commit or cumulatively ACK a semantically rejected signed marker.
|
|
2242
|
+
// In particular, a final for the wrong FIFO head must remain replayable
|
|
2243
|
+
// rather than becoming a permanently lost answer.
|
|
2244
|
+
connection.socket.destroy();
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
codexAppControlReplayWindow.commit(identity.generation, record.seq);
|
|
2248
|
+
codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
|
|
2249
|
+
if (!connection.socket.destroyed) {
|
|
2250
|
+
connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
const CODEX_APP_CONTROL_PENDING_LINE_LIMIT = 4_096;
|
|
2254
|
+
async function drainCodexAppControlLines(connection) {
|
|
2255
|
+
if (connection.processingLines)
|
|
2256
|
+
return;
|
|
2257
|
+
connection.processingLines = true;
|
|
2258
|
+
connection.socket.pause();
|
|
2259
|
+
try {
|
|
2260
|
+
while (!connection.socket.destroyed && connection.pendingLines.length > 0) {
|
|
2261
|
+
const line = connection.pendingLines.shift();
|
|
2262
|
+
await handleCodexAppControlLine(connection, line);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
catch (err) {
|
|
2266
|
+
log(`Codex App control record processing failed: ${err?.message ?? err}`);
|
|
2267
|
+
connection.socket.destroy();
|
|
2268
|
+
}
|
|
2269
|
+
finally {
|
|
2270
|
+
connection.processingLines = false;
|
|
2271
|
+
if (!connection.socket.destroyed)
|
|
2272
|
+
connection.socket.resume();
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
function acceptCodexAppControlSocket(socket, endpoint, epoch, channelId) {
|
|
2276
|
+
if (codexAppControlFatal || codexAppControlStopping || !lastInitConfig
|
|
2277
|
+
|| channelId !== codexAppControlChannelId) {
|
|
2278
|
+
socket.destroy();
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
// Never let a stalled unauthenticated client monopolize the endpoint. Every
|
|
2282
|
+
// connection gets its own challenge and timeout; new accepts continue while
|
|
2283
|
+
// bad same-UID clients are rejected independently.
|
|
2284
|
+
const unauthenticated = [...codexAppControlConnections.values()]
|
|
2285
|
+
.filter(connection => !connection.authenticated);
|
|
2286
|
+
if (unauthenticated.length >= 16) {
|
|
2287
|
+
socket.destroy();
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const challenge = generateCodexAppControlChallenge();
|
|
2291
|
+
const connection = {
|
|
2292
|
+
socket,
|
|
2293
|
+
endpoint,
|
|
2294
|
+
epoch,
|
|
2295
|
+
channelId,
|
|
2296
|
+
challenge,
|
|
2297
|
+
decoder: new CodexAppControlLineDecoder(),
|
|
2298
|
+
sequenceFence: new CodexAppControlSequenceFence(),
|
|
2299
|
+
finalAssembler: new CodexAppControlFinalAssembler(),
|
|
2300
|
+
authenticated: false,
|
|
2301
|
+
pendingLines: [],
|
|
2302
|
+
processingLines: false,
|
|
2303
|
+
authTimer: setTimeout(() => socket.destroy(), CODEX_APP_CONTROL_ENDPOINT_RETRY_MS),
|
|
2304
|
+
};
|
|
2305
|
+
connection.authTimer.unref?.();
|
|
2306
|
+
codexAppControlConnections.set(socket, connection);
|
|
2307
|
+
socket.setNoDelay(true);
|
|
2308
|
+
socket.on('data', chunk => {
|
|
2309
|
+
const decoded = connection.decoder.push(chunk);
|
|
2310
|
+
if (decoded.droppedMalformed) {
|
|
2311
|
+
if (!codexAppMalformedControlLogged) {
|
|
2312
|
+
codexAppMalformedControlLogged = true;
|
|
2313
|
+
log('Dropped malformed/oversized Codex App socket control line');
|
|
2314
|
+
}
|
|
2315
|
+
socket.destroy();
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (connection.pendingLines.length + decoded.lines.length > CODEX_APP_CONTROL_PENDING_LINE_LIMIT) {
|
|
2319
|
+
log('Dropped Codex App control connection whose pending line queue exceeded the bound');
|
|
2320
|
+
socket.destroy();
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
connection.pendingLines.push(...decoded.lines);
|
|
2324
|
+
void drainCodexAppControlLines(connection);
|
|
2325
|
+
});
|
|
2326
|
+
socket.on('error', () => { });
|
|
2327
|
+
socket.on('close', () => {
|
|
2328
|
+
clearTimeout(connection.authTimer);
|
|
2329
|
+
codexAppControlConnections.delete(socket);
|
|
2330
|
+
connection.pendingLines.length = 0;
|
|
2331
|
+
const wasActive = codexAppControlActiveSocket === socket;
|
|
2332
|
+
if (wasActive) {
|
|
2333
|
+
codexAppControlActiveSocket = undefined;
|
|
2334
|
+
codexAppControlActiveIdentity = undefined;
|
|
2335
|
+
codexAppControlProven = false;
|
|
2336
|
+
codexAppSignedStateObserved = false;
|
|
2337
|
+
codexAppInputReady = false;
|
|
2338
|
+
codexAppReadyAuthority.beginWork();
|
|
2339
|
+
codexAppTurnLiveness.beginReattachObservation();
|
|
2340
|
+
if (!codexAppControlStopping
|
|
2341
|
+
&& !codexAppControlFatal
|
|
2342
|
+
&& connection.channelId === codexAppControlChannelId) {
|
|
2343
|
+
codexAppProofDeadline.arm(() => {
|
|
2344
|
+
failCodexAppControlGeneration(`Codex App runner did not re-authenticate within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds after its control socket closed`);
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
log('Codex App authenticated control socket closed; waiting for a fresh challenge proof');
|
|
2348
|
+
}
|
|
2349
|
+
// Rotate only after the authenticated runner closes. An unauthenticated
|
|
2350
|
+
// close cannot prove that the legitimate runner has no pending connect to
|
|
2351
|
+
// this name; retiring it could let another local process rebind that name
|
|
2352
|
+
// under the pending attempt. Pre-auth failures therefore remain bound until
|
|
2353
|
+
// the shared 90-second fail-close deadline (availability-only boundary).
|
|
2354
|
+
if (wasActive
|
|
2355
|
+
&& !codexAppControlStopping
|
|
2356
|
+
&& !codexAppControlFatal
|
|
2357
|
+
&& connection.channelId === codexAppControlChannelId
|
|
2358
|
+
&& connection.epoch === codexAppControlEndpointEpoch) {
|
|
2359
|
+
void rotateCodexAppControlEndpoint('active socket closed');
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
socket.write(`${encodeCodexAppControlChallenge(lastInitConfig.sessionId, challenge)}\n`);
|
|
2363
|
+
}
|
|
2364
|
+
function removeStaleCodexAppSocket(path) {
|
|
2365
|
+
if (process.platform === 'win32')
|
|
2366
|
+
return;
|
|
2367
|
+
try {
|
|
2368
|
+
const stat = lstatSync(path);
|
|
2369
|
+
const uid = process.geteuid?.() ?? process.getuid?.();
|
|
2370
|
+
if (!stat.isSocket() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
|
|
2371
|
+
throw new Error('existing Codex App control socket path is not an owned socket');
|
|
2372
|
+
}
|
|
2373
|
+
unlinkSync(path);
|
|
2374
|
+
}
|
|
2375
|
+
catch (err) {
|
|
2376
|
+
if (err?.code !== 'ENOENT')
|
|
2377
|
+
throw err;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
function listenCodexAppControlServer(server, endpoint) {
|
|
2381
|
+
return new Promise((resolve, reject) => {
|
|
2382
|
+
const onError = (err) => {
|
|
2383
|
+
server.off('listening', onListening);
|
|
2384
|
+
reject(err);
|
|
2385
|
+
};
|
|
2386
|
+
const onListening = () => {
|
|
2387
|
+
server.off('error', onError);
|
|
2388
|
+
resolve();
|
|
2389
|
+
};
|
|
2390
|
+
server.once('error', onError);
|
|
2391
|
+
server.once('listening', onListening);
|
|
2392
|
+
server.listen(endpoint);
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
async function ensureCodexAppWindowsOwnerLease(sessionId) {
|
|
2396
|
+
if (process.platform !== 'win32')
|
|
2397
|
+
return;
|
|
2398
|
+
if (codexAppWindowsOwnerLeaseServer) {
|
|
2399
|
+
if (codexAppWindowsOwnerLeaseSessionId !== sessionId) {
|
|
2400
|
+
throw new Error('Windows Codex App owner lease belongs to another session');
|
|
2401
|
+
}
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
if (codexAppWindowsOwnerLeasePromise)
|
|
2405
|
+
return codexAppWindowsOwnerLeasePromise;
|
|
2406
|
+
const endpoint = codexAppWindowsOwnerPipeEndpoint(sessionId);
|
|
2407
|
+
let acquisition;
|
|
2408
|
+
acquisition = (async () => {
|
|
2409
|
+
const server = await acquireCodexAppControlOwnerLease({
|
|
2410
|
+
bind: async () => {
|
|
2411
|
+
const candidate = createNetServer(socket => socket.destroy());
|
|
2412
|
+
try {
|
|
2413
|
+
await listenCodexAppControlServer(candidate, endpoint);
|
|
2414
|
+
return candidate;
|
|
2415
|
+
}
|
|
2416
|
+
catch (err) {
|
|
2417
|
+
try {
|
|
2418
|
+
candidate.close();
|
|
2419
|
+
}
|
|
2420
|
+
catch { /* bind never completed */ }
|
|
2421
|
+
throw err;
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
});
|
|
2425
|
+
if (codexAppWindowsOwnerLeaseServer) {
|
|
2426
|
+
try {
|
|
2427
|
+
server.close();
|
|
2428
|
+
}
|
|
2429
|
+
catch { /* another local acquire won */ }
|
|
2430
|
+
if (codexAppWindowsOwnerLeaseSessionId !== sessionId) {
|
|
2431
|
+
throw new Error('Windows Codex App owner lease changed session during acquisition');
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
codexAppWindowsOwnerLeaseServer = server;
|
|
2436
|
+
codexAppWindowsOwnerLeaseSessionId = sessionId;
|
|
2437
|
+
server.on('error', err => {
|
|
2438
|
+
if (codexAppWindowsOwnerLeaseServer === server && !codexAppControlStopping) {
|
|
2439
|
+
failCodexAppControlGeneration(`Windows Codex App owner lease failed: ${err.message}`);
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
})().finally(() => {
|
|
2443
|
+
if (codexAppWindowsOwnerLeasePromise === acquisition) {
|
|
2444
|
+
codexAppWindowsOwnerLeasePromise = undefined;
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
codexAppWindowsOwnerLeasePromise = acquisition;
|
|
2448
|
+
return acquisition;
|
|
2449
|
+
}
|
|
2450
|
+
async function ensureCodexAppPosixOwnerLease(controlRoot, ownerSessionId) {
|
|
2451
|
+
if (process.platform === 'win32')
|
|
2452
|
+
return;
|
|
2453
|
+
if (codexAppPosixOwnerLease) {
|
|
2454
|
+
if (codexAppPosixOwnerLeaseSessionId !== ownerSessionId || !codexAppPosixOwnerLease.isOwned()) {
|
|
2455
|
+
throw new Error('POSIX Codex App owner lease is not owned by this worker/session');
|
|
2456
|
+
}
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (codexAppPosixOwnerLeasePromise)
|
|
2460
|
+
return codexAppPosixOwnerLeasePromise;
|
|
2461
|
+
let acquisition;
|
|
2462
|
+
acquisition = (async () => {
|
|
2463
|
+
const lease = await acquireCodexAppPosixOwnerLease({
|
|
2464
|
+
controlRoot,
|
|
2465
|
+
sessionId: ownerSessionId,
|
|
2466
|
+
});
|
|
2467
|
+
if (codexAppPosixOwnerLease) {
|
|
2468
|
+
lease.release();
|
|
2469
|
+
if (codexAppPosixOwnerLeaseSessionId !== ownerSessionId) {
|
|
2470
|
+
throw new Error('POSIX Codex App owner lease changed session during acquisition');
|
|
2471
|
+
}
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
codexAppPosixOwnerLease = lease;
|
|
2475
|
+
codexAppPosixOwnerLeaseSessionId = ownerSessionId;
|
|
2476
|
+
})().finally(() => {
|
|
2477
|
+
if (codexAppPosixOwnerLeasePromise === acquisition) {
|
|
2478
|
+
codexAppPosixOwnerLeasePromise = undefined;
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
codexAppPosixOwnerLeasePromise = acquisition;
|
|
2482
|
+
return acquisition;
|
|
2483
|
+
}
|
|
2484
|
+
function releaseCodexAppPosixOwnerLease() {
|
|
2485
|
+
codexAppPosixOwnerLease?.release();
|
|
2486
|
+
codexAppPosixOwnerLease = undefined;
|
|
2487
|
+
codexAppPosixOwnerLeaseSessionId = undefined;
|
|
2488
|
+
}
|
|
2489
|
+
async function startCodexAppControlEndpoint(cfg, channelId) {
|
|
2490
|
+
const epoch = generateCodexAppControlEpoch();
|
|
2491
|
+
const endpoint = process.platform === 'win32'
|
|
2492
|
+
? generateCodexAppWindowsPipeEndpoint()
|
|
2493
|
+
: codexAppControlSocketDirectory
|
|
2494
|
+
? generateCodexAppPosixSocketEndpoint(codexAppControlSocketDirectory)
|
|
2495
|
+
: undefined;
|
|
2496
|
+
if (!endpoint)
|
|
2497
|
+
throw new Error('Codex App control endpoint path was not prepared');
|
|
2498
|
+
const locatorPath = codexAppControlLocatorPathValue;
|
|
2499
|
+
if (!locatorPath)
|
|
2500
|
+
throw new Error('Codex App control locator path was not prepared');
|
|
2501
|
+
const server = createNetServer(socket => {
|
|
2502
|
+
acceptCodexAppControlSocket(socket, endpoint, epoch, channelId);
|
|
2503
|
+
});
|
|
2504
|
+
const priorServer = codexAppControlServer;
|
|
2505
|
+
const priorEndpoint = codexAppControlSocketPathValue;
|
|
2506
|
+
const priorEpoch = codexAppControlEndpointEpoch;
|
|
2507
|
+
try {
|
|
2508
|
+
const publisherLeaseOwned = () => process.platform === 'win32'
|
|
2509
|
+
? !!codexAppWindowsOwnerLeaseServer
|
|
2510
|
+
: codexAppPosixOwnerLease?.isOwned() === true;
|
|
2511
|
+
const published = await bindThenPublishCodexAppControlLocator({
|
|
2512
|
+
sessionId: cfg.sessionId,
|
|
2513
|
+
epoch,
|
|
2514
|
+
endpoint,
|
|
2515
|
+
platform: process.platform,
|
|
2516
|
+
locatorPath,
|
|
2517
|
+
expectedControlRoot: process.platform === 'win32'
|
|
2518
|
+
? undefined
|
|
2519
|
+
: codexAppPosixControlRoot(),
|
|
2520
|
+
listen: async () => {
|
|
2521
|
+
await listenCodexAppControlServer(server, endpoint);
|
|
2522
|
+
if (process.platform !== 'win32') {
|
|
2523
|
+
chmodSync(endpoint, 0o600);
|
|
2524
|
+
const stat = lstatSync(endpoint);
|
|
2525
|
+
const uid = process.geteuid?.() ?? process.getuid?.();
|
|
2526
|
+
if (!stat.isSocket() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
|
|
2527
|
+
throw new Error('listening path is not the owned Codex App socket');
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
},
|
|
2531
|
+
isCurrent: () => !codexAppControlStopping
|
|
2532
|
+
&& !codexAppControlFatal
|
|
2533
|
+
&& channelId === codexAppControlChannelId
|
|
2534
|
+
&& publisherLeaseOwned(),
|
|
2535
|
+
retire: () => { try {
|
|
2536
|
+
server.close();
|
|
2537
|
+
}
|
|
2538
|
+
catch { /* already retired */ } },
|
|
2539
|
+
publish: locator => {
|
|
2540
|
+
if (!publisherLeaseOwned())
|
|
2541
|
+
throw new Error('Codex App locator publisher lease was lost');
|
|
2542
|
+
// Install the bound epoch before making the locator visible. Any
|
|
2543
|
+
// immediate connection is therefore checked against this exact epoch.
|
|
2544
|
+
codexAppControlServer = server;
|
|
2545
|
+
codexAppControlSocketPathValue = endpoint;
|
|
2546
|
+
codexAppControlEndpointEpoch = epoch;
|
|
2547
|
+
writeCodexAppControlLocator(locatorPath, locator);
|
|
2548
|
+
},
|
|
2549
|
+
});
|
|
2550
|
+
if (!published)
|
|
2551
|
+
throw new Error('Codex App control endpoint was retired before locator publication');
|
|
2552
|
+
}
|
|
2553
|
+
catch (err) {
|
|
2554
|
+
if (codexAppControlServer === server) {
|
|
2555
|
+
codexAppControlServer = priorServer;
|
|
2556
|
+
codexAppControlSocketPathValue = priorEndpoint;
|
|
2557
|
+
codexAppControlEndpointEpoch = priorEpoch;
|
|
2558
|
+
}
|
|
2559
|
+
try {
|
|
2560
|
+
server.close();
|
|
2561
|
+
}
|
|
2562
|
+
catch { /* bind may not have completed */ }
|
|
2563
|
+
if (process.platform !== 'win32') {
|
|
2564
|
+
try {
|
|
2565
|
+
removeStaleCodexAppSocket(endpoint);
|
|
2566
|
+
}
|
|
2567
|
+
catch { /* preserve original error */ }
|
|
2568
|
+
}
|
|
2569
|
+
throw err;
|
|
2570
|
+
}
|
|
2571
|
+
server.on('error', err => {
|
|
2572
|
+
if (codexAppControlServer === server
|
|
2573
|
+
&& channelId === codexAppControlChannelId
|
|
2574
|
+
&& !codexAppControlStopping) {
|
|
2575
|
+
failCodexAppControlGeneration(`Codex App control endpoint failed: ${err.message}`);
|
|
2576
|
+
}
|
|
2577
|
+
});
|
|
2578
|
+
return { server, endpoint, epoch };
|
|
2579
|
+
}
|
|
2580
|
+
function installCodexAppControlEndpoint(started) {
|
|
2581
|
+
const previous = codexAppControlServer;
|
|
2582
|
+
codexAppControlServer = started.server;
|
|
2583
|
+
codexAppControlSocketPathValue = started.endpoint;
|
|
2584
|
+
codexAppControlEndpointEpoch = started.epoch;
|
|
2585
|
+
return previous;
|
|
2586
|
+
}
|
|
2587
|
+
function closeRetiredCodexAppControlEndpoint(server, endpoint) {
|
|
2588
|
+
try {
|
|
2589
|
+
server?.close();
|
|
2590
|
+
}
|
|
2591
|
+
catch { /* already closed */ }
|
|
2592
|
+
if (endpoint && process.platform !== 'win32') {
|
|
2593
|
+
try {
|
|
2594
|
+
removeStaleCodexAppSocket(endpoint);
|
|
2595
|
+
}
|
|
2596
|
+
catch { /* next bind will verify */ }
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
function rotateCodexAppControlEndpoint(reason) {
|
|
2600
|
+
if (codexAppControlStopping || codexAppControlFatal) {
|
|
2601
|
+
return Promise.resolve();
|
|
2602
|
+
}
|
|
2603
|
+
if (codexAppControlRotation)
|
|
2604
|
+
return codexAppControlRotation;
|
|
2605
|
+
const cfg = lastInitConfig;
|
|
2606
|
+
const channelId = codexAppControlChannelId;
|
|
2607
|
+
if (!cfg)
|
|
2608
|
+
return Promise.resolve();
|
|
2609
|
+
const oldServer = codexAppControlServer;
|
|
2610
|
+
const oldEndpoint = codexAppControlSocketPathValue;
|
|
2611
|
+
let rotation;
|
|
2612
|
+
rotation = (async () => {
|
|
2613
|
+
try {
|
|
2614
|
+
const started = await startCodexAppControlEndpoint(cfg, channelId);
|
|
2615
|
+
if (codexAppControlStopping || codexAppControlFatal
|
|
2616
|
+
|| channelId !== codexAppControlChannelId) {
|
|
2617
|
+
closeRetiredCodexAppControlEndpoint(started.server, started.endpoint);
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
installCodexAppControlEndpoint(started);
|
|
2621
|
+
// Bind + publish the fresh random endpoint before retiring the old one.
|
|
2622
|
+
closeRetiredCodexAppControlEndpoint(oldServer, oldEndpoint);
|
|
2623
|
+
for (const connection of [...codexAppControlConnections.values()]) {
|
|
2624
|
+
if (connection.epoch !== started.epoch)
|
|
2625
|
+
connection.socket.destroy();
|
|
2626
|
+
}
|
|
2627
|
+
log(`Rotated Codex App control endpoint (${reason}, epoch=${started.epoch.slice(0, 8)})`);
|
|
2628
|
+
}
|
|
2629
|
+
catch (err) {
|
|
2630
|
+
if (shouldFailCodexAppControlChannel({
|
|
2631
|
+
channelId,
|
|
2632
|
+
currentChannelId: codexAppControlChannelId,
|
|
2633
|
+
stopping: codexAppControlStopping,
|
|
2634
|
+
})) {
|
|
2635
|
+
failCodexAppControlGeneration(`Could not rotate Codex App control endpoint: ${err?.message ?? err}`);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
finally {
|
|
2639
|
+
if (codexAppControlRotation === rotation)
|
|
2640
|
+
codexAppControlRotation = undefined;
|
|
2641
|
+
}
|
|
2642
|
+
})();
|
|
2643
|
+
codexAppControlRotation = rotation;
|
|
2644
|
+
return rotation;
|
|
2645
|
+
}
|
|
2646
|
+
async function prepareCodexAppControlGeneration(cfg, _willReattachPersistent, persistentGeneration) {
|
|
2647
|
+
cleanupCodexAppControlBootstrap();
|
|
2648
|
+
// init already restored the daemon-owned FIFO before spawn. Retiring a
|
|
2649
|
+
// previous/empty socket endpoint must not erase that recovery snapshot just
|
|
2650
|
+
// before a warm runner replays its unacked final.
|
|
2651
|
+
stopCodexAppControlChannel({ preserveDispatchRecovery: true });
|
|
2652
|
+
codexAppControlStopping = false;
|
|
2653
|
+
const channelId = codexAppControlChannelId;
|
|
2654
|
+
codexAppControlStateValue = undefined;
|
|
2655
|
+
codexAppControlProven = false;
|
|
2656
|
+
codexAppSignedStateObserved = false;
|
|
2657
|
+
codexAppInputReady = false;
|
|
2658
|
+
codexAppReadyAuthority.reset();
|
|
2659
|
+
codexAppControlFatal = false;
|
|
2660
|
+
codexAppControlPersistentGeneration = false;
|
|
2661
|
+
codexAppControlDirectoryForSpawn = undefined;
|
|
2662
|
+
codexAppControlSocketPathValue = undefined;
|
|
2663
|
+
codexAppControlSocketDirectory = undefined;
|
|
2664
|
+
codexAppControlLocatorPathValue = undefined;
|
|
2665
|
+
codexAppControlEndpointEpoch = undefined;
|
|
2666
|
+
codexAppFreshCandidateGeneration = undefined;
|
|
2667
|
+
codexAppUnprovenPromptDeferred = false;
|
|
2668
|
+
codexAppRejectedControlLogged = false;
|
|
2669
|
+
codexAppMalformedControlLogged = false;
|
|
2670
|
+
if (cfg.cliId !== 'codex-app')
|
|
2671
|
+
return;
|
|
2672
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
2673
|
+
const windowsRoot = process.platform === 'win32' ? codexAppWindowsControlRoot() : undefined;
|
|
2674
|
+
const controlRoot = windowsRoot ?? codexAppPosixControlRoot();
|
|
2675
|
+
// The daemon's kill-then-fork replacement path can briefly overlap worker
|
|
2676
|
+
// processes. Hold a process-lifetime per-session publisher lease before
|
|
2677
|
+
// touching the fixed locator so an old process cannot publish after its
|
|
2678
|
+
// replacement. The lease is never exposed as a runner control endpoint.
|
|
2679
|
+
if (windowsRoot)
|
|
2680
|
+
await ensureCodexAppWindowsOwnerLease(cfg.sessionId);
|
|
2681
|
+
else
|
|
2682
|
+
await ensureCodexAppPosixOwnerLease(controlRoot, cfg.sessionId);
|
|
2683
|
+
const controlDirectory = windowsRoot
|
|
2684
|
+
? join(windowsRoot, 'bootstraps')
|
|
2685
|
+
: dataDir
|
|
2686
|
+
? join(botHomePath(dirname(dataDir), cfg.larkAppId), 'codex-app-control-bootstrap')
|
|
2687
|
+
: join(tmpdir(), `botmux-codex-app-control-${process.getuid?.() ?? 'unknown'}`);
|
|
2688
|
+
// AF_UNIX paths are capped at roughly 104 bytes on macOS. Keep random POSIX
|
|
2689
|
+
// endpoints in the short private control root; both platforms publish them
|
|
2690
|
+
// through a fixed protected locator while the process-lifetime owner lease
|
|
2691
|
+
// serializes replacement workers.
|
|
2692
|
+
const socketDirectory = join(controlRoot, 'sockets');
|
|
2693
|
+
ensureCodexAppControlDirectory(socketDirectory);
|
|
2694
|
+
ensureCodexAppControlDirectory(controlDirectory);
|
|
2695
|
+
cleanupStaleCodexAppControlBootstraps(controlDirectory, cfg.sessionId);
|
|
2696
|
+
codexAppControlDirectoryForSpawn = controlDirectory;
|
|
2697
|
+
codexAppControlSocketDirectory = socketDirectory;
|
|
2698
|
+
codexAppControlLocatorPathValue = codexAppControlLocatorPath(controlRoot, cfg.sessionId);
|
|
2699
|
+
ensureCodexAppControlDirectory(dirname(codexAppControlLocatorPathValue));
|
|
2700
|
+
// A crashed worker may leave a stale locator. Keep it until the new random
|
|
2701
|
+
// endpoint has bound, then atomically overwrite it. Deleting first would
|
|
2702
|
+
// introduce a cross-process read/unlink race and is unnecessary because
|
|
2703
|
+
// accepted carries the protected, independently random locator epoch.
|
|
2704
|
+
// Preserve old public identities for every persistent spawn attempt, even
|
|
2705
|
+
// when the existence probe predicted fresh. The backend may race between
|
|
2706
|
+
// probe and spawn; the socket proof, not that prediction, selects old reuse
|
|
2707
|
+
// versus the fresh candidate.
|
|
2708
|
+
if (persistentGeneration)
|
|
2709
|
+
codexAppControlStateValue = readPersistedCodexAppControlState(cfg);
|
|
2710
|
+
const started = await startCodexAppControlEndpoint(cfg, channelId);
|
|
2711
|
+
if (codexAppControlStopping || channelId !== codexAppControlChannelId) {
|
|
2712
|
+
closeRetiredCodexAppControlEndpoint(started.server, started.endpoint);
|
|
2713
|
+
throw new Error('Codex App control endpoint was retired during startup');
|
|
2714
|
+
}
|
|
2715
|
+
installCodexAppControlEndpoint(started);
|
|
2716
|
+
}
|
|
2717
|
+
/** Late-create the only secret-bearing file immediately before backend.spawn. */
|
|
2718
|
+
function prepareFreshCodexAppControlBootstrap(cfg, persistentGeneration) {
|
|
2719
|
+
if (cfg.cliId !== 'codex-app')
|
|
2720
|
+
return;
|
|
2721
|
+
if (!codexAppControlDirectoryForSpawn || !codexAppControlLocatorPathValue) {
|
|
2722
|
+
throw new Error('Codex App control channel was not prepared');
|
|
2723
|
+
}
|
|
2724
|
+
const bootstrap = createCodexAppControlBootstrap(codexAppControlDirectoryForSpawn, cfg.sessionId, { kind: 'locator', locatorPath: codexAppControlLocatorPathValue });
|
|
2725
|
+
codexAppControlBootstrapPathForSpawn = bootstrap.path;
|
|
2726
|
+
codexAppFreshCandidateGeneration = bootstrap.identity.generation;
|
|
2727
|
+
codexAppControlStateValue = mergeCodexAppControlCandidate(codexAppControlStateValue, bootstrap.identity);
|
|
2728
|
+
codexAppControlPersistentGeneration = persistentGeneration;
|
|
2729
|
+
if (persistentGeneration)
|
|
2730
|
+
persistCodexAppControlState(cfg, codexAppControlStateValue);
|
|
2731
|
+
codexAppBootstrapCleanupTimer = armCodexAppControlStartupTimeout(cleanupCodexAppControlBootstrap);
|
|
2732
|
+
codexAppBootstrapCleanupTimer.unref?.();
|
|
2733
|
+
}
|
|
2734
|
+
function finalizeCodexAppControlGeneration(cfg, _actuallyReattached, persistentGeneration) {
|
|
2735
|
+
if (cfg.cliId !== 'codex-app')
|
|
2736
|
+
return;
|
|
2737
|
+
codexAppControlPersistentGeneration = persistentGeneration;
|
|
2738
|
+
// A live runner can answer while synchronous backend.spawn is still
|
|
2739
|
+
// returning. Authentication already persisted the active public identity;
|
|
2740
|
+
// do not mistake that success for a missing pending bootstrap.
|
|
2741
|
+
if (codexAppControlProven && codexAppControlStateValue?.status === 'active') {
|
|
2742
|
+
cleanupCodexAppControlBootstrap();
|
|
2743
|
+
if (!codexAppSignedStateObserved && !codexAppProofDeadline.armed) {
|
|
2744
|
+
codexAppProofDeadline.arm(() => {
|
|
2745
|
+
failCodexAppControlGeneration(`Authenticated Codex App runner did not publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (codexAppControlStateValue?.status !== 'pending'
|
|
2751
|
+
|| !codexAppControlBootstrapPathForSpawn) {
|
|
2752
|
+
cleanupCodexAppControlBootstrap();
|
|
2753
|
+
throw new Error('Codex App pending asymmetric control generation was not prepared');
|
|
2754
|
+
}
|
|
2755
|
+
codexAppProofDeadline.arm(() => {
|
|
2756
|
+
failCodexAppControlGeneration(`Codex App runner did not authenticate and publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
2759
|
+
function rejectCodexAppControlMarker(kind) {
|
|
2760
|
+
if (codexAppRejectedControlLogged)
|
|
2761
|
+
return;
|
|
2762
|
+
codexAppRejectedControlLogged = true;
|
|
2763
|
+
log(`Ignored unauthenticated Codex App ${kind} control record`);
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Project an explicit Codex App no-progress state above the screen heuristic.
|
|
2767
|
+
* The warning is once-per-turn and intentionally does not restart or replay
|
|
2768
|
+
* anything: both actions could duplicate model/tool side effects.
|
|
2769
|
+
*/
|
|
2770
|
+
function codexAppLivenessStatus(base, nowMs = Date.now()) {
|
|
2771
|
+
if (lastInitConfig?.cliId !== 'codex-app')
|
|
2772
|
+
return base;
|
|
2773
|
+
base = projectCodexAppControlReadinessStatus(base, {
|
|
2774
|
+
controlProven: codexAppControlProven,
|
|
2775
|
+
signedStateObserved: codexAppSignedStateObserved,
|
|
2776
|
+
inputReady: codexAppInputReady,
|
|
2777
|
+
});
|
|
2778
|
+
const liveness = codexAppTurnLiveness.poll(nowMs);
|
|
2779
|
+
if (liveness.shouldNotify) {
|
|
2780
|
+
send({
|
|
2781
|
+
type: 'user_notify',
|
|
2782
|
+
turnId: liveness.turnId ?? currentBotmuxTurnId,
|
|
2783
|
+
...(liveness.turnId === currentBotmuxTurnId
|
|
2784
|
+
&& currentBotmuxDispatchAttempt !== undefined
|
|
2785
|
+
? { dispatchAttempt: currentBotmuxDispatchAttempt }
|
|
2786
|
+
: {}),
|
|
2787
|
+
message: t('worker.codex_app.no_progress', {
|
|
2788
|
+
seconds: Math.round(CODEX_APP_NO_PROGRESS_TIMEOUT_MS / 1000),
|
|
2789
|
+
}),
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
if (liveness.stalled)
|
|
2793
|
+
return 'stalled';
|
|
2794
|
+
return liveness.active && base === 'idle' ? 'working' : base;
|
|
2795
|
+
}
|
|
1896
2796
|
/**
|
|
1897
2797
|
* True when this CLI has an authoritative STRUCTURED rate-limit signal that is
|
|
1898
2798
|
* actually PUBLISHED as a `limited` screen_update — i.e. the Claude family,
|
|
@@ -2045,10 +2945,9 @@ const bridgeSecondaryPaths = new Map(); // path → offset
|
|
|
2045
2945
|
let bridgeOffset = 0;
|
|
2046
2946
|
let bridgePendingTail = '';
|
|
2047
2947
|
const bridgeQueue = new BridgeTurnQueue();
|
|
2048
|
-
/** uuids of
|
|
2049
|
-
* `limited` emit.
|
|
2050
|
-
*
|
|
2051
|
-
* can resurface; keying on the record's stable uuid makes the emit idempotent. */
|
|
2948
|
+
/** uuids of structured transcript rate-limit records already turned into a
|
|
2949
|
+
* `limited` emit. Readers may re-read from offset 0 after rotation, so the
|
|
2950
|
+
* stable record uuid keeps the notification idempotent. */
|
|
2052
2951
|
const emittedRateLimitUuids = new Set();
|
|
2053
2952
|
let bridgeWatcher = null;
|
|
2054
2953
|
let bridgeFallbackTimer = null;
|
|
@@ -2074,6 +2973,8 @@ let codexBridgeRolloutPath;
|
|
|
2074
2973
|
let codexBridgeOffset = 0;
|
|
2075
2974
|
let codexBridgePendingTail = '';
|
|
2076
2975
|
let codexBridgeBaselineDone = false;
|
|
2976
|
+
let publishedActiveRuntime = {};
|
|
2977
|
+
let activeRuntimePublished = false;
|
|
2077
2978
|
const codexBridgeQueue = new CodexBridgeQueue();
|
|
2078
2979
|
let codexBridgeWatcher = null;
|
|
2079
2980
|
let codexBridgeTimer = null;
|
|
@@ -2081,6 +2982,26 @@ let codexBridgeTimer = null;
|
|
|
2081
2982
|
* The tracker owns rollout-generation clear/update semantics and publishes a
|
|
2082
2983
|
* dedicated IPC event, independent of PTY redraw frequency. */
|
|
2083
2984
|
const codexServiceTierTracker = new CodexServiceTierTracker(resolveCodexServiceTierSnapshot, snapshot => send({ type: 'codex_service_tier', snapshot }));
|
|
2985
|
+
function publishActiveRuntime(runtime) {
|
|
2986
|
+
const normalized = {
|
|
2987
|
+
...(runtime.model?.trim() ? { model: runtime.model.trim() } : {}),
|
|
2988
|
+
...(runtime.reasoningEffort?.trim()
|
|
2989
|
+
? { reasoningEffort: runtime.reasoningEffort.trim() }
|
|
2990
|
+
: {}),
|
|
2991
|
+
};
|
|
2992
|
+
if (activeRuntimePublished
|
|
2993
|
+
&& normalized.model === publishedActiveRuntime.model
|
|
2994
|
+
&& normalized.reasoningEffort === publishedActiveRuntime.reasoningEffort) {
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
activeRuntimePublished = true;
|
|
2998
|
+
publishedActiveRuntime = normalized;
|
|
2999
|
+
send({
|
|
3000
|
+
type: 'active_runtime',
|
|
3001
|
+
model: normalized.model ?? null,
|
|
3002
|
+
reasoningEffort: normalized.reasoningEffort ?? null,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
2084
3005
|
let hermesBridgeOffset = 0;
|
|
2085
3006
|
let hermesBridgeBaselineDone = false;
|
|
2086
3007
|
let hermesBridgeDbPath;
|
|
@@ -2150,6 +3071,18 @@ function formatHeadlessLocalTurnContent(assistantText) {
|
|
|
2150
3071
|
function emptyCompletedBridgeFallbackContent() {
|
|
2151
3072
|
return t('worker.empty_final_completed', { cliName: cliName() });
|
|
2152
3073
|
}
|
|
3074
|
+
function failedBridgeFallbackContent(errorCode, summary, partialText) {
|
|
3075
|
+
const reason = summary || t('worker.failed_reason_unavailable');
|
|
3076
|
+
const key = errorCode === CODEX_INVALID_REQUEST_ERROR_CODE
|
|
3077
|
+
? 'worker.empty_final_failed_invalid_request'
|
|
3078
|
+
: errorCode === CODEX_AUTH_ERROR_CODE
|
|
3079
|
+
? 'worker.empty_final_failed_auth'
|
|
3080
|
+
: errorCode === CODEX_CONNECTION_ERROR_CODE
|
|
3081
|
+
? 'worker.empty_final_failed_connection'
|
|
3082
|
+
: 'worker.empty_final_failed';
|
|
3083
|
+
const failure = t(key, { cliName: cliName(), reason });
|
|
3084
|
+
return partialText?.trim() ? `${partialText.trim()}\n\n${failure}` : failure;
|
|
3085
|
+
}
|
|
2153
3086
|
// ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
|
|
2154
3087
|
//
|
|
2155
3088
|
// `botmux send` (cli.ts cmdSend) appends a line
|
|
@@ -3320,6 +4253,19 @@ function emitReadyTurns(opts = {}) {
|
|
|
3320
4253
|
notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
|
|
3321
4254
|
continue;
|
|
3322
4255
|
}
|
|
4256
|
+
// Gate let this through, so the final is a real answer. NON-ADOPT only: if
|
|
4257
|
+
// the model appended a trailing sentinel line (the "did work, forgot to
|
|
4258
|
+
// send, ended with the sentinel" shape), strip it so the literal token never
|
|
4259
|
+
// reaches Lark — the prose before it is what the user should see; a
|
|
4260
|
+
// pure-sentinel final was already suppressed by isBridgeNothingToSendFinal,
|
|
4261
|
+
// so what remains is non-empty (guard and skip if not). ADOPT must NEVER
|
|
4262
|
+
// touch the text: the adopted CLI is botmux-unaware, transcript drain is its
|
|
4263
|
+
// only channel, and it may legitimately output that literal string as
|
|
4264
|
+
// content — shouldSuppressBridgeEmit(adoptMode) already refuses to interpret
|
|
4265
|
+
// the sentinel, so stripping here would break that contract.
|
|
4266
|
+
const postText = bridgePostText(assistantText, adoptMode);
|
|
4267
|
+
if (!adoptMode && postText.trim().length === 0)
|
|
4268
|
+
continue;
|
|
3323
4269
|
if (turn.isLocal) {
|
|
3324
4270
|
if (turn.userUuid) {
|
|
3325
4271
|
// Local turn (adopt mode only): also surface the user prompt so the
|
|
@@ -3331,7 +4277,7 @@ function emitReadyTurns(opts = {}) {
|
|
|
3331
4277
|
// a normally-typed pane prompt.
|
|
3332
4278
|
const userEv = drained.events.find(e => e.uuid === turn.userUuid);
|
|
3333
4279
|
const rawUserText = userEv ? extractTurnStartText(userEv) : '';
|
|
3334
|
-
const fields = formatLocalTurnFields(rawUserText,
|
|
4280
|
+
const fields = formatLocalTurnFields(rawUserText, postText);
|
|
3335
4281
|
if (!fields)
|
|
3336
4282
|
continue;
|
|
3337
4283
|
send({
|
|
@@ -3346,7 +4292,7 @@ function emitReadyTurns(opts = {}) {
|
|
|
3346
4292
|
continue;
|
|
3347
4293
|
}
|
|
3348
4294
|
// Headless local turn — see formatHeadlessLocalTurnContent for context.
|
|
3349
|
-
const headlessContent = formatHeadlessLocalTurnContent(
|
|
4295
|
+
const headlessContent = formatHeadlessLocalTurnContent(postText);
|
|
3350
4296
|
if (!headlessContent)
|
|
3351
4297
|
continue;
|
|
3352
4298
|
send({
|
|
@@ -3361,7 +4307,7 @@ function emitReadyTurns(opts = {}) {
|
|
|
3361
4307
|
}
|
|
3362
4308
|
send({
|
|
3363
4309
|
type: 'final_output',
|
|
3364
|
-
content:
|
|
4310
|
+
content: postText,
|
|
3365
4311
|
lastUuid,
|
|
3366
4312
|
turnId: turn.turnId,
|
|
3367
4313
|
...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
|
|
@@ -3677,7 +4623,23 @@ function codexBridgeAttach(rolloutPath, mode) {
|
|
|
3677
4623
|
codexBridgePendingTail = result.pendingTail;
|
|
3678
4624
|
codexBridgeBaselineDone = true;
|
|
3679
4625
|
if (structuredBridgeIsCodex()) {
|
|
3680
|
-
|
|
4626
|
+
const codex = result;
|
|
4627
|
+
codexServiceTierTracker.observe(rolloutPath, codex.latestThreadSettings);
|
|
4628
|
+
// Reuse this drain's turn_context observation — split-live already read
|
|
4629
|
+
// the whole rollout above, so no second full-file scan is needed.
|
|
4630
|
+
publishActiveRuntime({
|
|
4631
|
+
model: codex.latestModel,
|
|
4632
|
+
reasoningEffort: codex.latestReasoningEffort,
|
|
4633
|
+
});
|
|
4634
|
+
}
|
|
4635
|
+
// Reuse this drain's runtime observation instead of a second full-file
|
|
4636
|
+
// scan — split-live already read the whole rollout above.
|
|
4637
|
+
if (structuredBridgeIsTraex()) {
|
|
4638
|
+
const traex = result;
|
|
4639
|
+
publishActiveRuntime({
|
|
4640
|
+
model: traex.latestModel,
|
|
4641
|
+
reasoningEffort: traex.latestReasoningEffort,
|
|
4642
|
+
});
|
|
3681
4643
|
}
|
|
3682
4644
|
log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
|
|
3683
4645
|
maybeEmitCodexAdoptPreamble(history);
|
|
@@ -3722,6 +4684,20 @@ function codexBridgeAttach(rolloutPath, mode) {
|
|
|
3722
4684
|
&& mode !== 'fresh-empty'
|
|
3723
4685
|
&& mode !== 'split-live') {
|
|
3724
4686
|
codexServiceTierTracker.observe(rolloutPath, scanCodexThreadSettings(rolloutPath));
|
|
4687
|
+
// Codex baseline modes cursor to the tail without draining, so seed the
|
|
4688
|
+
// active runtime (model/effort from turn_context) via a bounded backward
|
|
4689
|
+
// read — same as TRAE below. split-live already published from its own
|
|
4690
|
+
// drain; fresh-empty has no history and picks it up on first live ingest.
|
|
4691
|
+
publishActiveRuntime(readLatestCodexRuntime(rolloutPath));
|
|
4692
|
+
}
|
|
4693
|
+
// TRAE baseline modes only cursor to the tail without draining, so seed the
|
|
4694
|
+
// active runtime from a bounded backward read. split-live already published
|
|
4695
|
+
// from its own drain; fresh-empty has no history and picks it up on first
|
|
4696
|
+
// live ingest.
|
|
4697
|
+
if (structuredBridgeIsTraex()
|
|
4698
|
+
&& mode !== 'fresh-empty'
|
|
4699
|
+
&& mode !== 'split-live') {
|
|
4700
|
+
publishActiveRuntime(readLatestTraexRuntime(rolloutPath));
|
|
3725
4701
|
}
|
|
3726
4702
|
try {
|
|
3727
4703
|
codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
|
|
@@ -4144,8 +5120,21 @@ function codexBridgeIngest(opts = {}) {
|
|
|
4144
5120
|
const result = structuredBridgeIngestPath(codexBridgeRolloutPath, codexBridgeOffset);
|
|
4145
5121
|
codexBridgeOffset = result.newOffset;
|
|
4146
5122
|
codexBridgePendingTail = result.pendingTail;
|
|
5123
|
+
if (structuredBridgeIsTraex()) {
|
|
5124
|
+
const traex = result;
|
|
5125
|
+
publishActiveRuntime({
|
|
5126
|
+
model: traex.latestModel ?? publishedActiveRuntime.model,
|
|
5127
|
+
reasoningEffort: traex.latestReasoningEffort ?? publishedActiveRuntime.reasoningEffort,
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
4147
5130
|
if (structuredBridgeIsCodex()) {
|
|
4148
|
-
|
|
5131
|
+
const codex = result;
|
|
5132
|
+
codexServiceTierTracker.observe(codexBridgeRolloutPath, codex.latestThreadSettings);
|
|
5133
|
+
publishActiveRuntime({
|
|
5134
|
+
model: codex.latestModel ?? publishedActiveRuntime.model,
|
|
5135
|
+
reasoningEffort: codex.latestReasoningEffort ?? publishedActiveRuntime.reasoningEffort,
|
|
5136
|
+
});
|
|
5137
|
+
maybeEmitCodexStructuredRateLimit(result.events);
|
|
4149
5138
|
}
|
|
4150
5139
|
if (result.events.length > 0)
|
|
4151
5140
|
lastStructuredBridgeActivityAtMs = Date.now();
|
|
@@ -4160,6 +5149,26 @@ function codexBridgeIngest(opts = {}) {
|
|
|
4160
5149
|
idleDetector?.fireIdle();
|
|
4161
5150
|
}
|
|
4162
5151
|
}
|
|
5152
|
+
/** 将 Codex 的结构化 429 终态同步为既有的限流状态。 */
|
|
5153
|
+
function maybeEmitCodexStructuredRateLimit(events) {
|
|
5154
|
+
for (const ev of events) {
|
|
5155
|
+
if (!isCodexRateLimitEvent(ev) || emittedRateLimitUuids.has(ev.uuid))
|
|
5156
|
+
continue;
|
|
5157
|
+
emittedRateLimitUuids.add(ev.uuid);
|
|
5158
|
+
const usageLimit = structuredRateLimitState(ev.terminalErrorSummary ?? '429 Too Many Requests');
|
|
5159
|
+
usageLimitTracker.noteStructuredLimit();
|
|
5160
|
+
send({
|
|
5161
|
+
type: 'screen_update',
|
|
5162
|
+
content: currentUsageLimitSnapshot(),
|
|
5163
|
+
status: 'limited',
|
|
5164
|
+
usageLimit,
|
|
5165
|
+
turnId: currentBotmuxTurnId,
|
|
5166
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
5167
|
+
});
|
|
5168
|
+
log(`Structured rate-limit detected in Codex transcript (uuid=${ev.uuid.substring(0, 8)}, retryLabel=${usageLimit.retryLabel}) → emitted limited state.`);
|
|
5169
|
+
return;
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
4163
5172
|
/** Mark a pending Lark turn for Codex. Crucially this works even before a
|
|
4164
5173
|
* rollout path is known — the queue is path-agnostic, and ingest after
|
|
4165
5174
|
* late-attach picks up the user_message and matches the fingerprint. */
|
|
@@ -4245,11 +5254,14 @@ function emitReadyCodexTurns() {
|
|
|
4245
5254
|
finalText: turn.finalText,
|
|
4246
5255
|
terminalStatus: turn.terminalStatus,
|
|
4247
5256
|
};
|
|
4248
|
-
const content = turn.
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
5257
|
+
const content = turn.terminalErrorCode !== CODEX_RATE_LIMIT_ERROR_CODE
|
|
5258
|
+
&& shouldEmitFailedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
|
|
5259
|
+
? failedBridgeFallbackContent(turn.terminalErrorCode, turn.terminalErrorSummary, turn.finalText)
|
|
5260
|
+
: turn.finalText && turn.finalText.trim()
|
|
5261
|
+
? turn.finalText
|
|
5262
|
+
: shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
|
|
5263
|
+
? emptyCompletedBridgeFallbackContent()
|
|
5264
|
+
: '';
|
|
4253
5265
|
if (!content)
|
|
4254
5266
|
continue;
|
|
4255
5267
|
if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
|
|
@@ -4257,12 +5269,22 @@ function emitReadyCodexTurns() {
|
|
|
4257
5269
|
notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
|
|
4258
5270
|
continue;
|
|
4259
5271
|
}
|
|
5272
|
+
// NON-ADOPT only: strip a trailing sentinel line so the literal token never
|
|
5273
|
+
// reaches Lark (prose+sentinel = "did work, forgot to send" — post the
|
|
5274
|
+
// prose). A pure-sentinel final was suppressed above, and the fallback
|
|
5275
|
+
// string carries no sentinel, so `postContent` is normally non-empty (guard
|
|
5276
|
+
// and skip if not). ADOPT must NEVER touch the text — the adopted CLI is
|
|
5277
|
+
// botmux-unaware and may output that literal string as content; the gate
|
|
5278
|
+
// already refuses to interpret the sentinel under adoptMode.
|
|
5279
|
+
const postContent = bridgePostText(content, adoptMode);
|
|
5280
|
+
if (!adoptMode && postContent.trim().length === 0)
|
|
5281
|
+
continue;
|
|
4260
5282
|
if (turn.isLocal) {
|
|
4261
5283
|
// Local turn (adopt only): user typed in iTerm. Surface both sides
|
|
4262
5284
|
// so the Lark thread sees a complete exchange instead of an orphan
|
|
4263
5285
|
// reply. formatLocalTurnFields caps both texts to keep within
|
|
4264
5286
|
// Lark's per-message limit; daemon owns the card chrome.
|
|
4265
|
-
const fields = formatLocalTurnFields(turn.userText ?? '',
|
|
5287
|
+
const fields = formatLocalTurnFields(turn.userText ?? '', postContent);
|
|
4266
5288
|
if (!fields)
|
|
4267
5289
|
continue;
|
|
4268
5290
|
send({
|
|
@@ -4280,7 +5302,7 @@ function emitReadyCodexTurns() {
|
|
|
4280
5302
|
send({
|
|
4281
5303
|
type: 'final_output',
|
|
4282
5304
|
...(sourceHermesSessionId ? { sourceHermesSessionId } : {}),
|
|
4283
|
-
content,
|
|
5305
|
+
content: postContent,
|
|
4284
5306
|
lastUuid: turn.turnId,
|
|
4285
5307
|
turnId: turn.turnId,
|
|
4286
5308
|
...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
|
|
@@ -4303,6 +5325,8 @@ function stopCodexBridge() {
|
|
|
4303
5325
|
codexBridgeTimer = null;
|
|
4304
5326
|
}
|
|
4305
5327
|
codexServiceTierTracker.detach();
|
|
5328
|
+
activeRuntimePublished = false;
|
|
5329
|
+
publishedActiveRuntime = {};
|
|
4306
5330
|
codexBridgeRolloutPath = undefined;
|
|
4307
5331
|
codexBridgeOffset = 0;
|
|
4308
5332
|
codexBridgePendingTail = '';
|
|
@@ -4498,6 +5522,68 @@ function recentTerminalLogTail() {
|
|
|
4498
5522
|
return undefined;
|
|
4499
5523
|
return tailChars(plain, CRASH_LOG_TAIL_MAX);
|
|
4500
5524
|
}
|
|
5525
|
+
/**
|
|
5526
|
+
* 兜底:SessionStart 边界之后接受「屏幕上已有的」提示符。
|
|
5527
|
+
* 判据本身是 decidePostHookPromptEvidence()(input-gate.ts),这里只负责取数据
|
|
5528
|
+
* 和排定时器。
|
|
5529
|
+
*
|
|
5530
|
+
* 挡住启动选择器假提示符的**不是**静默窗口 —— 选择器停在那儿等按键时屏幕是静
|
|
5531
|
+
* 的(这正是它当初骗过 readyPattern 的原因)。真正的保障是 arm 的时机:只在
|
|
5532
|
+
* 收到 SessionStart ready 信号之后才 arm,而该 hook 在选择器还没过去时不会
|
|
5533
|
+
* 触发。窗口之外本兜底根本不运行。adopt / reattach 同理拿不到信号,不会 arm,
|
|
5534
|
+
* 与 shouldArmReadyGate() 的 !adoptMode && !willReattachPersistent 天然一致。
|
|
5535
|
+
*
|
|
5536
|
+
* 提示符必须从**渲染后的画面**读(renderer.rawSnapshot()),不能用
|
|
5537
|
+
* recentTerminalLogTail() —— 后者是 PTY 追加日志,stripAnsi 之后擦除语义全丢,
|
|
5538
|
+
* 一个早已被 TUI 抹掉的 ❯ 照样匹配得上,会把首条消息灌进还没就绪的界面。
|
|
5539
|
+
* 另外只有 rawSnapshot() 才含 ❯ 行,snapshot() 会把它过滤掉。
|
|
5540
|
+
*/
|
|
5541
|
+
let postHookEvidenceFallbackTimer = null;
|
|
5542
|
+
function clearPostHookEvidenceFallback() {
|
|
5543
|
+
if (postHookEvidenceFallbackTimer) {
|
|
5544
|
+
clearTimeout(postHookEvidenceFallbackTimer);
|
|
5545
|
+
postHookEvidenceFallbackTimer = null;
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
/** 当前渲染画面是否有提示符(renderer 尚未就绪时按「没有」处理,等下一轮)。 */
|
|
5549
|
+
function screenShowsReadyPattern() {
|
|
5550
|
+
const pattern = cliAdapter?.readyPattern;
|
|
5551
|
+
if (!pattern)
|
|
5552
|
+
return false;
|
|
5553
|
+
let screen = '';
|
|
5554
|
+
try {
|
|
5555
|
+
screen = renderer?.rawSnapshot() ?? '';
|
|
5556
|
+
}
|
|
5557
|
+
catch {
|
|
5558
|
+
return false;
|
|
5559
|
+
}
|
|
5560
|
+
return !!screen && pattern.test(screen);
|
|
5561
|
+
}
|
|
5562
|
+
function armPostHookPromptEvidenceFallback(startedAt = Date.now(), delayMs = POST_HOOK_EVIDENCE_FALLBACK_MS) {
|
|
5563
|
+
clearPostHookEvidenceFallback();
|
|
5564
|
+
postHookEvidenceFallbackTimer = setTimeout(() => {
|
|
5565
|
+
postHookEvidenceFallbackTimer = null;
|
|
5566
|
+
const quietMs = Date.now() - lastPtyOutputAtMs;
|
|
5567
|
+
const decision = decidePostHookPromptEvidence({
|
|
5568
|
+
stillWaiting: awaitingPostSessionStartPromptEvidence,
|
|
5569
|
+
elapsedMs: Date.now() - startedAt,
|
|
5570
|
+
quietMs,
|
|
5571
|
+
screenHasReadyPattern: screenShowsReadyPattern(),
|
|
5572
|
+
});
|
|
5573
|
+
if (decision.action === 'stop')
|
|
5574
|
+
return;
|
|
5575
|
+
if (decision.action === 'retry') {
|
|
5576
|
+
armPostHookPromptEvidenceFallback(startedAt, decision.retryInMs ?? POST_HOOK_EVIDENCE_RETRY_MS);
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5579
|
+
// seedReadyEvidence() 只补 readySeen,仍走完整静默判定;随后由既有的
|
|
5580
|
+
// markIdle('screen') → markPromptReadyFromPty() 链路清除等待标记。
|
|
5581
|
+
if (idleDetector?.seedReadyEvidence()) {
|
|
5582
|
+
log(`Post-SessionStart evidence fallback: screen quiet ${quietMs}ms with readyPattern on screen; accepting existing prompt`);
|
|
5583
|
+
}
|
|
5584
|
+
}, delayMs);
|
|
5585
|
+
postHookEvidenceFallbackTimer.unref?.();
|
|
5586
|
+
}
|
|
4501
5587
|
function crashDiagnosticPath() {
|
|
4502
5588
|
const dataDir = process.env.SESSION_DATA_DIR;
|
|
4503
5589
|
if (!dataDir || !sessionId)
|
|
@@ -4854,6 +5940,9 @@ async function captureAndUpload() {
|
|
|
4854
5940
|
return;
|
|
4855
5941
|
}
|
|
4856
5942
|
let status = isPromptReady ? 'idle' : 'working';
|
|
5943
|
+
// master removed the AI ScreenAnalyzer, so the `analyzing` projection is gone;
|
|
5944
|
+
// PR #597's Codex App no-progress liveness projection still layers on top.
|
|
5945
|
+
status = codexAppLivenessStatus(status);
|
|
4857
5946
|
send({
|
|
4858
5947
|
type: 'screenshot_uploaded',
|
|
4859
5948
|
imageKey,
|
|
@@ -5421,10 +6510,11 @@ function handleVisibleStartupInteraction(data) {
|
|
|
5421
6510
|
}
|
|
5422
6511
|
return true;
|
|
5423
6512
|
}
|
|
5424
|
-
//
|
|
5425
|
-
// not
|
|
5426
|
-
//
|
|
5427
|
-
|
|
6513
|
+
// Mira/Mir still send terminal OSC control messages. Codex App deliberately
|
|
6514
|
+
// does not (PR #597): its signed Unix-socket channel is independent of the
|
|
6515
|
+
// terminal/backend rendering (including Herdr and Zellij), so it is no longer
|
|
6516
|
+
// in the terminal-OSC decode set.
|
|
6517
|
+
const APP_RUNNER_OSC_CLI_IDS = new Set(['mira', 'mir']);
|
|
5428
6518
|
const appRunnerControlDecoder = new RunnerControlDecoder();
|
|
5429
6519
|
let kiroSessionIdCaptureArmed = false;
|
|
5430
6520
|
let kiroSessionIdCaptureBuffer = '';
|
|
@@ -5436,39 +6526,130 @@ function decodeCodexAppPayload(payload) {
|
|
|
5436
6526
|
return undefined;
|
|
5437
6527
|
}
|
|
5438
6528
|
}
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
6529
|
+
const CODEX_APP_DAEMON_PERSIST_TIMEOUT_MS = 30_000;
|
|
6530
|
+
function waitForCodexAppDaemonPersistence(requestId, publish) {
|
|
6531
|
+
return new Promise(resolve => {
|
|
6532
|
+
const timer = setTimeout(() => {
|
|
6533
|
+
const pending = codexAppPendingDaemonAcks.get(requestId);
|
|
6534
|
+
if (!pending)
|
|
6535
|
+
return;
|
|
6536
|
+
codexAppPendingDaemonAcks.delete(requestId);
|
|
6537
|
+
resolve(false);
|
|
6538
|
+
}, CODEX_APP_DAEMON_PERSIST_TIMEOUT_MS);
|
|
6539
|
+
timer.unref?.();
|
|
6540
|
+
codexAppPendingDaemonAcks.set(requestId, { resolve, timer });
|
|
6541
|
+
try {
|
|
6542
|
+
publish();
|
|
6543
|
+
}
|
|
6544
|
+
catch {
|
|
6545
|
+
clearTimeout(timer);
|
|
6546
|
+
codexAppPendingDaemonAcks.delete(requestId);
|
|
6547
|
+
resolve(false);
|
|
6548
|
+
}
|
|
6549
|
+
});
|
|
6550
|
+
}
|
|
6551
|
+
function requestCodexAppDispatchTransition(operation, entries) {
|
|
6552
|
+
if (!sessionId || entries.length === 0)
|
|
6553
|
+
return Promise.resolve(entries.length === 0);
|
|
6554
|
+
const requestId = randomBytes(16).toString('hex');
|
|
6555
|
+
return waitForCodexAppDaemonPersistence(requestId, () => send({
|
|
6556
|
+
type: 'codex_app_dispatch_transition',
|
|
6557
|
+
sessionId,
|
|
6558
|
+
requestId,
|
|
6559
|
+
operation,
|
|
6560
|
+
entries,
|
|
6561
|
+
}));
|
|
6562
|
+
}
|
|
6563
|
+
/** Retire one exact durable dispatch before its receipt/lease owner is allowed
|
|
6564
|
+
* to replay it. The daemon ledger is the authority; local queue removal is
|
|
6565
|
+
* deliberately after the durable ACK so a failed session-file write cannot
|
|
6566
|
+
* create a replay window while the old worker still owns the turn. */
|
|
6567
|
+
async function retireCodexAppDispatchForDurableReplay(turnId, dispatchAttempt) {
|
|
6568
|
+
if (lastInitConfig?.cliId !== 'codex-app') {
|
|
6569
|
+
for (let index = pendingMessages.length - 1; index >= 0; index--) {
|
|
6570
|
+
const item = pendingMessages[index];
|
|
6571
|
+
if (item.turnId === turnId && item.dispatchAttempt === dispatchAttempt) {
|
|
6572
|
+
pendingMessages.splice(index, 1);
|
|
6573
|
+
}
|
|
6574
|
+
}
|
|
6575
|
+
return true;
|
|
6576
|
+
}
|
|
6577
|
+
const reservation = codexAppTurnDispatchQueue.findExact(turnId, dispatchAttempt);
|
|
6578
|
+
const pending = pendingMessages.find(item => item.turnId === turnId
|
|
6579
|
+
&& item.dispatchAttempt === dispatchAttempt);
|
|
6580
|
+
const recovered = codexAppRecoveredDispatches.find(entry => entry.turnId === turnId
|
|
6581
|
+
&& entry.dispatchAttempt === dispatchAttempt);
|
|
6582
|
+
const dispatchIds = new Set([
|
|
6583
|
+
reservation?.dispatchId,
|
|
6584
|
+
pending?.codexAppDispatchId,
|
|
6585
|
+
recovered?.dispatchId,
|
|
6586
|
+
].filter((value) => !!value));
|
|
6587
|
+
if (dispatchIds.size !== 1) {
|
|
6588
|
+
log(`Cannot retire Codex App durable dispatch turn=${turnId.slice(0, 12)} `
|
|
6589
|
+
+ `attempt=${dispatchAttempt}: exact dispatch id is ${dispatchIds.size === 0 ? 'missing' : 'ambiguous'}`);
|
|
6590
|
+
return false;
|
|
6591
|
+
}
|
|
6592
|
+
const dispatchId = [...dispatchIds][0];
|
|
6593
|
+
if (!await requestCodexAppDispatchTransition('cancel', [{
|
|
6594
|
+
dispatchId,
|
|
6595
|
+
turnId,
|
|
6596
|
+
dispatchAttempt,
|
|
6597
|
+
}]))
|
|
6598
|
+
return false;
|
|
6599
|
+
if (reservation && !codexAppTurnDispatchQueue.cancelExact(reservation.handle))
|
|
6600
|
+
return false;
|
|
6601
|
+
for (let index = pendingMessages.length - 1; index >= 0; index--) {
|
|
6602
|
+
const item = pendingMessages[index];
|
|
6603
|
+
if (item.turnId === turnId && item.dispatchAttempt === dispatchAttempt) {
|
|
6604
|
+
pendingMessages.splice(index, 1);
|
|
6605
|
+
}
|
|
6606
|
+
}
|
|
6607
|
+
codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== dispatchId);
|
|
6608
|
+
return true;
|
|
6609
|
+
}
|
|
6610
|
+
async function handleTrustedCodexAppMarker(kind, payload, control) {
|
|
5447
6611
|
if (kind === 'thread' && typeof payload.threadId === 'string') {
|
|
5448
6612
|
persistCliSessionId(payload.threadId);
|
|
5449
|
-
return;
|
|
6613
|
+
return true;
|
|
5450
6614
|
}
|
|
6615
|
+
// master added lifecycle/steer markers. In the merged world codex-app reaches
|
|
6616
|
+
// this handler over the signed socket (PR #597 moved it off terminal OSC), so
|
|
6617
|
+
// the branch lives here rather than in handleAppRunnerOscMarker. These events
|
|
6618
|
+
// are informational (steer-ack correlation); a malformed or unmatched event is
|
|
6619
|
+
// logged/ignored but still counts as successfully applied so the signed record
|
|
6620
|
+
// is ACKed rather than destroying the control socket.
|
|
5451
6621
|
if (kind === 'lifecycle') {
|
|
5452
6622
|
const event = normalizeCodexAppLifecycleEvent(payload);
|
|
5453
6623
|
if (!event) {
|
|
5454
6624
|
log(`${cliName()} rejected malformed lifecycle marker`);
|
|
5455
|
-
return;
|
|
6625
|
+
return true;
|
|
5456
6626
|
}
|
|
5457
6627
|
log(`${cliName()} lifecycle kind=${event.kind}`
|
|
5458
6628
|
+ ` appTurn=${shortCorrelationId(event.appTurnId)}`
|
|
5459
6629
|
+ ` replyTurn=${shortCorrelationId(event.replyTurnId)}`
|
|
5460
6630
|
+ `${event.queueLength !== undefined ? ` queue=${event.queueLength}` : ''}`);
|
|
6631
|
+
// An authenticated `fatal` lifecycle is a control-plane kill, NOT an
|
|
6632
|
+
// informational steer-correlation event (B2): the runner fenced its
|
|
6633
|
+
// generation on an unknown turn/start|turn/steer outcome and will emit no
|
|
6634
|
+
// final for the in-doubt turn. Tear the generation down so no recovered
|
|
6635
|
+
// prepared frame is replayed and no ready is published. This MUST run before
|
|
6636
|
+
// the replyTurnId gate below — a fatal carries no replyTurnId, so gating on
|
|
6637
|
+
// it would (wrongly) swallow the kill as informational.
|
|
6638
|
+
if (event.kind === 'fatal') {
|
|
6639
|
+
failCodexAppControlGeneration(`Codex App runner fenced its generation (${event.operation}/${event.category})`);
|
|
6640
|
+
return false;
|
|
6641
|
+
}
|
|
5461
6642
|
if (!event.replyTurnId || !submittedCodexAppReplyTurnIds.has(event.replyTurnId))
|
|
5462
|
-
return;
|
|
6643
|
+
return true;
|
|
5463
6644
|
if (event.kind === 'steer_attempt') {
|
|
5464
6645
|
rememberBoundedMap(pendingCodexAppSteerAckIds, event.replyTurnId, event.appTurnId);
|
|
5465
|
-
return;
|
|
6646
|
+
return true;
|
|
5466
6647
|
}
|
|
5467
6648
|
const steerKey = `${event.appTurnId}\0${event.replyTurnId}`;
|
|
5468
6649
|
if (event.kind !== 'steer_accepted'
|
|
5469
6650
|
|| pendingCodexAppSteerAckIds.get(event.replyTurnId) !== event.appTurnId
|
|
5470
6651
|
|| acknowledgedCodexAppSteers.has(steerKey))
|
|
5471
|
-
return;
|
|
6652
|
+
return true;
|
|
5472
6653
|
pendingCodexAppSteerAckIds.delete(event.replyTurnId);
|
|
5473
6654
|
rememberBounded(acknowledgedCodexAppSteers, steerKey);
|
|
5474
6655
|
send({
|
|
@@ -5476,98 +6657,359 @@ function handleCodexAppMarker(body) {
|
|
|
5476
6657
|
appTurnId: event.appTurnId,
|
|
5477
6658
|
turnId: event.replyTurnId,
|
|
5478
6659
|
});
|
|
5479
|
-
return;
|
|
6660
|
+
return true;
|
|
5480
6661
|
}
|
|
5481
|
-
if (kind === '
|
|
5482
|
-
const
|
|
5483
|
-
if (
|
|
5484
|
-
|
|
5485
|
-
|
|
6662
|
+
if (kind === 'state' && lastInitConfig?.cliId === 'codex-app') {
|
|
6663
|
+
const readiness = codexAppSignedStateReadiness(payload);
|
|
6664
|
+
if (readiness === 'invalid') {
|
|
6665
|
+
rejectCodexAppControlMarker('signed state missing boolean acceptingInput');
|
|
6666
|
+
failCodexAppControlGeneration('Codex App runner published signed state without boolean acceptingInput');
|
|
6667
|
+
return false;
|
|
5486
6668
|
}
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
&& submittedCodexAppReplyTurnIds.has(marker.replyTurnId)
|
|
5492
|
-
? marker.replyTurnId
|
|
5493
|
-
: undefined;
|
|
5494
|
-
if (marker.replyTurnId && !trustedReplyTurnId) {
|
|
5495
|
-
log(`${cliName()} ignored unsubmitted final reply route `
|
|
5496
|
-
+ `(replyTurn=${shortCorrelationId(marker.replyTurnId)})`);
|
|
5497
|
-
}
|
|
5498
|
-
const identity = projectAppRunnerFinalIds({ ...marker, replyTurnId: trustedReplyTurnId }, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
|
|
5499
|
-
if (marker.appTurnId !== identity.turnId) {
|
|
5500
|
-
log(`${cliName()} app turn ${shortCorrelationId(marker.appTurnId)} mapped `
|
|
5501
|
-
+ `to botmux turn ${shortCorrelationId(identity.turnId)}`);
|
|
5502
|
-
}
|
|
5503
|
-
const dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
5504
|
-
if (startedAtMs !== undefined) {
|
|
5505
|
-
const markers = readSendMarkers();
|
|
5506
|
-
const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: marker.content };
|
|
5507
|
-
if (shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false)) {
|
|
5508
|
-
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
5509
|
-
// Symmetric with the legacy final branch: tell observers (e.g. the
|
|
5510
|
-
// message-listener run-preview lifecycle) that this turn's reply was
|
|
5511
|
-
// the model's explicit botmux send, so it stops showing "running".
|
|
5512
|
-
notifyExplicitReplyObserved(identity.turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
|
|
5513
|
-
emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
|
|
5514
|
-
return;
|
|
5515
|
-
}
|
|
6669
|
+
if (readiness === 'waiting') {
|
|
6670
|
+
if (typeof payload.busy !== 'boolean') {
|
|
6671
|
+
rejectCodexAppControlMarker('invalid signed state');
|
|
6672
|
+
return false;
|
|
5516
6673
|
}
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
6674
|
+
codexAppSignedStateObserved = false;
|
|
6675
|
+
codexAppInputReady = false;
|
|
6676
|
+
codexAppReadyAuthority.beginWork();
|
|
6677
|
+
isPromptReady = false;
|
|
6678
|
+
idleDetector?.reset();
|
|
6679
|
+
if (!codexAppProofDeadline.armed) {
|
|
6680
|
+
codexAppProofDeadline.arm(() => {
|
|
6681
|
+
failCodexAppControlGeneration(`Codex App runner did not become input-ready within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
|
|
6682
|
+
});
|
|
6683
|
+
}
|
|
6684
|
+
log('Authenticated Codex App runner is not yet accepting input; readiness deadline remains armed');
|
|
6685
|
+
return true;
|
|
5527
6686
|
}
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
if (!identity.ok) {
|
|
5533
|
-
log(`${cliName()} rejected final marker with mismatched turn `
|
|
5534
|
-
+ `(marker=${identity.markerTurnId.substring(0, 12)}, `
|
|
5535
|
-
+ `current=${identity.currentBotmuxTurnId?.substring(0, 12) ?? '-'})`);
|
|
5536
|
-
return;
|
|
6687
|
+
const state = applyTrustedCodexAppStateMarker(codexAppTurnLiveness, codexAppReadyAuthority, payload);
|
|
6688
|
+
if (!state.accepted) {
|
|
6689
|
+
rejectCodexAppControlMarker('invalid signed state');
|
|
6690
|
+
return false;
|
|
5537
6691
|
}
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
6692
|
+
// This is the first actual readiness proof. Authentication alone cannot
|
|
6693
|
+
// clear startup/reconnect failure detection or release queued input.
|
|
6694
|
+
codexAppSignedStateObserved = true;
|
|
6695
|
+
codexAppInputReady = true;
|
|
6696
|
+
codexAppProofDeadline.clear();
|
|
6697
|
+
cleanupCodexAppControlBootstrap();
|
|
6698
|
+
if (codexAppInputReady && awaitingFirstPrompt) {
|
|
6699
|
+
awaitingFirstPrompt = false;
|
|
6700
|
+
renderer?.markNewTurn();
|
|
5541
6701
|
}
|
|
5542
|
-
if (
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
6702
|
+
if (state.busy) {
|
|
6703
|
+
isPromptReady = false;
|
|
6704
|
+
idleDetector?.reset();
|
|
6705
|
+
if (codexAppInputReady)
|
|
6706
|
+
queueMicrotask(() => { void flushPending(); });
|
|
5547
6707
|
}
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
5557
|
-
notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
|
|
5558
|
-
emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
|
|
5559
|
-
return;
|
|
6708
|
+
else {
|
|
6709
|
+
if (codexAppCompletionAwaitingFinal) {
|
|
6710
|
+
// Every runner generation that can authenticate this new signed
|
|
6711
|
+
// channel emits an explicit final transaction, including zero chunks
|
|
6712
|
+
// for an empty answer. Guessing an empty final here would let a
|
|
6713
|
+
// mismatched/rejected final advance the FIFO and be ACKed as success.
|
|
6714
|
+
failCodexAppControlGeneration('Codex App runner published idle before the required final transaction');
|
|
6715
|
+
return false;
|
|
5560
6716
|
}
|
|
6717
|
+
// Signed idle proves only that the runner has no queued/active turn. It
|
|
6718
|
+
// does NOT prove its raw stdin inputBuffer is empty: the old worker can
|
|
6719
|
+
// die after writing the complete frame but before Enter, then this warm
|
|
6720
|
+
// runner reconnects and reports idle. Resetting prepared→accepted would
|
|
6721
|
+
// let the replacement pre-flush submit that old frame and then replay it
|
|
6722
|
+
// a second time. A recovered prepared entry therefore advances only by
|
|
6723
|
+
// replaying its final/high-water ACK; otherwise fail closed for explicit
|
|
6724
|
+
// operator abandon.
|
|
6725
|
+
if (codexAppTurnDispatchQueue.recoveredPrefix().length > 0) {
|
|
6726
|
+
failCodexAppControlGeneration('Codex App signed idle cannot prove the recovered prepared frame was never buffered');
|
|
6727
|
+
return false;
|
|
6728
|
+
}
|
|
6729
|
+
if (state.shouldPublishReady) {
|
|
6730
|
+
codexAppUnprovenPromptDeferred = false;
|
|
6731
|
+
queueMicrotask(() => markPromptReady());
|
|
6732
|
+
}
|
|
6733
|
+
}
|
|
6734
|
+
return true;
|
|
6735
|
+
}
|
|
6736
|
+
if (kind === 'diagnostic' && lastInitConfig?.cliId === 'codex-app') {
|
|
6737
|
+
if (payload.code !== 'native_turn_identity_conflict'
|
|
6738
|
+
|| typeof payload.message !== 'string') {
|
|
6739
|
+
rejectCodexAppControlMarker('invalid signed diagnostic');
|
|
6740
|
+
return false;
|
|
5561
6741
|
}
|
|
6742
|
+
log(`Codex App fail-closed diagnostic: ${payload.message}`);
|
|
5562
6743
|
send({
|
|
5563
|
-
type: '
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
turnId,
|
|
5567
|
-
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
6744
|
+
type: 'user_notify',
|
|
6745
|
+
message: payload.message,
|
|
6746
|
+
...(typeof payload.turnId === 'string' ? { turnId: payload.turnId } : {}),
|
|
5568
6747
|
});
|
|
6748
|
+
return true;
|
|
6749
|
+
}
|
|
6750
|
+
if (kind === 'activity' && lastInitConfig?.cliId === 'codex-app') {
|
|
6751
|
+
const activity = applyTrustedCodexAppActivityMarker(codexAppTurnLiveness, payload);
|
|
6752
|
+
if (!activity.accepted) {
|
|
6753
|
+
rejectCodexAppControlMarker('invalid signed activity');
|
|
6754
|
+
return false;
|
|
6755
|
+
}
|
|
6756
|
+
cleanupCodexAppControlBootstrap();
|
|
6757
|
+
if (activity.phase === 'completed') {
|
|
6758
|
+
codexAppCompletionAwaitingFinal = true;
|
|
6759
|
+
}
|
|
6760
|
+
else {
|
|
6761
|
+
if (activity.phase === 'submitted' && codexAppCompletionAwaitingFinal) {
|
|
6762
|
+
failCodexAppControlGeneration('Codex App runner submitted the next turn before the required final transaction');
|
|
6763
|
+
return false;
|
|
6764
|
+
}
|
|
6765
|
+
codexAppReadyAuthority.beginWork();
|
|
6766
|
+
isPromptReady = false;
|
|
6767
|
+
idleDetector?.reset();
|
|
6768
|
+
}
|
|
6769
|
+
return true;
|
|
6770
|
+
}
|
|
6771
|
+
if (kind === 'final' && typeof payload.content === 'string') {
|
|
6772
|
+
const finalContent = payload.content;
|
|
6773
|
+
// Blocking 1 N-final expansion: a `steer_superseded` disposition marks one of
|
|
6774
|
+
// the first N−1 members of an ordered steered group. It advances the worker
|
|
6775
|
+
// FIFO (durable settlement) but is NEVER delivered, carries no usage, and does
|
|
6776
|
+
// NOT clear completion-awaiting or publish ready — only the LAST (real) final
|
|
6777
|
+
// of the group does that. Validate the contract strictly: any other
|
|
6778
|
+
// disposition value, or a superseded final with content/usage or without an
|
|
6779
|
+
// awaited completion, is a malformed control record → reject (fail closed).
|
|
6780
|
+
const disposition = payload.disposition;
|
|
6781
|
+
const isSuperseded = disposition === 'steer_superseded';
|
|
6782
|
+
if (disposition !== undefined && !isSuperseded) {
|
|
6783
|
+
rejectCodexAppControlMarker(`unknown final disposition ${String(disposition)}`);
|
|
6784
|
+
return false;
|
|
6785
|
+
}
|
|
6786
|
+
if (isSuperseded
|
|
6787
|
+
&& (lastInitConfig?.cliId !== 'codex-app'
|
|
6788
|
+
|| finalContent !== ''
|
|
6789
|
+
|| payload.usage !== undefined
|
|
6790
|
+
|| !codexAppCompletionAwaitingFinal)) {
|
|
6791
|
+
rejectCodexAppControlMarker('invalid steer_superseded final (content/usage/awaiting contract)');
|
|
6792
|
+
return false;
|
|
6793
|
+
}
|
|
6794
|
+
// Forward the runner's four-bucket token usage on the final_output IPC so
|
|
6795
|
+
// the daemon's async-trigger sink (worker-pool recordCompleted) can persist
|
|
6796
|
+
// it. normalizeFinalUsage validates non-negative integers and drops a
|
|
6797
|
+
// malformed/partial packet — never let a compromised runner poison usage.
|
|
6798
|
+
// A superseded final never carries usage (validated above).
|
|
6799
|
+
const finalUsage = normalizeFinalUsage(payload.usage);
|
|
6800
|
+
const startedAtMs = typeof payload.startedAtMs === 'number' && Number.isFinite(payload.startedAtMs)
|
|
6801
|
+
? payload.startedAtMs
|
|
6802
|
+
: undefined;
|
|
6803
|
+
const receivedAtMs = Date.now();
|
|
6804
|
+
const completedAtMs = typeof payload.completedAtMs === 'number' && Number.isFinite(payload.completedAtMs)
|
|
6805
|
+
? Math.min(payload.completedAtMs, receivedAtMs)
|
|
6806
|
+
: receivedAtMs;
|
|
6807
|
+
let turnId;
|
|
6808
|
+
let nativeTurnId;
|
|
6809
|
+
let replyTurnId;
|
|
6810
|
+
let dispatchAttempt;
|
|
6811
|
+
let codexAppDispatchId;
|
|
6812
|
+
let codexAppDispatchHandle;
|
|
6813
|
+
if (lastInitConfig?.cliId === 'codex-app') {
|
|
6814
|
+
// flushPending may finish writing N+1 before final N arrives. Attribute
|
|
6815
|
+
// only from the immutable worker-owned FIFO head; currentBotmuxTurnId is
|
|
6816
|
+
// intentionally not consulted here.
|
|
6817
|
+
const settlement = codexAppTurnDispatchQueue.settleFinal(payload, false);
|
|
6818
|
+
if (!settlement.ok) {
|
|
6819
|
+
log(`${cliName()} rejected final marker (${settlement.reason}; `
|
|
6820
|
+
+ `marker=${settlement.markerTurnId?.substring(0, 12) ?? '-'}, `
|
|
6821
|
+
+ `expected=${settlement.expectedTurnId?.substring(0, 12) ?? '-'})`);
|
|
6822
|
+
return false;
|
|
6823
|
+
}
|
|
6824
|
+
({
|
|
6825
|
+
turnId,
|
|
6826
|
+
replyTurnId,
|
|
6827
|
+
nativeTurnId,
|
|
6828
|
+
dispatchAttempt,
|
|
6829
|
+
dispatchId: codexAppDispatchId,
|
|
6830
|
+
handle: codexAppDispatchHandle,
|
|
6831
|
+
} = settlement);
|
|
6832
|
+
// R4-B4 worker-side defense-in-depth: a superseded settlement is only valid
|
|
6833
|
+
// for a steerable exact head that STILL has a successor reservation
|
|
6834
|
+
// (remaining > 0). A single forged superseded on the only head would
|
|
6835
|
+
// otherwise silently commit it, and the real final would then find no
|
|
6836
|
+
// pending turn. Reject before any commit — the reservation, logical slot,
|
|
6837
|
+
// and awaiting-final all stay untouched (settleFinal used consume:false).
|
|
6838
|
+
if (isSuperseded
|
|
6839
|
+
&& (settlement.codexAppSteerable !== true || settlement.remaining <= 0)) {
|
|
6840
|
+
rejectCodexAppControlMarker(`steer_superseded head not steerable / has no successor `
|
|
6841
|
+
+ `(steerable=${settlement.codexAppSteerable === true}, remaining=${settlement.remaining})`);
|
|
6842
|
+
return false;
|
|
6843
|
+
}
|
|
6844
|
+
// Liveness slot retirement + completion-awaiting clearing happen AFTER the
|
|
6845
|
+
// durable commit below (a persist/commit failure must not retire a slot or
|
|
6846
|
+
// clear awaiting), and are disposition-aware: a superseded member retires
|
|
6847
|
+
// exactly one extra logical slot but keeps awaiting=true / never publishes
|
|
6848
|
+
// ready; only the real final clears awaiting. See the post-commit block.
|
|
6849
|
+
}
|
|
6850
|
+
else {
|
|
6851
|
+
// Mira/Mir retain their terminal OSC control path and do not use the
|
|
6852
|
+
// Codex App serial dispatch FIFO.
|
|
6853
|
+
const identity = resolveCodexAppFinalTurnIdentity(payload, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
|
|
6854
|
+
if (!identity.ok) {
|
|
6855
|
+
log(`${cliName()} rejected final marker with mismatched turn `
|
|
6856
|
+
+ `(marker=${identity.markerTurnId.substring(0, 12)}, `
|
|
6857
|
+
+ `current=${identity.currentBotmuxTurnId?.substring(0, 12) ?? '-'})`);
|
|
6858
|
+
return false;
|
|
6859
|
+
}
|
|
6860
|
+
({ turnId, nativeTurnId } = identity);
|
|
6861
|
+
if (payload.dispatchAttempt !== undefined
|
|
6862
|
+
&& payload.dispatchAttempt !== currentBotmuxDispatchAttempt) {
|
|
6863
|
+
log(`${cliName()} rejected final marker with mismatched dispatch attempt `
|
|
6864
|
+
+ `(marker=${String(payload.dispatchAttempt)}, current=${currentBotmuxDispatchAttempt ?? '-'})`);
|
|
6865
|
+
return false;
|
|
6866
|
+
}
|
|
6867
|
+
dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
6868
|
+
}
|
|
6869
|
+
if (nativeTurnId && nativeTurnId !== turnId) {
|
|
6870
|
+
log(`${cliName()} native turn ${nativeTurnId.substring(0, 12)} mapped to botmux turn ${turnId.substring(0, 12)}`);
|
|
6871
|
+
}
|
|
6872
|
+
let suppressDelivery = false;
|
|
6873
|
+
// What actually reaches Lark: strip a trailing sentinel line so the literal
|
|
6874
|
+
// token never posts. `finalContent` stays RAW above (the steer_superseded
|
|
6875
|
+
// validator asserts finalContent==='' and must see the unstripped payload).
|
|
6876
|
+
// If nothing remains after stripping, this was a pure-silence final → treat
|
|
6877
|
+
// as suppressed so the daemon persists the FIFO advance without delivering.
|
|
6878
|
+
const deliverableContent = stripTrailingBridgeSentinelLine(finalContent);
|
|
6879
|
+
if (deliverableContent.trim().length === 0 && finalContent.trim().length > 0) {
|
|
6880
|
+
suppressDelivery = true;
|
|
6881
|
+
}
|
|
6882
|
+
if (deliverableContent && startedAtMs !== undefined) {
|
|
6883
|
+
const suppressMarkers = readSendMarkers();
|
|
6884
|
+
const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: deliverableContent };
|
|
6885
|
+
suppressDelivery = suppressDelivery || shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, suppressMarkers, false);
|
|
6886
|
+
if (suppressDelivery) {
|
|
6887
|
+
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
6888
|
+
// Symmetric with the legacy/master appTurnId final branch: tell
|
|
6889
|
+
// observers (e.g. the message-listener run-preview lifecycle) that this
|
|
6890
|
+
// turn's reply was the model's explicit botmux send, so it stops showing
|
|
6891
|
+
// "running". The signed suppress path forwards final_output with
|
|
6892
|
+
// suppressDelivery:true, which the daemon short-circuits WITHOUT calling
|
|
6893
|
+
// deliverFinalOutput — the only site that otherwise marks run-preview
|
|
6894
|
+
// replied — so without this the preview shows "running" forever (F3).
|
|
6895
|
+
notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, suppressMarkers, false));
|
|
6896
|
+
}
|
|
6897
|
+
}
|
|
6898
|
+
if (codexAppDispatchId) {
|
|
6899
|
+
if (!control || codexAppDispatchHandle === undefined)
|
|
6900
|
+
return false;
|
|
6901
|
+
const requestId = randomBytes(16).toString('hex');
|
|
6902
|
+
// A superseded member is durably settled but NEVER delivered: force empty
|
|
6903
|
+
// content + suppressDelivery so the daemon persists the FIFO advance without
|
|
6904
|
+
// deliverFinalOutput, and tag the disposition so the sink is explicit.
|
|
6905
|
+
const persisted = await waitForCodexAppDaemonPersistence(requestId, () => send({
|
|
6906
|
+
type: 'final_output',
|
|
6907
|
+
content: (suppressDelivery || isSuperseded) ? '' : deliverableContent,
|
|
6908
|
+
lastUuid: turnId,
|
|
6909
|
+
turnId,
|
|
6910
|
+
...(replyTurnId ? { replyTurnId } : {}),
|
|
6911
|
+
...(finalUsage ? { usage: finalUsage } : {}),
|
|
6912
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
6913
|
+
...((suppressDelivery || isSuperseded) ? { suppressDelivery: true } : {}),
|
|
6914
|
+
...(isSuperseded ? { disposition: 'steer_superseded' } : {}),
|
|
6915
|
+
codexAppSettlement: {
|
|
6916
|
+
requestId,
|
|
6917
|
+
generation: control.generation,
|
|
6918
|
+
seq: control.seq,
|
|
6919
|
+
dispatchId: codexAppDispatchId,
|
|
6920
|
+
},
|
|
6921
|
+
}));
|
|
6922
|
+
if (!persisted || !codexAppTurnDispatchQueue.commitExactHead(codexAppDispatchHandle)) {
|
|
6923
|
+
// Persist/commit failed: do NOT retire a liveness slot or clear awaiting
|
|
6924
|
+
// (codex step 5) — the generation may replay this exact transaction.
|
|
6925
|
+
return false;
|
|
6926
|
+
}
|
|
6927
|
+
codexAppGenerationCommits = [
|
|
6928
|
+
...codexAppGenerationCommits.filter(commit => commit.generation !== control.generation),
|
|
6929
|
+
{
|
|
6930
|
+
generation: control.generation,
|
|
6931
|
+
committedThrough: Math.max(control.seq, codexAppGenerationCommits.find(commit => commit.generation === control.generation)?.committedThrough ?? 0),
|
|
6932
|
+
},
|
|
6933
|
+
];
|
|
6934
|
+
codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== codexAppDispatchId);
|
|
6935
|
+
}
|
|
6936
|
+
else {
|
|
6937
|
+
if (lastInitConfig?.cliId === 'codex-app'
|
|
6938
|
+
&& codexAppDispatchHandle !== undefined
|
|
6939
|
+
&& !codexAppTurnDispatchQueue.commitExactHead(codexAppDispatchHandle)) {
|
|
6940
|
+
return false;
|
|
6941
|
+
}
|
|
6942
|
+
if (finalContent && !suppressDelivery) {
|
|
6943
|
+
send({
|
|
6944
|
+
type: 'final_output',
|
|
6945
|
+
content: deliverableContent,
|
|
6946
|
+
lastUuid: turnId,
|
|
6947
|
+
turnId,
|
|
6948
|
+
...(finalUsage ? { usage: finalUsage } : {}),
|
|
6949
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
6950
|
+
});
|
|
6951
|
+
}
|
|
6952
|
+
else if (isSuperseded) {
|
|
6953
|
+
// A superseded member still emits a final_output so the daemon (and any
|
|
6954
|
+
// async-trigger/observer sink) records the FIFO advance — but with empty
|
|
6955
|
+
// content + suppressDelivery + the disposition tag so it is persisted and
|
|
6956
|
+
// NEVER delivered. Without this the member would be invisible downstream.
|
|
6957
|
+
send({
|
|
6958
|
+
type: 'final_output',
|
|
6959
|
+
content: '',
|
|
6960
|
+
lastUuid: turnId,
|
|
6961
|
+
turnId,
|
|
6962
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
6963
|
+
suppressDelivery: true,
|
|
6964
|
+
disposition: 'steer_superseded',
|
|
6965
|
+
});
|
|
6966
|
+
}
|
|
6967
|
+
else if (!finalContent) {
|
|
6968
|
+
log(`${cliName()} empty final settled for botmux turn ${turnId.substring(0, 12)}`);
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
// Post-commit liveness (codex-app only; runs only after a successful durable
|
|
6972
|
+
// or fallback commit — a failed commit returned above without touching state):
|
|
6973
|
+
if (lastInitConfig?.cliId === 'codex-app') {
|
|
6974
|
+
if (isSuperseded) {
|
|
6975
|
+
// A superseded member retires exactly ONE extra logical liveness slot
|
|
6976
|
+
// (worker flush created N slots for the group; the single native
|
|
6977
|
+
// activity:completed already retired the first). Keep awaiting-final TRUE
|
|
6978
|
+
// and never publish ready — the group's real final is still to come. This
|
|
6979
|
+
// is "retire a logical slot", NOT "close the native completion gate".
|
|
6980
|
+
codexAppTurnLiveness.completeCurrent(completedAtMs);
|
|
6981
|
+
// codexAppCompletionAwaitingFinal intentionally left true.
|
|
6982
|
+
}
|
|
6983
|
+
else {
|
|
6984
|
+
if (!codexAppCompletionAwaitingFinal) {
|
|
6985
|
+
// turn/start failures emit a final transaction without an app-server
|
|
6986
|
+
// completed activity edge. Close exactly one liveness slot here.
|
|
6987
|
+
codexAppTurnLiveness.completeCurrent(completedAtMs);
|
|
6988
|
+
}
|
|
6989
|
+
// The real final of the group clears awaiting; the runner sequences the
|
|
6990
|
+
// signed state{busy:false} after this complete final transaction, so
|
|
6991
|
+
// ready publishes there (never here).
|
|
6992
|
+
codexAppCompletionAwaitingFinal = false;
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
5569
6995
|
emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
|
|
6996
|
+
return true;
|
|
5570
6997
|
}
|
|
6998
|
+
rejectCodexAppControlMarker(`unsupported signed ${kind}`);
|
|
6999
|
+
return false;
|
|
7000
|
+
}
|
|
7001
|
+
function handleAppRunnerOscMarker(body) {
|
|
7002
|
+
const sep = body.indexOf(':');
|
|
7003
|
+
if (sep < 0)
|
|
7004
|
+
return;
|
|
7005
|
+
const kind = body.slice(0, sep);
|
|
7006
|
+
const payload = decodeCodexAppPayload(body.slice(sep + 1));
|
|
7007
|
+
if (!payload || typeof payload !== 'object')
|
|
7008
|
+
return;
|
|
7009
|
+
// PR #597 routes every runner marker (thread/state/diagnostic/activity/final
|
|
7010
|
+
// and master's lifecycle/steer) through the single trusted handler; codex-app
|
|
7011
|
+
// reaches it over the signed socket, mira/mir over this terminal-OSC path.
|
|
7012
|
+
void handleTrustedCodexAppMarker(kind, payload);
|
|
5571
7013
|
}
|
|
5572
7014
|
function maybeCaptureKiroSessionId(data) {
|
|
5573
7015
|
if (!kiroSessionIdCaptureArmed || lastInitConfig?.cliId !== 'kiro-cli')
|
|
@@ -5581,7 +7023,7 @@ function maybeCaptureKiroSessionId(data) {
|
|
|
5581
7023
|
kiroSessionIdCaptureBuffer = '';
|
|
5582
7024
|
}
|
|
5583
7025
|
function splitCodexAppControl(data) {
|
|
5584
|
-
return appRunnerControlDecoder.push(data, APP_RUNNER_OSC_CLI_IDS.has(lastInitConfig?.cliId ?? ''),
|
|
7026
|
+
return appRunnerControlDecoder.push(data, APP_RUNNER_OSC_CLI_IDS.has(lastInitConfig?.cliId ?? ''), handleAppRunnerOscMarker);
|
|
5585
7027
|
}
|
|
5586
7028
|
/** Riff runs `botmux send` in a remote filesystem, so its local binding
|
|
5587
7029
|
* sidecar is invisible to the host daemon. The CLI includes the claimed root
|
|
@@ -6043,6 +7485,13 @@ function markPromptReady() {
|
|
|
6043
7485
|
}
|
|
6044
7486
|
if (isPromptReady)
|
|
6045
7487
|
return; // guard against duplicate calls
|
|
7488
|
+
if (lastInitConfig?.cliId === 'codex-app' && !codexAppControlProven) {
|
|
7489
|
+
if (!codexAppUnprovenPromptDeferred) {
|
|
7490
|
+
log('Ignoring Codex App prompt until this worker verifies a fresh Ed25519 challenge response');
|
|
7491
|
+
}
|
|
7492
|
+
codexAppUnprovenPromptDeferred = true;
|
|
7493
|
+
return;
|
|
7494
|
+
}
|
|
6046
7495
|
if (cliRestartInProgress && !replacementSpawnInProgress) {
|
|
6047
7496
|
log('Ignoring prompt-ready from backend generation being replaced');
|
|
6048
7497
|
return;
|
|
@@ -6069,6 +7518,7 @@ function markPromptReady() {
|
|
|
6069
7518
|
return;
|
|
6070
7519
|
}
|
|
6071
7520
|
awaitingPostSessionStartPromptEvidence = false;
|
|
7521
|
+
clearPostHookEvidenceFallback();
|
|
6072
7522
|
log('Fresh prompt evidence observed after SessionStart hooks');
|
|
6073
7523
|
}
|
|
6074
7524
|
if (isSettlingFirstFlush) {
|
|
@@ -6076,6 +7526,22 @@ function markPromptReady() {
|
|
|
6076
7526
|
log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
|
|
6077
7527
|
return;
|
|
6078
7528
|
}
|
|
7529
|
+
// Authenticated runners advance their explicit control queue on signed
|
|
7530
|
+
// completed/final records.
|
|
7531
|
+
// A prompt is authoritative only for the synthetic observation installed
|
|
7532
|
+
// after a verified warm reattach; clearing normal queued turns here could
|
|
7533
|
+
// lose follow-up inputs written by one flushPending() call.
|
|
7534
|
+
if (lastInitConfig?.cliId === 'codex-app' && !codexAppTurnLiveness.notePrompt()) {
|
|
7535
|
+
log('Ignoring transient Codex App prompt while an explicit runner turn remains queued');
|
|
7536
|
+
return;
|
|
7537
|
+
}
|
|
7538
|
+
if (lastInitConfig?.cliId === 'codex-app' && !codexAppReadyAuthority.canPublishPromptReady()) {
|
|
7539
|
+
if (!codexAppReadyAuthority.consumeLatePromptRecovery(!codexAppTurnLiveness.hasActiveTurn())) {
|
|
7540
|
+
log('Deferring Codex App terminal prompt until signed runner state confirms busy=false');
|
|
7541
|
+
return;
|
|
7542
|
+
}
|
|
7543
|
+
log('Accepted one late Codex App prompt after exact local submit cancellation');
|
|
7544
|
+
}
|
|
6079
7545
|
const freshnessAction = freshnessInputQueue.onPromptReady();
|
|
6080
7546
|
if (freshnessAction === 'reload') {
|
|
6081
7547
|
log('Stale Codex App runner became idle; replacing it before releasing queued input');
|
|
@@ -6112,6 +7578,7 @@ function markPromptReady() {
|
|
|
6112
7578
|
if (awaitingFirstPrompt) {
|
|
6113
7579
|
awaitingFirstPrompt = false;
|
|
6114
7580
|
awaitingPostSessionStartPromptEvidence = false;
|
|
7581
|
+
clearPostHookEvidenceFallback();
|
|
6115
7582
|
renderer?.markNewTurn(); // exclude history replay from streaming card
|
|
6116
7583
|
}
|
|
6117
7584
|
send({ type: 'prompt_ready' });
|
|
@@ -6202,40 +7669,16 @@ function markPromptReady() {
|
|
|
6202
7669
|
}
|
|
6203
7670
|
}
|
|
6204
7671
|
function persistCliSessionId(cliSessionId) {
|
|
6205
|
-
|
|
6206
|
-
return;
|
|
6207
|
-
if (lastInitConfig)
|
|
6208
|
-
lastInitConfig.cliSessionId = cliSessionId;
|
|
6209
|
-
send({
|
|
6210
|
-
type: 'cli_session_id',
|
|
7672
|
+
const published = publishCliSessionIdToDaemon({
|
|
6211
7673
|
cliSessionId,
|
|
7674
|
+
sessionId,
|
|
7675
|
+
initConfig: lastInitConfig,
|
|
6212
7676
|
turnId: currentBotmuxTurnId,
|
|
6213
7677
|
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
7678
|
+
send,
|
|
6214
7679
|
});
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
if (!session)
|
|
6218
|
-
return;
|
|
6219
|
-
// One-shot native fork completed: the child now has its own CLI-native id
|
|
6220
|
-
// (Claude/Codex minted it during --fork-session / codex fork). Clear the
|
|
6221
|
-
// pending-fork marker so a later refork resumes THIS transcript instead of
|
|
6222
|
-
// re-forking the parent's again. Done HERE (worker process, same write that
|
|
6223
|
-
// sets cliSessionId) rather than only in the daemon's cli_session_id
|
|
6224
|
-
// handler — the worker writes the sessions file directly, and if it reloaded
|
|
6225
|
-
// the row from disk (pendingForkSession still true) its write would race and
|
|
6226
|
-
// clobber the daemon-side clear.
|
|
6227
|
-
const forkMarkerNeedsClear = session.pendingForkSession === true;
|
|
6228
|
-
if (session.cliSessionId === cliSessionId && !forkMarkerNeedsClear)
|
|
6229
|
-
return;
|
|
6230
|
-
session.cliSessionId = cliSessionId;
|
|
6231
|
-
if (forkMarkerNeedsClear)
|
|
6232
|
-
session.pendingForkSession = undefined;
|
|
6233
|
-
sessionStore.updateSession(session);
|
|
6234
|
-
log(`Persisted CLI session id: ${cliSessionId}${forkMarkerNeedsClear ? ' (cleared pending-fork marker)' : ''}`);
|
|
6235
|
-
}
|
|
6236
|
-
catch (err) {
|
|
6237
|
-
log(`Failed to persist CLI session id: ${err.message}`);
|
|
6238
|
-
}
|
|
7680
|
+
if (published)
|
|
7681
|
+
log(`Published CLI session id for daemon persistence: ${cliSessionId}`);
|
|
6239
7682
|
}
|
|
6240
7683
|
function observeCursorCliSessionId(pid, label = 'spawn') {
|
|
6241
7684
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -6316,7 +7759,7 @@ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
|
|
|
6316
7759
|
* full-directory scans every poll tick for a fingerprint that's permanently
|
|
6317
7760
|
* dead, the 99% CPU bug this whole patch series is fixing. */
|
|
6318
7761
|
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
|
|
6319
|
-
const preview = msg
|
|
7762
|
+
const preview = buildSubmitMessagePreview(msg);
|
|
6320
7763
|
const emitDurableTerminal = (errorCode) => {
|
|
6321
7764
|
if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
|
|
6322
7765
|
emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
|
|
@@ -6525,6 +7968,25 @@ async function detectBareShellLaunch() {
|
|
|
6525
7968
|
* sends the next message (type-ahead) without waiting for idle detection.
|
|
6526
7969
|
* Messages pushed during a flush are picked up by the while loop.
|
|
6527
7970
|
*/
|
|
7971
|
+
function requeueUnsubmittedQueuedActivation(item) {
|
|
7972
|
+
if (!item.queuedActivationToken)
|
|
7973
|
+
return;
|
|
7974
|
+
// backend.onExit already moved the same object into carryOver; spawnCli will
|
|
7975
|
+
// restore it after the fenced restart, so do not create a second owner here.
|
|
7976
|
+
if (!backend)
|
|
7977
|
+
return;
|
|
7978
|
+
inflightInputs.forget(item);
|
|
7979
|
+
if (!pendingMessages.some(candidate => candidate.queuedActivationToken === item.queuedActivationToken)) {
|
|
7980
|
+
pendingMessages.unshift(item);
|
|
7981
|
+
}
|
|
7982
|
+
log(`Retained queued activation ${item.queuedActivationToken.substring(0, 8)} for retry`);
|
|
7983
|
+
}
|
|
7984
|
+
function codexAppRuntimeTypeAheadReady() {
|
|
7985
|
+
return lastInitConfig?.cliId === 'codex-app'
|
|
7986
|
+
&& codexAppControlProven
|
|
7987
|
+
&& codexAppSignedStateObserved
|
|
7988
|
+
&& codexAppInputReady;
|
|
7989
|
+
}
|
|
6528
7990
|
async function flushPending() {
|
|
6529
7991
|
// destroySession() may be asynchronous while `backend` still references the
|
|
6530
7992
|
// old CLI. Never let a new flush (including one triggered by the old
|
|
@@ -6616,7 +8078,7 @@ async function flushPending() {
|
|
|
6616
8078
|
// Codex on codex-cli 0.134.0.
|
|
6617
8079
|
const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
|
|
6618
8080
|
const codexBridgeActive = codexBridgeFallbackActive();
|
|
6619
|
-
const typeAheadAllowed = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, pendingMessages[0]);
|
|
8081
|
+
const typeAheadAllowed = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true || codexAppRuntimeTypeAheadReady(), durableTurnInFlight, pendingMessages[0]);
|
|
6620
8082
|
// Native /rename is an administrative command, not a steer/queued model
|
|
6621
8083
|
// message. It must wait for a real prompt even on type-ahead CLIs. Normal
|
|
6622
8084
|
// pending messages can still drain while busy; the rename stays queued.
|
|
@@ -6635,6 +8097,7 @@ async function flushPending() {
|
|
|
6635
8097
|
if (!isPromptReady && !typeAheadAllowed)
|
|
6636
8098
|
return;
|
|
6637
8099
|
isFlushing = true;
|
|
8100
|
+
const codexAppPromptReplay = new CodexAppFlushPromptReplay();
|
|
6638
8101
|
if (isPromptReady) {
|
|
6639
8102
|
isPromptReady = false;
|
|
6640
8103
|
idleDetector?.reset();
|
|
@@ -6778,6 +8241,49 @@ async function flushPending() {
|
|
|
6778
8241
|
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
6779
8242
|
}
|
|
6780
8243
|
};
|
|
8244
|
+
// Unlike the transcript bridge, Codex App has an explicit app-server
|
|
8245
|
+
// runner. Start its liveness clock before the control line is written so
|
|
8246
|
+
// an accepted-but-never-dequeued input is diagnosed too; authenticated
|
|
8247
|
+
// activity records can arrive while writeInput awaits chunk delivery.
|
|
8248
|
+
// This bookkeeping runs before master's runAmbiguousSubmissionTransaction;
|
|
8249
|
+
// prepareNormalWrite (its beforeWrite) still rotates attribution atomically
|
|
8250
|
+
// with the fenced write, and it logs the "Writing …" line for both paths.
|
|
8251
|
+
const tracksCodexAppLiveness = lastInitConfig?.cliId === 'codex-app';
|
|
8252
|
+
const codexAppFrozenTurnId = tracksCodexAppLiveness
|
|
8253
|
+
? item.turnId
|
|
8254
|
+
|| item.codexAppInput?.clientUserMessageId
|
|
8255
|
+
|| `codex-app-${sessionId || 'unknown'}-${Date.now().toString(36)}-${++codexAppFallbackTurnSequence}`
|
|
8256
|
+
: undefined;
|
|
8257
|
+
// Reserve attribution before the first chunk is written. The runner can
|
|
8258
|
+
// dequeue and finish a small turn while writeRunnerInput is still
|
|
8259
|
+
// awaiting later chunk throttles, and this flush may then write N+1.
|
|
8260
|
+
const codexAppDispatchReservation = codexAppFrozenTurnId
|
|
8261
|
+
? codexAppTurnDispatchQueue.reserve(codexAppFrozenTurnId, item.dispatchAttempt, item.codexAppDispatchId, false, item.replyTurnId,
|
|
8262
|
+
// R4-B4: COPY the frozen steer authorization onto the reservation so a
|
|
8263
|
+
// superseded settlement can verify the exact head is a steerable turn.
|
|
8264
|
+
item.codexAppSteerable ? true : undefined)
|
|
8265
|
+
: undefined;
|
|
8266
|
+
if (tracksCodexAppLiveness && item.codexAppDispatchId && codexAppFrozenTurnId) {
|
|
8267
|
+
const prepared = await requestCodexAppDispatchTransition('submit', [{
|
|
8268
|
+
dispatchId: item.codexAppDispatchId,
|
|
8269
|
+
turnId: codexAppFrozenTurnId,
|
|
8270
|
+
...(item.dispatchAttempt !== undefined
|
|
8271
|
+
? { dispatchAttempt: item.dispatchAttempt }
|
|
8272
|
+
: {}),
|
|
8273
|
+
}]);
|
|
8274
|
+
if (!prepared) {
|
|
8275
|
+
if (codexAppDispatchReservation) {
|
|
8276
|
+
codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
|
|
8277
|
+
}
|
|
8278
|
+
failCodexAppControlGeneration('Daemon could not persist the Codex App prepared dispatch before runner write');
|
|
8279
|
+
break;
|
|
8280
|
+
}
|
|
8281
|
+
}
|
|
8282
|
+
const codexAppLivenessHandle = tracksCodexAppLiveness
|
|
8283
|
+
? codexAppTurnLiveness.begin(codexAppFrozenTurnId)
|
|
8284
|
+
: undefined;
|
|
8285
|
+
if (tracksCodexAppLiveness)
|
|
8286
|
+
codexAppReadyAuthority.beginWork();
|
|
6781
8287
|
// Defense in depth: TmuxPipeBackend's send methods no longer throw on a
|
|
6782
8288
|
// dead pane (they fire onExit instead), but writeInput can still throw
|
|
6783
8289
|
// for other reasons (fs errors while resolving the JSONL, a future
|
|
@@ -6805,7 +8311,7 @@ async function flushPending() {
|
|
|
6805
8311
|
const targetBackend = backend;
|
|
6806
8312
|
const targetAdapter = cliAdapter;
|
|
6807
8313
|
submissionBackend = targetBackend;
|
|
6808
|
-
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
8314
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
6809
8315
|
result = transaction.result;
|
|
6810
8316
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
6811
8317
|
}
|
|
@@ -6813,7 +8319,7 @@ async function flushPending() {
|
|
|
6813
8319
|
const targetBackend = backend;
|
|
6814
8320
|
const targetAdapter = cliAdapter;
|
|
6815
8321
|
submissionBackend = targetBackend;
|
|
6816
|
-
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
8322
|
+
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
6817
8323
|
result = transaction.result;
|
|
6818
8324
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
6819
8325
|
}
|
|
@@ -6838,12 +8344,51 @@ async function flushPending() {
|
|
|
6838
8344
|
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
6839
8345
|
&& !!err.recoveryFailureReason
|
|
6840
8346
|
&& !err.submissionStarted;
|
|
8347
|
+
// Roll back this turn's Codex App liveness slot regardless of the failure
|
|
8348
|
+
// shape; the slot was opened before the fenced write.
|
|
8349
|
+
codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
|
|
6841
8350
|
if (submissionPreparationFailed) {
|
|
6842
8351
|
if (item.turnId) {
|
|
6843
8352
|
emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
|
|
6844
8353
|
}
|
|
6845
8354
|
break;
|
|
6846
8355
|
}
|
|
8356
|
+
if (tracksCodexAppLiveness) {
|
|
8357
|
+
// A throwing framed write has unknown side effects: the runner may
|
|
8358
|
+
// hold a complete valid line without its newline. Never cancel and
|
|
8359
|
+
// continue in this generation. Ordinary IM ownership stays in the
|
|
8360
|
+
// daemon's prepared ledger for explicit crash recovery. Durable
|
|
8361
|
+
// ownership also stays intact: the worker-exit path atomically marks
|
|
8362
|
+
// its receipt ambiguous and arms the runtime fence before replay.
|
|
8363
|
+
// This supersedes master's blockedBeforeWrite/held-path for codex-app:
|
|
8364
|
+
// the dispatch ledger owns recovery, so any write failure (including a
|
|
8365
|
+
// provable pre-write block) fences the whole generation rather than
|
|
8366
|
+
// holding the item with a dangling reservation/prepared transition.
|
|
8367
|
+
log(`writeInput threw with unknown Codex App runner state: ${err?.message ?? err}`);
|
|
8368
|
+
failCodexAppControlGeneration('Codex App input write became ambiguous; fenced the runner before any successor could submit');
|
|
8369
|
+
break;
|
|
8370
|
+
}
|
|
8371
|
+
// Legacy/non-control adapters keep their existing submit-failure path.
|
|
8372
|
+
// A durable receiver attempt transfers replay ownership to the
|
|
8373
|
+
// receipt/lease reconciler on the ambiguous terminal below, so remove
|
|
8374
|
+
// any exact local reservation first to avoid two independent replayers.
|
|
8375
|
+
let dispatchStillPending = true;
|
|
8376
|
+
if (codexAppDispatchReservation) {
|
|
8377
|
+
if (item.codexAppDispatchId && durableWrite && codexAppFrozenTurnId) {
|
|
8378
|
+
const cancelled = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle) && await requestCodexAppDispatchTransition('cancel', [{
|
|
8379
|
+
dispatchId: item.codexAppDispatchId,
|
|
8380
|
+
turnId: codexAppFrozenTurnId,
|
|
8381
|
+
dispatchAttempt: item.dispatchAttempt,
|
|
8382
|
+
}]);
|
|
8383
|
+
if (!cancelled) {
|
|
8384
|
+
failCodexAppControlGeneration('Could not transfer an ambiguous Codex App delivery to durable recovery');
|
|
8385
|
+
break;
|
|
8386
|
+
}
|
|
8387
|
+
}
|
|
8388
|
+
else if (!item.codexAppDispatchId) {
|
|
8389
|
+
dispatchStillPending = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
6847
8392
|
log(`writeInput threw: ${err?.message ?? err}`);
|
|
6848
8393
|
if (blockedBeforeWrite && submissionBackend) {
|
|
6849
8394
|
// This exact item is known not to have touched the PTY. Keep its
|
|
@@ -6857,10 +8402,12 @@ async function flushPending() {
|
|
|
6857
8402
|
};
|
|
6858
8403
|
log('Held definitely-unwritten input until ZMX recovery restart');
|
|
6859
8404
|
}
|
|
6860
|
-
else
|
|
6861
|
-
|
|
8405
|
+
else {
|
|
8406
|
+
requeueUnsubmittedQueuedActivation(item);
|
|
8407
|
+
if (recoveryFailureReason)
|
|
8408
|
+
inflightInputs.retire(item);
|
|
6862
8409
|
}
|
|
6863
|
-
if (durableWrite && item.turnId && !recoveryFailureReason) {
|
|
8410
|
+
if (dispatchStillPending && durableWrite && item.turnId && !recoveryFailureReason) {
|
|
6864
8411
|
// A throwing backend cannot prove whether bytes reached the CLI.
|
|
6865
8412
|
// Reconcile as ambiguous (not a definitive failure) so the receiver
|
|
6866
8413
|
// can replay the same frozen delivery behind the action gate.
|
|
@@ -6870,7 +8417,7 @@ async function flushPending() {
|
|
|
6870
8417
|
// nulled `backend` and told the user the CLI exited) — nothing more to
|
|
6871
8418
|
// do. Otherwise surface it as a submit failure so the message isn't
|
|
6872
8419
|
// silently lost.
|
|
6873
|
-
if (backend) {
|
|
8420
|
+
if (backend && dispatchStillPending) {
|
|
6874
8421
|
if (recoveryFailureReason) {
|
|
6875
8422
|
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
|
|
6876
8423
|
}
|
|
@@ -6907,17 +8454,67 @@ async function flushPending() {
|
|
|
6907
8454
|
// `&& backend`: if the CLI exited during this write (pane gone → onExit
|
|
6908
8455
|
// nulled backend) the user already got a "CLI exited" notice; don't also
|
|
6909
8456
|
// nag that the submit wasn't confirmed.
|
|
6910
|
-
if (result && result.submitted === false
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
//
|
|
8457
|
+
if (result && result.submitted === false) {
|
|
8458
|
+
codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
|
|
8459
|
+
const codexAppSafeNonSubmission = result.submissionDisposition === 'untouched'
|
|
8460
|
+
|| result.submissionDisposition === 'flushed_invalid';
|
|
8461
|
+
if (tracksCodexAppLiveness && !codexAppSafeNonSubmission) {
|
|
8462
|
+
// final-Enter exhaustion, cleanup failure, and thrown/unknown writes
|
|
8463
|
+
// can leave a complete valid frame buffered. Retain an ordinary
|
|
8464
|
+
// prepared ledger entry and fence the entire generation. For a
|
|
8465
|
+
// durable attempt, worker exit owns the ambiguous receipt + runtime
|
|
8466
|
+
// fence transition; publishing an earlier terminal would open a
|
|
8467
|
+
// replay window before the old runner teardown is proven.
|
|
8468
|
+
failCodexAppControlGeneration('Codex App runner input buffer is not provably clean; fenced before any successor could submit');
|
|
6916
8469
|
break;
|
|
6917
8470
|
}
|
|
6918
|
-
|
|
8471
|
+
const dispatchStillPending = codexAppDispatchReservation
|
|
8472
|
+
? codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle)
|
|
8473
|
+
: true;
|
|
8474
|
+
if (dispatchStillPending && item.codexAppDispatchId && codexAppFrozenTurnId) {
|
|
8475
|
+
const retryQueuedActivation = tracksCodexAppLiveness
|
|
8476
|
+
&& !!item.queuedActivationToken
|
|
8477
|
+
&& codexAppSafeNonSubmission;
|
|
8478
|
+
const transitioned = await requestCodexAppDispatchTransition(retryQueuedActivation ? 'retry' : 'cancel', [{
|
|
8479
|
+
dispatchId: item.codexAppDispatchId,
|
|
8480
|
+
turnId: codexAppFrozenTurnId,
|
|
8481
|
+
...(item.dispatchAttempt !== undefined
|
|
8482
|
+
? { dispatchAttempt: item.dispatchAttempt }
|
|
8483
|
+
: {}),
|
|
8484
|
+
}]);
|
|
8485
|
+
if (!transitioned) {
|
|
8486
|
+
failCodexAppControlGeneration(retryQueuedActivation
|
|
8487
|
+
? 'Daemon could not return a safely untouched queued activation to accepted'
|
|
8488
|
+
: 'Daemon could not cancel a Codex App dispatch rejected before submission');
|
|
8489
|
+
break;
|
|
8490
|
+
}
|
|
8491
|
+
if (!retryQueuedActivation) {
|
|
8492
|
+
codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== item.codexAppDispatchId);
|
|
8493
|
+
}
|
|
8494
|
+
}
|
|
8495
|
+
if (backend && dispatchStillPending) {
|
|
8496
|
+
// master's ZMX recovery: a sticky submission-journal failure means the
|
|
8497
|
+
// current item may actually have landed. Retire it and stop the batch
|
|
8498
|
+
// rather than requeue (which could double-submit).
|
|
8499
|
+
if (recoveryFailureReason) {
|
|
8500
|
+
inflightInputs.retire(item);
|
|
8501
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
|
|
8502
|
+
break;
|
|
8503
|
+
}
|
|
6919
8504
|
scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item, 'failed');
|
|
6920
8505
|
}
|
|
8506
|
+
if (!recoveryFailureReason)
|
|
8507
|
+
requeueUnsubmittedQueuedActivation(item);
|
|
8508
|
+
}
|
|
8509
|
+
else if (item.queuedActivationToken) {
|
|
8510
|
+
// The daemon keeps the exact journal and route reservation until this
|
|
8511
|
+
// adapter-level boundary. IPC loss may replay at-least-once; an early
|
|
8512
|
+
// worker/daemon crash can never silently consume the opening turn.
|
|
8513
|
+
send({
|
|
8514
|
+
type: 'queued_activation_submitted',
|
|
8515
|
+
sessionId,
|
|
8516
|
+
activationToken: item.queuedActivationToken,
|
|
8517
|
+
});
|
|
6921
8518
|
}
|
|
6922
8519
|
// All structured bridges now drain every pending message in one flush:
|
|
6923
8520
|
// Claude's BridgeTurnQueue handles `attachment(queued_command)` events
|
|
@@ -6934,18 +8531,35 @@ async function flushPending() {
|
|
|
6934
8531
|
}
|
|
6935
8532
|
finally {
|
|
6936
8533
|
isFlushing = false;
|
|
8534
|
+
// A prompt can arrive after turn N completes but before turn N+1's chunked
|
|
8535
|
+
// control line finishes. We reject it while N+1 has a liveness slot. If that
|
|
8536
|
+
// write then fails, the prompt is still the runner's real ready boundary;
|
|
8537
|
+
// replay it after releasing the flush mutex so queued peers can drain.
|
|
8538
|
+
if (codexAppPromptReplay.consumeAfterFlush(codexAppTurnLiveness)) {
|
|
8539
|
+
log('Replaying deferred Codex App prompt after a queued submission was cancelled');
|
|
8540
|
+
markPromptReady();
|
|
8541
|
+
}
|
|
6937
8542
|
}
|
|
6938
8543
|
}
|
|
6939
8544
|
function sendToPty(content, turnId, opts = {}) {
|
|
6940
8545
|
const next = {
|
|
6941
8546
|
content,
|
|
6942
8547
|
turnId,
|
|
8548
|
+
...(opts.replyTurnId ? { replyTurnId: opts.replyTurnId } : {}),
|
|
8549
|
+
...(opts.codexAppDispatchId ? { codexAppDispatchId: opts.codexAppDispatchId } : {}),
|
|
8550
|
+
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
8551
|
+
...(opts.queuedActivationToken ? { queuedActivationToken: opts.queuedActivationToken } : {}),
|
|
6943
8552
|
...(opts.codexAppInput ? { codexAppInput: opts.codexAppInput } : {}),
|
|
6944
8553
|
...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
|
|
6945
8554
|
...(opts.vcMeetingImTurnOrigin
|
|
6946
8555
|
? { vcMeetingImTurnOrigin: opts.vcMeetingImTurnOrigin }
|
|
6947
8556
|
: {}),
|
|
6948
8557
|
};
|
|
8558
|
+
if (!initPromptMaterialized) {
|
|
8559
|
+
pendingMessages.push(next);
|
|
8560
|
+
log(`Queued message behind async init materialization (${pendingMessages.length} pending)`);
|
|
8561
|
+
return true;
|
|
8562
|
+
}
|
|
6949
8563
|
// During an exact lease-fenced CLI restart the worker stays alive while the
|
|
6950
8564
|
// backend is rebuilt. Preserve incoming attempt N+1 in the worker queue; the
|
|
6951
8565
|
// old early-return silently dropped it after receiver had already persisted
|
|
@@ -6957,7 +8571,7 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
6957
8571
|
}
|
|
6958
8572
|
if (!cliAdapter)
|
|
6959
8573
|
return false;
|
|
6960
|
-
const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
|
|
8574
|
+
const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true || codexAppRuntimeTypeAheadReady(), durableTurnInFlight, next);
|
|
6961
8575
|
const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
|
|
6962
8576
|
&& !isFlushing && !shouldWriteNow({
|
|
6963
8577
|
isPromptReady,
|
|
@@ -7045,6 +8659,9 @@ function startScreenUpdates() {
|
|
|
7045
8659
|
if (awaitingFirstPrompt)
|
|
7046
8660
|
return;
|
|
7047
8661
|
let status = isPromptReady ? 'idle' : 'working';
|
|
8662
|
+
// master removed the AI ScreenAnalyzer; PR #597's Codex App no-progress
|
|
8663
|
+
// liveness projection still layers on top of the base idle/working verdict.
|
|
8664
|
+
status = codexAppLivenessStatus(status);
|
|
7048
8665
|
void (async () => {
|
|
7049
8666
|
let content = lastContent;
|
|
7050
8667
|
let changed = false;
|
|
@@ -7464,6 +9081,7 @@ function scheduleBusyPatternIdleProbe(source) {
|
|
|
7464
9081
|
busyPatternIdleProbeTimer.unref?.();
|
|
7465
9082
|
}
|
|
7466
9083
|
async function spawnCli(cfg, opts = {}) {
|
|
9084
|
+
const spawnGeneration = ++cliSpawnGeneration;
|
|
7467
9085
|
clearSessionRenameInFlight();
|
|
7468
9086
|
currentCliCredentialIsolated = false;
|
|
7469
9087
|
// Enrollment writes the fixed marker before any device credential appears.
|
|
@@ -7902,6 +9520,45 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7902
9520
|
currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
|
|
7903
9521
|
const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
|
|
7904
9522
|
?? join(defaultBotmuxHome, 'data');
|
|
9523
|
+
// The unified Darwin sandbox enforces both read and write isolation. Keep
|
|
9524
|
+
// the legacy marker fields because a live persistent pane carries the
|
|
9525
|
+
// compiled Seatbelt policy in-process and may only be reattached when that
|
|
9526
|
+
// exact policy and authority channel still match.
|
|
9527
|
+
const willReadIsolate = process.platform === 'darwin' && sandboxRequested;
|
|
9528
|
+
const willWriteSandbox = process.platform === 'darwin' && sandboxRequested;
|
|
9529
|
+
const canonicalPolicyPath = (path) => {
|
|
9530
|
+
if (!path)
|
|
9531
|
+
return '';
|
|
9532
|
+
try {
|
|
9533
|
+
return realpathSync(path);
|
|
9534
|
+
}
|
|
9535
|
+
catch {
|
|
9536
|
+
return path;
|
|
9537
|
+
}
|
|
9538
|
+
};
|
|
9539
|
+
const darwinIsolationPolicyDigest = process.platform === 'darwin'
|
|
9540
|
+
? isolationPanePolicyDigest({
|
|
9541
|
+
readIsolation: willReadIsolate,
|
|
9542
|
+
writeSandbox: willWriteSandbox,
|
|
9543
|
+
readDenyExtraPaths: [
|
|
9544
|
+
...(cfg.readDenyExtraPaths ?? []),
|
|
9545
|
+
...(cfg.sandboxPaths?.deny ?? []),
|
|
9546
|
+
].map(canonicalPolicyPath),
|
|
9547
|
+
readOnlyExtraPaths: (cfg.sandboxPaths?.readOnly ?? []).map(canonicalPolicyPath),
|
|
9548
|
+
readWriteExtraPaths: (cfg.sandboxPaths?.readWrite ?? []).map(canonicalPolicyPath),
|
|
9549
|
+
writeAllowExtraPaths: process.env.TMPDIR
|
|
9550
|
+
? [canonicalPolicyPath(process.env.TMPDIR)]
|
|
9551
|
+
: [],
|
|
9552
|
+
workingDir: canonicalPolicyPath(cfg.workingDir),
|
|
9553
|
+
homeDir: canonicalPolicyPath(homedir()),
|
|
9554
|
+
osUserHomeDir: canonicalPolicyPath(userInfo().homedir),
|
|
9555
|
+
botmuxHome: canonicalPolicyPath(dirname(isolationRuntimeDataDir)),
|
|
9556
|
+
sessionDataDir: canonicalPolicyPath(isolationRuntimeDataDir),
|
|
9557
|
+
currentAppId: cfg.larkAppId,
|
|
9558
|
+
cliId: cfg.cliId,
|
|
9559
|
+
resolvedBin: canonicalPolicyPath(cliAdapter.resolvedBin),
|
|
9560
|
+
})
|
|
9561
|
+
: undefined;
|
|
7905
9562
|
let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
|
|
7906
9563
|
const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
|
|
7907
9564
|
const reuseRecordedHerdrTarget = !sandboxRequested && !hasMcpRuntimeEntries;
|
|
@@ -8031,6 +9688,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8031
9688
|
// so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
|
|
8032
9689
|
// this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
|
|
8033
9690
|
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
9691
|
+
let persistentPaneOriginChannelId;
|
|
8034
9692
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
8035
9693
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
8036
9694
|
// ZMX ownership is verified against the frozen PID, not just the name — a
|
|
@@ -8055,9 +9713,30 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8055
9713
|
}
|
|
8056
9714
|
const paneLive = paneProbe === 'exists';
|
|
8057
9715
|
if (paneLive) {
|
|
8058
|
-
|
|
8059
|
-
marker =
|
|
8060
|
-
|
|
9716
|
+
const markerPath = join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`);
|
|
9717
|
+
const marker = readManagedOriginAuthorityFile(markerPath);
|
|
9718
|
+
const darwinPolicyExpected = process.platform === 'darwin'
|
|
9719
|
+
&& (willReadIsolate || willWriteSandbox);
|
|
9720
|
+
// A stamped pane must match even when the new policy is OFF. Otherwise a
|
|
9721
|
+
// disable followed by restart could reattach the still-confined process
|
|
9722
|
+
// without rebuilding its authority/profile. An unsafe planted marker
|
|
9723
|
+
// leaf is treated as stamped/unknown by the no-follow existence check.
|
|
9724
|
+
const policyMatches = appliedIsolationCapabilities.length > 0
|
|
9725
|
+
? isolatedPaneReattachSafe(marker, {
|
|
9726
|
+
requiredCapabilities: appliedIsolationCapabilities,
|
|
9727
|
+
exactCapabilities: true,
|
|
9728
|
+
...(darwinPolicyExpected ? {
|
|
9729
|
+
readIsolation: willReadIsolate,
|
|
9730
|
+
writeSandbox: willWriteSandbox,
|
|
9731
|
+
requireOriginChannel: true,
|
|
9732
|
+
policyDigest: darwinIsolationPolicyDigest,
|
|
9733
|
+
} : {}),
|
|
9734
|
+
})
|
|
9735
|
+
: marker === null && !hostEntryExistsNoFollow(markerPath);
|
|
9736
|
+
if (policyMatches) {
|
|
9737
|
+
if (darwinPolicyExpected) {
|
|
9738
|
+
persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker);
|
|
9739
|
+
}
|
|
8061
9740
|
// Pane was spawned under the current isolation policy → still confined
|
|
8062
9741
|
// on the running process across daemon restarts; warm reattach preserves
|
|
8063
9742
|
// resume/context + tmux idle-suspend.
|
|
@@ -8116,6 +9795,12 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8116
9795
|
}
|
|
8117
9796
|
}
|
|
8118
9797
|
}
|
|
9798
|
+
readIsolationOriginChannelId = willReadIsolate
|
|
9799
|
+
? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex'))
|
|
9800
|
+
: null;
|
|
9801
|
+
if (readIsolationOriginChannelId) {
|
|
9802
|
+
persistentPaneOriginChannelId = readIsolationOriginChannelId;
|
|
9803
|
+
}
|
|
8119
9804
|
let willReattachPersistent = selectedBackend.isReattach === true;
|
|
8120
9805
|
if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
8121
9806
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
@@ -8179,6 +9864,40 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8179
9864
|
willReattachPersistent = selectedBackend.isReattach === true;
|
|
8180
9865
|
}
|
|
8181
9866
|
}
|
|
9867
|
+
// A pane created before asymmetric control framing has no persisted public
|
|
9868
|
+
// identity capable of answering this worker's fresh challenge. Never fall
|
|
9869
|
+
// back to terminal OSC trust: terminate it and cold-spawn a signed runner.
|
|
9870
|
+
if (persistentSessionName && shouldColdStartCodexAppReattach({
|
|
9871
|
+
cliId: cfg.cliId,
|
|
9872
|
+
backendType: effectiveBackendType,
|
|
9873
|
+
isReattach: willReattachPersistent,
|
|
9874
|
+
persistedState: readPersistedCodexAppControlState(cfg),
|
|
9875
|
+
})) {
|
|
9876
|
+
log(`Codex App persistent pane ${persistentSessionName} has no valid public control identity — killing + cold-spawning authenticated runner`);
|
|
9877
|
+
try {
|
|
9878
|
+
killPersistentSession(effectiveBackendType, persistentSessionName);
|
|
9879
|
+
}
|
|
9880
|
+
catch (err) {
|
|
9881
|
+
throw new Error(`Refusing unauthenticated Codex App reattach: could not kill stale pane (${err?.message ?? err})`);
|
|
9882
|
+
}
|
|
9883
|
+
willReattachPersistent = false;
|
|
9884
|
+
}
|
|
9885
|
+
// The worker establishes trust before any runner output can be parsed. A
|
|
9886
|
+
// fresh runner gets a new capability; a persistent reattach reloads the
|
|
9887
|
+
// capability created by that same runner generation.
|
|
9888
|
+
// The control endpoint is an authenticated lifecycle prerequisite. Await
|
|
9889
|
+
// bind + protected locator publication before backend.spawn so
|
|
9890
|
+
// a runner can never observe a not-yet-owned or stale endpoint.
|
|
9891
|
+
try {
|
|
9892
|
+
await prepareCodexAppControlGeneration(cfg, willReattachPersistent, !!persistentSessionName);
|
|
9893
|
+
}
|
|
9894
|
+
catch (err) {
|
|
9895
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
9896
|
+
throw new CliSpawnSupersededError();
|
|
9897
|
+
throw err;
|
|
9898
|
+
}
|
|
9899
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
9900
|
+
throw new CliSpawnSupersededError();
|
|
8182
9901
|
const replacementExpectedFresh = codexRunnerFreshness === 'restarting_fresh';
|
|
8183
9902
|
const freshness = decideCodexRunnerFreshness({
|
|
8184
9903
|
cliId: cfg.cliId,
|
|
@@ -8208,6 +9927,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8208
9927
|
? readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir)
|
|
8209
9928
|
: await prepareCliPluginGenerationAndGateway(cfg, cliAdapter);
|
|
8210
9929
|
}
|
|
9930
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
9931
|
+
throw new CliSpawnSupersededError();
|
|
8211
9932
|
// Re-arm the startup-commands one-shot ONLY for a genuinely fresh CLI process.
|
|
8212
9933
|
// A reattach to a LIVE persistent pane (daemon-restart recovery) is the SAME
|
|
8213
9934
|
// CLI with /effort etc. already applied — replaying would re-type them (and
|
|
@@ -8374,6 +10095,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8374
10095
|
passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
|
|
8375
10096
|
adoptMode: cfg.adoptMode === true,
|
|
8376
10097
|
dispatchAttempt: cfg.dispatchAttempt,
|
|
10098
|
+
queuedActivationToken: cfg.queuedActivationToken,
|
|
8377
10099
|
}) || (effectiveResume && cliAdapter.initialPromptArgsIgnoredOnResume === true)
|
|
8378
10100
|
|| (!promptArgPreparationChanged && shouldDeferInitialPromptForArgLimit({
|
|
8379
10101
|
passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
|
|
@@ -8552,6 +10274,9 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8552
10274
|
delete childEnv[MCP_GATEWAY_SOCKET_ENV];
|
|
8553
10275
|
delete childEnv[MCP_GATEWAY_REQUIRED_ENV];
|
|
8554
10276
|
}
|
|
10277
|
+
// Never inherit an ambient/stale bootstrap path from the daemon's own launch
|
|
10278
|
+
// environment. The raw capability is never placed in any environment.
|
|
10279
|
+
delete childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
|
|
8555
10280
|
// Put the daemon-written wrapper dir (~/.botmux/bin/botmux = THIS build) ahead of any
|
|
8556
10281
|
// stale npm-global botmux in PATH, so the agent's `botmux` is always this build. Matters
|
|
8557
10282
|
// most under read isolation: only this build has the send-cred reader — a shadowing stale
|
|
@@ -8674,6 +10399,13 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8674
10399
|
else
|
|
8675
10400
|
childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
|
|
8676
10401
|
}
|
|
10402
|
+
if (willReadIsolate) {
|
|
10403
|
+
if (!readIsolationOriginChannelId) {
|
|
10404
|
+
throw new Error('[read-isolation] origin channel is unavailable');
|
|
10405
|
+
}
|
|
10406
|
+
childEnv.BOTMUX_READ_ISOLATED = '1';
|
|
10407
|
+
childEnv.BOTMUX_ORIGIN_CHANNEL_ID = readIsolationOriginChannelId;
|
|
10408
|
+
}
|
|
8677
10409
|
// Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
|
|
8678
10410
|
// ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
|
|
8679
10411
|
// provider, an HTTPS_PROXY, or a CLI feature flag. Passed as injectEnv (NOT
|
|
@@ -8872,15 +10604,39 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8872
10604
|
if (process.platform === 'linux') {
|
|
8873
10605
|
mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
|
|
8874
10606
|
}
|
|
10607
|
+
if (process.platform === 'darwin') {
|
|
10608
|
+
const osUserHomeDir = userInfo().homedir;
|
|
10609
|
+
if (!osUserHomeDir) {
|
|
10610
|
+
throw new Error('[read-isolation] OS account home is unavailable; refusing to create sentinel');
|
|
10611
|
+
}
|
|
10612
|
+
ensureManagedOriginIsolationSentinel(osUserHomeDir);
|
|
10613
|
+
ensureManagedOriginRootLocator(osUserHomeDir, cfg.sessionId, dataDir);
|
|
10614
|
+
const canonicalSessionDataDir = realpathSync(dataDir);
|
|
10615
|
+
ensureManagedOriginDataRootProbe(canonicalSessionDataDir, cfg.sessionId);
|
|
10616
|
+
const legacyProbe = managedOriginLegacyIsolationProbeAccess(osUserHomeDir);
|
|
10617
|
+
const fixedProbe = managedOriginIsolationSentinelAccess(osUserHomeDir);
|
|
10618
|
+
const dataRootProbe = managedOriginDataRootProbeAccess(canonicalSessionDataDir, cfg.sessionId);
|
|
10619
|
+
if (legacyProbe !== 'host_accessible' && fixedProbe !== 'host_accessible') {
|
|
10620
|
+
throw new Error('[read-isolation] kernel isolation probes are unavailable or unsafe');
|
|
10621
|
+
}
|
|
10622
|
+
if (dataRootProbe !== 'host_accessible') {
|
|
10623
|
+
throw new Error('[read-isolation] locator-selected data-root probe is unavailable or unsafe');
|
|
10624
|
+
}
|
|
10625
|
+
ensureManagedOriginAttestationDirectory(dataDir, cfg.sessionId, readIsolationOriginChannelId);
|
|
10626
|
+
sweepManagedOriginAttestationProofs(dataDir, cfg.sessionId, readIsolationOriginChannelId);
|
|
10627
|
+
}
|
|
8875
10628
|
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
8876
|
-
? managedOriginCapabilityPath(dataDir, cfg.sessionId)
|
|
10629
|
+
? managedOriginCapabilityPath(dataDir, cfg.sessionId, readIsolationOriginChannelId)
|
|
8877
10630
|
: null;
|
|
8878
10631
|
// The macOS child reads the per-session rotating capability directly.
|
|
8879
10632
|
// Materialize it before the policy's existence filter, and make the exact
|
|
8880
10633
|
// file a mandatory read-only carve-out that user rules cannot shadow.
|
|
8881
10634
|
if (readIsolationOriginCapabilityFile) {
|
|
10635
|
+
ensureManagedOriginCapabilityLeafSafe(readIsolationOriginCapabilityFile);
|
|
8882
10636
|
publishSandboxRelayCapability({ failClosed: true });
|
|
8883
10637
|
mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
|
|
10638
|
+
mandatoryReadOnlyPaths.push(managedOriginAttestationDirectory(dataDir, cfg.sessionId, readIsolationOriginChannelId));
|
|
10639
|
+
mandatoryReadOnlyPaths.push(managedOriginRootLocatorPath(userInfo().homedir, cfg.sessionId));
|
|
8884
10640
|
}
|
|
8885
10641
|
if (credentialBoundaryActive) {
|
|
8886
10642
|
const credentialRules = buildCredentialIsolationRules({
|
|
@@ -9164,7 +10920,14 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9164
10920
|
try {
|
|
9165
10921
|
const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
|
|
9166
10922
|
mkdirSync(markerDir, { recursive: true });
|
|
9167
|
-
|
|
10923
|
+
replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities, willReadIsolate || willWriteSandbox
|
|
10924
|
+
? {
|
|
10925
|
+
originChannelId: persistentPaneOriginChannelId,
|
|
10926
|
+
readIsolation: willReadIsolate,
|
|
10927
|
+
writeSandbox: willWriteSandbox,
|
|
10928
|
+
policyDigest: darwinIsolationPolicyDigest,
|
|
10929
|
+
}
|
|
10930
|
+
: undefined));
|
|
9168
10931
|
}
|
|
9169
10932
|
catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
|
|
9170
10933
|
}
|
|
@@ -9391,14 +11154,55 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9391
11154
|
capturedSpawnCommand = null;
|
|
9392
11155
|
log(`Failed to capture reproduce command: ${err?.message ?? err}`);
|
|
9393
11156
|
}
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
11157
|
+
// Create the asymmetric candidate as late as possible. Persistent backends
|
|
11158
|
+
// receive it even on a predicted reattach: an actual reuse ignores the
|
|
11159
|
+
// launch env and proves the old key, while an actual fresh start consumes
|
|
11160
|
+
// the candidate and proves the new key. The worker trusts cryptographic
|
|
11161
|
+
// proof, not a backend's prediction flag.
|
|
11162
|
+
try {
|
|
11163
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
11164
|
+
throw new CliSpawnSupersededError();
|
|
11165
|
+
prepareFreshCodexAppControlBootstrap(cfg, !!persistentSessionName);
|
|
11166
|
+
if (codexAppControlBootstrapPathForSpawn) {
|
|
11167
|
+
childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV] = codexAppControlBootstrapPathForSpawn;
|
|
11168
|
+
}
|
|
11169
|
+
backend.spawn(spawnBin, spawnArgs, {
|
|
11170
|
+
cwd: spawnCwd,
|
|
11171
|
+
cols: PTY_COLS,
|
|
11172
|
+
rows: PTY_ROWS,
|
|
11173
|
+
env: childEnv,
|
|
11174
|
+
injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
|
|
11175
|
+
launchShell: lastInitConfig?.launchShell,
|
|
11176
|
+
});
|
|
11177
|
+
}
|
|
11178
|
+
catch (err) {
|
|
11179
|
+
cleanupCodexAppControlBootstrap();
|
|
11180
|
+
throw err;
|
|
11181
|
+
}
|
|
11182
|
+
finally {
|
|
11183
|
+
delete childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
|
|
11184
|
+
}
|
|
11185
|
+
const actuallyReattachedPersistent = 'isReattach' in backend
|
|
11186
|
+
&& backend.isReattach === true;
|
|
11187
|
+
try {
|
|
11188
|
+
finalizeCodexAppControlGeneration(cfg, actuallyReattachedPersistent, !!persistentSessionName);
|
|
11189
|
+
}
|
|
11190
|
+
catch (err) {
|
|
11191
|
+
cleanupCodexAppControlBootstrap();
|
|
11192
|
+
// A runner whose generation cannot be authenticated durably must not keep
|
|
11193
|
+
// executing behind an apparently failed worker. This covers both a
|
|
11194
|
+
// fresh-persist failure or a generation that cannot complete challenge
|
|
11195
|
+
// setup.
|
|
11196
|
+
if (persistentSessionName) {
|
|
11197
|
+
try {
|
|
11198
|
+
killPersistentSession(effectiveBackendType, persistentSessionName);
|
|
11199
|
+
}
|
|
11200
|
+
catch (killErr) {
|
|
11201
|
+
log(`Failed to kill unauthenticated Codex App persistent generation: ${killErr?.message ?? killErr}`);
|
|
11202
|
+
}
|
|
11203
|
+
}
|
|
11204
|
+
throw err;
|
|
11205
|
+
}
|
|
9402
11206
|
if (selectedBackend.createdHerdrSessionName) {
|
|
9403
11207
|
send({
|
|
9404
11208
|
type: 'user_notify',
|
|
@@ -9487,6 +11291,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9487
11291
|
log(`TRAE sandbox: resolved real traex leaf pid ${realPid} under bwrap supervisor ${launcherPid}; rewiring ownership pid`);
|
|
9488
11292
|
backend.cliPid = realPid;
|
|
9489
11293
|
backend.cliCwd = cfg.workingDir;
|
|
11294
|
+
codexAdoptPendingPid = realPid;
|
|
9490
11295
|
publishLocalProcessAttestation(realPid);
|
|
9491
11296
|
},
|
|
9492
11297
|
schedule: (fn, ms) => { setTimeout(fn, ms); },
|
|
@@ -9517,6 +11322,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9517
11322
|
const wiredPid = cfg.cliId === 'traex' ? resolveTraexOwnershipPid(cliPid, outerBwrapActive) : cliPid;
|
|
9518
11323
|
backend.cliPid = wiredPid;
|
|
9519
11324
|
backend.cliCwd = cfg.workingDir;
|
|
11325
|
+
if (cfg.cliId === 'traex')
|
|
11326
|
+
codexAdoptPendingPid = wiredPid;
|
|
9520
11327
|
if (cfg.cliId === 'traex' && outerBwrapActive)
|
|
9521
11328
|
startTraexSandboxPidResolve(cliPid);
|
|
9522
11329
|
}
|
|
@@ -9551,6 +11358,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9551
11358
|
const wiredPid = cfg.cliId === 'traex' ? resolveTraexOwnershipPid(pid, outerBwrapActive) : pid;
|
|
9552
11359
|
backend.cliPid = wiredPid;
|
|
9553
11360
|
backend.cliCwd = cfg.workingDir;
|
|
11361
|
+
if (cfg.cliId === 'traex')
|
|
11362
|
+
codexAdoptPendingPid = wiredPid;
|
|
9554
11363
|
if (cfg.cliId === 'traex' && outerBwrapActive)
|
|
9555
11364
|
startTraexSandboxPidResolve(pid);
|
|
9556
11365
|
}
|
|
@@ -9706,6 +11515,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9706
11515
|
promptReadyDetectedDuringSettle = false;
|
|
9707
11516
|
readyPatternSeenDuringHold = false;
|
|
9708
11517
|
awaitingPostSessionStartPromptEvidence = false;
|
|
11518
|
+
clearPostHookEvidenceFallback();
|
|
9709
11519
|
// Reset quiescence baseline so the settle measures silence from THIS spawn.
|
|
9710
11520
|
lastPtyOutputAtMs = Date.now();
|
|
9711
11521
|
const readyHookAvailable = effectiveReadyHookInstall
|
|
@@ -9719,7 +11529,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9719
11529
|
const readyPortAvailable = !isolatedReadyTransportRequired
|
|
9720
11530
|
|| parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
|
|
9721
11531
|
const readyCapabilityAvailable = !isolatedReadyTransportRequired
|
|
9722
|
-
|| hasMatchingManagedOriginCapability(process.env.SESSION_DATA_DIR ?? '', cfg.sessionId, sandboxRelayCapability?.token, sandboxRelayOutbox ?? undefined);
|
|
11532
|
+
|| hasMatchingManagedOriginCapability(process.env.SESSION_DATA_DIR ?? '', cfg.sessionId, sandboxRelayCapability?.token, sandboxRelayOutbox ?? undefined, readIsolationOriginChannelId ?? undefined);
|
|
9723
11533
|
const readySignalAvailable = readyHookAvailable && readyPortAvailable && readyCapabilityAvailable;
|
|
9724
11534
|
const freshReadyGateCandidate = cliAdapter.injectsReadyHook === true
|
|
9725
11535
|
&& cfg.adoptMode !== true
|
|
@@ -9842,6 +11652,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9842
11652
|
// prompt-ready — without this hook a follow-up arriving mid-task would sit
|
|
9843
11653
|
// in pendingMessages forever once the task finishes.
|
|
9844
11654
|
backend.onTaskDone?.(() => {
|
|
11655
|
+
if (fatalWorkerErrorPending)
|
|
11656
|
+
return;
|
|
9845
11657
|
// Generation fence (matches the onAgentStatus above and the onExit below):
|
|
9846
11658
|
// a stale RiffBackend's `fetchAndEmitOutput(...).finally(taskDoneCb)` can
|
|
9847
11659
|
// resolve AFTER destroySession()/kill() during a restart (neither clears
|
|
@@ -9896,6 +11708,30 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9896
11708
|
// token or explicit IM origin must not remain usable in that interval.
|
|
9897
11709
|
completeManagedTurnOriginRevocation(sandboxRelayCapability, exitedTurnId, exitedDispatchAttempt);
|
|
9898
11710
|
log(`${cliName()} exited (code: ${code}, signal: ${signal})`);
|
|
11711
|
+
if (lastInitConfig?.cliId === 'codex-app' && codexAppControlFatal) {
|
|
11712
|
+
// An earlier control-path failure already made this whole worker
|
|
11713
|
+
// generation terminal and scheduled its hard OS exit. That failure may
|
|
11714
|
+
// synchronously kill the backend, invoking this callback after
|
|
11715
|
+
// stopCodexAppControlChannel() cleared the local FIFO. Never reinterpret
|
|
11716
|
+
// that cleared snapshot as safe ownership: publishing ambiguous or
|
|
11717
|
+
// claude_exit here would let the daemon admit N+1 before the Node-worker
|
|
11718
|
+
// exit arms the durable recovery fence.
|
|
11719
|
+
log('Suppressed Codex App backend-exit signals for a fatal worker generation');
|
|
11720
|
+
return;
|
|
11721
|
+
}
|
|
11722
|
+
const codexAppPreparedAtExit = !intentionalRestart
|
|
11723
|
+
&& lastInitConfig?.cliId === 'codex-app'
|
|
11724
|
+
&& (codexAppTurnDispatchQueue.size() > 0
|
|
11725
|
+
|| codexAppRecoveredDispatches.some(entry => entry.state === 'prepared'));
|
|
11726
|
+
if (codexAppPreparedAtExit) {
|
|
11727
|
+
// Do not publish a standalone ambiguous terminal or claude_exit first.
|
|
11728
|
+
// Either signal can let the durable receiver admit N+1 before the later
|
|
11729
|
+
// Node-worker exit arms its persistent-pane fence. Exit this worker
|
|
11730
|
+
// generation directly; daemon onWorkerExit performs the ambiguous
|
|
11731
|
+
// transition and recovery-arm as one ordering boundary.
|
|
11732
|
+
failCodexAppControlGeneration('Codex App runner exited with a prepared dispatch; worker replacement requires exact recovery');
|
|
11733
|
+
return;
|
|
11734
|
+
}
|
|
9899
11735
|
if (cliAdapter?.reliableTurnTerminal === true
|
|
9900
11736
|
&& exitedTurnId
|
|
9901
11737
|
&& exitedDispatchAttempt !== undefined) {
|
|
@@ -9919,6 +11755,22 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9919
11755
|
rpcDialogDismissTimer = null;
|
|
9920
11756
|
}
|
|
9921
11757
|
}
|
|
11758
|
+
cleanupCodexAppControlBootstrap();
|
|
11759
|
+
// A natural Codex App runner exit is only the CLI-generation terminal,
|
|
11760
|
+
// not proof that any prepared frame was unconsumed. Keep the exact local
|
|
11761
|
+
// FIFO/recovery snapshot until the daemon answers claude_exit with its
|
|
11762
|
+
// cli_crash decision. The restart handler then sees prepared ownership and
|
|
11763
|
+
// exits this Node worker too, which is what drives the daemon's
|
|
11764
|
+
// onWorkerExit durable-receipt fence. Intentional in-worker teardown has
|
|
11765
|
+
// already established its own replay/cancel boundary and may clear here.
|
|
11766
|
+
stopCodexAppControlChannel({
|
|
11767
|
+
preserveDispatchRecovery: lastInitConfig?.cliId === 'codex-app' && !intentionalRestart,
|
|
11768
|
+
});
|
|
11769
|
+
codexAppControlStateValue = undefined;
|
|
11770
|
+
codexAppControlProven = false;
|
|
11771
|
+
codexAppTurnLiveness.clear();
|
|
11772
|
+
codexAppReadyAuthority.reset();
|
|
11773
|
+
codexAppCompletionAwaitingFinal = false;
|
|
9922
11774
|
const logTail = recentTerminalLogTail();
|
|
9923
11775
|
// Don't park a diagnostic shell here: most exits are immediately
|
|
9924
11776
|
// auto-restarted by the daemon, so an inline park would just be torn down
|
|
@@ -9937,10 +11789,30 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9937
11789
|
// onCliExit tells the daemon to mark that receipt ambiguous; replaying the
|
|
9938
11790
|
// same attempt locally as ordinary carry-over would race hub attempt N+1
|
|
9939
11791
|
// and execute the prompt twice.
|
|
9940
|
-
|
|
11792
|
+
// At-most-once (idempotency lease) inputs are ALSO excluded — but PER ITEM,
|
|
11793
|
+
// never per session. The daemon terminalizes the keyed turn to
|
|
11794
|
+
// dispatch_unknown on this exit, so replaying THAT input on the auto-restarted
|
|
11795
|
+
// CLI would run a turn the caller already saw failed (codex #776 round-7
|
|
11796
|
+
// finding #1). It must NOT, however, drop a later PLAIN (no-key) turn folded
|
|
11797
|
+
// into the same http_async_ session via target.sessionId — the API allows that
|
|
11798
|
+
// resume, so a whole-session flag would strand a legitimate follow-up input
|
|
11799
|
+
// (codex #776 round-8). The keyed input is tagged item.noReplay at enqueue;
|
|
11800
|
+
// only it is excluded, from BOTH the inflight carry-over and the still-queued
|
|
11801
|
+
// pendingMessages.
|
|
11802
|
+
const stashed = inflightInputs.onCliExit(item => item.dispatchAttempt === undefined && !item.noReplay);
|
|
9941
11803
|
if (stashed > 0) {
|
|
9942
11804
|
log(`CLI exited with ${stashed} in-flight message(s); will re-queue after restart`);
|
|
9943
11805
|
}
|
|
11806
|
+
const droppedPending = pendingMessages.filter(m => m.noReplay).length;
|
|
11807
|
+
if (droppedPending > 0) {
|
|
11808
|
+
// Remove ONLY the no-replay items still queued (never written); keep any
|
|
11809
|
+
// ordinary follow-up input for the restart's normal flush.
|
|
11810
|
+
for (let i = pendingMessages.length - 1; i >= 0; i--) {
|
|
11811
|
+
if (pendingMessages[i].noReplay)
|
|
11812
|
+
pendingMessages.splice(i, 1);
|
|
11813
|
+
}
|
|
11814
|
+
log(`Dropped ${droppedPending} at-most-once pending message(s) on CLI exit (no replay)`);
|
|
11815
|
+
}
|
|
9944
11816
|
backend = null;
|
|
9945
11817
|
isPromptReady = false;
|
|
9946
11818
|
currentBotmuxTurnId = undefined;
|
|
@@ -9963,8 +11835,15 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9963
11835
|
send({ type: 'claude_exit', code, signal, logTail, canParkDiagnostic, turnId: exitedTurnId, dispatchAttempt: exitedDispatchAttempt });
|
|
9964
11836
|
}
|
|
9965
11837
|
});
|
|
9966
|
-
|
|
11838
|
+
const isPersistentBackendReattach = actuallyReattachedPersistent;
|
|
11839
|
+
// Codex App warm observation is armed only after the old generation proves
|
|
11840
|
+
// its private key over this worker's fresh socket challenge. Backend flags
|
|
11841
|
+
// remain useful for screen seeding but are not authentication evidence.
|
|
11842
|
+
if (isPipeMode && backend && isPersistentBackendReattach) {
|
|
9967
11843
|
log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`);
|
|
11844
|
+
// Pipe backends expose an authoritative snapshot + busy-pattern probe.
|
|
11845
|
+
// Keep those operations pipe-only; the liveness slot above is backend-
|
|
11846
|
+
// independent and Zellij receives its screen through normal PTY output.
|
|
9968
11847
|
seedBackendScreen(`${effectiveBackendType} reattach`, backend);
|
|
9969
11848
|
scheduleReattachIdleProbe(`${effectiveBackendType} reattach`, backend);
|
|
9970
11849
|
}
|
|
@@ -9993,6 +11872,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
9993
11872
|
}
|
|
9994
11873
|
awaitingFirstPrompt = false;
|
|
9995
11874
|
awaitingPostSessionStartPromptEvidence = false;
|
|
11875
|
+
clearPostHookEvidenceFallback();
|
|
9996
11876
|
renderer?.markNewTurn();
|
|
9997
11877
|
log(forced
|
|
9998
11878
|
? `WARN First prompt hard timeout — ${cliName()} readyPattern did not arrive; forcing queued message flush`
|
|
@@ -10036,10 +11916,21 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
10036
11916
|
}
|
|
10037
11917
|
}
|
|
10038
11918
|
function killCli(opts = {}) {
|
|
11919
|
+
// Invalidate an in-flight async spawn before any other teardown hook runs. The
|
|
11920
|
+
// remaining cleanup is synchronous today, but generation ownership must not
|
|
11921
|
+
// depend on that implementation detail.
|
|
11922
|
+
cliSpawnGeneration++;
|
|
10039
11923
|
currentCliCredentialIsolated = false;
|
|
10040
11924
|
stopNativeSessionTitleSync();
|
|
10041
11925
|
stopSessionMcpGatewayHost();
|
|
10042
11926
|
stopCodexRpcEngine();
|
|
11927
|
+
cleanupCodexAppControlBootstrap();
|
|
11928
|
+
stopCodexAppControlChannel();
|
|
11929
|
+
codexAppControlStateValue = undefined;
|
|
11930
|
+
codexAppControlProven = false;
|
|
11931
|
+
codexAppTurnLiveness.clear();
|
|
11932
|
+
codexAppReadyAuthority.reset();
|
|
11933
|
+
codexAppCompletionAwaitingFinal = false;
|
|
10043
11934
|
if (!opts.preservePending)
|
|
10044
11935
|
cleanupPiInitialPromptFiles();
|
|
10045
11936
|
destroyCrashDiagnosticTerminal('killCli');
|
|
@@ -10060,6 +11951,7 @@ function killCli(opts = {}) {
|
|
|
10060
11951
|
promptReadyDetectedDuringSettle = false;
|
|
10061
11952
|
readyPatternSeenDuringHold = false;
|
|
10062
11953
|
awaitingPostSessionStartPromptEvidence = false;
|
|
11954
|
+
clearPostHookEvidenceFallback();
|
|
10063
11955
|
stopStuckDetector();
|
|
10064
11956
|
tuiPromptBlocking = false;
|
|
10065
11957
|
stopScreenUpdates();
|
|
@@ -10092,6 +11984,7 @@ function killCli(opts = {}) {
|
|
|
10092
11984
|
}
|
|
10093
11985
|
sandboxRelayOutbox = null;
|
|
10094
11986
|
readIsolationOriginCapabilityFile = null;
|
|
11987
|
+
readIsolationOriginChannelId = null;
|
|
10095
11988
|
currentBotmuxTurnId = undefined;
|
|
10096
11989
|
currentBotmuxDispatchAttempt = undefined;
|
|
10097
11990
|
currentVcMeetingImTurnOrigin = undefined;
|
|
@@ -10173,12 +12066,6 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
10173
12066
|
const restartingBackend = backend;
|
|
10174
12067
|
if (restartingBackend)
|
|
10175
12068
|
intentionalRestartBackend = restartingBackend;
|
|
10176
|
-
// Riff teardown cancels a remote task asynchronously. Wait for the cancel
|
|
10177
|
-
// (bounded) before respawning, otherwise the old and replacement tasks can
|
|
10178
|
-
// overlap and both emit into the same Lark thread. Local backends normally
|
|
10179
|
-
// return void here and continue synchronously. A synchronous throw is
|
|
10180
|
-
// handled by the same fatal restart path below, so it cannot leave the
|
|
10181
|
-
// input gate permanently armed.
|
|
10182
12069
|
const teardown = restartingBackend?.destroySession?.();
|
|
10183
12070
|
if (teardown && typeof teardown.then === 'function') {
|
|
10184
12071
|
try {
|
|
@@ -10219,6 +12106,8 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
10219
12106
|
catch (err) {
|
|
10220
12107
|
replacementSpawnInProgress = false;
|
|
10221
12108
|
cliRestartInProgress = false;
|
|
12109
|
+
if (err instanceof CliSpawnSupersededError)
|
|
12110
|
+
return;
|
|
10222
12111
|
await sendFatalWorkerErrorAndExit(err);
|
|
10223
12112
|
return;
|
|
10224
12113
|
}
|
|
@@ -11592,11 +13481,23 @@ function sendAndFlush(msg) {
|
|
|
11592
13481
|
resolve();
|
|
11593
13482
|
return;
|
|
11594
13483
|
}
|
|
13484
|
+
let settled = false;
|
|
13485
|
+
const finish = () => {
|
|
13486
|
+
if (settled)
|
|
13487
|
+
return;
|
|
13488
|
+
settled = true;
|
|
13489
|
+
clearTimeout(timer);
|
|
13490
|
+
resolve();
|
|
13491
|
+
};
|
|
13492
|
+
// IPC callbacks are best-effort transport evidence, not an exit gate. A
|
|
13493
|
+
// parent that has stopped draining (or a runtime callback edge) must not
|
|
13494
|
+
// keep a fail-closed worker and its stale ownership alive indefinitely.
|
|
13495
|
+
const timer = setTimeout(finish, 1_000);
|
|
11595
13496
|
try {
|
|
11596
|
-
process.send(payload,
|
|
13497
|
+
process.send(payload, finish);
|
|
11597
13498
|
}
|
|
11598
13499
|
catch {
|
|
11599
|
-
|
|
13500
|
+
finish();
|
|
11600
13501
|
}
|
|
11601
13502
|
});
|
|
11602
13503
|
}
|
|
@@ -11626,7 +13527,7 @@ let fatalWorkerErrorPending = false;
|
|
|
11626
13527
|
* used both during init and after a previously-ready worker tries to recover
|
|
11627
13528
|
* from a stopped/crashed CLI, so those later paths cannot regress to a silent
|
|
11628
13529
|
* unhandled rejection/exception. */
|
|
11629
|
-
async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, dispatchAttempt = currentBotmuxDispatchAttempt) {
|
|
13530
|
+
async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, dispatchAttempt = currentBotmuxDispatchAttempt, opts = {}) {
|
|
11630
13531
|
if (fatalWorkerErrorPending)
|
|
11631
13532
|
return;
|
|
11632
13533
|
fatalWorkerErrorPending = true;
|
|
@@ -11647,6 +13548,28 @@ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, di
|
|
|
11647
13548
|
// failure to the receipt/lease recovery path instead of replying out-of-band.
|
|
11648
13549
|
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
11649
13550
|
});
|
|
13551
|
+
log('Fatal worker error delivered; exiting process');
|
|
13552
|
+
if (opts.hardExit) {
|
|
13553
|
+
// A fail-closed Codex App generation must produce a real OS-level worker
|
|
13554
|
+
// exit so the daemon can arm its worker-generation receipt fence. On some
|
|
13555
|
+
// runtimes process.exit() can hang joining a native/libuv fs worker (the
|
|
13556
|
+
// control/terminal stack uses long-lived reads). Perform the synchronous
|
|
13557
|
+
// cleanup we control, then use an uncatchable self-signal for this one
|
|
13558
|
+
// replacement path. Persistent-session and SIGKILL residue both have
|
|
13559
|
+
// daemon/next-worker reconcilers as additional backstops.
|
|
13560
|
+
try {
|
|
13561
|
+
teardownSandboxBestEffort();
|
|
13562
|
+
}
|
|
13563
|
+
catch { /* best effort */ }
|
|
13564
|
+
try {
|
|
13565
|
+
cleanup();
|
|
13566
|
+
}
|
|
13567
|
+
catch { /* best effort */ }
|
|
13568
|
+
try {
|
|
13569
|
+
process.kill(process.pid, 'SIGKILL');
|
|
13570
|
+
}
|
|
13571
|
+
catch { /* fall through */ }
|
|
13572
|
+
}
|
|
11650
13573
|
process.exit(1);
|
|
11651
13574
|
}
|
|
11652
13575
|
function log(msg) {
|
|
@@ -11717,6 +13640,27 @@ process.on('message', async (raw) => {
|
|
|
11717
13640
|
if (msg.larkAppId && process.env.BOTMUX_WORKFLOW !== '1') {
|
|
11718
13641
|
sessionStore.init(msg.larkAppId);
|
|
11719
13642
|
}
|
|
13643
|
+
if (msg.cliId === 'codex-app') {
|
|
13644
|
+
codexAppRecoveredDispatches = (msg.codexAppRecoveredDispatches ?? []).map(entry => ({
|
|
13645
|
+
...entry,
|
|
13646
|
+
codexAppInput: entry.codexAppInput ? { ...entry.codexAppInput } : undefined,
|
|
13647
|
+
}));
|
|
13648
|
+
codexAppGenerationCommits = [...(msg.codexAppGenerationCommits ?? [])];
|
|
13649
|
+
codexAppTurnDispatchQueue.restore(codexAppRecoveredDispatches
|
|
13650
|
+
.filter(entry => entry.state === 'prepared')
|
|
13651
|
+
.map(entry => ({
|
|
13652
|
+
dispatchId: entry.dispatchId,
|
|
13653
|
+
turnId: entry.turnId,
|
|
13654
|
+
replyTurnId: entry.replyTurnId,
|
|
13655
|
+
dispatchAttempt: entry.dispatchAttempt,
|
|
13656
|
+
// R5-B4-1: COPY the frozen steer authorization onto the restored
|
|
13657
|
+
// prepared reservation. Without it a replacement worker's exact-head
|
|
13658
|
+
// reservation is steerable=false while the daemon ledger head is
|
|
13659
|
+
// true, so a legitimate superseded settlement would be rejected and
|
|
13660
|
+
// the crash-replay chain wedges.
|
|
13661
|
+
...(entry.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
13662
|
+
})));
|
|
13663
|
+
}
|
|
11720
13664
|
// Capture credentials for direct image upload from worker
|
|
11721
13665
|
larkAppIdForUpload = msg.larkAppId;
|
|
11722
13666
|
larkAppSecretForUpload = msg.larkAppSecret;
|
|
@@ -11843,6 +13787,25 @@ process.on('message', async (raw) => {
|
|
|
11843
13787
|
// execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
|
|
11844
13788
|
// - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
|
|
11845
13789
|
// marked at flush time, P0-1).
|
|
13790
|
+
const recoveredAcceptedEntries = codexAppRecoveredDispatches
|
|
13791
|
+
.filter(entry => entry.state === 'accepted');
|
|
13792
|
+
const recoveredAcceptedInputs = recoveredAcceptedEntries
|
|
13793
|
+
.map(entry => ({
|
|
13794
|
+
content: entry.content,
|
|
13795
|
+
turnId: entry.turnId,
|
|
13796
|
+
replyTurnId: entry.replyTurnId,
|
|
13797
|
+
dispatchAttempt: entry.dispatchAttempt,
|
|
13798
|
+
codexAppDispatchId: entry.dispatchId,
|
|
13799
|
+
// COPY the frozen steer authorization from the ledger entry; never
|
|
13800
|
+
// recompute it on recovery (codex decision A).
|
|
13801
|
+
...(entry.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
13802
|
+
queuedActivationToken: entry.queuedActivationToken
|
|
13803
|
+
?? (recoveredAcceptedEntries.length === 1
|
|
13804
|
+
? msg.queuedActivationToken
|
|
13805
|
+
: undefined),
|
|
13806
|
+
codexAppInput: entry.codexAppInput,
|
|
13807
|
+
vcMeetingImTurnOrigin: entry.vcMeetingImTurnOrigin,
|
|
13808
|
+
}));
|
|
11846
13809
|
let initialInputCommitted = false;
|
|
11847
13810
|
if (shouldQueueInitialPrompt({
|
|
11848
13811
|
hasPrompt: !!msg.prompt,
|
|
@@ -11854,18 +13817,38 @@ process.on('message', async (raw) => {
|
|
|
11854
13817
|
// Follow-up IPC handlers can run while this async init is awaiting
|
|
11855
13818
|
// backend startup. They queue safely behind !backend above, but the
|
|
11856
13819
|
// original init prompt must remain first in logical turn order.
|
|
11857
|
-
|
|
13820
|
+
// #597: accepted entries that never crossed the old worker's
|
|
13821
|
+
// prepared/write boundary are replayable payload, not runner
|
|
13822
|
+
// attribution — queue them BEFORE this fork's new prompt while prepared
|
|
13823
|
+
// predecessors stay preloaded in the exact final FIFO above.
|
|
13824
|
+
pendingMessages.unshift(...recoveredAcceptedInputs, {
|
|
11858
13825
|
content: lastSpawnQueuedInitialPrompt ?? msg.prompt,
|
|
11859
13826
|
...(lastSpawnQueuedInitialPromptLogicalContent
|
|
11860
13827
|
? { logicalContent: lastSpawnQueuedInitialPromptLogicalContent }
|
|
11861
13828
|
: {}),
|
|
11862
13829
|
turnId: msg.turnId,
|
|
13830
|
+
replyTurnId: msg.replyTurnId,
|
|
11863
13831
|
dispatchAttempt: msg.dispatchAttempt,
|
|
13832
|
+
codexAppDispatchId: msg.codexAppDispatchId,
|
|
13833
|
+
queuedActivationToken: msg.queuedActivationToken,
|
|
11864
13834
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
11865
13835
|
codexAppInput: msg.promptCodexAppInput,
|
|
13836
|
+
// Thread the root's steer authorization so ordered pre-final steer can
|
|
13837
|
+
// fold follow-ups into THIS turn (the runner's canSteer requires the
|
|
13838
|
+
// group root — accepted[0] — to be steerable). Without this the first
|
|
13839
|
+
// turn of a codex-app session could never absorb a follow-up steer.
|
|
13840
|
+
...(msg.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
13841
|
+
// At-most-once (idempotency lease): tag the KEYED init prompt so a CLI
|
|
13842
|
+
// exit never replays it onto the auto-restarted CLI — while leaving a
|
|
13843
|
+
// later plain follow-up turn on the same http_async_ session intact
|
|
13844
|
+
// (codex #776 round-8: per-item, not per-session).
|
|
13845
|
+
...(msg.atMostOnce ? { noReplay: true } : {}),
|
|
11866
13846
|
});
|
|
11867
13847
|
initialInputCommitted = true;
|
|
11868
13848
|
}
|
|
13849
|
+
else if (msg.cliId === 'codex-app') {
|
|
13850
|
+
pendingMessages.unshift(...recoveredAcceptedInputs);
|
|
13851
|
+
}
|
|
11869
13852
|
else if (msg.prompt) {
|
|
11870
13853
|
// A successful spawn with a non-queued prompt means the adapter baked
|
|
11871
13854
|
// it into argv or the RPC engine already accepted it.
|
|
@@ -11877,6 +13860,7 @@ process.on('message', async (raw) => {
|
|
|
11877
13860
|
initialInputOwnershipPending = false;
|
|
11878
13861
|
if (initialInputCommitted)
|
|
11879
13862
|
acknowledgeTurnInputCommitted(msg.turnId);
|
|
13863
|
+
initPromptMaterialized = true;
|
|
11880
13864
|
// A backend may become prompt-ready before spawnCli() returns. The
|
|
11881
13865
|
// initial prompt is queued only afterwards, so the earlier
|
|
11882
13866
|
// markPromptReady() necessarily flushed an empty queue. This is normal
|
|
@@ -11901,12 +13885,26 @@ process.on('message', async (raw) => {
|
|
|
11901
13885
|
});
|
|
11902
13886
|
}
|
|
11903
13887
|
catch (err) {
|
|
13888
|
+
if (err instanceof CliSpawnSupersededError)
|
|
13889
|
+
return;
|
|
11904
13890
|
initialInputOwnershipPending = false;
|
|
11905
13891
|
await sendFatalWorkerErrorAndExit(err);
|
|
11906
13892
|
return;
|
|
11907
13893
|
}
|
|
11908
13894
|
break;
|
|
11909
13895
|
}
|
|
13896
|
+
case 'codex_app_dispatch_persisted': {
|
|
13897
|
+
const pending = codexAppPendingDaemonAcks.get(msg.requestId);
|
|
13898
|
+
if (!pending)
|
|
13899
|
+
break;
|
|
13900
|
+
codexAppPendingDaemonAcks.delete(msg.requestId);
|
|
13901
|
+
clearTimeout(pending.timer);
|
|
13902
|
+
pending.resolve(msg.ok);
|
|
13903
|
+
if (!msg.ok) {
|
|
13904
|
+
log(`Daemon rejected Codex App dispatch persistence: ${msg.error ?? 'unknown error'}`);
|
|
13905
|
+
}
|
|
13906
|
+
break;
|
|
13907
|
+
}
|
|
11910
13908
|
case 'message': {
|
|
11911
13909
|
const messageAdoptMode = lastInitConfig?.adoptMode === true;
|
|
11912
13910
|
const ordinaryImTurnId = !messageAdoptMode
|
|
@@ -11980,6 +13978,8 @@ process.on('message', async (raw) => {
|
|
|
11980
13978
|
// Pass the message's own attempt (not the stale currentBotmux* from a
|
|
11981
13979
|
// prior IM turn) so a durable delivery relaunch failure carries the
|
|
11982
13980
|
// right attribution for the daemon's receipt/lease gate.
|
|
13981
|
+
if (err instanceof CliSpawnSupersededError)
|
|
13982
|
+
return;
|
|
11983
13983
|
await sendFatalWorkerErrorAndExit(err, msg.turnId, msg.dispatchAttempt);
|
|
11984
13984
|
return;
|
|
11985
13985
|
}
|
|
@@ -12163,6 +14163,10 @@ process.on('message', async (raw) => {
|
|
|
12163
14163
|
const inputCommitted = sendToPty(content, msg.turnId, {
|
|
12164
14164
|
codexAppInput,
|
|
12165
14165
|
dispatchAttempt: msg.dispatchAttempt,
|
|
14166
|
+
codexAppDispatchId: msg.codexAppDispatchId,
|
|
14167
|
+
...(msg.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
14168
|
+
queuedActivationToken: msg.queuedActivationToken,
|
|
14169
|
+
replyTurnId: msg.replyTurnId,
|
|
12166
14170
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
12167
14171
|
});
|
|
12168
14172
|
if (inputCommitted)
|
|
@@ -12228,6 +14232,10 @@ process.on('message', async (raw) => {
|
|
|
12228
14232
|
break;
|
|
12229
14233
|
}
|
|
12230
14234
|
case 'restart': {
|
|
14235
|
+
if (effectiveBackendType === 'riff') {
|
|
14236
|
+
log('Refused Riff generation restart; the existing lineage-owning worker is retained');
|
|
14237
|
+
break;
|
|
14238
|
+
}
|
|
12231
14239
|
// 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true},
|
|
12232
14240
|
// 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
|
|
12233
14241
|
// 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
|
|
@@ -12308,6 +14316,10 @@ process.on('message', async (raw) => {
|
|
|
12308
14316
|
log('Refused durable expiry ACK for adopt-mode session');
|
|
12309
14317
|
break;
|
|
12310
14318
|
}
|
|
14319
|
+
if (!await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
|
|
14320
|
+
log('Withholding durable expiry ACK because the daemon dispatch ledger could not retire exactly');
|
|
14321
|
+
break;
|
|
14322
|
+
}
|
|
12311
14323
|
log(`Expiring active durable turn=${msg.turnId.slice(0, 12)} attempt=${msg.dispatchAttempt}; `
|
|
12312
14324
|
+ 'restarting CLI with queued follow-ups preserved');
|
|
12313
14325
|
durableTurnInFlight = false;
|
|
@@ -12324,30 +14336,34 @@ process.on('message', async (raw) => {
|
|
|
12324
14336
|
// ordinary IM turn. Remove that exact queued generation and ACK without
|
|
12325
14337
|
// restarting the unrelated active turn; otherwise attempt N survives in
|
|
12326
14338
|
// the queue and executes after the hub has already replayed N+1.
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
14339
|
+
const removedPending = pendingMessages.filter(item => item.turnId === msg.turnId
|
|
14340
|
+
&& item.dispatchAttempt === msg.dispatchAttempt).length;
|
|
14341
|
+
if (removedPending > 0
|
|
14342
|
+
&& ambiguousSubmissionRecoveryHold?.backend === backend
|
|
14343
|
+
&& !lastInitConfig?.adoptMode) {
|
|
14344
|
+
// A recovery-held item at the queue head blocked this durable
|
|
14345
|
+
// attempt from ever reaching the PTY. It may be this exact delivery
|
|
14346
|
+
// or an ordinary IM immediately ahead of it; either way, hub replay
|
|
14347
|
+
// must not hit the same poisoned generation. Fence it now, preserve
|
|
14348
|
+
// every other queued item, then admit the next attempt to the fresh
|
|
14349
|
+
// session. (Recovery-hold is only ever set for non-codex-app backends,
|
|
14350
|
+
// so there is no dispatch-ledger entry to retire here — remove the
|
|
14351
|
+
// expired items directly before the preservePending restart.)
|
|
14352
|
+
for (let i = pendingMessages.length - 1; i >= 0; i--) {
|
|
14353
|
+
const item = pendingMessages[i];
|
|
14354
|
+
if (item.turnId === msg.turnId && item.dispatchAttempt === msg.dispatchAttempt) {
|
|
14355
|
+
pendingMessages.splice(i, 1);
|
|
14356
|
+
}
|
|
12333
14357
|
}
|
|
14358
|
+
ambiguousSubmissionRecoveryHold = null;
|
|
14359
|
+
log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
|
|
14360
|
+
+ `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
|
|
14361
|
+
await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
|
|
14362
|
+
acknowledge('cli_fenced');
|
|
14363
|
+
break;
|
|
12334
14364
|
}
|
|
12335
|
-
if (removedPending > 0
|
|
12336
|
-
|
|
12337
|
-
&& !lastInitConfig?.adoptMode) {
|
|
12338
|
-
// A recovery-held item at the queue head blocked this durable
|
|
12339
|
-
// attempt from ever reaching the PTY. It may be this exact delivery
|
|
12340
|
-
// or an ordinary IM immediately ahead of it; either way, hub replay
|
|
12341
|
-
// must not hit the same poisoned generation. Fence it now, preserve
|
|
12342
|
-
// every other queued item, then admit the next attempt to the fresh
|
|
12343
|
-
// session.
|
|
12344
|
-
ambiguousSubmissionRecoveryHold = null;
|
|
12345
|
-
log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
|
|
12346
|
-
+ `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
|
|
12347
|
-
await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
|
|
12348
|
-
acknowledge('cli_fenced');
|
|
12349
|
-
break;
|
|
12350
|
-
}
|
|
14365
|
+
if (removedPending > 0
|
|
14366
|
+
&& await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
|
|
12351
14367
|
log(`Expired ${removedPending} queued durable input(s) turn=${msg.turnId.slice(0, 12)} `
|
|
12352
14368
|
+ `attempt=${msg.dispatchAttempt}`);
|
|
12353
14369
|
acknowledge('queued_removed');
|
|
@@ -12372,6 +14388,10 @@ process.on('message', async (raw) => {
|
|
|
12372
14388
|
}
|
|
12373
14389
|
log(`Boot recovery fencing ambiguous receiver turn=${msg.turnId.slice(0, 12)} `
|
|
12374
14390
|
+ `attempt=${msg.dispatchAttempt}`);
|
|
14391
|
+
if (!await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
|
|
14392
|
+
log('Withholding receiver reset ACK because the daemon dispatch ledger could not retire exactly');
|
|
14393
|
+
break;
|
|
14394
|
+
}
|
|
12375
14395
|
durableTurnInFlight = false;
|
|
12376
14396
|
inflightInputs.onTurnComplete();
|
|
12377
14397
|
await restartCliProcess('ambiguous receiver boot recovery', { immediate: true, preservePending: true });
|
|
@@ -12522,6 +14542,16 @@ process.on('message', async (raw) => {
|
|
|
12522
14542
|
isPromptReady,
|
|
12523
14543
|
alreadyWaiting: awaitingPostSessionStartPromptEvidence,
|
|
12524
14544
|
});
|
|
14545
|
+
// 「接受屏幕已有提示符」兜底只对全新会话(source=startup)arm:新建会话在
|
|
14546
|
+
// hook 跑完前就画好提示符、之后不再重绘,fence 必须靠兜底才解。resume/clear/
|
|
14547
|
+
// compact 会在边界后自行重绘一个新 ❯(transcript 重放/重印),fence 靠真证据
|
|
14548
|
+
// ~2s 自解;给它们 arm 反而会让回放中残留的历史 ❯ 满足静默门控、提前接受边界
|
|
14549
|
+
// 前的旧提示符,破坏 resume 本就依赖的 fresh-evidence fence。未识别的 source
|
|
14550
|
+
// 按非 startup 处理(fail-safe 不 arm,退回既有 15s 首提示符超时,无新回归)。
|
|
14551
|
+
const armPostHookFallback = shouldArmPostHookPromptEvidenceFallback({
|
|
14552
|
+
waitingForPostHookPrompt: waitForPostHookPrompt,
|
|
14553
|
+
source: msg.source,
|
|
14554
|
+
});
|
|
12525
14555
|
if (waitForPostHookPrompt) {
|
|
12526
14556
|
awaitingPostSessionStartPromptEvidence = true;
|
|
12527
14557
|
promptReadyDetectedDuringSettle = false;
|
|
@@ -12529,6 +14559,8 @@ process.on('message', async (raw) => {
|
|
|
12529
14559
|
idleDetector?.resetReadyEvidence();
|
|
12530
14560
|
lastPtyOutputAtMs = Date.now();
|
|
12531
14561
|
log('SessionStart boundary recorded — waiting for fresh post-hook prompt evidence');
|
|
14562
|
+
if (armPostHookFallback)
|
|
14563
|
+
armPostHookPromptEvidenceFallback();
|
|
12532
14564
|
}
|
|
12533
14565
|
// 先记下 gate 是否已被 45s fallback 释放:ReadyGate.receive() 是一次性
|
|
12534
14566
|
// 语义,fallback 抢先后 releaseReadyGate 会整块跳过迟到的真信号。
|
|
@@ -12579,21 +14611,82 @@ process.on('message', async (raw) => {
|
|
|
12579
14611
|
}
|
|
12580
14612
|
case 'close': {
|
|
12581
14613
|
log('Close requested');
|
|
14614
|
+
// destroySession kills tmux session permanently; kill() only detaches.
|
|
14615
|
+
// riff 的 destroySession 是异步远端取消——必须有界 await:紧跟着的
|
|
14616
|
+
// process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
|
|
14617
|
+
if (effectiveBackendType === 'riff') {
|
|
14618
|
+
if (!msg.requestId) {
|
|
14619
|
+
log('Refused unsafe request-less Riff close; explicit close requires prepare/commit');
|
|
14620
|
+
break;
|
|
14621
|
+
}
|
|
14622
|
+
let result;
|
|
14623
|
+
let attemptedPrepare = false;
|
|
14624
|
+
if (shutdownDetachRequestId) {
|
|
14625
|
+
result = {
|
|
14626
|
+
ok: false,
|
|
14627
|
+
error: `shutdown detach already ${shutdownDetachPhase ?? 'active'} as ${shutdownDetachRequestId}`,
|
|
14628
|
+
};
|
|
14629
|
+
}
|
|
14630
|
+
else if (preparedCloseRequestId) {
|
|
14631
|
+
result = {
|
|
14632
|
+
ok: false,
|
|
14633
|
+
error: `close already prepared as ${preparedCloseRequestId}`,
|
|
14634
|
+
};
|
|
14635
|
+
}
|
|
14636
|
+
else if (closeRequestInFlightId) {
|
|
14637
|
+
result = {
|
|
14638
|
+
ok: false,
|
|
14639
|
+
error: `close prepare already in flight as ${closeRequestInFlightId}`,
|
|
14640
|
+
};
|
|
14641
|
+
}
|
|
14642
|
+
else {
|
|
14643
|
+
attemptedPrepare = true;
|
|
14644
|
+
lastAbortedCloseRequestId = null;
|
|
14645
|
+
closeRequestInFlightId = msg.requestId;
|
|
14646
|
+
try {
|
|
14647
|
+
const raw = await backend?.destroySession?.();
|
|
14648
|
+
result = raw && typeof raw === 'object' && 'ok' in raw
|
|
14649
|
+
? raw
|
|
14650
|
+
: { ok: true };
|
|
14651
|
+
}
|
|
14652
|
+
catch (err) {
|
|
14653
|
+
result = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
14654
|
+
}
|
|
14655
|
+
}
|
|
14656
|
+
if (result.ok) {
|
|
14657
|
+
preparedCloseRequestId = msg.requestId;
|
|
14658
|
+
}
|
|
14659
|
+
else if (attemptedPrepare) {
|
|
14660
|
+
await backend?.abortDestroySession?.();
|
|
14661
|
+
lastAbortedCloseRequestId = msg.requestId;
|
|
14662
|
+
}
|
|
14663
|
+
if (closeRequestInFlightId === msg.requestId)
|
|
14664
|
+
closeRequestInFlightId = null;
|
|
14665
|
+
send({
|
|
14666
|
+
type: 'close_result',
|
|
14667
|
+
requestId: msg.requestId,
|
|
14668
|
+
ok: result.ok,
|
|
14669
|
+
...(result.taskId ? { taskId: result.taskId } : {}),
|
|
14670
|
+
...(result.error ? { error: result.error } : {}),
|
|
14671
|
+
});
|
|
14672
|
+
if (!result.ok) {
|
|
14673
|
+
log(`Riff close prepare failed (${result.error ?? 'cancel failed'}); session stays active for retry`);
|
|
14674
|
+
}
|
|
14675
|
+
break;
|
|
14676
|
+
}
|
|
12582
14677
|
closeRequested = true;
|
|
12583
14678
|
send({ type: 'session_close_ready', sessionId });
|
|
12584
14679
|
stopScreenshotLoop();
|
|
12585
14680
|
stopBridgeWatcher();
|
|
12586
14681
|
stopCodexBridge();
|
|
12587
|
-
// destroySession kills
|
|
12588
|
-
//
|
|
12589
|
-
// process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
|
|
14682
|
+
// Local close: destroySession kills persistent owned sessions. Riff has
|
|
14683
|
+
// already been handled above and cannot enter this request-less path.
|
|
12590
14684
|
const closeTeardown = backend?.destroySession?.();
|
|
12591
14685
|
if (closeTeardown && typeof closeTeardown.then === 'function') {
|
|
12592
14686
|
try {
|
|
12593
|
-
// 预算层级见 RiffBackend.destroySession(总 deadline 20s)——这里 22s 只作兜底。
|
|
12594
14687
|
await Promise.race([closeTeardown, new Promise((r) => setTimeout(r, 22_000))]);
|
|
12595
14688
|
}
|
|
12596
|
-
catch { /* logged
|
|
14689
|
+
catch { /* logged by backend */ }
|
|
12597
14690
|
}
|
|
12598
14691
|
killCli();
|
|
12599
14692
|
// Bridge marker file outlives a single CLI process (we keep it across
|
|
@@ -12625,7 +14718,192 @@ process.on('message', async (raw) => {
|
|
|
12625
14718
|
// We still request a clean exit; the daemon SIGKILL is the hard backstop.
|
|
12626
14719
|
process.exit(0);
|
|
12627
14720
|
}
|
|
14721
|
+
case 'riff_shutdown_prepare': {
|
|
14722
|
+
if (effectiveBackendType !== 'riff') {
|
|
14723
|
+
send({
|
|
14724
|
+
type: 'riff_shutdown_result',
|
|
14725
|
+
requestId: msg.requestId,
|
|
14726
|
+
phase: 'prepare',
|
|
14727
|
+
ok: false,
|
|
14728
|
+
taskId: null,
|
|
14729
|
+
error: 'not_riff_backend',
|
|
14730
|
+
});
|
|
14731
|
+
break;
|
|
14732
|
+
}
|
|
14733
|
+
let result;
|
|
14734
|
+
const inputBlocker = riffWorkerShutdownInputBlocker({
|
|
14735
|
+
initPromptMaterialized,
|
|
14736
|
+
isFlushing,
|
|
14737
|
+
pendingMessages: pendingMessages.length,
|
|
14738
|
+
pendingRawInputs: pendingRawInputs.length,
|
|
14739
|
+
pendingSessionRename: pendingSessionRename !== null,
|
|
14740
|
+
sessionRenameInFlight,
|
|
14741
|
+
commandLineWritesPending,
|
|
14742
|
+
});
|
|
14743
|
+
if (preparedCloseRequestId || closeRequestInFlightId) {
|
|
14744
|
+
result = { ok: false, taskId: null, error: 'explicit_close_in_progress' };
|
|
14745
|
+
}
|
|
14746
|
+
else if (shutdownDetachRequestId) {
|
|
14747
|
+
result = {
|
|
14748
|
+
ok: false,
|
|
14749
|
+
taskId: null,
|
|
14750
|
+
error: `shutdown detach already ${shutdownDetachPhase ?? 'active'} as ${shutdownDetachRequestId}`,
|
|
14751
|
+
};
|
|
14752
|
+
}
|
|
14753
|
+
else if (inputBlocker) {
|
|
14754
|
+
result = {
|
|
14755
|
+
ok: false,
|
|
14756
|
+
taskId: null,
|
|
14757
|
+
error: `worker_inputs_not_drained:${inputBlocker}`,
|
|
14758
|
+
};
|
|
14759
|
+
}
|
|
14760
|
+
else {
|
|
14761
|
+
shutdownDetachRequestId = msg.requestId;
|
|
14762
|
+
shutdownDetachPhase = 'preparing';
|
|
14763
|
+
try {
|
|
14764
|
+
result = await backend?.prepareShutdownDetach?.()
|
|
14765
|
+
?? { ok: false, taskId: null, error: 'shutdown_detach_unsupported' };
|
|
14766
|
+
}
|
|
14767
|
+
catch (err) {
|
|
14768
|
+
result = {
|
|
14769
|
+
ok: false,
|
|
14770
|
+
taskId: null,
|
|
14771
|
+
error: err instanceof Error ? err.message : String(err),
|
|
14772
|
+
};
|
|
14773
|
+
}
|
|
14774
|
+
if (shutdownDetachRequestId === msg.requestId && result.ok) {
|
|
14775
|
+
shutdownDetachPhase = 'prepared';
|
|
14776
|
+
}
|
|
14777
|
+
}
|
|
14778
|
+
send({
|
|
14779
|
+
type: 'riff_shutdown_result',
|
|
14780
|
+
requestId: msg.requestId,
|
|
14781
|
+
phase: 'prepare',
|
|
14782
|
+
ok: result.ok,
|
|
14783
|
+
taskId: result.taskId,
|
|
14784
|
+
...(result.error ? { error: result.error } : {}),
|
|
14785
|
+
});
|
|
14786
|
+
break;
|
|
14787
|
+
}
|
|
14788
|
+
case 'riff_shutdown_commit': {
|
|
14789
|
+
if (effectiveBackendType !== 'riff'
|
|
14790
|
+
|| shutdownDetachRequestId !== msg.requestId
|
|
14791
|
+
|| shutdownDetachPhase !== 'prepared') {
|
|
14792
|
+
log(`Ignoring stale Riff shutdown commit ${msg.requestId}`);
|
|
14793
|
+
break;
|
|
14794
|
+
}
|
|
14795
|
+
log(`Riff shutdown detach committed (${msg.requestId})`);
|
|
14796
|
+
shutdownDetachRequestId = null;
|
|
14797
|
+
shutdownDetachPhase = null;
|
|
14798
|
+
backend?.commitShutdownDetach?.();
|
|
14799
|
+
intentionalRestartBackend = backend;
|
|
14800
|
+
stopScreenshotLoop();
|
|
14801
|
+
killCli();
|
|
14802
|
+
cleanup();
|
|
14803
|
+
process.exit(0);
|
|
14804
|
+
}
|
|
14805
|
+
case 'riff_shutdown_abort': {
|
|
14806
|
+
if (shutdownDetachRequestId !== msg.requestId) {
|
|
14807
|
+
log(`Ignoring stale Riff shutdown abort ${msg.requestId}`);
|
|
14808
|
+
send({
|
|
14809
|
+
type: 'riff_shutdown_result',
|
|
14810
|
+
requestId: msg.requestId,
|
|
14811
|
+
phase: 'abort',
|
|
14812
|
+
ok: false,
|
|
14813
|
+
taskId: null,
|
|
14814
|
+
error: 'shutdown_detach_not_active',
|
|
14815
|
+
});
|
|
14816
|
+
break;
|
|
14817
|
+
}
|
|
14818
|
+
log(`Riff shutdown detach aborted (${msg.requestId})`);
|
|
14819
|
+
let result;
|
|
14820
|
+
try {
|
|
14821
|
+
result = await backend?.abortShutdownDetach?.()
|
|
14822
|
+
?? { ok: false, taskId: null, error: 'shutdown_abort_unsupported' };
|
|
14823
|
+
}
|
|
14824
|
+
catch (err) {
|
|
14825
|
+
result = {
|
|
14826
|
+
ok: false,
|
|
14827
|
+
taskId: null,
|
|
14828
|
+
error: err instanceof Error ? err.message : String(err),
|
|
14829
|
+
};
|
|
14830
|
+
}
|
|
14831
|
+
if (result.ok && shutdownDetachRequestId === msg.requestId) {
|
|
14832
|
+
shutdownDetachRequestId = null;
|
|
14833
|
+
shutdownDetachPhase = null;
|
|
14834
|
+
}
|
|
14835
|
+
send({
|
|
14836
|
+
type: 'riff_shutdown_result',
|
|
14837
|
+
requestId: msg.requestId,
|
|
14838
|
+
phase: 'abort',
|
|
14839
|
+
ok: result.ok,
|
|
14840
|
+
taskId: result.taskId,
|
|
14841
|
+
...(result.error ? { error: result.error } : {}),
|
|
14842
|
+
});
|
|
14843
|
+
break;
|
|
14844
|
+
}
|
|
14845
|
+
case 'close_commit': {
|
|
14846
|
+
if (!preparedCloseRequestId || preparedCloseRequestId !== msg.requestId) {
|
|
14847
|
+
log(`Ignoring stale close_commit ${msg.requestId}`);
|
|
14848
|
+
break;
|
|
14849
|
+
}
|
|
14850
|
+
log(`Close committed (${msg.requestId})`);
|
|
14851
|
+
preparedCloseRequestId = null;
|
|
14852
|
+
closeRequestInFlightId = null;
|
|
14853
|
+
lastAbortedCloseRequestId = null;
|
|
14854
|
+
backend?.commitDestroySession?.();
|
|
14855
|
+
closeRequested = true;
|
|
14856
|
+
send({ type: 'session_close_ready', sessionId });
|
|
14857
|
+
stopScreenshotLoop();
|
|
14858
|
+
stopBridgeWatcher();
|
|
14859
|
+
stopCodexBridge();
|
|
14860
|
+
killCli();
|
|
14861
|
+
clearSendMarkers();
|
|
14862
|
+
cleanup();
|
|
14863
|
+
process.exit(0);
|
|
14864
|
+
}
|
|
14865
|
+
case 'close_abort': {
|
|
14866
|
+
const alreadyRestored = lastAbortedCloseRequestId === msg.requestId;
|
|
14867
|
+
if (!alreadyRestored
|
|
14868
|
+
&& preparedCloseRequestId !== msg.requestId
|
|
14869
|
+
&& closeRequestInFlightId !== msg.requestId) {
|
|
14870
|
+
log(`Ignoring stale close_abort ${msg.requestId}`);
|
|
14871
|
+
send({
|
|
14872
|
+
type: 'close_abort_result',
|
|
14873
|
+
requestId: msg.requestId,
|
|
14874
|
+
ok: false,
|
|
14875
|
+
error: 'close_abort_not_active',
|
|
14876
|
+
});
|
|
14877
|
+
break;
|
|
14878
|
+
}
|
|
14879
|
+
log(`Close aborted (${msg.requestId}); Riff admission restored`);
|
|
14880
|
+
let abortError = null;
|
|
14881
|
+
if (!alreadyRestored) {
|
|
14882
|
+
try {
|
|
14883
|
+
await backend?.abortDestroySession?.();
|
|
14884
|
+
}
|
|
14885
|
+
catch (err) {
|
|
14886
|
+
abortError = err instanceof Error ? err.message : String(err);
|
|
14887
|
+
}
|
|
14888
|
+
}
|
|
14889
|
+
if (!abortError) {
|
|
14890
|
+
preparedCloseRequestId = null;
|
|
14891
|
+
closeRequestInFlightId = null;
|
|
14892
|
+
lastAbortedCloseRequestId = null;
|
|
14893
|
+
}
|
|
14894
|
+
send({
|
|
14895
|
+
type: 'close_abort_result',
|
|
14896
|
+
requestId: msg.requestId,
|
|
14897
|
+
ok: abortError === null,
|
|
14898
|
+
...(abortError ? { error: abortError } : {}),
|
|
14899
|
+
});
|
|
14900
|
+
break;
|
|
14901
|
+
}
|
|
12628
14902
|
case 'suspend': {
|
|
14903
|
+
if (effectiveBackendType === 'riff') {
|
|
14904
|
+
log('Refused unsafe Riff suspend; explicit close requires prepare/commit');
|
|
14905
|
+
break;
|
|
14906
|
+
}
|
|
12629
14907
|
log('Suspend requested');
|
|
12630
14908
|
stopScreenshotLoop();
|
|
12631
14909
|
stopBridgeWatcher();
|
|
@@ -12644,12 +14922,7 @@ process.on('message', async (raw) => {
|
|
|
12644
14922
|
// uses to recover sessions after a reboot kills the tmux server).
|
|
12645
14923
|
revokeManagedTurnOriginForRestart();
|
|
12646
14924
|
try {
|
|
12647
|
-
|
|
12648
|
-
// 恢复时 follow-up 续上);只断流 detach。
|
|
12649
|
-
if (effectiveBackendType === 'riff')
|
|
12650
|
-
backend?.kill();
|
|
12651
|
-
else
|
|
12652
|
-
(backend?.destroySession ?? backend?.kill)?.call(backend);
|
|
14925
|
+
(backend?.destroySession ?? backend?.kill)?.call(backend);
|
|
12653
14926
|
}
|
|
12654
14927
|
catch { /* best-effort */ }
|
|
12655
14928
|
backend = null;
|
|
@@ -12676,6 +14949,8 @@ process.on('message', async (raw) => {
|
|
|
12676
14949
|
}
|
|
12677
14950
|
}
|
|
12678
14951
|
});
|
|
14952
|
+
// 预加载层会在正式处理器注册前暂存冷启动消息;现在按到达顺序交还。
|
|
14953
|
+
process.emit(WORKER_IPC_HANDLER_READY_EVENT);
|
|
12679
14954
|
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
|
12680
14955
|
function cleanup() {
|
|
12681
14956
|
stopNativeSessionTitleSync();
|
|
@@ -12711,6 +14986,10 @@ function cleanup() {
|
|
|
12711
14986
|
catch { /* already closed */ }
|
|
12712
14987
|
workflowPtyLogStream = undefined;
|
|
12713
14988
|
}
|
|
14989
|
+
// Publisher ownership spans every CLI generation in this worker. Releasing
|
|
14990
|
+
// from killCli/restart would reopen a stale-publish window; only process-level
|
|
14991
|
+
// cleanup retires the lease. SIGKILL residue is reclaimed by the next worker.
|
|
14992
|
+
releaseCodexAppPosixOwnerLease();
|
|
12714
14993
|
}
|
|
12715
14994
|
process.on('SIGTERM', () => { stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });
|
|
12716
14995
|
process.on('SIGINT', () => { stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });
|