botmux 3.5.0 → 3.6.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/README.en.md +56 -396
- package/README.md +55 -396
- package/dist/.runtime-build-id +1 -0
- package/dist/adapters/backend/sandbox.d.ts +99 -279
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +424 -826
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts +6 -0
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +197 -11
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/coco.js +4 -4
- package/dist/adapters/cli/coco.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +9 -6
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +9 -5
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/fs-policy.d.ts +244 -0
- package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
- package/dist/adapters/cli/fs-policy.js +537 -0
- package/dist/adapters/cli/fs-policy.js.map +1 -0
- package/dist/adapters/cli/grok.d.ts +2 -1
- package/dist/adapters/cli/grok.d.ts.map +1 -1
- package/dist/adapters/cli/grok.js +2 -1
- package/dist/adapters/cli/grok.js.map +1 -1
- package/dist/adapters/cli/hermes.d.ts.map +1 -1
- package/dist/adapters/cli/hermes.js +8 -4
- package/dist/adapters/cli/hermes.js.map +1 -1
- package/dist/adapters/cli/mtr.d.ts.map +1 -1
- package/dist/adapters/cli/mtr.js +3 -2
- package/dist/adapters/cli/mtr.js.map +1 -1
- package/dist/adapters/cli/opencode.d.ts.map +1 -1
- package/dist/adapters/cli/opencode.js +5 -4
- package/dist/adapters/cli/opencode.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +14 -200
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +18 -477
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/relay.js +2 -2
- package/dist/adapters/cli/relay.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +2 -2
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +9 -6
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/seed.d.ts.map +1 -1
- package/dist/adapters/cli/seed.js +2 -1
- package/dist/adapters/cli/seed.js.map +1 -1
- package/dist/adapters/cli/traex.d.ts.map +1 -1
- package/dist/adapters/cli/traex.js +3 -2
- package/dist/adapters/cli/traex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +25 -18
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +54 -23
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +91 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +297 -104
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +85 -192
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/config.d.ts +0 -16
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -30
- package/dist/config.js.map +1 -1
- package/dist/core/ask-broker.d.ts +12 -0
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +41 -0
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/bot-turn-mutation-gate.d.ts +24 -0
- package/dist/core/bot-turn-mutation-gate.d.ts.map +1 -0
- package/dist/core/bot-turn-mutation-gate.js +369 -0
- package/dist/core/bot-turn-mutation-gate.js.map +1 -0
- package/dist/core/command-handler.d.ts +5 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +53 -73
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +356 -84
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts +8 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/host-overload-alert.d.ts +157 -0
- package/dist/core/host-overload-alert.d.ts.map +1 -0
- package/dist/core/host-overload-alert.js +251 -0
- package/dist/core/host-overload-alert.js.map +1 -0
- package/dist/core/passthrough-commands.d.ts +8 -0
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +20 -1
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.js +14 -2
- package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
- package/dist/core/restart-coordinator.d.ts +28 -0
- package/dist/core/restart-coordinator.d.ts.map +1 -0
- package/dist/core/restart-coordinator.js +82 -0
- package/dist/core/restart-coordinator.js.map +1 -0
- package/dist/core/restart-followup-policy.d.ts +112 -0
- package/dist/core/restart-followup-policy.d.ts.map +1 -0
- package/dist/core/restart-followup-policy.js +49 -0
- package/dist/core/restart-followup-policy.js.map +1 -0
- package/dist/core/role-library.d.ts +1 -1
- package/dist/core/role-library.js +1 -1
- package/dist/core/session-cwd.d.ts.map +1 -1
- package/dist/core/session-cwd.js +8 -0
- package/dist/core/session-cwd.js.map +1 -1
- package/dist/core/session-discovery.d.ts +14 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +25 -0
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-liveness.d.ts +15 -0
- package/dist/core/session-liveness.d.ts.map +1 -0
- package/dist/core/session-liveness.js +76 -0
- package/dist/core/session-liveness.js.map +1 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +90 -60
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-row-enrichment.d.ts +15 -0
- package/dist/core/session-row-enrichment.d.ts.map +1 -0
- package/dist/core/session-row-enrichment.js +93 -0
- package/dist/core/session-row-enrichment.js.map +1 -0
- package/dist/core/slash-inject.js +1 -1
- package/dist/core/slash-inject.js.map +1 -1
- package/dist/core/trigger-final-suppression.d.ts +24 -0
- package/dist/core/trigger-final-suppression.d.ts.map +1 -0
- package/dist/core/trigger-final-suppression.js +97 -0
- package/dist/core/trigger-final-suppression.js.map +1 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +64 -4
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +44 -0
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +333 -38
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +5 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +526 -35
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +1 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -1
- package/dist/dashboard/aggregator.js +34 -6
- package/dist/dashboard/aggregator.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +8 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +10 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/compat.d.ts +102 -2
- package/dist/dashboard/compat.d.ts.map +1 -1
- package/dist/dashboard/compat.js +78 -14
- package/dist/dashboard/compat.js.map +1 -1
- package/dist/dashboard/connector-api.d.ts.map +1 -1
- package/dist/dashboard/connector-api.js +1 -0
- package/dist/dashboard/connector-api.js.map +1 -1
- package/dist/dashboard/desktop-asks.d.ts +21 -0
- package/dist/dashboard/desktop-asks.d.ts.map +1 -0
- package/dist/dashboard/desktop-asks.js +49 -0
- package/dist/dashboard/desktop-asks.js.map +1 -0
- package/dist/dashboard/public-redact.d.ts +7 -0
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +35 -3
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/session-presentation.d.ts +16 -0
- package/dist/dashboard/session-presentation.d.ts.map +1 -0
- package/dist/dashboard/session-presentation.js +62 -0
- package/dist/dashboard/session-presentation.js.map +1 -0
- package/dist/dashboard/settings-write-applier.d.ts +2 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +19 -1
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/trigger-api.d.ts.map +1 -1
- package/dist/dashboard/trigger-api.js +19 -1
- package/dist/dashboard/trigger-api.js.map +1 -1
- package/dist/dashboard/vc-consumer-profiles-api.d.ts +7 -2
- package/dist/dashboard/vc-consumer-profiles-api.d.ts.map +1 -1
- package/dist/dashboard/vc-consumer-profiles-api.js +13 -0
- package/dist/dashboard/vc-consumer-profiles-api.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +18 -5
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +16 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +288 -7
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +10 -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/client-shell.d.ts +29 -0
- package/dist/dashboard/web/client-shell.d.ts.map +1 -0
- package/dist/dashboard/web/client-shell.js +89 -0
- package/dist/dashboard/web/client-shell.js.map +1 -0
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +5 -1
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +130 -22
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/preferences.d.ts +7 -0
- package/dist/dashboard/web/preferences.d.ts.map +1 -1
- package/dist/dashboard/web/preferences.js +32 -0
- package/dist/dashboard/web/preferences.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +1 -1
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +78 -67
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts +4 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +14 -1
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +6 -0
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +55 -30
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/store.d.ts +1 -2
- package/dist/dashboard/web/store.d.ts.map +1 -1
- package/dist/dashboard/web/store.js +69 -20
- package/dist/dashboard/web/store.js.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.js +128 -42
- package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
- package/dist/dashboard/webhook-routes.d.ts.map +1 -1
- package/dist/dashboard/webhook-routes.js +9 -2
- package/dist/dashboard/webhook-routes.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-VQMPUJ4Y.js +10 -0
- package/dist/dashboard-web/chunks/chunk-AGCPF6MT.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-QCPPKCX3.js → chunk-JMRGZQDB.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-EJDYUINZ.js → chunk-NP4JUBGS.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-WXPMNEYV.js → chunk-P327LXOT.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-SNKATZWK.js +49 -0
- package/dist/dashboard-web/chunks/connectors-page-DOY4J3ZL.js +1 -0
- package/dist/dashboard-web/chunks/{groups-page-3P4TLUMF.js → groups-page-74TS2DIH.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-TDCZV56A.js → insights-page-AYCO65JC.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-QWCWL3SX.js → monitor-room-YKYLTSAN.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-IOWFSNVC.js → monitoring-page-QBIUNRBR.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-5AUPEHZP.js → office-page-4PH2A26P.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-H73D5UT4.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-WJVB52ET.js → roles-page-U4BTOQ7F.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-RVRWHP4V.js → schedules-page-RHRHX7R7.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-VSC6AM5G.js +2 -0
- package/dist/dashboard-web/chunks/settings-page-LF3RKM5P.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-7YT5TIX5.js → skills-page-SYOIGGXH.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-NJZKTU7A.js → team-federation-page-RMBLASTW.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-NQ73UA2Q.js → v3-page-XJXKKJRN.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-DSM3MDMN.js → whiteboards-page-K3YVSTHJ.js} +1 -1
- package/dist/dashboard-web/style.css +571 -3
- package/dist/dashboard.js +238 -9
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +11 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +27 -1
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +6 -19
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +6 -19
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +1 -14
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +1 -35
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +10 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +161 -44
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +47 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +127 -18
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +13 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +48 -3
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/identity-cache.d.ts +25 -1
- package/dist/im/lark/identity-cache.d.ts.map +1 -1
- package/dist/im/lark/identity-cache.js +47 -1
- package/dist/im/lark/identity-cache.js.map +1 -1
- package/dist/im/lark/overload-nonce.d.ts +19 -0
- package/dist/im/lark/overload-nonce.d.ts.map +1 -0
- package/dist/im/lark/overload-nonce.js +72 -0
- package/dist/im/lark/overload-nonce.js.map +1 -0
- package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
- package/dist/im/lark/v3-progress-card.js +5 -0
- package/dist/im/lark/v3-progress-card.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +82 -0
- package/dist/services/async-trigger-state.d.ts.map +1 -0
- package/dist/services/async-trigger-state.js +95 -0
- package/dist/services/async-trigger-state.js.map +1 -0
- package/dist/services/async-trigger-store.d.ts +28 -0
- package/dist/services/async-trigger-store.d.ts.map +1 -0
- package/dist/services/async-trigger-store.js +120 -0
- package/dist/services/async-trigger-store.js.map +1 -0
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +34 -2
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +32 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +39 -0
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/bridge-turn-queue.d.ts.map +1 -1
- package/dist/services/bridge-turn-queue.js +15 -1
- package/dist/services/bridge-turn-queue.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +4 -0
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +8 -0
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/claude-transcript.d.ts +23 -0
- package/dist/services/claude-transcript.d.ts.map +1 -1
- package/dist/services/claude-transcript.js +33 -0
- package/dist/services/claude-transcript.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +61 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -0
- package/dist/services/codex-app-runner-protocol.js +170 -0
- package/dist/services/codex-app-runner-protocol.js.map +1 -0
- package/dist/services/codex-app-turn-controller.d.ts +72 -0
- package/dist/services/codex-app-turn-controller.d.ts.map +1 -0
- package/dist/services/codex-app-turn-controller.js +524 -0
- package/dist/services/codex-app-turn-controller.js.map +1 -0
- package/dist/services/codex-runner-freshness.d.ts +39 -0
- package/dist/services/codex-runner-freshness.d.ts.map +1 -0
- package/dist/services/codex-runner-freshness.js +78 -0
- package/dist/services/codex-runner-freshness.js.map +1 -0
- package/dist/services/connector-store.d.ts +6 -0
- package/dist/services/connector-store.d.ts.map +1 -1
- package/dist/services/connector-store.js.map +1 -1
- package/dist/services/grant-store.d.ts.map +1 -1
- package/dist/services/grant-store.js +16 -0
- package/dist/services/grant-store.js.map +1 -1
- package/dist/services/hermes-transcript.d.ts +1 -0
- package/dist/services/hermes-transcript.d.ts.map +1 -1
- package/dist/services/hermes-transcript.js +49 -0
- package/dist/services/hermes-transcript.js.map +1 -1
- package/dist/services/sandbox-migration.d.ts +14 -0
- package/dist/services/sandbox-migration.d.ts.map +1 -0
- package/dist/services/sandbox-migration.js +85 -0
- package/dist/services/sandbox-migration.js.map +1 -0
- package/dist/services/sandbox-store.d.ts +28 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +82 -0
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/schedule-split-migration.d.ts +2 -0
- package/dist/services/schedule-split-migration.d.ts.map +1 -0
- package/dist/services/schedule-split-migration.js +159 -0
- package/dist/services/schedule-split-migration.js.map +1 -0
- package/dist/services/schedule-store.d.ts +28 -4
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +118 -33
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/trigger-types.d.ts +18 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +3 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/services/vc-meeting-consumer-profile-store.d.ts.map +1 -1
- package/dist/services/vc-meeting-consumer-profile-store.js +1 -0
- package/dist/services/vc-meeting-consumer-profile-store.js.map +1 -1
- package/dist/services/vc-meeting-consumer-profile-templates.d.ts +32 -0
- package/dist/services/vc-meeting-consumer-profile-templates.d.ts.map +1 -0
- package/dist/services/vc-meeting-consumer-profile-templates.js +106 -0
- package/dist/services/vc-meeting-consumer-profile-templates.js.map +1 -0
- package/dist/services/vc-meeting-delivery-feed.d.ts +1 -1
- package/dist/services/vc-meeting-delivery-feed.d.ts.map +1 -1
- package/dist/services/vc-meeting-delivery-feed.js +2 -1
- package/dist/services/vc-meeting-delivery-feed.js.map +1 -1
- package/dist/services/vc-meeting-delivery-hub-store.d.ts +3 -1
- package/dist/services/vc-meeting-delivery-hub-store.d.ts.map +1 -1
- package/dist/services/vc-meeting-delivery-hub-store.js +11 -0
- package/dist/services/vc-meeting-delivery-hub-store.js.map +1 -1
- package/dist/services/vc-meeting-delivery-protocol.d.ts +9 -2
- package/dist/services/vc-meeting-delivery-protocol.d.ts.map +1 -1
- package/dist/services/vc-meeting-delivery-protocol.js +10 -1
- package/dist/services/vc-meeting-delivery-protocol.js.map +1 -1
- package/dist/services/vc-meeting-delivery-receiver.d.ts.map +1 -1
- package/dist/services/vc-meeting-delivery-receiver.js +10 -1
- package/dist/services/vc-meeting-delivery-receiver.js.map +1 -1
- package/dist/services/vc-meeting-delivery-store.d.ts +12 -2
- package/dist/services/vc-meeting-delivery-store.d.ts.map +1 -1
- package/dist/services/vc-meeting-delivery-store.js +14 -2
- package/dist/services/vc-meeting-delivery-store.js.map +1 -1
- package/dist/services/vc-meeting-im-reply.d.ts +2 -1
- package/dist/services/vc-meeting-im-reply.d.ts.map +1 -1
- package/dist/services/vc-meeting-im-reply.js +19 -3
- package/dist/services/vc-meeting-im-reply.js.map +1 -1
- package/dist/services/vc-meeting-listener-output-protocol.d.ts +27 -0
- package/dist/services/vc-meeting-listener-output-protocol.d.ts.map +1 -0
- package/dist/services/vc-meeting-listener-output-protocol.js +58 -0
- package/dist/services/vc-meeting-listener-output-protocol.js.map +1 -0
- package/dist/services/vc-meeting-listener-topic-store.d.ts +31 -0
- package/dist/services/vc-meeting-listener-topic-store.d.ts.map +1 -0
- package/dist/services/vc-meeting-listener-topic-store.js +120 -0
- package/dist/services/vc-meeting-listener-topic-store.js.map +1 -0
- package/dist/services/vc-meeting-runtime-store.d.ts +2 -1
- package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
- package/dist/services/vc-meeting-runtime-store.js +8 -0
- package/dist/services/vc-meeting-runtime-store.js.map +1 -1
- package/dist/services/vc-meeting-send-policy.d.ts +8 -1
- package/dist/services/vc-meeting-send-policy.d.ts.map +1 -1
- package/dist/services/vc-meeting-send-policy.js +8 -0
- package/dist/services/vc-meeting-send-policy.js.map +1 -1
- package/dist/types.d.ts +53 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/allowed-users-apply.d.ts +76 -0
- package/dist/utils/allowed-users-apply.d.ts.map +1 -0
- package/dist/utils/allowed-users-apply.js +123 -0
- package/dist/utils/allowed-users-apply.js.map +1 -0
- package/dist/utils/allowed-users-cache.d.ts +29 -0
- package/dist/utils/allowed-users-cache.d.ts.map +1 -0
- package/dist/utils/allowed-users-cache.js +82 -0
- package/dist/utils/allowed-users-cache.js.map +1 -0
- package/dist/utils/child-env.d.ts +4 -3
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +16 -1
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/cli-usage-limit.d.ts +27 -1
- package/dist/utils/cli-usage-limit.d.ts.map +1 -1
- package/dist/utils/cli-usage-limit.js +92 -20
- package/dist/utils/cli-usage-limit.js.map +1 -1
- package/dist/utils/input-gate.d.ts +2 -0
- package/dist/utils/input-gate.d.ts.map +1 -1
- package/dist/utils/input-gate.js +2 -0
- package/dist/utils/input-gate.js.map +1 -1
- package/dist/utils/runtime-build-id.d.ts +21 -0
- package/dist/utils/runtime-build-id.d.ts.map +1 -0
- package/dist/utils/runtime-build-id.js +83 -0
- package/dist/utils/runtime-build-id.js.map +1 -0
- package/dist/utils/terminal-renderer.d.ts +3 -3
- package/dist/utils/terminal-renderer.js +3 -3
- package/dist/vc-agent/cards.d.ts.map +1 -1
- package/dist/vc-agent/cards.js +21 -1
- package/dist/vc-agent/cards.js.map +1 -1
- package/dist/worker.js +1010 -669
- package/dist/worker.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +56 -0
- package/dist/workflows/events/payloads.d.ts.map +1 -1
- package/dist/workflows/events/payloads.js +9 -0
- package/dist/workflows/events/payloads.js.map +1 -1
- package/dist/workflows/events/schema.d.ts +86 -0
- package/dist/workflows/events/schema.d.ts.map +1 -1
- package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
- package/dist/workflows/hostExecutors/botmux-schedule.js +13 -1
- package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
- package/dist/workflows/migration/v2-run-archive-schema.d.ts +20 -20
- package/dist/workflows/migration/v2-to-v3.d.ts.map +1 -1
- package/dist/workflows/migration/v2-to-v3.js +5 -1
- package/dist/workflows/migration/v2-to-v3.js.map +1 -1
- package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
- package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
- package/dist/workflows/shared/sandbox-policy.js +12 -0
- package/dist/workflows/shared/sandbox-policy.js.map +1 -1
- package/dist/workflows/v3/architect.d.ts.map +1 -1
- package/dist/workflows/v3/architect.js +1 -0
- package/dist/workflows/v3/architect.js.map +1 -1
- package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
- package/dist/workflows/v3/bot-resolve.js +36 -0
- package/dist/workflows/v3/bot-resolve.js.map +1 -1
- package/dist/workflows/v3/cli-run.d.ts +9 -0
- package/dist/workflows/v3/cli-run.d.ts.map +1 -1
- package/dist/workflows/v3/cli-run.js +17 -2
- package/dist/workflows/v3/cli-run.js.map +1 -1
- package/dist/workflows/v3/contract.d.ts +8 -0
- package/dist/workflows/v3/contract.d.ts.map +1 -1
- package/dist/workflows/v3/contract.js.map +1 -1
- package/dist/workflows/v3/goal-cli.d.ts +58 -0
- package/dist/workflows/v3/goal-cli.d.ts.map +1 -0
- package/dist/workflows/v3/goal-cli.js +430 -0
- package/dist/workflows/v3/goal-cli.js.map +1 -0
- package/dist/workflows/v3/library-materialize.d.ts.map +1 -1
- package/dist/workflows/v3/library-materialize.js +5 -1
- package/dist/workflows/v3/library-materialize.js.map +1 -1
- package/dist/workflows/v3/library-schema.d.ts +21 -0
- package/dist/workflows/v3/library-schema.d.ts.map +1 -1
- package/dist/workflows/v3/library-schema.js +95 -1
- package/dist/workflows/v3/library-schema.js.map +1 -1
- package/dist/workflows/v3/progress-projection.d.ts +3 -0
- package/dist/workflows/v3/progress-projection.d.ts.map +1 -1
- package/dist/workflows/v3/progress-projection.js +23 -1
- package/dist/workflows/v3/progress-projection.js.map +1 -1
- package/dist/workflows/v3/run-envelope.d.ts +46 -1
- package/dist/workflows/v3/run-envelope.d.ts.map +1 -1
- package/dist/workflows/v3/run-envelope.js +32 -1
- package/dist/workflows/v3/run-envelope.js.map +1 -1
- package/package.json +2 -2
- package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +0 -5
- package/dist/dashboard-web/chunks/chunk-H7P5UV4W.js +0 -49
- package/dist/dashboard-web/chunks/chunk-SZOB6VK2.js +0 -1
- package/dist/dashboard-web/chunks/connectors-page-IR54AOAI.js +0 -1
- package/dist/dashboard-web/chunks/overview-page-QJMI6NLW.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +0 -3
- package/dist/dashboard-web/chunks/settings-page-TNZEPC7V.js +0 -2
- package/dist/services/sandbox-land.d.ts +0 -45
- package/dist/services/sandbox-land.d.ts.map +0 -1
- package/dist/services/sandbox-land.js +0 -394
- package/dist/services/sandbox-land.js.map +0 -1
- package/dist/utils/screen-analyzer.d.ts +0 -75
- package/dist/utils/screen-analyzer.d.ts.map +0 -1
- package/dist/utils/screen-analyzer.js +0 -333
- package/dist/utils/screen-analyzer.js.map +0 -1
package/dist/worker.js
CHANGED
|
@@ -18,14 +18,17 @@ import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
|
18
18
|
import { join, basename, dirname, delimiter } from 'node:path';
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
import { spawnSync } from 'node:child_process';
|
|
21
|
-
import {
|
|
21
|
+
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
|
|
22
|
+
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
|
|
22
23
|
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
|
|
23
24
|
import { readProcessStartIdentity } from './core/session-marker.js';
|
|
24
|
-
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
|
|
25
|
+
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
|
|
25
26
|
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
|
|
26
|
-
import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
|
|
27
|
+
import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
|
|
27
28
|
import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, } from './utils/input-gate.js';
|
|
29
|
+
import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
|
|
28
30
|
import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
|
|
31
|
+
import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
|
|
29
32
|
import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
|
|
30
33
|
import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
|
|
31
34
|
import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
|
|
@@ -40,7 +43,7 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
|
|
|
40
43
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
41
44
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
42
45
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
43
|
-
import { loadBotConfigs } from './bot-registry.js';
|
|
46
|
+
import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
|
|
44
47
|
import { readGlobalConfig } from './global-config.js';
|
|
45
48
|
import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
46
49
|
import { readPlatformBinding } from './platform/binding.js';
|
|
@@ -65,6 +68,7 @@ import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByCha
|
|
|
65
68
|
import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
|
|
66
69
|
import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
|
|
67
70
|
import { baselineJsonlCursor } from './services/jsonl-cursor.js';
|
|
71
|
+
import { fileURLToPath } from 'node:url';
|
|
68
72
|
import { createServer as createHttpServer } from 'node:http';
|
|
69
73
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
70
74
|
import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
|
|
@@ -76,7 +80,7 @@ import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_R
|
|
|
76
80
|
import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
|
|
77
81
|
import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
|
|
78
82
|
import { cliUnavailableMessage } from './setup/cli-availability.js';
|
|
79
|
-
import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind } from './core/session-discovery.js';
|
|
83
|
+
import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
|
|
80
84
|
import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
|
|
81
85
|
import { delay } from './utils/timing.js';
|
|
82
86
|
import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
|
|
@@ -92,12 +96,11 @@ import { isObserveBackend } from './adapters/backend/types.js';
|
|
|
92
96
|
import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
|
|
93
97
|
import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
|
|
94
98
|
import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
|
|
95
|
-
import {
|
|
99
|
+
import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
|
|
96
100
|
import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
|
|
97
101
|
import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
|
|
98
102
|
import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
|
|
99
103
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
100
|
-
import { ScreenAnalyzer } from './utils/screen-analyzer.js';
|
|
101
104
|
import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
|
|
102
105
|
import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
|
|
103
106
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
@@ -105,7 +108,7 @@ import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven
|
|
|
105
108
|
import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
|
|
106
109
|
import { mergeHerdrWebSnapshot, renderHerdrWebHistory, } from './utils/herdr-web-history.js';
|
|
107
110
|
import { parseWorkerRequestUrl } from './utils/worker-http.js';
|
|
108
|
-
import { detectCliUsageLimit, usageLimitStateKey } from './utils/cli-usage-limit.js';
|
|
111
|
+
import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState } from './utils/cli-usage-limit.js';
|
|
109
112
|
import { uploadImageBuffer } from './utils/lark-upload.js';
|
|
110
113
|
import { redactChildEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
|
|
111
114
|
import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
|
|
@@ -120,6 +123,7 @@ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
|
|
|
120
123
|
import { withCodexAppContext } from './utils/codex-app-context.js';
|
|
121
124
|
import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
|
|
122
125
|
import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
|
|
126
|
+
import { normalizeAppRunnerFinalMarker, normalizeCodexAppLifecycleEvent, projectAppRunnerFinalIds, } from './services/codex-app-runner-protocol.js';
|
|
123
127
|
import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
|
|
124
128
|
import { CodexRpcEngine } from './codex-rpc-engine.js';
|
|
125
129
|
// A worker must never trust an INHERITED session-level CLI home pointer
|
|
@@ -690,7 +694,7 @@ function armRpcStartupDialogDismiss() {
|
|
|
690
694
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
691
695
|
let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
|
|
692
696
|
let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
|
|
693
|
-
let sandboxCleanup = null; //
|
|
697
|
+
let sandboxCleanup = null; // reclaim deny-mask mountpoints + rm the per-session sandbox tree
|
|
694
698
|
let sandboxRelayOutbox = null;
|
|
695
699
|
let sandboxRelayCapability = null;
|
|
696
700
|
let readIsolationOriginCapabilityFile = null;
|
|
@@ -953,6 +957,12 @@ const IDLE_PROBE_INTERVAL_MS = 3_500;
|
|
|
953
957
|
const IDLE_PROBE_MAX_ATTEMPTS = 24;
|
|
954
958
|
let busyPatternIdleProbeTimer = null;
|
|
955
959
|
let reattachIdleProbeTimer = null;
|
|
960
|
+
let codexRunnerFreshness = 'current';
|
|
961
|
+
let persistCodexRunnerBuildOnReady = false;
|
|
962
|
+
let activeRestartAttemptId;
|
|
963
|
+
/** Distinguishes a replacement's synchronous ready signal (Riff) from a late
|
|
964
|
+
* idle callback emitted by the backend being torn down. */
|
|
965
|
+
let replacementSpawnInProgress = false;
|
|
956
966
|
/** The effectiveResume flag used by the most recent spawnCli call. Written
|
|
957
967
|
* immediately after the two-tier fallback check so late-attach timers
|
|
958
968
|
* (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
|
|
@@ -1104,6 +1114,17 @@ let bareShellLaunchBlocked = false;
|
|
|
1104
1114
|
* one-shot so it also covers a reattach onto a pane that degraded to a bare
|
|
1105
1115
|
* shell. Reset per spawn in spawnCli. */
|
|
1106
1116
|
let bareShellChecked = false;
|
|
1117
|
+
/** True only while detectBareShellLaunch() is inside its async launch-settle
|
|
1118
|
+
* window (settleLaunchComm's bounded ≤2s poll for a wrapper's final `exec
|
|
1119
|
+
* <cli>`). The message/injection flush paths already hold the isFlushing /
|
|
1120
|
+
* injectionFlushing mutexes across that await, but raw_input (passthrough
|
|
1121
|
+
* slash commands: /compact, /model, /btw) deliberately bypasses those to
|
|
1122
|
+
* preserve busy-delivery — so it would otherwise type into a pane whose leaf
|
|
1123
|
+
* is still the transient shell (or a shell already about to be blocked). This
|
|
1124
|
+
* latch lets raw_input defer for exactly that window without borrowing the
|
|
1125
|
+
* general isFlushing mutex (which would wrongly also block busy-delivery when
|
|
1126
|
+
* no settle is in progress). Reset per spawn in spawnCli. */
|
|
1127
|
+
let bareShellCheckInProgress = false;
|
|
1107
1128
|
/** Ready-gate (Claude-family): holds the first prompt until the SessionStart
|
|
1108
1129
|
* hook proves a cjadk-style startup selector is behind us. Claude then needs
|
|
1109
1130
|
* fresh post-hook prompt evidence because sibling hooks may still be running.
|
|
@@ -1342,11 +1363,42 @@ async function runStartupCommands() {
|
|
|
1342
1363
|
isPromptReady = false;
|
|
1343
1364
|
idleDetector?.reset();
|
|
1344
1365
|
}
|
|
1345
|
-
const
|
|
1366
|
+
const freshnessInputQueue = new CodexRunnerFreshnessInputQueue(() => codexRunnerFreshness, state => { codexRunnerFreshness = state; });
|
|
1367
|
+
const pendingMessages = freshnessInputQueue.normal;
|
|
1368
|
+
/** Correlation ids that this worker actually wrote into the owned Codex App
|
|
1369
|
+
* runner. Runner lifecycle/final markers may route only to ids in this bounded
|
|
1370
|
+
* local set; model/user display bytes cannot add entries. */
|
|
1371
|
+
const submittedCodexAppReplyTurnIds = new Set();
|
|
1372
|
+
const pendingCodexAppSteerAckIds = new Map();
|
|
1373
|
+
const acknowledgedCodexAppSteers = new Set();
|
|
1374
|
+
const CODEX_APP_CORRELATION_LIMIT = 256;
|
|
1375
|
+
function rememberBounded(set, value) {
|
|
1376
|
+
set.delete(value);
|
|
1377
|
+
set.add(value);
|
|
1378
|
+
while (set.size > CODEX_APP_CORRELATION_LIMIT) {
|
|
1379
|
+
const oldest = set.values().next().value;
|
|
1380
|
+
if (typeof oldest !== 'string')
|
|
1381
|
+
break;
|
|
1382
|
+
set.delete(oldest);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function rememberBoundedMap(map, key, value) {
|
|
1386
|
+
map.delete(key);
|
|
1387
|
+
map.set(key, value);
|
|
1388
|
+
while (map.size > CODEX_APP_CORRELATION_LIMIT) {
|
|
1389
|
+
const oldest = map.keys().next().value;
|
|
1390
|
+
if (typeof oldest !== 'string')
|
|
1391
|
+
break;
|
|
1392
|
+
map.delete(oldest);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
function shortCorrelationId(value) {
|
|
1396
|
+
return value?.slice(0, 12) ?? '-';
|
|
1397
|
+
}
|
|
1346
1398
|
/** Literal commands that arrived while native /rename owned the TUI or while
|
|
1347
1399
|
* an owned CLI restart was fenced. Normal raw_input commands are still
|
|
1348
1400
|
* delivered immediately (including while busy). */
|
|
1349
|
-
const pendingRawInputs =
|
|
1401
|
+
const pendingRawInputs = freshnessInputQueue.raw;
|
|
1350
1402
|
/** Latest requested canonical session title. Unlike a normal prompt this is an
|
|
1351
1403
|
* administrative TUI command: never type-ahead while the agent is busy, never
|
|
1352
1404
|
* open a model turn, and latest-wins if several renames arrive before idle. */
|
|
@@ -1690,6 +1742,24 @@ function writeCliPidMarker() {
|
|
|
1690
1742
|
}
|
|
1691
1743
|
}
|
|
1692
1744
|
let lastStructuredBridgeActivityAtMs = 0;
|
|
1745
|
+
/**
|
|
1746
|
+
* True when this CLI has an authoritative STRUCTURED rate-limit signal in its
|
|
1747
|
+
* transcript (Claude family — `error:"rate_limit"`, surfaced by
|
|
1748
|
+
* maybeEmitStructuredRateLimit). For those CLIs the screen-text `rate`
|
|
1749
|
+
* heuristic is not just redundant but harmful: the model's own output or a dev
|
|
1750
|
+
* editing rate-limit code/tests puts phrases like "429 Too Many Requests" /
|
|
1751
|
+
* "exceeded retry limit" on screen, which the scraper cannot distinguish from a
|
|
1752
|
+
* real limit. So we suppress the screen-scan `rate` verdict and let the
|
|
1753
|
+
* structured path be the sole authority. `usage` (quota "hit your limit …")
|
|
1754
|
+
* has no structured equivalent yet, so it still comes from the screen.
|
|
1755
|
+
*
|
|
1756
|
+
* reliableTurnTerminal is exactly the "transcript-backed" capability flag
|
|
1757
|
+
* (claude-code / seed set it); non-transcript CLIs (Codex, gemini, …) keep the
|
|
1758
|
+
* screen scanner as their only rate-limit signal.
|
|
1759
|
+
*/
|
|
1760
|
+
function structuredRateLimitAuthoritative() {
|
|
1761
|
+
return cliAdapter?.reliableTurnTerminal === true;
|
|
1762
|
+
}
|
|
1693
1763
|
// Per-turn usage-limit state machine. Owns the turn counter plus the
|
|
1694
1764
|
// "did this turn hit a limit" / "suppress a stale retry-ready banner" flags, so
|
|
1695
1765
|
// classify()'s state writes are explicit method calls rather than hidden
|
|
@@ -1708,7 +1778,7 @@ function createUsageLimitTracker() {
|
|
|
1708
1778
|
beginTurn(snapshot) {
|
|
1709
1779
|
turnSeq++;
|
|
1710
1780
|
detectedTurn = undefined;
|
|
1711
|
-
const current = detectCliUsageLimit(snapshot);
|
|
1781
|
+
const current = detectCliUsageLimit(snapshot, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
|
|
1712
1782
|
suppressedRetryReadyKey = current.limited && current.retryReady
|
|
1713
1783
|
? usageLimitStateKey(current)
|
|
1714
1784
|
: undefined;
|
|
@@ -1717,7 +1787,7 @@ function createUsageLimitTracker() {
|
|
|
1717
1787
|
// Map a runtime status to a usage-limit-aware status, recording whether this
|
|
1718
1788
|
// turn hit a limit (read back via detectedThisTurn).
|
|
1719
1789
|
classify(content, status) {
|
|
1720
|
-
const detected = detectCliUsageLimit(content);
|
|
1790
|
+
const detected = detectCliUsageLimit(content, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
|
|
1721
1791
|
if (!detected.limited)
|
|
1722
1792
|
return { status };
|
|
1723
1793
|
const key = usageLimitStateKey(detected);
|
|
@@ -1731,6 +1801,15 @@ function createUsageLimitTracker() {
|
|
|
1731
1801
|
detectedThisTurn(seq) {
|
|
1732
1802
|
return detectedTurn === seq;
|
|
1733
1803
|
},
|
|
1804
|
+
// Record a limit that came from a STRUCTURED signal (transcript error
|
|
1805
|
+
// record) rather than screen text. Mirrors classify()'s state writes so
|
|
1806
|
+
// the tracker stays coherent: mark this turn as having hit a limit (read
|
|
1807
|
+
// by detectedThisTurn for the submit-confirmation recheck) and clear any
|
|
1808
|
+
// stale retry-ready suppression. The actual emit is done by the caller.
|
|
1809
|
+
noteStructuredLimit() {
|
|
1810
|
+
suppressedRetryReadyKey = undefined;
|
|
1811
|
+
detectedTurn = turnSeq;
|
|
1812
|
+
},
|
|
1734
1813
|
};
|
|
1735
1814
|
}
|
|
1736
1815
|
const usageLimitTracker = createUsageLimitTracker();
|
|
@@ -1800,6 +1879,11 @@ const bridgeSecondaryPaths = new Map(); // path → offset
|
|
|
1800
1879
|
let bridgeOffset = 0;
|
|
1801
1880
|
let bridgePendingTail = '';
|
|
1802
1881
|
const bridgeQueue = new BridgeTurnQueue();
|
|
1882
|
+
/** uuids of Claude transcript rate-limit records we've already turned into a
|
|
1883
|
+
* `limited` emit. drainTranscript re-reads from offset 0 on truncation /
|
|
1884
|
+
* rotation and emitReadyTurns re-drains from 0, so the same rate_limit record
|
|
1885
|
+
* can resurface; keying on the record's stable uuid makes the emit idempotent. */
|
|
1886
|
+
const emittedRateLimitUuids = new Set();
|
|
1803
1887
|
let bridgeWatcher = null;
|
|
1804
1888
|
let bridgeFallbackTimer = null;
|
|
1805
1889
|
let herdrAdoptBridgeQuietTimer = null;
|
|
@@ -1893,6 +1977,9 @@ function formatHeadlessLocalTurnContent(assistantText) {
|
|
|
1893
1977
|
const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
|
|
1894
1978
|
return a || null;
|
|
1895
1979
|
}
|
|
1980
|
+
function emptyCompletedBridgeFallbackContent() {
|
|
1981
|
+
return t('worker.empty_final_completed', { cliName: cliName() });
|
|
1982
|
+
}
|
|
1896
1983
|
// ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
|
|
1897
1984
|
//
|
|
1898
1985
|
// `botmux send` (cli.ts cmdSend) appends a line
|
|
@@ -2730,11 +2817,45 @@ function bridgeIngest() {
|
|
|
2730
2817
|
if (result.events.length > 0)
|
|
2731
2818
|
lastStructuredBridgeActivityAtMs = Date.now();
|
|
2732
2819
|
bridgeQueue.ingest(result.events, bridgeJsonlPath);
|
|
2820
|
+
// Structured rate-limit: Claude Code writes an `error:"rate_limit"` record
|
|
2821
|
+
// at the turn's terminal boundary. This is the authoritative "limited"
|
|
2822
|
+
// signal — read it here (event-driven, once per record) instead of scraping
|
|
2823
|
+
// the TUI. The queue already skips it as an assistant reply.
|
|
2824
|
+
maybeEmitStructuredRateLimit(result.events);
|
|
2733
2825
|
// Transcript terminal markers are authoritative and may settle a durable
|
|
2734
2826
|
// turn immediately. Do not wait for the screen prompt: permission/AskUser
|
|
2735
2827
|
// surfaces can resemble idle, while an explicit JSONL boundary cannot.
|
|
2736
2828
|
emitReadyTurns({ explicitTerminalOnly: true });
|
|
2737
2829
|
}
|
|
2830
|
+
/** Scan newly-drained Claude transcript events for a structured rate-limit
|
|
2831
|
+
* record and, on the first unseen one, emit a `limited` screen_update so the
|
|
2832
|
+
* session surfaces in Dashboard「需要你」with a retry countdown — identical
|
|
2833
|
+
* wire shape to the screen-text detector's classify() output, so the daemon /
|
|
2834
|
+
* card / persistence paths need no change. Claude-only (bridgeQueue is the
|
|
2835
|
+
* Claude bridge; Codex uses codexBridgeQueue and has no structured 429). */
|
|
2836
|
+
function maybeEmitStructuredRateLimit(events) {
|
|
2837
|
+
for (const ev of events) {
|
|
2838
|
+
if (!ev.uuid || emittedRateLimitUuids.has(ev.uuid))
|
|
2839
|
+
continue;
|
|
2840
|
+
if (!isTranscriptRateLimitEvent(ev))
|
|
2841
|
+
continue;
|
|
2842
|
+
emittedRateLimitUuids.add(ev.uuid);
|
|
2843
|
+
// Prefer a clock parsed from the record's own text ("... resets 10:40pm");
|
|
2844
|
+
// fall back to the shared bucketed cooldown when it carries none.
|
|
2845
|
+
const usageLimit = structuredRateLimitState(apiErrorMessageText(ev));
|
|
2846
|
+
usageLimitTracker.noteStructuredLimit();
|
|
2847
|
+
send({
|
|
2848
|
+
type: 'screen_update',
|
|
2849
|
+
content: currentUsageLimitSnapshot(),
|
|
2850
|
+
status: 'limited',
|
|
2851
|
+
usageLimit,
|
|
2852
|
+
turnId: currentBotmuxTurnId,
|
|
2853
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
2854
|
+
});
|
|
2855
|
+
log(`Structured rate-limit detected in Claude transcript (uuid=${ev.uuid.substring(0, 8)}, retryLabel=${usageLimit.retryLabel}) → emitted limited state.`);
|
|
2856
|
+
return; // one limited emit per ingest is enough; state key is stable
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2738
2859
|
function performBridgeIngestAndScheduleQuietEmit() {
|
|
2739
2860
|
const beforePath = bridgeJsonlPath;
|
|
2740
2861
|
const beforeOffset = bridgeOffset;
|
|
@@ -3263,6 +3384,7 @@ function hermesBridgeIngest() {
|
|
|
3263
3384
|
// `/clear`-rotates mid-batch; the daemon accumulates them into its authorized
|
|
3264
3385
|
// set, so a completed turn from an earlier source is not dropped as foreign.
|
|
3265
3386
|
for (const boundSourceSessionId of filtered.newlyBoundSourceSessionIds) {
|
|
3387
|
+
persistCliSessionId(boundSourceSessionId);
|
|
3266
3388
|
send({ type: 'bridge_source_session', bridge: 'hermes', sourceSessionId: boundSourceSessionId });
|
|
3267
3389
|
log(`Hermes bridge bound sourceSessionId=${boundSourceSessionId}`);
|
|
3268
3390
|
}
|
|
@@ -3675,6 +3797,37 @@ function codexBridgeDrainAndMaybeEmit(opts = {}) {
|
|
|
3675
3797
|
}
|
|
3676
3798
|
emitReadyCodexTurns();
|
|
3677
3799
|
}
|
|
3800
|
+
/** Before we emit a fail-closed `ambiguous` terminal for a durable turn that a
|
|
3801
|
+
* CLI kill is about to interrupt, give an already-persisted `completed` record
|
|
3802
|
+
* a chance to win the worker-local terminal deduper. `reliableTurnTerminal`
|
|
3803
|
+
* CLIs (claude-code / codex / grok / traex) durably append their terminal line
|
|
3804
|
+
* to the transcript; if that line landed microseconds before the kill but the
|
|
3805
|
+
* fs.watch/1s poller hasn't consumed it yet, an unconditional `ambiguous` would
|
|
3806
|
+
* claim the deduper first and needlessly mark a turn that actually completed →
|
|
3807
|
+
* same durable key re-dispatchable → external side effect executed twice.
|
|
3808
|
+
* Draining here publishes that `completed` (claiming the deduper) so the later
|
|
3809
|
+
* `ambiguous` becomes a no-op. Shared by the CLI `onExit` path and the daemon
|
|
3810
|
+
* `restart` IPC path — both must drain identically before their ambiguous emit. */
|
|
3811
|
+
function drainReliableTerminalBeforeInterrupt() {
|
|
3812
|
+
if (cliAdapter?.reliableTurnTerminal !== true)
|
|
3813
|
+
return;
|
|
3814
|
+
if (bridgeJsonlPath) {
|
|
3815
|
+
try {
|
|
3816
|
+
bridgeDrainAndMaybeEmit();
|
|
3817
|
+
}
|
|
3818
|
+
catch (err) {
|
|
3819
|
+
log(`Bridge terminal drain failed: ${err.message}`);
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
if (codexBridgeFallbackActive()) {
|
|
3823
|
+
try {
|
|
3824
|
+
codexBridgeDrainAndMaybeEmit({ signalIdle: false });
|
|
3825
|
+
}
|
|
3826
|
+
catch (err) {
|
|
3827
|
+
log(`Codex bridge terminal drain failed: ${err.message}`);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3678
3831
|
function emitReadyCodexTurns() {
|
|
3679
3832
|
const ready = codexBridgeQueue.drainEmittable();
|
|
3680
3833
|
if (ready.length === 0)
|
|
@@ -3698,11 +3851,22 @@ function emitReadyCodexTurns() {
|
|
|
3698
3851
|
: undefined;
|
|
3699
3852
|
for (let i = 0; i < ready.length; i++) {
|
|
3700
3853
|
const turn = ready[i];
|
|
3701
|
-
if (!turn.finalText)
|
|
3702
|
-
continue;
|
|
3703
3854
|
const sourceHermesSessionId = structuredBridgeIsHermes() ? turn.sourceSessionId : undefined;
|
|
3704
3855
|
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
3705
|
-
|
|
3856
|
+
const gateInput = {
|
|
3857
|
+
markTimeMs: turn.markTimeMs,
|
|
3858
|
+
isLocal: turn.isLocal,
|
|
3859
|
+
finalText: turn.finalText,
|
|
3860
|
+
terminalStatus: turn.terminalStatus,
|
|
3861
|
+
};
|
|
3862
|
+
const content = turn.finalText && turn.finalText.trim()
|
|
3863
|
+
? turn.finalText
|
|
3864
|
+
: shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
|
|
3865
|
+
? emptyCompletedBridgeFallbackContent()
|
|
3866
|
+
: '';
|
|
3867
|
+
if (!content)
|
|
3868
|
+
continue;
|
|
3869
|
+
if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
|
|
3706
3870
|
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
|
|
3707
3871
|
continue;
|
|
3708
3872
|
}
|
|
@@ -3711,7 +3875,7 @@ function emitReadyCodexTurns() {
|
|
|
3711
3875
|
// so the Lark thread sees a complete exchange instead of an orphan
|
|
3712
3876
|
// reply. formatLocalTurnFields caps both texts to keep within
|
|
3713
3877
|
// Lark's per-message limit; daemon owns the card chrome.
|
|
3714
|
-
const fields = formatLocalTurnFields(turn.userText ?? '',
|
|
3878
|
+
const fields = formatLocalTurnFields(turn.userText ?? '', content);
|
|
3715
3879
|
if (!fields)
|
|
3716
3880
|
continue;
|
|
3717
3881
|
send({
|
|
@@ -3729,7 +3893,7 @@ function emitReadyCodexTurns() {
|
|
|
3729
3893
|
send({
|
|
3730
3894
|
type: 'final_output',
|
|
3731
3895
|
...(sourceHermesSessionId ? { sourceHermesSessionId } : {}),
|
|
3732
|
-
content
|
|
3896
|
+
content,
|
|
3733
3897
|
lastUuid: turn.turnId,
|
|
3734
3898
|
turnId: turn.turnId,
|
|
3735
3899
|
...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
|
|
@@ -3851,8 +4015,9 @@ let renderRows = PTY_ROWS;
|
|
|
3851
4015
|
// ─── Headless Terminal for Screen Capture ────────────────────────────────────
|
|
3852
4016
|
let renderer = null;
|
|
3853
4017
|
/** Most recent unfiltered viewport text — kept in sync by the screen_update
|
|
3854
|
-
* timer for pipe-pane backends so
|
|
3855
|
-
* a fresh snapshot to read without needing
|
|
4018
|
+
* timer for pipe-pane backends so usage-limit detection and the CoCo picker
|
|
4019
|
+
* have a fresh snapshot to read without needing their own tmux capture-pane
|
|
4020
|
+
* call. (Historically also fed the AI ScreenAnalyzer, now removed.) */
|
|
3856
4021
|
let lastAnalyzerSnapshot = '';
|
|
3857
4022
|
let screenUpdateTimer = null;
|
|
3858
4023
|
const SCREEN_UPDATE_INTERVAL_MS = 2_000;
|
|
@@ -4026,14 +4191,14 @@ function parkCrashDiagnosticTerminal(code, signal) {
|
|
|
4026
4191
|
// so the diagnostic shell stays visible. flushPending's retry path restarts
|
|
4027
4192
|
// both when the next message respawns the CLI.
|
|
4028
4193
|
stopScreenUpdates();
|
|
4029
|
-
stopScreenAnalyzer();
|
|
4030
4194
|
stopStuckDetector();
|
|
4031
4195
|
log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
|
|
4032
4196
|
return true;
|
|
4033
4197
|
}
|
|
4034
|
-
// ───
|
|
4035
|
-
|
|
4036
|
-
|
|
4198
|
+
// ─── TUI prompt blocking state ──────────────────────────────────────────────
|
|
4199
|
+
/** When true, user messages are queued because a TUI prompt is active. Set by
|
|
4200
|
+
* the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
|
|
4201
|
+
* when the prompt resolves or a Lark/terminal input overrides it. */
|
|
4037
4202
|
let tuiPromptBlocking = false;
|
|
4038
4203
|
function isWorkflowWorker() {
|
|
4039
4204
|
return process.env.BOTMUX_WORKFLOW === '1';
|
|
@@ -4095,54 +4260,6 @@ function maybeEmitWorkflowTranscriptOutput() {
|
|
|
4095
4260
|
});
|
|
4096
4261
|
log('Workflow PTY transcript final_output emitted');
|
|
4097
4262
|
}
|
|
4098
|
-
function startScreenAnalyzer() {
|
|
4099
|
-
const sa = config.screenAnalyzer;
|
|
4100
|
-
log(`ScreenAnalyzer config: enabled=${sa.enabled}, baseUrl=${sa.baseUrl ? 'set' : 'empty'}, model=${sa.model || 'empty'}, extraHeaders=${JSON.stringify(sa.extraHeaders)}`);
|
|
4101
|
-
if (!sa.enabled || !sa.baseUrl || !sa.apiKey || !sa.model)
|
|
4102
|
-
return;
|
|
4103
|
-
screenAnalyzer = new ScreenAnalyzer({
|
|
4104
|
-
baseUrl: sa.baseUrl,
|
|
4105
|
-
apiKey: sa.apiKey,
|
|
4106
|
-
model: sa.model,
|
|
4107
|
-
intervalMs: sa.intervalMs,
|
|
4108
|
-
stableCount: sa.stableCount,
|
|
4109
|
-
snapshotMaxChars: sa.snapshotMaxChars,
|
|
4110
|
-
extraHeaders: sa.extraHeaders,
|
|
4111
|
-
extraBody: sa.extraBody,
|
|
4112
|
-
}, {
|
|
4113
|
-
getSnapshot: () => {
|
|
4114
|
-
// ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
|
|
4115
|
-
// can't make this async without overhauling the analyzer, so cache
|
|
4116
|
-
// the last pipe-pane text snapshot here and refresh it eagerly.
|
|
4117
|
-
// For pipe-pane backends, the cache is repopulated by the screen
|
|
4118
|
-
// update timer; for others, fall through to the long-lived renderer.
|
|
4119
|
-
return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
|
|
4120
|
-
},
|
|
4121
|
-
onAnalyzing: () => { },
|
|
4122
|
-
onTuiPrompt: (description, options, multiSelect) => {
|
|
4123
|
-
tuiPromptBlocking = true;
|
|
4124
|
-
send({ type: 'tui_prompt', description, options, multiSelect, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
|
|
4125
|
-
},
|
|
4126
|
-
onTuiPromptResolved: (selectedText) => {
|
|
4127
|
-
tuiPromptBlocking = false;
|
|
4128
|
-
send({
|
|
4129
|
-
type: 'tui_prompt_resolved',
|
|
4130
|
-
selectedText,
|
|
4131
|
-
turnId: currentBotmuxTurnId,
|
|
4132
|
-
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
4133
|
-
});
|
|
4134
|
-
// Flush any messages that were queued during the prompt
|
|
4135
|
-
flushPending();
|
|
4136
|
-
},
|
|
4137
|
-
log,
|
|
4138
|
-
});
|
|
4139
|
-
screenAnalyzer.start();
|
|
4140
|
-
}
|
|
4141
|
-
function stopScreenAnalyzer() {
|
|
4142
|
-
screenAnalyzer?.dispose();
|
|
4143
|
-
screenAnalyzer = null;
|
|
4144
|
-
tuiPromptBlocking = false;
|
|
4145
|
-
}
|
|
4146
4263
|
// ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
|
|
4147
4264
|
let stuckDetector = null;
|
|
4148
4265
|
// Monotonic counter bumped on every CLI/backend (re)start within this worker.
|
|
@@ -4339,8 +4456,6 @@ async function captureAndUpload() {
|
|
|
4339
4456
|
return;
|
|
4340
4457
|
}
|
|
4341
4458
|
let status = isPromptReady ? 'idle' : 'working';
|
|
4342
|
-
if (screenAnalyzer?.isAnalyzing)
|
|
4343
|
-
status = 'analyzing';
|
|
4344
4459
|
send({
|
|
4345
4460
|
type: 'screenshot_uploaded',
|
|
4346
4461
|
imageKey,
|
|
@@ -4430,14 +4545,12 @@ function handleTermAction(key) {
|
|
|
4430
4545
|
else if (PTY_SEQ_MAP[key]) {
|
|
4431
4546
|
backend.write(PTY_SEQ_MAP[key]);
|
|
4432
4547
|
}
|
|
4433
|
-
// ESC/Ctrl-C/Enter likely ends an active TUI prompt.
|
|
4434
|
-
//
|
|
4435
|
-
//
|
|
4436
|
-
//
|
|
4437
|
-
// subsequent user message.
|
|
4548
|
+
// ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking
|
|
4549
|
+
// flag here — without this, dismissing an AskUserQuestion dialog via the
|
|
4550
|
+
// quick-key button leaves tuiPromptBlocking=true forever and silently queues
|
|
4551
|
+
// every subsequent user message.
|
|
4438
4552
|
if (tuiPromptBlocking && (key === 'esc' || key === 'ctrlc' || key === 'enter')) {
|
|
4439
4553
|
tuiPromptBlocking = false;
|
|
4440
|
-
screenAnalyzer?.notifySelection(`term_action:${key}`);
|
|
4441
4554
|
void flushPending();
|
|
4442
4555
|
}
|
|
4443
4556
|
log(`Term action: ${key}`);
|
|
@@ -4484,18 +4597,18 @@ async function handleTuiKeys(keys, isFinal) {
|
|
|
4484
4597
|
isPromptReady = false;
|
|
4485
4598
|
idleDetector?.reset();
|
|
4486
4599
|
}
|
|
4487
|
-
screenAnalyzer?.notifySelection('final');
|
|
4488
4600
|
}
|
|
4489
4601
|
log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
|
|
4490
4602
|
return true;
|
|
4491
4603
|
}
|
|
4492
4604
|
// 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
|
|
4493
4605
|
// 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
|
|
4494
|
-
// barrier=true
|
|
4495
|
-
//
|
|
4496
|
-
//
|
|
4497
|
-
// core/inject-queue-policy.ts
|
|
4498
|
-
//
|
|
4606
|
+
// barrier 语义:barrier=true 的注入必须先于本次 pending 用户消息落地。历史上
|
|
4607
|
+
// 唯一的 barrier 来源是 cwd-move 的 /cd 注入;角色切换改为 restart respawn 后
|
|
4608
|
+
// 现存发送方(/slash 白名单注入)全部 barrier=false,机制保留供未来有顺序
|
|
4609
|
+
// 依赖的注入复用。排队策略判定收敛到 core/inject-queue-policy.ts
|
|
4610
|
+
// (shouldDeferUserFlush / shouldFlushInjectionsFirst)以获得可单测的纯函数
|
|
4611
|
+
// 单元——本文件只持有队列状态。
|
|
4499
4612
|
const pendingInjections = [];
|
|
4500
4613
|
let injectionFlushing = false;
|
|
4501
4614
|
/** 排队注入一行 TUI 命令:idle(isPromptReady)时经 sendRawCommandLineSerially 敲入。 */
|
|
@@ -4532,9 +4645,18 @@ async function flushPendingInjections() {
|
|
|
4532
4645
|
// path sends first runs the detection.
|
|
4533
4646
|
if (!bareShellChecked) {
|
|
4534
4647
|
bareShellChecked = true;
|
|
4535
|
-
if (detectBareShellLaunch())
|
|
4648
|
+
if (await detectBareShellLaunch())
|
|
4536
4649
|
return; // finally{} releases the mutex; queue stays
|
|
4537
4650
|
}
|
|
4651
|
+
// The detector's settle await can span a restart's tmux jitter window
|
|
4652
|
+
// (cliRestartInProgress true, old backend still alive). Re-check the fence
|
|
4653
|
+
// before shift()/write so a queued injection never lands in a CLI already
|
|
4654
|
+
// being torn down. The pending injections are then handled by killCli's
|
|
4655
|
+
// restart policy — which drops them (barrier /cd is already durable in
|
|
4656
|
+
// workingDir; non-barrier injects are best-effort and not replayed
|
|
4657
|
+
// cross-process), unlike pendingMessages which killCli preserves.
|
|
4658
|
+
if (cliRestartInProgress)
|
|
4659
|
+
return;
|
|
4538
4660
|
const item = pendingInjections.shift();
|
|
4539
4661
|
const cmd = item.command;
|
|
4540
4662
|
isPromptReady = false;
|
|
@@ -4594,7 +4716,6 @@ async function handleTuiTextInput(keys, text) {
|
|
|
4594
4716
|
isPromptReady = false;
|
|
4595
4717
|
idleDetector?.reset();
|
|
4596
4718
|
}
|
|
4597
|
-
screenAnalyzer?.notifySelection('text-input');
|
|
4598
4719
|
// Wait briefly so the cursor position is stable before pasting
|
|
4599
4720
|
await new Promise(r => setTimeout(r, 200));
|
|
4600
4721
|
// Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter)
|
|
@@ -4753,9 +4874,74 @@ function handleCodexAppMarker(body) {
|
|
|
4753
4874
|
persistCliSessionId(payload.threadId);
|
|
4754
4875
|
return;
|
|
4755
4876
|
}
|
|
4756
|
-
if (kind === '
|
|
4757
|
-
const
|
|
4758
|
-
|
|
4877
|
+
if (kind === 'lifecycle') {
|
|
4878
|
+
const event = normalizeCodexAppLifecycleEvent(payload);
|
|
4879
|
+
if (!event) {
|
|
4880
|
+
log(`${cliName()} rejected malformed lifecycle marker`);
|
|
4881
|
+
return;
|
|
4882
|
+
}
|
|
4883
|
+
log(`${cliName()} lifecycle kind=${event.kind}`
|
|
4884
|
+
+ ` appTurn=${shortCorrelationId(event.appTurnId)}`
|
|
4885
|
+
+ ` replyTurn=${shortCorrelationId(event.replyTurnId)}`
|
|
4886
|
+
+ `${event.queueLength !== undefined ? ` queue=${event.queueLength}` : ''}`);
|
|
4887
|
+
if (!event.replyTurnId || !submittedCodexAppReplyTurnIds.has(event.replyTurnId))
|
|
4888
|
+
return;
|
|
4889
|
+
if (event.kind === 'steer_attempt') {
|
|
4890
|
+
rememberBoundedMap(pendingCodexAppSteerAckIds, event.replyTurnId, event.appTurnId);
|
|
4891
|
+
return;
|
|
4892
|
+
}
|
|
4893
|
+
const steerKey = `${event.appTurnId}\0${event.replyTurnId}`;
|
|
4894
|
+
if (event.kind !== 'steer_accepted'
|
|
4895
|
+
|| pendingCodexAppSteerAckIds.get(event.replyTurnId) !== event.appTurnId
|
|
4896
|
+
|| acknowledgedCodexAppSteers.has(steerKey))
|
|
4897
|
+
return;
|
|
4898
|
+
pendingCodexAppSteerAckIds.delete(event.replyTurnId);
|
|
4899
|
+
rememberBounded(acknowledgedCodexAppSteers, steerKey);
|
|
4900
|
+
send({
|
|
4901
|
+
type: 'steer_accepted',
|
|
4902
|
+
appTurnId: event.appTurnId,
|
|
4903
|
+
turnId: event.replyTurnId,
|
|
4904
|
+
});
|
|
4905
|
+
return;
|
|
4906
|
+
}
|
|
4907
|
+
if (kind === 'final') {
|
|
4908
|
+
const marker = normalizeAppRunnerFinalMarker(payload);
|
|
4909
|
+
if (!marker) {
|
|
4910
|
+
log(`${cliName()} rejected malformed final marker`);
|
|
4911
|
+
return;
|
|
4912
|
+
}
|
|
4913
|
+
const startedAtMs = marker.startedAtMs;
|
|
4914
|
+
const completedAtMs = marker.completedAtMs ?? Date.now();
|
|
4915
|
+
if (marker.appTurnId) {
|
|
4916
|
+
const trustedReplyTurnId = marker.replyTurnId
|
|
4917
|
+
&& submittedCodexAppReplyTurnIds.has(marker.replyTurnId)
|
|
4918
|
+
? marker.replyTurnId
|
|
4919
|
+
: undefined;
|
|
4920
|
+
if (marker.replyTurnId && !trustedReplyTurnId) {
|
|
4921
|
+
log(`${cliName()} ignored unsubmitted final reply route `
|
|
4922
|
+
+ `(replyTurn=${shortCorrelationId(marker.replyTurnId)})`);
|
|
4923
|
+
}
|
|
4924
|
+
const identity = projectAppRunnerFinalIds({ ...marker, replyTurnId: trustedReplyTurnId }, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
|
|
4925
|
+
if (marker.appTurnId !== identity.turnId) {
|
|
4926
|
+
log(`${cliName()} app turn ${shortCorrelationId(marker.appTurnId)} mapped `
|
|
4927
|
+
+ `to botmux turn ${shortCorrelationId(identity.turnId)}`);
|
|
4928
|
+
}
|
|
4929
|
+
const dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
4930
|
+
if (startedAtMs !== undefined && shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false)) {
|
|
4931
|
+
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
4932
|
+
emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
|
|
4933
|
+
return;
|
|
4934
|
+
}
|
|
4935
|
+
send({
|
|
4936
|
+
type: 'final_output',
|
|
4937
|
+
content: marker.content,
|
|
4938
|
+
lastUuid: identity.lastUuid,
|
|
4939
|
+
turnId: identity.turnId,
|
|
4940
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
4941
|
+
});
|
|
4942
|
+
emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
|
|
4943
|
+
return;
|
|
4944
|
+
}
|
|
4759
4945
|
// Codex App keeps the app-server-generated id separately for diagnostics.
|
|
4760
4946
|
// Routing must use its stable clientUserMessageId marker; legacy envelopes
|
|
4761
4947
|
// intentionally omit it and fall back to the worker's frozen botmux turn.
|
|
@@ -4780,7 +4966,7 @@ function handleCodexAppMarker(body) {
|
|
|
4780
4966
|
// equality for compatibility, but can never select another attempt.
|
|
4781
4967
|
const dispatchAttempt = currentBotmuxDispatchAttempt;
|
|
4782
4968
|
if (startedAtMs !== undefined) {
|
|
4783
|
-
const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText:
|
|
4969
|
+
const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false);
|
|
4784
4970
|
if (sentByModel) {
|
|
4785
4971
|
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
4786
4972
|
emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
|
|
@@ -4789,7 +4975,7 @@ function handleCodexAppMarker(body) {
|
|
|
4789
4975
|
}
|
|
4790
4976
|
send({
|
|
4791
4977
|
type: 'final_output',
|
|
4792
|
-
content:
|
|
4978
|
+
content: marker.content,
|
|
4793
4979
|
lastUuid: turnId,
|
|
4794
4980
|
turnId,
|
|
4795
4981
|
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
@@ -4943,6 +5129,10 @@ function markPromptReadyFromPty() {
|
|
|
4943
5129
|
function markPromptReady() {
|
|
4944
5130
|
if (isPromptReady)
|
|
4945
5131
|
return; // guard against duplicate calls
|
|
5132
|
+
if (cliRestartInProgress && !replacementSpawnInProgress) {
|
|
5133
|
+
log('Ignoring prompt-ready from backend generation being replaced');
|
|
5134
|
+
return;
|
|
5135
|
+
}
|
|
4946
5136
|
stopBusyPatternIdleProbe();
|
|
4947
5137
|
// Ready-gate: a startup selector's ❯ (cjadk et al.) falsely matches
|
|
4948
5138
|
// readyPattern → the IdleDetector fires idle while the CLI is NOT actually at
|
|
@@ -4972,6 +5162,14 @@ function markPromptReady() {
|
|
|
4972
5162
|
log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
|
|
4973
5163
|
return;
|
|
4974
5164
|
}
|
|
5165
|
+
const freshnessAction = freshnessInputQueue.onPromptReady();
|
|
5166
|
+
if (freshnessAction === 'reload') {
|
|
5167
|
+
log('Stale Codex App runner became idle; replacing it before releasing queued input');
|
|
5168
|
+
void restartCliProcess('stale runner reached idle', { immediate: true, preservePending: true });
|
|
5169
|
+
return;
|
|
5170
|
+
}
|
|
5171
|
+
if (freshnessAction === 'ignore')
|
|
5172
|
+
return;
|
|
4975
5173
|
isPromptReady = true;
|
|
4976
5174
|
clearSessionRenameInFlight();
|
|
4977
5175
|
// An old backend can still report idle while its async teardown is running.
|
|
@@ -5003,6 +5201,33 @@ function markPromptReady() {
|
|
|
5003
5201
|
renderer?.markNewTurn(); // exclude history replay from streaming card
|
|
5004
5202
|
}
|
|
5005
5203
|
send({ type: 'prompt_ready' });
|
|
5204
|
+
if (persistCodexRunnerBuildOnReady
|
|
5205
|
+
&& lastInitConfig?.cliId === 'codex-app'
|
|
5206
|
+
&& lastInitConfig.runnerBuildId) {
|
|
5207
|
+
send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
|
|
5208
|
+
persistCodexRunnerBuildOnReady = false;
|
|
5209
|
+
}
|
|
5210
|
+
if (activeRestartAttemptId) {
|
|
5211
|
+
// Defense in depth: only report a successful restart when a replacement
|
|
5212
|
+
// backend is actually installed. Every legitimate ready path assigns
|
|
5213
|
+
// `backend` before firing (spawnCli sets it, then idle/PTY callbacks run);
|
|
5214
|
+
// a stray callback that reached here with no backend (e.g. a late stale
|
|
5215
|
+
// generation slipping through a future gate change) must NOT claim success
|
|
5216
|
+
// and consume the attempt id — leave it for the real replacement or the
|
|
5217
|
+
// coordinator timeout.
|
|
5218
|
+
if (backend) {
|
|
5219
|
+
send({
|
|
5220
|
+
type: 'restart_result',
|
|
5221
|
+
attemptId: activeRestartAttemptId,
|
|
5222
|
+
status: 'succeeded',
|
|
5223
|
+
category: 'prompt_ready',
|
|
5224
|
+
});
|
|
5225
|
+
activeRestartAttemptId = undefined;
|
|
5226
|
+
}
|
|
5227
|
+
else {
|
|
5228
|
+
log('prompt-ready with no backend installed — deferring restart success receipt');
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5006
5231
|
// Send immediate idle snapshot so Lark card reflects idle status.
|
|
5007
5232
|
// BUT: skip when messages are pending — flushPending() will immediately
|
|
5008
5233
|
// make the CLI busy, so the idle state is transient and shouldn't appear
|
|
@@ -5012,13 +5237,11 @@ function markPromptReady() {
|
|
|
5012
5237
|
const { content } = renderer.snapshot();
|
|
5013
5238
|
send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
|
|
5014
5239
|
}
|
|
5015
|
-
//
|
|
5016
|
-
//
|
|
5017
|
-
// flushPending
|
|
5018
|
-
//
|
|
5019
|
-
//
|
|
5020
|
-
// flushPendingInjections 的 finally 会在注入完成、CLI 重新 idle 后补踢一次
|
|
5021
|
-
// flushPending 排空 pendingMessages。
|
|
5240
|
+
// barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
|
|
5241
|
+
// 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
|
|
5242
|
+
// flushPending 不会饿死用户消息:flushPending 自身的 injectionFlushing 守卫
|
|
5243
|
+
// 会挡住并发写入,flushPendingInjections 的 finally 会在注入完成、CLI 重新
|
|
5244
|
+
// idle 后补踢一次 flushPending 排空 pendingMessages。
|
|
5022
5245
|
if (shouldFlushInjectionsFirst(pendingInjections)) {
|
|
5023
5246
|
void flushPendingInjections();
|
|
5024
5247
|
}
|
|
@@ -5236,19 +5459,41 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
5236
5459
|
* and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the
|
|
5237
5460
|
* CLI itself, never a shell).
|
|
5238
5461
|
*
|
|
5239
|
-
* Returns true when a bare-shell launch was detected (caller must
|
|
5462
|
+
* Returns true when a persistent bare-shell launch was detected (caller must
|
|
5463
|
+
* NOT flush). A transient wrapper shell gets a bounded chance to exec the CLI.
|
|
5240
5464
|
*/
|
|
5241
|
-
function detectBareShellLaunch() {
|
|
5465
|
+
async function detectBareShellLaunch() {
|
|
5242
5466
|
if (bareShellLaunchBlocked)
|
|
5243
5467
|
return true;
|
|
5244
5468
|
if (lastInitConfig?.adoptMode)
|
|
5245
5469
|
return false; // observing an existing pane, not launching
|
|
5246
5470
|
if (lastInitConfig?.wrapperCli)
|
|
5247
5471
|
return false; // launcher legitimately wraps the CLI (transient shell shim)
|
|
5248
|
-
|
|
5249
|
-
|
|
5472
|
+
// Hold the raw_input passthrough latch across the bounded settle poll: the
|
|
5473
|
+
// await below yields the event loop for up to 2s, and a concurrent raw_input
|
|
5474
|
+
// IPC handler (which bypasses isFlushing to preserve busy-delivery) must not
|
|
5475
|
+
// type into the pane while its leaf is still the unsettled shell.
|
|
5476
|
+
bareShellCheckInProgress = true;
|
|
5477
|
+
let comm;
|
|
5478
|
+
try {
|
|
5479
|
+
comm = await settleLaunchComm(() => {
|
|
5480
|
+
const pid = backend?.getChildPid?.();
|
|
5481
|
+
return pid ? readComm(pid) : undefined;
|
|
5482
|
+
});
|
|
5483
|
+
}
|
|
5484
|
+
finally {
|
|
5485
|
+
bareShellCheckInProgress = false;
|
|
5486
|
+
}
|
|
5487
|
+
// A restart can begin while we're suspended in the settle await: tmux staggers
|
|
5488
|
+
// teardown behind a 250–1999ms jitter, so cliRestartInProgress is already true
|
|
5489
|
+
// but the old backend is still alive. Do NOT classify that torn-down pane as a
|
|
5490
|
+
// failed launch — it would set the persistent bareShellLaunchBlocked and emit a
|
|
5491
|
+
// misdiagnosis card for a CLI that's about to be replaced. Return healthy; the
|
|
5492
|
+
// caller's own post-await restart fence keeps the queue intact for the
|
|
5493
|
+
// replacement generation (whose spawnCli resets bareShellChecked and re-runs
|
|
5494
|
+
// this check).
|
|
5495
|
+
if (cliRestartInProgress)
|
|
5250
5496
|
return false;
|
|
5251
|
-
const comm = readComm(pid);
|
|
5252
5497
|
if (!isBareShellComm(comm))
|
|
5253
5498
|
return false; // CLI (rust/go/node) is running — healthy launch
|
|
5254
5499
|
// Bare shell is the pane leaf → the CLI never launched. Tier the message on
|
|
@@ -5297,6 +5542,8 @@ async function flushPending() {
|
|
|
5297
5542
|
// backend's idle/task-done callback) write across that restart boundary.
|
|
5298
5543
|
if (cliRestartInProgress)
|
|
5299
5544
|
return;
|
|
5545
|
+
if (shouldHoldCodexRunnerInput(codexRunnerFreshness))
|
|
5546
|
+
return;
|
|
5300
5547
|
if (isFlushing)
|
|
5301
5548
|
return; // while loop in active flush will pick up new messages
|
|
5302
5549
|
if (!backend || !cliAdapter)
|
|
@@ -5405,10 +5652,19 @@ async function flushPending() {
|
|
|
5405
5652
|
// doesn't get them typed into the bare shell first.
|
|
5406
5653
|
if (!bareShellChecked) {
|
|
5407
5654
|
bareShellChecked = true;
|
|
5408
|
-
if (detectBareShellLaunch()) {
|
|
5655
|
+
if (await detectBareShellLaunch()) {
|
|
5409
5656
|
return; // finally{} releases the mutex; pendingMessages stay queued, untouched
|
|
5410
5657
|
}
|
|
5411
5658
|
}
|
|
5659
|
+
// detectBareShellLaunch() awaits a bounded settle poll; a restart can begin
|
|
5660
|
+
// during that yield (tmux staggers teardown behind a 250–1999ms jitter, so
|
|
5661
|
+
// cliRestartInProgress flips true while the old backend is still alive). Do
|
|
5662
|
+
// not write startup commands / rename / user prompts into a CLI already
|
|
5663
|
+
// promised to teardown — re-check the restart fence now, before any shift or
|
|
5664
|
+
// write. The queue is untouched; the replacement generation's markPromptReady
|
|
5665
|
+
// re-invokes flushPending.
|
|
5666
|
+
if (cliRestartInProgress)
|
|
5667
|
+
return; // finally{} releases the mutex; queue stays
|
|
5412
5668
|
// One-shot per spawn: type the bot's startup commands (e.g. `/effort
|
|
5413
5669
|
// ultracode`) into the CLI before the first user prompt drains. Both ready
|
|
5414
5670
|
// paths funnel through flushPending — the ready-gate settle for Claude-family
|
|
@@ -5423,7 +5679,9 @@ async function flushPending() {
|
|
|
5423
5679
|
// rename. Some passthroughs (/clear, /new) can rotate the native session;
|
|
5424
5680
|
// applying the canonical title last keeps the resume-picker label aligned.
|
|
5425
5681
|
if (rawInputReady && pendingRawInputs.length > 0 && backend) {
|
|
5426
|
-
const raw =
|
|
5682
|
+
const raw = freshnessInputQueue.takeRaw();
|
|
5683
|
+
if (!raw)
|
|
5684
|
+
return;
|
|
5427
5685
|
await deliverRawInput(raw);
|
|
5428
5686
|
return;
|
|
5429
5687
|
}
|
|
@@ -5452,7 +5710,9 @@ async function flushPending() {
|
|
|
5452
5710
|
return;
|
|
5453
5711
|
}
|
|
5454
5712
|
while (pendingMessages.length > 0 && backend && cliAdapter) {
|
|
5455
|
-
const item =
|
|
5713
|
+
const item = freshnessInputQueue.takeNormal();
|
|
5714
|
+
if (!item)
|
|
5715
|
+
break;
|
|
5456
5716
|
const durableWrite = item.dispatchAttempt !== undefined;
|
|
5457
5717
|
if (durableWrite)
|
|
5458
5718
|
durableTurnInFlight = true;
|
|
@@ -5512,7 +5772,13 @@ async function flushPending() {
|
|
|
5512
5772
|
log('Refused durable Claude submit: transcript terminal bridge is unavailable');
|
|
5513
5773
|
break;
|
|
5514
5774
|
}
|
|
5515
|
-
|
|
5775
|
+
if (lastInitConfig?.cliId === 'codex-app') {
|
|
5776
|
+
log(`Writing Codex App input to PTY (flush): `
|
|
5777
|
+
+ `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
|
|
5778
|
+
}
|
|
5779
|
+
else {
|
|
5780
|
+
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
5781
|
+
}
|
|
5516
5782
|
// Defense in depth: TmuxPipeBackend's send methods no longer throw on a
|
|
5517
5783
|
// dead pane (they fire onExit instead), but writeInput can still throw
|
|
5518
5784
|
// for other reasons (fs errors while resolving the JSONL, a future
|
|
@@ -5531,10 +5797,10 @@ async function flushPending() {
|
|
|
5531
5797
|
result = { submitted: true };
|
|
5532
5798
|
}
|
|
5533
5799
|
else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
|
|
5534
|
-
result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput);
|
|
5800
|
+
result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput, { turnId: item.turnId });
|
|
5535
5801
|
}
|
|
5536
5802
|
else {
|
|
5537
|
-
result = await cliAdapter.writeInput(backend, msg);
|
|
5803
|
+
result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId });
|
|
5538
5804
|
}
|
|
5539
5805
|
scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
|
|
5540
5806
|
}
|
|
@@ -5554,6 +5820,11 @@ async function flushPending() {
|
|
|
5554
5820
|
scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
|
|
5555
5821
|
break;
|
|
5556
5822
|
}
|
|
5823
|
+
if (lastInitConfig?.cliId === 'codex-app'
|
|
5824
|
+
&& item.turnId
|
|
5825
|
+
&& result?.submitted !== false) {
|
|
5826
|
+
rememberBounded(submittedCodexAppReplyTurnIds, item.turnId);
|
|
5827
|
+
}
|
|
5557
5828
|
// Persist any sessionId the adapter observed via authoritative sources
|
|
5558
5829
|
// (Claude's pid file, Codex's history). Done independently of submit
|
|
5559
5830
|
// outcome — the rotation is real even when the current Enter didn't
|
|
@@ -5613,7 +5884,7 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5613
5884
|
// old early-return silently dropped it after receiver had already persisted
|
|
5614
5885
|
// DISPATCHED.
|
|
5615
5886
|
if (cliRestartInProgress || !backend) {
|
|
5616
|
-
|
|
5887
|
+
freshnessInputQueue.enqueueNormal(next);
|
|
5617
5888
|
log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
|
|
5618
5889
|
return true;
|
|
5619
5890
|
}
|
|
@@ -5624,13 +5895,14 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5624
5895
|
isFlushing,
|
|
5625
5896
|
supportsTypeAhead,
|
|
5626
5897
|
awaitingFirstPrompt,
|
|
5898
|
+
holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
|
|
5627
5899
|
}) && cliAdapter.mergeQueuedInput === true;
|
|
5628
5900
|
const mergedQueued = shouldMergeQueued && mergeQueuedCliInput(pendingMessages, next);
|
|
5629
5901
|
if (mergedQueued) {
|
|
5630
5902
|
log(`Merged queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
|
|
5631
5903
|
}
|
|
5632
5904
|
else {
|
|
5633
|
-
|
|
5905
|
+
freshnessInputQueue.enqueueNormal(next);
|
|
5634
5906
|
}
|
|
5635
5907
|
// User-override semantics: a fresh Lark message while a TUI prompt is "active"
|
|
5636
5908
|
// takes precedence over the AI-detected prompt. The screen analyzer can be
|
|
@@ -5641,7 +5913,6 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5641
5913
|
if (tuiPromptBlocking) {
|
|
5642
5914
|
log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
|
|
5643
5915
|
tuiPromptBlocking = false;
|
|
5644
|
-
screenAnalyzer?.notifySelection('lark-input');
|
|
5645
5916
|
// Tear down the prompt card so the user doesn't see stale options.
|
|
5646
5917
|
send({
|
|
5647
5918
|
type: 'tui_prompt_resolved',
|
|
@@ -5661,7 +5932,11 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
5661
5932
|
// delivers queued messages instead. See input-gate.ts; this fixes dispatch's
|
|
5662
5933
|
// brief reaching Codex before its first idle and never landing.
|
|
5663
5934
|
if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
|
|
5664
|
-
isPromptReady,
|
|
5935
|
+
isPromptReady,
|
|
5936
|
+
isFlushing,
|
|
5937
|
+
supportsTypeAhead,
|
|
5938
|
+
awaitingFirstPrompt,
|
|
5939
|
+
holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
|
|
5665
5940
|
})) {
|
|
5666
5941
|
if (!mergedQueued)
|
|
5667
5942
|
log(`Writing to PTY: "${content.substring(0, 80)}"`);
|
|
@@ -5697,8 +5972,6 @@ function startScreenUpdates() {
|
|
|
5697
5972
|
if (awaitingFirstPrompt)
|
|
5698
5973
|
return;
|
|
5699
5974
|
let status = isPromptReady ? 'idle' : 'working';
|
|
5700
|
-
if (screenAnalyzer?.isAnalyzing)
|
|
5701
|
-
status = 'analyzing';
|
|
5702
5975
|
void (async () => {
|
|
5703
5976
|
let content = lastContent;
|
|
5704
5977
|
let changed = false;
|
|
@@ -5725,8 +5998,9 @@ function startScreenUpdates() {
|
|
|
5725
5998
|
const hash = pipeText.ansi;
|
|
5726
5999
|
changed = hash !== lastTextSnapshotHash;
|
|
5727
6000
|
lastTextSnapshotHash = hash;
|
|
5728
|
-
// Refresh the unfiltered cache that
|
|
5729
|
-
//
|
|
6001
|
+
// Refresh the unfiltered snapshot cache (lastAnalyzerSnapshot) that
|
|
6002
|
+
// usage-limit detection and the CoCo picker read from. Same tmux
|
|
6003
|
+
// call would otherwise need to fire twice per tick.
|
|
5730
6004
|
if (changed) {
|
|
5731
6005
|
const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
|
|
5732
6006
|
if (rawSnap)
|
|
@@ -6364,81 +6638,20 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6364
6638
|
// so a fork inherits the whole submit-confirm + bridge-fallback machinery.
|
|
6365
6639
|
let claudeDataDir = cliAdapter.claudeDataDir;
|
|
6366
6640
|
let effectiveReadyHookInstall = cliAdapter.hookInstall;
|
|
6367
|
-
//
|
|
6368
|
-
//
|
|
6369
|
-
//
|
|
6370
|
-
//
|
|
6371
|
-
//
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
!!process.env.SESSION_DATA_DIR;
|
|
6376
|
-
if (claudeDataDir && willFileSandbox) {
|
|
6377
|
-
const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir, {
|
|
6378
|
-
sourceWorkingDir: cfg.workingDir,
|
|
6379
|
-
dataDir: process.env.SESSION_DATA_DIR,
|
|
6380
|
-
});
|
|
6381
|
-
log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`);
|
|
6382
|
-
claudeDataDir = redirected;
|
|
6383
|
-
}
|
|
6384
|
-
// v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME
|
|
6385
|
-
// (`<BOTMUX_HOME>/bots/<appId>/{claude,codex}`) so each bot's transcripts/memory
|
|
6386
|
-
// land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex
|
|
6387
|
-
// (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so
|
|
6388
|
-
// every JSONL/bridge/resume path below already targets the per-bot dir. The
|
|
6389
|
-
// matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt
|
|
6390
|
-
// wrapper are applied at spawn time further down. This gate is the SINGLE
|
|
6391
|
-
// decision point: configured-but-unenforceable fail-closes HERE (never run a
|
|
6392
|
-
// session unisolated that asked for isolation).
|
|
6393
|
-
// UNIFIED file sandbox: read isolation engages with the same toggle as write
|
|
6394
|
-
// isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it
|
|
6395
|
-
// (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES
|
|
6396
|
-
// the bwrap sandbox (its masks go into the same bwrap plan), so it requires the
|
|
6397
|
-
// sandbox to be on — hence the trigger is the sandbox toggle there, never the bare
|
|
6398
|
-
// legacy flag (which would redirect the data dir but apply no masks → a hole).
|
|
6399
|
-
// riff runs in its own REMOTE sandbox with no local CLI process — every
|
|
6400
|
-
// local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read
|
|
6401
|
-
// isolation incl. the legacy fail-closed flag) is meaningless for it and
|
|
6402
|
-
// must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot
|
|
6403
|
-
// bricks the moment it switches to riff.
|
|
6404
|
-
const riffRemoteBackend = effectiveBackendType === 'riff';
|
|
6641
|
+
// ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ──
|
|
6642
|
+
// ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics
|
|
6643
|
+
// (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to
|
|
6644
|
+
// `sandbox` at daemon startup; honored here too for an unmigrated read-only
|
|
6645
|
+
// BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process —
|
|
6646
|
+
// local confinement is meaningless there and must be bypassed on ALL
|
|
6647
|
+
// platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
|
|
6648
|
+
const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
|
|
6405
6649
|
if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
|
|
6406
|
-
log('Sandbox
|
|
6407
|
-
}
|
|
6408
|
-
const
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
// FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it.
|
|
6412
|
-
const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend;
|
|
6413
|
-
const readIsolationGate = evaluateReadIsolationGate({
|
|
6414
|
-
configured: wantsFileSandbox || explicitLegacyReadIso,
|
|
6415
|
-
adapterSupports: cliAdapter.supportsReadIsolation === true,
|
|
6416
|
-
wrapperCliSet: !!cfg.wrapperCli,
|
|
6417
|
-
platform: process.platform,
|
|
6418
|
-
sessionDataDirSet: !!process.env.SESSION_DATA_DIR,
|
|
6419
|
-
});
|
|
6420
|
-
// Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle
|
|
6421
|
-
// read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny
|
|
6422
|
-
// (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get
|
|
6423
|
-
// the read masks. Never brick a sandboxed session just because read-iso is N/A.
|
|
6424
|
-
if (readIsolationGate.failClosedReason && explicitLegacyReadIso) {
|
|
6425
|
-
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`);
|
|
6426
|
-
}
|
|
6427
|
-
const willReadIsolate = readIsolationGate.enabled;
|
|
6428
|
-
// macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap
|
|
6429
|
-
// overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines
|
|
6430
|
-
// WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap
|
|
6431
|
-
// (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated"
|
|
6432
|
-
// model; if read isolation is ALSO on, its read-deny set layers into the SAME
|
|
6433
|
-
// profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be
|
|
6434
|
-
// established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
|
|
6435
|
-
// spawn site below, never a silent unconfined run.
|
|
6436
|
-
const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
|
|
6437
|
-
if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
|
|
6438
|
-
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
|
|
6439
|
-
}
|
|
6440
|
-
const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
|
|
6441
|
-
|| (process.platform === 'linux' && wantsFileSandbox));
|
|
6650
|
+
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
|
|
6651
|
+
}
|
|
6652
|
+
const sandboxRequested = !riffRemoteBackend
|
|
6653
|
+
&& (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
|
|
6654
|
+
const fullIsolationCoversCredentials = sandboxRequested;
|
|
6442
6655
|
let credentialMechanismAvailable = true;
|
|
6443
6656
|
let credentialMechanismExecutable;
|
|
6444
6657
|
if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
|
|
@@ -6471,16 +6684,11 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6471
6684
|
const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
|
|
6472
6685
|
const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
|
|
6473
6686
|
const appliedIsolationCapabilities = [];
|
|
6474
|
-
if (credentialBoundaryActive
|
|
6475
|
-
|| willReadIsolate
|
|
6476
|
-
|| (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6687
|
+
if (credentialBoundaryActive || sandboxRequested) {
|
|
6477
6688
|
appliedIsolationCapabilities.push('credential');
|
|
6478
6689
|
}
|
|
6479
|
-
if (
|
|
6480
|
-
appliedIsolationCapabilities.push('read');
|
|
6481
|
-
if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6482
|
-
appliedIsolationCapabilities.push('write');
|
|
6483
|
-
}
|
|
6690
|
+
if (sandboxRequested)
|
|
6691
|
+
appliedIsolationCapabilities.push('read', 'write');
|
|
6484
6692
|
currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
|
|
6485
6693
|
const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
|
|
6486
6694
|
?? join(defaultBotmuxHome, 'data');
|
|
@@ -6495,8 +6703,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6495
6703
|
// Preserve that recorded target for compatibility unless this incarnation
|
|
6496
6704
|
// requires an isolation/MCP boundary that only a Botmux-owned session can
|
|
6497
6705
|
// safely provide. Fresh tasks use distinct agents in one machine-wide host.
|
|
6498
|
-
reuseRecordedHerdrTarget: !
|
|
6499
|
-
&& !willWriteSandbox
|
|
6706
|
+
reuseRecordedHerdrTarget: !sandboxRequested
|
|
6500
6707
|
&& !hasMcpRuntimeEntries,
|
|
6501
6708
|
});
|
|
6502
6709
|
let selectedBackend = selectBackend();
|
|
@@ -6504,6 +6711,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6504
6711
|
isPipeMode = selectedBackend.isPipeMode;
|
|
6505
6712
|
isZellijMode = selectedBackend.isZellijMode;
|
|
6506
6713
|
backend = selectedBackend.backend;
|
|
6714
|
+
// BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep
|
|
6715
|
+
// their CLI data (transcripts/memory/auth) in their own BOT_HOME via
|
|
6716
|
+
// CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude|
|
|
6717
|
+
// ~/.codex are simply not exposed. Best-effort: a non-supporting adapter
|
|
6718
|
+
// keeps its REAL data dirs instead, which the policy exposes readWrite (the
|
|
6719
|
+
// sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume
|
|
6720
|
+
// path below already targets the right dir. wrapperCli strips spawn args, so
|
|
6721
|
+
// the redirect (and its env) can't be guaranteed there → not redirected.
|
|
6722
|
+
const willRedirectCliData = sandboxRequested
|
|
6723
|
+
&& cliAdapter.supportsReadIsolation === true
|
|
6724
|
+
&& !cfg.wrapperCli
|
|
6725
|
+
&& !!process.env.SESSION_DATA_DIR;
|
|
6507
6726
|
// Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
|
|
6508
6727
|
// backend instance (within this same worker) must not inject keys into the
|
|
6509
6728
|
// new one. The worker echoes this nonce in stuck_warning and re-checks it on
|
|
@@ -6527,7 +6746,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6527
6746
|
}
|
|
6528
6747
|
let isolationBotHome;
|
|
6529
6748
|
let isolatedCodexHome;
|
|
6530
|
-
if (
|
|
6749
|
+
if (willRedirectCliData) {
|
|
6531
6750
|
isolationBotHome = ownBotHome;
|
|
6532
6751
|
const isClaudeFam = !!claudeDataDir;
|
|
6533
6752
|
if (isClaudeFam)
|
|
@@ -6571,19 +6790,19 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6571
6790
|
// the full in-memory conversation. In that case the resume-vs-fresh question
|
|
6572
6791
|
// is moot: we must NOT run the pre-flight fallback (which would drop --resume
|
|
6573
6792
|
// and post a misleading "started a fresh clean session — context lost" card
|
|
6574
|
-
// on EVERY restart, e.g. for a sandboxed session whose transcript lives
|
|
6575
|
-
//
|
|
6793
|
+
// on EVERY restart, e.g. for a sandboxed session whose transcript lives at a
|
|
6794
|
+
// real host path the probe may not be able to stat). Computed here (not at
|
|
6576
6795
|
// the spawn site below) so the pre-flight can short-circuit on it.
|
|
6577
6796
|
let persistentSessionName = selectedBackend.persistentSessionName;
|
|
6578
6797
|
// [read-isolation] Before we decide to reattach a persistent pane: a pane can
|
|
6579
6798
|
// survive a daemon restart still running a CLI that may NOT be isolated (e.g.
|
|
6580
6799
|
// spawned before isolation was enabled, or by an old build). Isolation is only
|
|
6581
6800
|
// injectable at spawn time, so reattaching such a pane would silently run
|
|
6582
|
-
// unisolated. We stamp a
|
|
6583
|
-
//
|
|
6584
|
-
// so the probe below sees no pane and we cold-spawn fresh isolated. A
|
|
6585
|
-
//
|
|
6586
|
-
//
|
|
6801
|
+
// unisolated. We stamp a boot-id marker when we spawn an isolated pane; if this
|
|
6802
|
+
// isolated bot's existing pane is NOT stamped by THIS daemon lifetime, kill it
|
|
6803
|
+
// so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
|
|
6804
|
+
// this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
|
|
6805
|
+
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
6587
6806
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6588
6807
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6589
6808
|
const paneLive = persistentTarget
|
|
@@ -6648,6 +6867,27 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6648
6867
|
willReattachPersistent = selectedBackend.isReattach === true;
|
|
6649
6868
|
}
|
|
6650
6869
|
}
|
|
6870
|
+
const replacementExpectedFresh = codexRunnerFreshness === 'restarting_fresh';
|
|
6871
|
+
const freshness = decideCodexRunnerFreshness({
|
|
6872
|
+
cliId: cfg.cliId,
|
|
6873
|
+
adoptMode: cfg.adoptMode === true,
|
|
6874
|
+
persistentReattach: willReattachPersistent,
|
|
6875
|
+
replacementExpectedFresh,
|
|
6876
|
+
currentBuildId: cfg.runnerBuildId,
|
|
6877
|
+
persistedBuildId: cfg.persistedRunnerBuildId,
|
|
6878
|
+
});
|
|
6879
|
+
codexRunnerFreshness = freshness.state;
|
|
6880
|
+
persistCodexRunnerBuildOnReady = freshness.persistOnReady;
|
|
6881
|
+
log(`Codex runner freshness=${freshness.state} reason=${freshness.reason}`);
|
|
6882
|
+
if (freshness.reason === 'replacement_reattached' && activeRestartAttemptId) {
|
|
6883
|
+
send({
|
|
6884
|
+
type: 'restart_result',
|
|
6885
|
+
attemptId: activeRestartAttemptId,
|
|
6886
|
+
status: 'failed',
|
|
6887
|
+
category: 'spawn_failed',
|
|
6888
|
+
});
|
|
6889
|
+
activeRestartAttemptId = undefined;
|
|
6890
|
+
}
|
|
6651
6891
|
// The plugin set is stable only for the lifetime of one real CLI process.
|
|
6652
6892
|
// A warm worker reattach keeps the existing Gateway and catalog untouched;
|
|
6653
6893
|
// every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
|
|
@@ -6671,6 +6911,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6671
6911
|
// diagnostic instead of having the prompt typed into it.
|
|
6672
6912
|
bareShellLaunchBlocked = false;
|
|
6673
6913
|
bareShellChecked = false;
|
|
6914
|
+
bareShellCheckInProgress = false;
|
|
6674
6915
|
// ── Resume pre-flight check + two-tier fallback ──────────────────────────
|
|
6675
6916
|
// Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false
|
|
6676
6917
|
// → skip --resume, spawn FRESH.
|
|
@@ -6708,6 +6949,13 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6708
6949
|
log(`WARN Claude resume transcript sync failed: ${err.message}`);
|
|
6709
6950
|
}
|
|
6710
6951
|
}
|
|
6952
|
+
// Hermes stores sessions in a SQLite state.db (not cwd-scoped JSONL). Resolve
|
|
6953
|
+
// the same path the bridge uses — honoring per-bot env, the forced
|
|
6954
|
+
// BOTMUX_SESSION_ID, and the hermes-botmux-session wrapper profile — so the
|
|
6955
|
+
// adapter probe below queries the exact DB Hermes will `--resume` against.
|
|
6956
|
+
const hermesResumeStateDbPath = cfg.cliId === 'hermes'
|
|
6957
|
+
? resolveHermesStateDbPath({ ...process.env, ...sanitizePerBotEnv(cfg.env), BOTMUX_SESSION_ID: cfg.sessionId }, { botmuxSessionProfile: basename(cfg.cliPathOverride ?? '') === 'hermes-botmux-session' })
|
|
6958
|
+
: undefined;
|
|
6711
6959
|
const tier2ForceFresh = effectiveResume && consecutiveInWorkerRestarts >= 2;
|
|
6712
6960
|
let tier1ProbeFalse = false;
|
|
6713
6961
|
if (effectiveResume && !tier2ForceFresh && !willReattachPersistent) {
|
|
@@ -6716,6 +6964,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6716
6964
|
cliSessionId: effectiveCliSessionId,
|
|
6717
6965
|
workingDir: cfg.workingDir,
|
|
6718
6966
|
dataDir: claudeDataDir,
|
|
6967
|
+
stateDbPath: hermesResumeStateDbPath,
|
|
6719
6968
|
});
|
|
6720
6969
|
if (probe === false)
|
|
6721
6970
|
tier1ProbeFalse = true;
|
|
@@ -6835,81 +7084,39 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6835
7084
|
lastSpawnDeferInitialPrompt = deferInitialPrompt;
|
|
6836
7085
|
kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
|
|
6837
7086
|
kiroSessionIdCaptureBuffer = '';
|
|
6838
|
-
//
|
|
6839
|
-
//
|
|
6840
|
-
//
|
|
6841
|
-
//
|
|
6842
|
-
//
|
|
6843
|
-
|
|
6844
|
-
// pane spawned isolated (still the confined process).
|
|
6845
|
-
let readIsolationCtx;
|
|
6846
|
-
if (willReadIsolate) {
|
|
6847
|
-
const sessionDataDir = process.env.SESSION_DATA_DIR;
|
|
6848
|
-
readIsolationCtx = {
|
|
6849
|
-
homeDir: homedir(),
|
|
6850
|
-
botmuxHome: dirname(sessionDataDir),
|
|
6851
|
-
defaultBotmuxHome: join(homedir(), '.botmux'),
|
|
6852
|
-
sessionDataDir,
|
|
6853
|
-
currentAppId: cfg.larkAppId,
|
|
6854
|
-
currentSessionId: cfg.sessionId,
|
|
6855
|
-
extraDenyPaths: cfg.readDenyExtraPaths,
|
|
6856
|
-
};
|
|
6857
|
-
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
6858
|
-
? managedOriginCapabilityPath(sessionDataDir, cfg.sessionId)
|
|
6859
|
-
: null;
|
|
6860
|
-
// Replace any legacy child-planted symlink before Seatbelt canonicalizes
|
|
6861
|
-
// carve-outs. A failure is fatal: spawning with a missing/ambiguous
|
|
6862
|
-
// capability transport would either break all IPC or reopen an attacker-
|
|
6863
|
-
// selected realpath in the generated profile.
|
|
6864
|
-
if (readIsolationOriginCapabilityFile) {
|
|
6865
|
-
publishSandboxRelayCapability({ failClosed: true });
|
|
6866
|
-
}
|
|
6867
|
-
// Write this bot's OWN send-credential into its BOT_HOME (the same per-bot
|
|
6868
|
-
// private storage as its CLI data; siblings' BOT_HOMEs are whole-denied).
|
|
6869
|
-
// `botmux send` reads the secret from here instead of bots.json — so the
|
|
6870
|
-
// secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI
|
|
6871
|
-
// never needs to escape the sandbox.
|
|
7087
|
+
// Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME.
|
|
7088
|
+
// `botmux send` reads the secret from here instead of bots.json (which
|
|
7089
|
+
// deny-by-default never exposes) — the secret never travels via env/argv (no
|
|
7090
|
+
// cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox.
|
|
7091
|
+
// The worker itself runs on the host (NOT sandboxed) and keeps full access.
|
|
7092
|
+
if (sandboxRequested && process.env.SESSION_DATA_DIR) {
|
|
6872
7093
|
try {
|
|
6873
|
-
const credPath = sendCredFilePath(
|
|
7094
|
+
const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
|
|
6874
7095
|
mkdirSync(dirname(credPath), { recursive: true });
|
|
6875
7096
|
writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
|
|
6876
7097
|
}
|
|
6877
7098
|
catch (e) {
|
|
6878
|
-
log(`[
|
|
7099
|
+
log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
|
|
6879
7100
|
}
|
|
6880
7101
|
}
|
|
6881
|
-
//
|
|
6882
|
-
//
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
};
|
|
6896
|
-
const sandboxHome = canonicalSandboxPath(homedir());
|
|
6897
|
-
writeSandboxRules = buildWriteSandboxRules({
|
|
6898
|
-
homeDir: sandboxHome,
|
|
6899
|
-
botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
|
|
6900
|
-
defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
|
|
6901
|
-
sessionDataDir: canonicalSandboxPath(sessionDataDir),
|
|
6902
|
-
workingDir: cfg.workingDir,
|
|
6903
|
-
currentAppId: cfg.larkAppId,
|
|
6904
|
-
// TMPDIR on macOS resolves under /private/var/folders (already allowed), but
|
|
6905
|
-
// pass it explicitly so a customized TMPDIR is covered too.
|
|
6906
|
-
extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [],
|
|
6907
|
-
});
|
|
6908
|
-
}
|
|
7102
|
+
// In the file sandbox, canonicalize the workingDir handed to buildArgs. CLIs
|
|
7103
|
+
// that pass it as a chdir arg (codex/traex `-C`) fail otherwise on a symlinked
|
|
7104
|
+
// $HOME host: the lexical /home/u/... prefix doesn't exist inside the bwrap
|
|
7105
|
+
// root (only canonical /data00/... is bound), so the CLI's chdir/readlink
|
|
7106
|
+
// ENOENTs and it aborts with "No such file or directory (os error 2)". Off
|
|
7107
|
+
// sandbox this is a no-op (same dir); best-effort if unresolvable.
|
|
7108
|
+
const buildArgsWorkingDir = sandboxRequested
|
|
7109
|
+
? (() => { try {
|
|
7110
|
+
return realpathSync(cfg.workingDir);
|
|
7111
|
+
}
|
|
7112
|
+
catch {
|
|
7113
|
+
return cfg.workingDir;
|
|
7114
|
+
} })()
|
|
7115
|
+
: cfg.workingDir;
|
|
6909
7116
|
const args = cliAdapter.buildArgs({
|
|
6910
7117
|
sessionId: effectiveAdapterSessionId,
|
|
6911
7118
|
resume: effectiveResume,
|
|
6912
|
-
workingDir:
|
|
7119
|
+
workingDir: buildArgsWorkingDir,
|
|
6913
7120
|
resumeSessionId: effectiveCliSessionId,
|
|
6914
7121
|
initialPrompt: preparedInitialPrompt,
|
|
6915
7122
|
botName: cfg.botName,
|
|
@@ -6919,12 +7126,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6919
7126
|
model: ttadkGateway ? undefined : cfg.model,
|
|
6920
7127
|
disableCliBypass: cfg.disableCliBypass === true,
|
|
6921
7128
|
skillPluginDir: cfg.skillPluginDir,
|
|
6922
|
-
readIsolation:
|
|
6923
|
-
// Set (as a pair) by the init handler when hybrid RPC input is engaged;
|
|
6924
|
-
// makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
|
|
6925
|
-
// the normal paste-mode codex. Undefined for every other bot/CLI.
|
|
6926
|
-
remoteWsUrl,
|
|
6927
|
-
remoteThreadId,
|
|
7129
|
+
readIsolation: willRedirectCliData,
|
|
6928
7130
|
});
|
|
6929
7131
|
// Pi's deferred long-first-prompt command is implemented by a session-scoped
|
|
6930
7132
|
// extension. Keep its launch args across owned process restarts while the
|
|
@@ -7023,15 +7225,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7023
7225
|
delete childEnv.BOTMUX_CHAT_TYPE;
|
|
7024
7226
|
childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
|
|
7025
7227
|
childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
|
|
7026
|
-
//
|
|
7027
|
-
//
|
|
7028
|
-
//
|
|
7029
|
-
//
|
|
7030
|
-
//
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7228
|
+
// This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
|
|
7229
|
+
// send` renders the role-name footer without reading bots.json (deny-by-
|
|
7230
|
+
// default → EPERM → role footer would silently fall back to the default
|
|
7231
|
+
// [botmux] label). resolveBrandLabel honours this env first (gated on the
|
|
7232
|
+
// own appId). Only set the key when a brandLabel is configured (present-but-
|
|
7233
|
+
// empty '' = suppress is preserved; unset key → the CLI falls through). It is
|
|
7234
|
+
// a cosmetic markdown template, not a credential, so env-passing is safe.
|
|
7235
|
+
{
|
|
7236
|
+
const bl = resolveBrandLabel(cfg.larkAppId);
|
|
7237
|
+
if (typeof bl === 'string')
|
|
7238
|
+
childEnv.BOTMUX_BRAND_LABEL = bl;
|
|
7239
|
+
}
|
|
7035
7240
|
// NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
|
|
7036
7241
|
// written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
|
|
7037
7242
|
// via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
|
|
@@ -7075,10 +7280,27 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7075
7280
|
// already guarantee the pane starts clean, and the CLI's built-in default
|
|
7076
7281
|
// preserves stock semantics.
|
|
7077
7282
|
if (isolationBotHome) {
|
|
7283
|
+
// In the file sandbox, bwrap binds only CANONICAL paths (the fs-policy
|
|
7284
|
+
// realpaths every rule). On a symlinked-$HOME host (/home/u → /data00/home/u)
|
|
7285
|
+
// the lexical BOT_HOME (/home/u/.botmux/bots/…/claude) does NOT exist in the
|
|
7286
|
+
// fresh bwrap root, so the CLI can't open its own CLAUDE_CONFIG_DIR/CODEX_HOME
|
|
7287
|
+
// → settings.json (with ANTHROPIC_BASE_URL/token) is unreadable → it falls
|
|
7288
|
+
// back to the public endpoint and fails to connect. Canonicalize so the env
|
|
7289
|
+
// points at the same path bwrap bound. Best-effort: keep lexical if unresolved.
|
|
7290
|
+
const canonicalizeForSandbox = (p) => {
|
|
7291
|
+
if (!sandboxRequested)
|
|
7292
|
+
return p;
|
|
7293
|
+
try {
|
|
7294
|
+
return realpathSync(p);
|
|
7295
|
+
}
|
|
7296
|
+
catch {
|
|
7297
|
+
return p;
|
|
7298
|
+
}
|
|
7299
|
+
};
|
|
7078
7300
|
if (claudeDataDir)
|
|
7079
|
-
childEnv.CLAUDE_CONFIG_DIR = claudeDataDir; // = <BOT_HOME>/claude
|
|
7301
|
+
childEnv.CLAUDE_CONFIG_DIR = canonicalizeForSandbox(claudeDataDir); // = <BOT_HOME>/claude
|
|
7080
7302
|
else
|
|
7081
|
-
childEnv.CODEX_HOME = isolatedCodexHome;
|
|
7303
|
+
childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
|
|
7082
7304
|
}
|
|
7083
7305
|
// Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
|
|
7084
7306
|
// ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
|
|
@@ -7108,356 +7330,348 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7108
7330
|
// reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
|
|
7109
7331
|
const reproduceBaseBin = spawnBin;
|
|
7110
7332
|
const reproduceBaseArgs = [...spawnArgs];
|
|
7111
|
-
//
|
|
7112
|
-
//
|
|
7113
|
-
//
|
|
7114
|
-
//
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
//
|
|
7118
|
-
//
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7333
|
+
// ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ──
|
|
7334
|
+
// Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap
|
|
7335
|
+
// (linux). Cross-bot read isolation is inherent (siblings' data simply isn't
|
|
7336
|
+
// exposed) — no enumeration, no deny-list to keep in sync.
|
|
7337
|
+
if (sandboxRequested) {
|
|
7338
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
7339
|
+
// FAIL-SAFE (not fail-open): a requested sandbox that can't be established
|
|
7340
|
+
// must be a HARD ERROR, never a silent unconfined run.
|
|
7341
|
+
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
7342
|
+
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
7343
|
+
log(msg);
|
|
7344
|
+
throw new Error(msg);
|
|
7345
|
+
}
|
|
7346
|
+
if (!dataDir) {
|
|
7347
|
+
const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
|
|
7348
|
+
log(msg);
|
|
7349
|
+
throw new Error(msg);
|
|
7350
|
+
}
|
|
7351
|
+
// Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap
|
|
7352
|
+
// resolves mount sources) — realpath everything, or a symlinked prefix
|
|
7353
|
+
// (the /tmp→/private/tmp class) silently fail-opens.
|
|
7124
7354
|
const canonical = (p) => { try {
|
|
7125
7355
|
return realpathSync(p);
|
|
7126
7356
|
}
|
|
7127
7357
|
catch {
|
|
7128
7358
|
return p;
|
|
7129
7359
|
} };
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
denyPaths = buildV2DenyPaths(profileCtx).map(canonical);
|
|
7146
|
-
denyRegexes = buildV2DenyRegexes(profileCtx);
|
|
7147
|
-
const carve = buildV2CarveOuts(profileCtx);
|
|
7148
|
-
const capabilityCarvePath = managedOriginCapabilityPath(profileCtx.sessionDataDir, profileCtx.currentSessionId);
|
|
7149
|
-
allowPaths = [
|
|
7150
|
-
// Never realpath the capability leaf: another legacy sandbox can race
|
|
7151
|
-
// a symlink into that shared host directory while multiple persistent
|
|
7152
|
-
// sessions recover. Keeping the exact path may fail closed on a raced
|
|
7153
|
-
// symlink, but can never turn its target into a Seatbelt allow rule.
|
|
7154
|
-
...carve.allowPaths.map(path => path === capabilityCarvePath ? path : canonical(path)),
|
|
7155
|
-
...buildCliExecutableReadCarveOuts({
|
|
7156
|
-
homeDir: profileCtx.homeDir,
|
|
7157
|
-
cliId: cliAdapter.id,
|
|
7158
|
-
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
7159
|
-
}),
|
|
7160
|
-
// buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
|
|
7161
|
-
// only this session's private child directory for Pi's @file/extension.
|
|
7162
|
-
...piInitialPromptReadonlyRoots.map(canonical),
|
|
7163
|
-
];
|
|
7164
|
-
finalDenyPaths = carve.finalDenyPaths.map(canonical);
|
|
7165
|
-
traverseDirs = carve.traverseDirs.map(canonical);
|
|
7166
|
-
protectedWrites = buildReadIsolationProtectedWriteRules(profileCtx, {
|
|
7167
|
-
// Keep lexical roots as well as their canonical targets. Canonical
|
|
7168
|
-
// rules protect contents reached through a symlink; lexical literals
|
|
7169
|
-
// prevent replacing the symlink/root directory entry itself.
|
|
7170
|
-
dashboardRoots: [
|
|
7171
|
-
readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux'),
|
|
7172
|
-
readIsolationCtx.botmuxHome,
|
|
7173
|
-
],
|
|
7174
|
-
sessionDataDirs: [readIsolationCtx.sessionDataDir],
|
|
7175
|
-
});
|
|
7176
|
-
}
|
|
7177
|
-
if (credentialBoundaryActive) {
|
|
7178
|
-
const credentialRules = buildCredentialIsolationRules({
|
|
7179
|
-
homeDir: canonical(hostHomeDir),
|
|
7180
|
-
botmuxHome: canonical(configuredBotmuxHome),
|
|
7181
|
-
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
7182
|
-
});
|
|
7183
|
-
denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
|
|
7184
|
-
denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
|
|
7185
|
-
protectedWrites = {
|
|
7186
|
-
denyWritePaths: [...new Set([
|
|
7187
|
-
...(protectedWrites?.denyWritePaths ?? []),
|
|
7188
|
-
...credentialRules.denyWritePaths.map(canonical),
|
|
7189
|
-
])],
|
|
7190
|
-
denyWriteRegexes: [...new Set([
|
|
7191
|
-
...(protectedWrites?.denyWriteRegexes ?? []),
|
|
7192
|
-
...credentialRules.denyWriteRegexes,
|
|
7193
|
-
])],
|
|
7194
|
-
denyWriteLiterals: [...new Set([
|
|
7195
|
-
...(protectedWrites?.denyWriteLiterals ?? []),
|
|
7196
|
-
...credentialRules.denyWriteLiterals.map(canonical),
|
|
7197
|
-
defaultBotmuxHome,
|
|
7198
|
-
configuredBotmuxHome,
|
|
7199
|
-
])],
|
|
7200
|
-
};
|
|
7201
|
-
}
|
|
7202
|
-
if (mcpRuntimeManifest) {
|
|
7203
|
-
finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
7204
|
-
}
|
|
7205
|
-
// Every isolated macOS CLI is denied access to all same-UID Gateway socket
|
|
7206
|
-
// directories. A session with its own Gateway gets one narrow, later READ
|
|
7207
|
-
// carve-out for its token; writes stay denied so the CLI cannot unlink or
|
|
7208
|
-
// replace even its own worker-managed socket. Sessions without plugin MCP
|
|
7209
|
-
// state get no read carve-out at all.
|
|
7210
|
-
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
7211
|
-
const gatewaySocketRegex = sessionMcpGatewayPathRegex(gatewaySocketRoot);
|
|
7212
|
-
denyRegexes.push(gatewaySocketRegex);
|
|
7213
|
-
if (sessionMcpGatewayHost)
|
|
7214
|
-
allowPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7215
|
-
protectedWrites = {
|
|
7216
|
-
denyWritePaths: protectedWrites?.denyWritePaths ?? [],
|
|
7217
|
-
denyWriteRegexes: [...(protectedWrites?.denyWriteRegexes ?? []), gatewaySocketRegex],
|
|
7218
|
-
denyWriteLiterals: protectedWrites?.denyWriteLiterals ?? [],
|
|
7360
|
+
const sandboxHome = canonical(homedir());
|
|
7361
|
+
const expandTilde = (raw) => raw.replace(/^~(?=\/|$)/, sandboxHome);
|
|
7362
|
+
const keepExisting = (paths) => {
|
|
7363
|
+
const out = [];
|
|
7364
|
+
for (const raw of paths) {
|
|
7365
|
+
if (!raw || typeof raw !== 'string')
|
|
7366
|
+
continue;
|
|
7367
|
+
const p = expandTilde(raw);
|
|
7368
|
+
try {
|
|
7369
|
+
if (existsSync(p))
|
|
7370
|
+
out.push(canonical(p));
|
|
7371
|
+
}
|
|
7372
|
+
catch { /* */ }
|
|
7373
|
+
}
|
|
7374
|
+
return out;
|
|
7219
7375
|
};
|
|
7220
|
-
//
|
|
7221
|
-
//
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7376
|
+
// User three-tier lists: the new sandboxPaths field, or a pre-migration
|
|
7377
|
+
// session/config's legacy fields mapped through the SAME lossless mapping
|
|
7378
|
+
// the startup migration uses.
|
|
7379
|
+
const legacyMapped = migrateLegacySandboxFields({
|
|
7380
|
+
sandbox: cfg.sandbox,
|
|
7381
|
+
readIsolation: cfg.readIsolation,
|
|
7382
|
+
sandboxReadonlyPaths: cfg.sandboxReadonlyPaths,
|
|
7383
|
+
sandboxHidePaths: cfg.sandboxHidePaths,
|
|
7384
|
+
readDenyExtraPaths: cfg.readDenyExtraPaths,
|
|
7385
|
+
sandboxPaths: cfg.sandboxPaths,
|
|
7386
|
+
});
|
|
7387
|
+
const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths;
|
|
7388
|
+
const droppedUser = [];
|
|
7389
|
+
const resolveUser = (paths) => {
|
|
7390
|
+
const out = [];
|
|
7391
|
+
for (const raw of paths ?? []) {
|
|
7392
|
+
if (!raw || typeof raw !== 'string')
|
|
7393
|
+
continue;
|
|
7394
|
+
const p = expandTilde(raw);
|
|
7395
|
+
if (existsSync(p))
|
|
7396
|
+
out.push(canonical(p));
|
|
7397
|
+
else
|
|
7398
|
+
droppedUser.push(raw);
|
|
7228
7399
|
}
|
|
7400
|
+
return out;
|
|
7401
|
+
};
|
|
7402
|
+
// deny paths are NOT existence-filtered on EITHER platform: a deny must guard
|
|
7403
|
+
// a path that does not exist YET (else the agent creates it under an exposed
|
|
7404
|
+
// rw parent and the deny is bypassed — codex review finding). Seatbelt keeps
|
|
7405
|
+
// the rule literally; bwrap masks it with a tmpfs the mount creates (writes
|
|
7406
|
+
// land in the ephemeral tmpfs, the real path is never created).
|
|
7407
|
+
const resolveDeny = (paths) => (paths ?? []).filter((p) => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p)));
|
|
7408
|
+
const userPaths = {
|
|
7409
|
+
readWrite: resolveUser(userLists?.readWrite),
|
|
7410
|
+
readOnly: resolveUser(userLists?.readOnly),
|
|
7411
|
+
deny: resolveDeny(userLists?.deny),
|
|
7412
|
+
};
|
|
7413
|
+
if (droppedUser.length)
|
|
7414
|
+
log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`);
|
|
7415
|
+
// Every executable spawned INSIDE the sandbox must be readable: the CLI
|
|
7416
|
+
// binary's dir, the daemon's own node (fnm farms under /run land here),
|
|
7417
|
+
// adapter second-stage bins, plus the standalone-codex package tree.
|
|
7418
|
+
const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])]
|
|
7419
|
+
.filter((p) => typeof p === 'string' && !!p)
|
|
7420
|
+
.map(p => dirname(canonical(p)));
|
|
7421
|
+
const execCarve = buildCliExecutableReadCarveOuts({
|
|
7422
|
+
homeDir: sandboxHome,
|
|
7423
|
+
cliId: cliAdapter.id,
|
|
7424
|
+
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
7425
|
+
}).map(canonical);
|
|
7426
|
+
// Linux relay outbox (created by prepareDirectSandbox; in the policy so the
|
|
7427
|
+
// compiled plan binds it read-write).
|
|
7428
|
+
const outbox = process.platform === 'linux'
|
|
7429
|
+
? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox')
|
|
7229
7430
|
: undefined;
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7431
|
+
// Pre-create the outbox BEFORE buildFsPolicy so it survives the allow-rule
|
|
7432
|
+
// existence-filter below (a not-yet-existing readWrite path is dropped, and
|
|
7433
|
+
// bwrap can't bind a missing source → the sandboxed `botmux send` relay
|
|
7434
|
+
// would EPERM/ENOENT writing its <hash>.content into an unbound dir).
|
|
7435
|
+
// prepareDirectSandbox re-mkdirs it too; recursive make is idempotent.
|
|
7436
|
+
if (outbox) {
|
|
7437
|
+
try {
|
|
7438
|
+
mkdirSync(outbox, { recursive: true });
|
|
7439
|
+
}
|
|
7440
|
+
catch { /* best-effort; prepareDirectSandbox retries */ }
|
|
7441
|
+
}
|
|
7442
|
+
// The botmux install/checkout root (dir containing dist/ + node_modules).
|
|
7443
|
+
// This module compiles to <checkout>/dist/worker.js, so `../../` from here is
|
|
7444
|
+
// the checkout root. Exposed readOnly so `botmux` + claude hooks can exec
|
|
7445
|
+
// `node <checkout>/dist/cli.js`.
|
|
7446
|
+
const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7447
|
+
// Pre-create the OWN writable dirs/files the sandboxed CLI creates on
|
|
7448
|
+
// demand, so they EXIST at spawn and survive the existence-filter below
|
|
7449
|
+
// (bwrap can't bind a nonexistent source; a dropped rule → the CLI's
|
|
7450
|
+
// mkdir/write EPERMs).
|
|
7451
|
+
// - data/turn-sends/<sessionId>.jsonl: `botmux send` appends its dedup
|
|
7452
|
+
// marker. The policy grants ONLY this exact file (not the whole dir) so a
|
|
7453
|
+
// sandboxed CLI can't rewrite another session's marker — pre-create the
|
|
7454
|
+
// file itself (touch) so the single-file bind has a source.
|
|
7455
|
+
// - data/attachments/<self>: `botmux quoted`/downloadResources writes here
|
|
7456
|
+
// (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just
|
|
7457
|
+
// reverts to the pre-existing drop behaviour.
|
|
7247
7458
|
try {
|
|
7248
|
-
const
|
|
7249
|
-
mkdirSync(
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
}
|
|
7254
|
-
// Sandbox wraps the spawned binary in bwrap. Works for pty (PtyBackend runs
|
|
7255
|
-
// bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`);
|
|
7256
|
-
// env is carried via bwrap --setenv (see prepareSandbox), not the backend.
|
|
7257
|
-
// Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt
|
|
7258
|
-
// write-sandbox above (willWriteSandbox), so bwrap must not run here.
|
|
7259
|
-
//
|
|
7260
|
-
// riff bypass: there is NO local CLI process to wrap — execution happens in
|
|
7261
|
-
// riff's own remote sandbox, and the fail-safe "backend not sandboxable"
|
|
7262
|
-
// hard error below would otherwise brick every sandbox-enabled bot the
|
|
7263
|
-
// moment it switches to riff (the dashboard agent switch clears only
|
|
7264
|
-
// readIsolation, not `sandbox`). Bypassing is safe (nothing local runs);
|
|
7265
|
-
// log it so the state is visible.
|
|
7266
|
-
if (cfg.sandbox === true && effectiveBackendType === 'riff') {
|
|
7267
|
-
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed');
|
|
7268
|
-
}
|
|
7269
|
-
const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled());
|
|
7270
|
-
// Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and
|
|
7271
|
-
// fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the
|
|
7272
|
-
// FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) —
|
|
7273
|
-
// spawn-time-static, so a bot added after this spawn is covered on its next cold
|
|
7274
|
-
// start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation
|
|
7275
|
-
// is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux.
|
|
7276
|
-
let readIsoLinuxMasks;
|
|
7277
|
-
if (readIsolationCtx && process.platform === 'linux') {
|
|
7278
|
-
const canon = (p) => { try {
|
|
7279
|
-
return realpathSync(p);
|
|
7459
|
+
const tsDir = join(dataDir, 'turn-sends');
|
|
7460
|
+
mkdirSync(tsDir, { recursive: true });
|
|
7461
|
+
const tsFile = join(tsDir, `${cfg.sessionId}.jsonl`);
|
|
7462
|
+
if (!existsSync(tsFile))
|
|
7463
|
+
writeFileSync(tsFile, '');
|
|
7280
7464
|
}
|
|
7281
|
-
catch {
|
|
7282
|
-
return p;
|
|
7283
|
-
} };
|
|
7284
|
-
// Canonicalize the root prefixes FIRST so every derived mask path is symlink-safe
|
|
7285
|
-
// (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open).
|
|
7286
|
-
const ctxReal = {
|
|
7287
|
-
...readIsolationCtx,
|
|
7288
|
-
homeDir: canon(readIsolationCtx.homeDir),
|
|
7289
|
-
botmuxHome: canon(readIsolationCtx.botmuxHome),
|
|
7290
|
-
defaultBotmuxHome: canon(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
|
|
7291
|
-
sessionDataDir: canon(readIsolationCtx.sessionDataDir),
|
|
7292
|
-
};
|
|
7293
|
-
const ids = new Set();
|
|
7294
|
-
const scan = (dir, pick) => {
|
|
7295
|
-
try {
|
|
7296
|
-
for (const name of readdirSync(dir)) {
|
|
7297
|
-
const id = pick(name);
|
|
7298
|
-
if (id)
|
|
7299
|
-
ids.add(id);
|
|
7300
|
-
}
|
|
7301
|
-
}
|
|
7302
|
-
catch { /* dir absent → no siblings there */ }
|
|
7303
|
-
};
|
|
7304
|
-
scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs
|
|
7305
|
-
scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs
|
|
7306
|
-
scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; });
|
|
7307
|
-
ids.delete(cfg.larkAppId); // never mask own
|
|
7308
|
-
let sidecars = [];
|
|
7465
|
+
catch { /* */ }
|
|
7309
7466
|
try {
|
|
7310
|
-
|
|
7467
|
+
mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
|
|
7311
7468
|
}
|
|
7312
7469
|
catch { /* */ }
|
|
7313
|
-
|
|
7314
|
-
//
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
// precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a
|
|
7333
|
-
// HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED
|
|
7334
|
-
// with full host write access and no log, the exact opposite of the
|
|
7335
|
-
// oncall-untrusted-agent invariant. In normal operation worker-pool sets
|
|
7336
|
-
// SESSION_DATA_DIR and the backend is pty/tmux, so this never trips.
|
|
7337
|
-
const dataDir = process.env.SESSION_DATA_DIR;
|
|
7338
|
-
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
7339
|
-
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
7340
|
-
log(msg);
|
|
7341
|
-
throw new Error(msg);
|
|
7470
|
+
// (Schedules moved into each bot's BOT_HOME — the whole dir is already
|
|
7471
|
+
// bound readWrite for the owner, so no per-file pre-create is needed.)
|
|
7472
|
+
const mandatoryDenyPaths = [];
|
|
7473
|
+
const mandatoryDenyRegexes = [];
|
|
7474
|
+
const mandatoryReadOnlyPaths = [];
|
|
7475
|
+
// Linux: the per-session sandbox tree (`sandboxes/<sid>`) holds the deny-mask
|
|
7476
|
+
// cleanup manifest + the mode-000 empty ro-bind SOURCES. If SESSION_DATA_DIR
|
|
7477
|
+
// is configured INSIDE the working dir (a custom data dir under a RW-bound
|
|
7478
|
+
// project), the project's readWrite rule would otherwise cover the whole
|
|
7479
|
+
// tree and let the sandboxed CLI tamper with the manifest (→ trick teardown
|
|
7480
|
+
// into deleting arbitrary host paths) or the empty sources. Deny the tree
|
|
7481
|
+
// as a MANDATORY rule (user paths can't override); the outbox is re-granted
|
|
7482
|
+
// readWrite via ctx.outbox as a deeper carve-out (the compiler masks the
|
|
7483
|
+
// tree with a tmpfs that hosts the nested outbox bind, then remounts it RO —
|
|
7484
|
+
// manifest/empties stay unreadable AND unwritable in-sandbox). bwrap reads
|
|
7485
|
+
// ro-bind SOURCES from the host FS, so the empty masks still work even though
|
|
7486
|
+
// the child can't see them.
|
|
7487
|
+
if (process.platform === 'linux') {
|
|
7488
|
+
mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
|
|
7342
7489
|
}
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7490
|
+
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
7491
|
+
? managedOriginCapabilityPath(dataDir, cfg.sessionId)
|
|
7492
|
+
: null;
|
|
7493
|
+
// The macOS child reads the per-session rotating capability directly.
|
|
7494
|
+
// Materialize it before the policy's existence filter, and make the exact
|
|
7495
|
+
// file a mandatory read-only carve-out that user rules cannot shadow.
|
|
7496
|
+
if (readIsolationOriginCapabilityFile) {
|
|
7497
|
+
publishSandboxRelayCapability({ failClosed: true });
|
|
7498
|
+
mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
|
|
7347
7499
|
}
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
catch { /* */ }
|
|
7364
|
-
}
|
|
7365
|
-
if (sandboxCleanup) {
|
|
7366
|
-
try {
|
|
7367
|
-
sandboxCleanup();
|
|
7500
|
+
if (credentialBoundaryActive) {
|
|
7501
|
+
const credentialRules = buildCredentialIsolationRules({
|
|
7502
|
+
homeDir: sandboxHome,
|
|
7503
|
+
botmuxHome: canonical(configuredBotmuxHome),
|
|
7504
|
+
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
7505
|
+
});
|
|
7506
|
+
mandatoryDenyPaths.push(...credentialRules.denyPaths.map(canonical));
|
|
7507
|
+
mandatoryDenyRegexes.push(...credentialRules.denyRegexes);
|
|
7508
|
+
// Materialize every existing atomic sidecar as a concrete Linux mask.
|
|
7509
|
+
// Seatbelt additionally keeps the regexes above for files created later.
|
|
7510
|
+
for (const root of [...new Set([defaultBotmuxHome, configuredBotmuxHome])]) {
|
|
7511
|
+
try {
|
|
7512
|
+
for (const name of readdirSync(root)) {
|
|
7513
|
+
if (isCredentialIsolationReservedBasename(name)) {
|
|
7514
|
+
mandatoryDenyPaths.push(canonical(join(root, name)));
|
|
7368
7515
|
}
|
|
7369
|
-
catch { /* */ }
|
|
7370
7516
|
}
|
|
7371
|
-
sandboxCleanup = att.cleanup;
|
|
7372
|
-
sandboxRelayOutbox = att.outbox;
|
|
7373
|
-
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7374
|
-
publishSandboxRelayCapability();
|
|
7375
|
-
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`);
|
|
7376
7517
|
}
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7518
|
+
catch { /* absent authority root */ }
|
|
7519
|
+
}
|
|
7520
|
+
}
|
|
7521
|
+
if (mcpRuntimeManifest) {
|
|
7522
|
+
mandatoryDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
7523
|
+
}
|
|
7524
|
+
if (process.platform === 'darwin') {
|
|
7525
|
+
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
7526
|
+
mandatoryDenyRegexes.push(sessionMcpGatewayPathRegex(gatewaySocketRoot));
|
|
7527
|
+
if (sessionMcpGatewayHost) {
|
|
7528
|
+
mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7529
|
+
}
|
|
7530
|
+
}
|
|
7531
|
+
const policy = buildFsPolicy({
|
|
7532
|
+
platform: process.platform,
|
|
7533
|
+
homeDir: sandboxHome,
|
|
7534
|
+
botmuxHome: canonical(dirname(dataDir)),
|
|
7535
|
+
sessionDataDir: canonical(dataDir),
|
|
7536
|
+
workingDir: canonical(cfg.workingDir),
|
|
7537
|
+
currentAppId: cfg.larkAppId,
|
|
7538
|
+
sessionId: cfg.sessionId,
|
|
7539
|
+
botHome: canonical(ownBotHome),
|
|
7540
|
+
redirectedCliData: willRedirectCliData,
|
|
7541
|
+
cliDataPaths: willRedirectCliData ? undefined : keepExisting([
|
|
7542
|
+
cliAdapter.claudeDataDir,
|
|
7543
|
+
claudeDataDir ? `${sandboxHome}/.claude.json` : undefined,
|
|
7544
|
+
claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined,
|
|
7545
|
+
claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined,
|
|
7546
|
+
claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined,
|
|
7547
|
+
]),
|
|
7548
|
+
authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]),
|
|
7549
|
+
execPaths: keepExisting([...execDirs, ...execCarve]),
|
|
7550
|
+
readonlyRoots: keepExisting([
|
|
7551
|
+
...(cfg.skillReadonlyRoots ?? []),
|
|
7552
|
+
...piInitialPromptReadonlyRoots,
|
|
7553
|
+
]),
|
|
7554
|
+
botmuxInstallRoot,
|
|
7555
|
+
outbox,
|
|
7556
|
+
extraWritePaths: keepExisting([process.env.TMPDIR]),
|
|
7557
|
+
userPaths,
|
|
7558
|
+
mandatoryDenyPaths,
|
|
7559
|
+
mandatoryDenyRegexes,
|
|
7560
|
+
mandatoryReadOnlyPaths,
|
|
7561
|
+
net: cfg.sandboxNetwork !== false,
|
|
7562
|
+
// Claude Code saves ~/.claude.json atomically via a PID/random-suffixed
|
|
7563
|
+
// sibling — only relevant when the data dir is NOT redirected to BOT_HOME.
|
|
7564
|
+
writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
|
|
7565
|
+
? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
|
|
7566
|
+
: [],
|
|
7567
|
+
});
|
|
7568
|
+
// Existence-filter only the ALLOW rules (baseline entries are candidates,
|
|
7569
|
+
// not guarantees): a non-existent allow path has nothing to expose, and
|
|
7570
|
+
// bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
|
|
7571
|
+
// they must guard a path created later in the session (Seatbelt keeps the
|
|
7572
|
+
// literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates).
|
|
7573
|
+
policy.rules = policy.rules.filter(r => {
|
|
7574
|
+
if (r.access === 'deny')
|
|
7575
|
+
return true;
|
|
7576
|
+
try {
|
|
7577
|
+
return existsSync(r.path);
|
|
7578
|
+
}
|
|
7579
|
+
catch {
|
|
7580
|
+
return false;
|
|
7581
|
+
}
|
|
7582
|
+
});
|
|
7583
|
+
if (process.platform === 'darwin') {
|
|
7584
|
+
if (!locateOnPath('sandbox-exec')) {
|
|
7585
|
+
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
|
|
7586
|
+
}
|
|
7587
|
+
const profileDir = join(dataDir, 'read-isolation');
|
|
7588
|
+
mkdirSync(profileDir, { recursive: true });
|
|
7589
|
+
const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
|
|
7590
|
+
writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 });
|
|
7591
|
+
seatbeltProfilePath = profilePath;
|
|
7592
|
+
spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
|
|
7593
|
+
spawnBin = 'sandbox-exec';
|
|
7594
|
+
log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`);
|
|
7595
|
+
}
|
|
7596
|
+
else if (willReattachPersistent) {
|
|
7597
|
+
// Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores
|
|
7598
|
+
// bin/args and just re-attaches. Only re-wire the outbox watcher (so the
|
|
7599
|
+
// live CLI's `botmux send` keeps being serviced) + the cleanup ref. The
|
|
7600
|
+
// direct model has NO mounts — cleanup is a plain rm at close/exit.
|
|
7601
|
+
const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
|
|
7602
|
+
if (att) {
|
|
7603
|
+
if (sandboxStopWatcher) {
|
|
7604
|
+
try {
|
|
7605
|
+
sandboxStopWatcher();
|
|
7606
|
+
}
|
|
7607
|
+
catch { /* */ }
|
|
7382
7608
|
}
|
|
7609
|
+
sandboxCleanup = att.cleanup;
|
|
7610
|
+
sandboxRelayOutbox = att.outbox;
|
|
7611
|
+
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7612
|
+
publishSandboxRelayCapability();
|
|
7613
|
+
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`);
|
|
7383
7614
|
}
|
|
7384
7615
|
else {
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
7413
|
-
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7414
|
-
userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [],
|
|
7415
|
-
net: cfg.sandboxNetwork !== false,
|
|
7416
|
-
});
|
|
7417
|
-
if (sbx) {
|
|
7418
|
-
spawnBin = sbx.bin;
|
|
7419
|
-
spawnArgs = sbx.args;
|
|
7420
|
-
// In the overlay model the child still chdirs to projectMount (via bwrap
|
|
7421
|
-
// --chdir), so spawnCwd stays the real workingDir; the overlay merged dir
|
|
7422
|
-
// is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd.
|
|
7423
|
-
Object.assign(childEnv, sbx.env);
|
|
7424
|
-
if (sandboxStopWatcher) {
|
|
7425
|
-
try {
|
|
7426
|
-
sandboxStopWatcher();
|
|
7427
|
-
}
|
|
7428
|
-
catch { /* */ }
|
|
7429
|
-
}
|
|
7430
|
-
if (sandboxCleanup) {
|
|
7431
|
-
try {
|
|
7432
|
-
sandboxCleanup();
|
|
7433
|
-
}
|
|
7434
|
-
catch { /* */ }
|
|
7435
|
-
}
|
|
7436
|
-
sandboxCleanup = sbx.cleanup;
|
|
7437
|
-
sandboxRelayOutbox = sbx.outbox;
|
|
7438
|
-
// session-id is FORCED here so a relayed send can't target another session.
|
|
7439
|
-
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7440
|
-
publishSandboxRelayCapability();
|
|
7441
|
-
log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`);
|
|
7616
|
+
log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`);
|
|
7617
|
+
}
|
|
7618
|
+
}
|
|
7619
|
+
else {
|
|
7620
|
+
const sbx = prepareDirectSandbox({
|
|
7621
|
+
sessionId: cfg.sessionId,
|
|
7622
|
+
dataDir,
|
|
7623
|
+
policy,
|
|
7624
|
+
chdir: canonical(cfg.workingDir),
|
|
7625
|
+
home: sandboxHome,
|
|
7626
|
+
cliBin: cliAdapter.resolvedBin,
|
|
7627
|
+
cliArgs: args,
|
|
7628
|
+
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7629
|
+
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
7630
|
+
});
|
|
7631
|
+
if (!sbx) {
|
|
7632
|
+
// FAIL-SAFE: never silently run unsandboxed.
|
|
7633
|
+
const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn';
|
|
7634
|
+
log(msg);
|
|
7635
|
+
throw new Error(msg);
|
|
7636
|
+
}
|
|
7637
|
+
spawnBin = sbx.bin;
|
|
7638
|
+
spawnArgs = sbx.args;
|
|
7639
|
+
Object.assign(childEnv, sbx.env);
|
|
7640
|
+
if (sandboxStopWatcher) {
|
|
7641
|
+
try {
|
|
7642
|
+
sandboxStopWatcher();
|
|
7442
7643
|
}
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
throw new Error('sandbox requested but could not be established');
|
|
7644
|
+
catch { /* */ }
|
|
7645
|
+
}
|
|
7646
|
+
if (sandboxCleanup) {
|
|
7647
|
+
try {
|
|
7648
|
+
sandboxCleanup();
|
|
7449
7649
|
}
|
|
7650
|
+
catch { /* */ }
|
|
7450
7651
|
}
|
|
7652
|
+
sandboxCleanup = sbx.cleanup;
|
|
7653
|
+
sandboxRelayOutbox = sbx.outbox;
|
|
7654
|
+
// session-id is FORCED here so a relayed send can't target another session.
|
|
7655
|
+
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7656
|
+
publishSandboxRelayCapability();
|
|
7657
|
+
log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
|
|
7451
7658
|
}
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7659
|
+
}
|
|
7660
|
+
// Fresh sandboxed spawn on a persistent backend: stamp the pane with this
|
|
7661
|
+
// daemon's boot id so a later reattach can be trusted (see the stale-pane
|
|
7662
|
+
// guard above). pty needs no marker (never reattached).
|
|
7663
|
+
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
|
|
7664
|
+
try {
|
|
7665
|
+
const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
|
|
7666
|
+
mkdirSync(markerDir, { recursive: true });
|
|
7667
|
+
writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
|
|
7455
7668
|
}
|
|
7669
|
+
catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
|
|
7456
7670
|
}
|
|
7457
7671
|
// 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
|
|
7458
7672
|
// bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
|
|
7459
7673
|
// --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加
|
|
7460
|
-
//
|
|
7674
|
+
// 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
|
|
7461
7675
|
// CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch
|
|
7462
7676
|
// below. Strip any value inherited from the daemon's own env first so a
|
|
7463
7677
|
// daemon launched under `cjadk feishu` (which exports it) can't leak it via
|
|
@@ -7466,8 +7680,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7466
7680
|
// keeps the behaviour intentional rather than ambient. (Codex review note.)
|
|
7467
7681
|
delete childEnv.CJADK_INTERACTIVE;
|
|
7468
7682
|
if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
|
|
7469
|
-
if (
|
|
7470
|
-
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with
|
|
7683
|
+
if (sandboxRequested) {
|
|
7684
|
+
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
|
|
7471
7685
|
}
|
|
7472
7686
|
else {
|
|
7473
7687
|
const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
|
|
@@ -7660,7 +7874,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7660
7874
|
baseBin: reproduceBaseBin,
|
|
7661
7875
|
baseArgs: reproduceBaseArgs,
|
|
7662
7876
|
wrapperCli: cfg.wrapperCli,
|
|
7663
|
-
sandboxOn,
|
|
7877
|
+
sandboxOn: sandboxRequested,
|
|
7664
7878
|
binResolver: (b) => locateOnPath(b) ?? b,
|
|
7665
7879
|
ttadkModel: cfg.model,
|
|
7666
7880
|
});
|
|
@@ -7726,7 +7940,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7726
7940
|
// MARKER inference is unaffected (the launcher-pid marker is still a valid
|
|
7727
7941
|
// ancestor of an in-CLI `botmux send`, and the env fallback covers it too).
|
|
7728
7942
|
const startWrapperRealPidResolve = (launcherPid) => {
|
|
7729
|
-
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() ||
|
|
7943
|
+
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir)
|
|
7730
7944
|
return;
|
|
7731
7945
|
const targetCliId = cfg.cliId;
|
|
7732
7946
|
scheduleWrapperRealCliPid(launcherPid, {
|
|
@@ -7948,7 +8162,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7948
8162
|
const readyHookAvailable = effectiveReadyHookInstall
|
|
7949
8163
|
? hasInstalledSessionReadyHook(effectiveReadyHookInstall)
|
|
7950
8164
|
: true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook.
|
|
7951
|
-
const isolatedReadyTransportRequired =
|
|
8165
|
+
const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
|
|
7952
8166
|
const readyPortAvailable = !isolatedReadyTransportRequired
|
|
7953
8167
|
|| parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
|
|
7954
8168
|
const readyCapabilityAvailable = !isolatedReadyTransportRequired
|
|
@@ -8045,6 +8259,16 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8045
8259
|
// prompt-ready — without this hook a follow-up arriving mid-task would sit
|
|
8046
8260
|
// in pendingMessages forever once the task finishes.
|
|
8047
8261
|
backend.onTaskDone?.(() => {
|
|
8262
|
+
// Generation fence (matches the onAgentStatus above and the onExit below):
|
|
8263
|
+
// a stale RiffBackend's `fetchAndEmitOutput(...).finally(taskDoneCb)` can
|
|
8264
|
+
// resolve AFTER destroySession()/kill() during a restart (neither clears
|
|
8265
|
+
// taskDoneCb nor awaits the in-flight fetch). If that late callback reached
|
|
8266
|
+
// markPromptReady() while a replacement is spawning, it would ride through
|
|
8267
|
+
// the global restart gate and emit a premature `restart_result: succeeded`,
|
|
8268
|
+
// swallowing the replacement's true terminal outcome. Only the current
|
|
8269
|
+
// backend generation may re-arm prompt-ready.
|
|
8270
|
+
if (backend !== observedBackend)
|
|
8271
|
+
return;
|
|
8048
8272
|
log(`${cliName()} task finished — re-arming prompt-ready for queued follow-ups`);
|
|
8049
8273
|
markPromptReady();
|
|
8050
8274
|
});
|
|
@@ -8083,22 +8307,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8083
8307
|
// before exiting while fs.watch/the 1s poller is still queued. Drain it
|
|
8084
8308
|
// synchronously before claiming `cli_exit`; otherwise ambiguous wins the
|
|
8085
8309
|
// deduper and needlessly replays a turn that actually completed.
|
|
8086
|
-
|
|
8087
|
-
try {
|
|
8088
|
-
bridgeDrainAndMaybeEmit();
|
|
8089
|
-
}
|
|
8090
|
-
catch (err) {
|
|
8091
|
-
log(`Bridge exit drain failed: ${err.message}`);
|
|
8092
|
-
}
|
|
8093
|
-
}
|
|
8094
|
-
if (codexBridgeFallbackActive()) {
|
|
8095
|
-
try {
|
|
8096
|
-
codexBridgeDrainAndMaybeEmit({ signalIdle: false });
|
|
8097
|
-
}
|
|
8098
|
-
catch (err) {
|
|
8099
|
-
log(`Codex bridge exit drain failed: ${err.message}`);
|
|
8100
|
-
}
|
|
8101
|
-
}
|
|
8310
|
+
drainReliableTerminalBeforeInterrupt();
|
|
8102
8311
|
// Race-safe with transcript final / submit-failure: the worker-local
|
|
8103
8312
|
// terminal deduper lets exactly one status win for this attempt.
|
|
8104
8313
|
emitTurnTerminal(exitedTurnId, 'ambiguous', 'cli_exit', exitedDispatchAttempt);
|
|
@@ -8140,6 +8349,17 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
8140
8349
|
isPromptReady = false;
|
|
8141
8350
|
currentBotmuxTurnId = undefined;
|
|
8142
8351
|
currentBotmuxDispatchAttempt = undefined;
|
|
8352
|
+
if (!intentionalRestart && activeRestartAttemptId) {
|
|
8353
|
+
send({
|
|
8354
|
+
type: 'restart_result',
|
|
8355
|
+
attemptId: activeRestartAttemptId,
|
|
8356
|
+
status: 'failed',
|
|
8357
|
+
category: 'runner_exited',
|
|
8358
|
+
});
|
|
8359
|
+
activeRestartAttemptId = undefined;
|
|
8360
|
+
}
|
|
8361
|
+
if (!intentionalRestart)
|
|
8362
|
+
freshnessInputQueue.onReplacementFailed();
|
|
8143
8363
|
if (intentionalRestart) {
|
|
8144
8364
|
log('Suppressed claude_exit for intentional in-worker restart');
|
|
8145
8365
|
}
|
|
@@ -8242,8 +8462,8 @@ function killCli(opts = {}) {
|
|
|
8242
8462
|
promptReadyDetectedDuringSettle = false;
|
|
8243
8463
|
readyPatternSeenDuringHold = false;
|
|
8244
8464
|
awaitingPostSessionStartPromptEvidence = false;
|
|
8245
|
-
stopScreenAnalyzer();
|
|
8246
8465
|
stopStuckDetector();
|
|
8466
|
+
tuiPromptBlocking = false;
|
|
8247
8467
|
stopScreenUpdates();
|
|
8248
8468
|
backend?.kill();
|
|
8249
8469
|
backend = null;
|
|
@@ -8261,10 +8481,10 @@ function killCli(opts = {}) {
|
|
|
8261
8481
|
cliPidMarker = null;
|
|
8262
8482
|
}
|
|
8263
8483
|
completeManagedTurnOriginRevocation(sandboxRelayCapability, currentBotmuxTurnId, currentBotmuxDispatchAttempt);
|
|
8264
|
-
// Stop the sandbox outbox watcher, then
|
|
8265
|
-
// per-session sandbox tree. In the
|
|
8266
|
-
//
|
|
8267
|
-
//
|
|
8484
|
+
// Stop the sandbox outbox watcher, then reclaim the deny-mask mountpoints +
|
|
8485
|
+
// remove the per-session sandbox tree. In the fs-policy model the CLI writes
|
|
8486
|
+
// the project DIRECTLY at real host paths (no upper changeset to land), so by
|
|
8487
|
+
// close time everything worth keeping is already on disk.
|
|
8268
8488
|
if (sandboxStopWatcher) {
|
|
8269
8489
|
try {
|
|
8270
8490
|
sandboxStopWatcher();
|
|
@@ -8277,6 +8497,9 @@ function killCli(opts = {}) {
|
|
|
8277
8497
|
currentBotmuxTurnId = undefined;
|
|
8278
8498
|
currentBotmuxDispatchAttempt = undefined;
|
|
8279
8499
|
currentVcMeetingImTurnOrigin = undefined;
|
|
8500
|
+
submittedCodexAppReplyTurnIds.clear();
|
|
8501
|
+
pendingCodexAppSteerAckIds.clear();
|
|
8502
|
+
acknowledgedCodexAppSteers.clear();
|
|
8280
8503
|
if (sandboxCleanup) {
|
|
8281
8504
|
try {
|
|
8282
8505
|
sandboxCleanup();
|
|
@@ -8316,6 +8539,7 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
8316
8539
|
// of firing idle/task-done callbacks. Inputs accepted in that interval must
|
|
8317
8540
|
// remain queued until a replacement backend has been installed.
|
|
8318
8541
|
cliRestartInProgress = true;
|
|
8542
|
+
replacementSpawnInProgress = false;
|
|
8319
8543
|
rawInputRestartGate = true;
|
|
8320
8544
|
// The Node worker stays alive through this restart, so the daemon will see
|
|
8321
8545
|
// neither claude_exit nor worker-exit. Explicitly revoke the old turn's
|
|
@@ -8325,9 +8549,13 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
8325
8549
|
revokeManagedTurnOriginForRestart();
|
|
8326
8550
|
log(`Restart requested (${reason})`);
|
|
8327
8551
|
// Tier-2 guard: 2nd consecutive in-worker restart forces FRESH. Tier-1
|
|
8328
|
-
// adapter probing is still re-run on every spawn.
|
|
8329
|
-
|
|
8330
|
-
|
|
8552
|
+
// adapter probing is still re-run on every spawn. skipRestartBudget(角色
|
|
8553
|
+
// 切换的 cwd-move respawn)不计入:那是用户主动迁移不是崩溃恢复,计进去
|
|
8554
|
+
// 会让「切角色 + 一次无关重启」无故触发强制 FRESH 丢上下文。
|
|
8555
|
+
if (!opts.skipRestartBudget) {
|
|
8556
|
+
consecutiveInWorkerRestarts++;
|
|
8557
|
+
log(`Restart count: ${consecutiveInWorkerRestarts} (>=2 forces FRESH)`);
|
|
8558
|
+
}
|
|
8331
8559
|
const restart = async () => {
|
|
8332
8560
|
try {
|
|
8333
8561
|
tmuxRestartTimer = null;
|
|
@@ -8357,12 +8585,13 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
8357
8585
|
killCli({ preservePending: opts.preservePending });
|
|
8358
8586
|
awaitingFirstPrompt = true;
|
|
8359
8587
|
setTimeout(async () => {
|
|
8588
|
+
let spawnedWorkingDir;
|
|
8360
8589
|
if (lastInitConfig) {
|
|
8361
8590
|
startScreenUpdates();
|
|
8362
|
-
startScreenAnalyzer();
|
|
8363
8591
|
startStuckDetector();
|
|
8364
8592
|
try {
|
|
8365
8593
|
const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
|
|
8594
|
+
spawnedWorkingDir = restartCfg.workingDir;
|
|
8366
8595
|
// Re-engage RPC so the new --remote pane binds to the CURRENT app-server
|
|
8367
8596
|
// (a fresh port), not the dead prior one. engageCodexRpc only sets
|
|
8368
8597
|
// remote* on success, else spawnCli falls back to paste.
|
|
@@ -8373,27 +8602,64 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
8373
8602
|
rpcPluginGenerationPrepared = true;
|
|
8374
8603
|
await engageCodexRpc(restartCfg);
|
|
8375
8604
|
}
|
|
8605
|
+
replacementSpawnInProgress = true;
|
|
8376
8606
|
await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
|
|
8377
8607
|
await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
|
|
8608
|
+
replacementSpawnInProgress = false;
|
|
8378
8609
|
if (codexRpcEngine)
|
|
8379
8610
|
armRpcStartupDialogDismiss();
|
|
8380
8611
|
}
|
|
8381
8612
|
catch (err) {
|
|
8613
|
+
replacementSpawnInProgress = false;
|
|
8382
8614
|
cliRestartInProgress = false;
|
|
8383
8615
|
await sendFatalWorkerErrorAndExit(err);
|
|
8384
8616
|
return;
|
|
8385
8617
|
}
|
|
8386
8618
|
}
|
|
8387
8619
|
cliRestartInProgress = false;
|
|
8620
|
+
replacementSpawnInProgress = false;
|
|
8621
|
+
// Follow-up decision (pure, unit-tested in restart-followup-policy.ts):
|
|
8622
|
+
// - cwd-move: a role-switch restart landed after restartCfg was
|
|
8623
|
+
// snapshotted → CLI came up in the old cwd while daemon repinned to
|
|
8624
|
+
// the new one. Respawn to converge (budget skipped only if the
|
|
8625
|
+
// backend is still alive; a dead one keeps the crash evidence).
|
|
8626
|
+
// - replacement-recovery: the freshly-spawned CLI exited inside the
|
|
8627
|
+
// window cliRestartInProgress still covers (spawnCli +
|
|
8628
|
+
// prepareCodexNativeTitleGeneration). onExit nulled `backend`
|
|
8629
|
+
// synchronously, so `!backend` is the ground-truth recovery signal —
|
|
8630
|
+
// NOT a merged daemon auto-restart (a restart message carries no
|
|
8631
|
+
// trustworthy source, so a healthy duplicate /restart during the
|
|
8632
|
+
// window must NOT be misread as a crash and force a budget-burning
|
|
8633
|
+
// second restart). Recover now or the session strands at
|
|
8634
|
+
// backend=null needing a manual /restart. Genuine crash recovery →
|
|
8635
|
+
// COUNTS toward tier-2 FRESH.
|
|
8636
|
+
const followup = decideRestartFollowup({
|
|
8637
|
+
spawnedWorkingDir,
|
|
8638
|
+
currentWorkingDir: lastInitConfig?.workingDir,
|
|
8639
|
+
backendAlive: !!backend,
|
|
8640
|
+
});
|
|
8641
|
+
if (followup.kind !== 'none') {
|
|
8642
|
+
const reason = followup.kind === 'cwd-move'
|
|
8643
|
+
? `cwd-move follow-up respawn → ${lastInitConfig?.workingDir}`
|
|
8644
|
+
: 'replacement-exit follow-up restart (replacement exited during in-flight restart)';
|
|
8645
|
+
log(reason);
|
|
8646
|
+
void restartCliProcess(reason, { preservePending: true, skipRestartBudget: followup.skipRestartBudget });
|
|
8647
|
+
return;
|
|
8648
|
+
}
|
|
8388
8649
|
// Riff marks itself prompt-ready inside spawnCli(); that early flush is
|
|
8389
8650
|
// intentionally held by the restart gate above. Release its raw-input
|
|
8390
8651
|
// fence now; other backends keep it until their later markPromptReady().
|
|
8391
8652
|
if (effectiveBackendType === 'riff' && isPromptReady)
|
|
8392
8653
|
releaseRawInputRestartGate();
|
|
8393
|
-
|
|
8654
|
+
// A local replacement process can exist before its TUI input box does.
|
|
8655
|
+
// Only re-kick a prompt that became ready while the restart fence was
|
|
8656
|
+
// still armed; otherwise markPromptReady() owns the first flush.
|
|
8657
|
+
if (isPromptReady)
|
|
8658
|
+
void flushPending();
|
|
8394
8659
|
}, 500);
|
|
8395
8660
|
}
|
|
8396
8661
|
catch (err) {
|
|
8662
|
+
replacementSpawnInProgress = false;
|
|
8397
8663
|
cliRestartInProgress = false;
|
|
8398
8664
|
try {
|
|
8399
8665
|
await sendFatalWorkerErrorAndExit(err);
|
|
@@ -8516,6 +8782,14 @@ function startWebServer(host, preferredPort) {
|
|
|
8516
8782
|
const startAttach = (cols, rows) => {
|
|
8517
8783
|
if (cp)
|
|
8518
8784
|
return;
|
|
8785
|
+
// Why: a prior web resize may leave the shared tmux window in manual mode.
|
|
8786
|
+
// `largest` restores the shared default without letting a smaller/newer
|
|
8787
|
+
// viewer resize every other attached client.
|
|
8788
|
+
spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'largest'], {
|
|
8789
|
+
stdio: 'ignore',
|
|
8790
|
+
env: tmuxEnv(),
|
|
8791
|
+
timeout: 3000,
|
|
8792
|
+
});
|
|
8519
8793
|
// Defense in depth: view-capability clients attach through tmux's
|
|
8520
8794
|
// own read-only mode as well as the WebSocket input gate below.
|
|
8521
8795
|
cp = pty.spawn('tmux', [
|
|
@@ -9609,6 +9883,15 @@ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, di
|
|
|
9609
9883
|
if (fatalWorkerErrorPending)
|
|
9610
9884
|
return;
|
|
9611
9885
|
fatalWorkerErrorPending = true;
|
|
9886
|
+
if (activeRestartAttemptId) {
|
|
9887
|
+
await sendAndFlush({
|
|
9888
|
+
type: 'restart_result',
|
|
9889
|
+
attemptId: activeRestartAttemptId,
|
|
9890
|
+
status: 'failed',
|
|
9891
|
+
category: 'spawn_failed',
|
|
9892
|
+
});
|
|
9893
|
+
activeRestartAttemptId = undefined;
|
|
9894
|
+
}
|
|
9612
9895
|
await sendAndFlush({
|
|
9613
9896
|
type: 'error',
|
|
9614
9897
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -9632,6 +9915,7 @@ process.on('message', async (raw) => {
|
|
|
9632
9915
|
if (lastInitConfig)
|
|
9633
9916
|
return; // already initialized
|
|
9634
9917
|
lastInitConfig = msg;
|
|
9918
|
+
activeRestartAttemptId = msg.restartAttemptId;
|
|
9635
9919
|
sessionId = msg.sessionId;
|
|
9636
9920
|
refreshTerminalViewToken();
|
|
9637
9921
|
refreshTerminalWriteToken();
|
|
@@ -9677,7 +9961,6 @@ process.on('message', async (raw) => {
|
|
|
9677
9961
|
if (!isWorkflowWorker()) {
|
|
9678
9962
|
port = await startWebServer(config.web.workerHost, msg.webPort);
|
|
9679
9963
|
startScreenUpdates();
|
|
9680
|
-
startScreenAnalyzer();
|
|
9681
9964
|
startStuckDetector();
|
|
9682
9965
|
}
|
|
9683
9966
|
else {
|
|
@@ -9845,12 +10128,11 @@ process.on('message', async (raw) => {
|
|
|
9845
10128
|
if (!backend && crashDiagnosticStopped && lastInitConfig && !lastInitConfig.adoptMode) {
|
|
9846
10129
|
log('Message received after crash-loop stop; retrying CLI start');
|
|
9847
10130
|
destroyCrashDiagnosticTerminal('retry after message');
|
|
9848
|
-
stopScreenAnalyzer();
|
|
9849
10131
|
stopStuckDetector();
|
|
10132
|
+
tuiPromptBlocking = false;
|
|
9850
10133
|
stopScreenUpdates();
|
|
9851
10134
|
awaitingFirstPrompt = true;
|
|
9852
10135
|
startScreenUpdates();
|
|
9853
|
-
startScreenAnalyzer();
|
|
9854
10136
|
startStuckDetector();
|
|
9855
10137
|
try {
|
|
9856
10138
|
const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
|
|
@@ -9993,9 +10275,18 @@ process.on('message', async (raw) => {
|
|
|
9993
10275
|
// barrier(shouldDeferUserFlush——/cd 未落地前任何用户输入都不得写入,
|
|
9994
10276
|
// 否则 passthrough 会执行在旧 cwd 的 CLI 里)时入队。注入排空后由
|
|
9995
10277
|
// flushPendingInjections 的 finally 补踢 flushPending 送达。
|
|
10278
|
+
// 第四个例外是启动稳定窗口:detectBareShellLaunch() 采到裸 shell 时会
|
|
10279
|
+
// await settleLaunchComm() 最长 2s 等 wrapper 完成 `exec <cli>`——这段
|
|
10280
|
+
// await 让出事件循环,而 IPC handler 不串行(见 raw-input-followup-
|
|
10281
|
+
// atomicity.test.ts),若此刻放行 passthrough 就会打进尚未 exec 的临时
|
|
10282
|
+
// shell。bareShellCheckInProgress 覆盖"检查进行中"、bareShellLaunchBlocked
|
|
10283
|
+
// 覆盖"已确认裸 shell 启动失败"两种状态,一并入队。裸 shell 确认失败后
|
|
10284
|
+
// 这些 pending 不会再被 flush(与 pendingMessages 同款处理),符合预期。
|
|
9996
10285
|
if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
|
|
9997
|
-
||
|
|
9998
|
-
|
|
10286
|
+
|| shouldHoldCodexRunnerInput(codexRunnerFreshness)
|
|
10287
|
+
|| injectionFlushing || shouldDeferUserFlush(pendingInjections)
|
|
10288
|
+
|| bareShellCheckInProgress || bareShellLaunchBlocked) {
|
|
10289
|
+
freshnessInputQueue.enqueueRaw(msg);
|
|
9999
10290
|
log(`Deferred passthrough slash command until CLI input gate settles: ${msg.content}`);
|
|
10000
10291
|
}
|
|
10001
10292
|
else {
|
|
@@ -10030,7 +10321,58 @@ process.on('message', async (raw) => {
|
|
|
10030
10321
|
break;
|
|
10031
10322
|
}
|
|
10032
10323
|
case 'restart': {
|
|
10033
|
-
|
|
10324
|
+
// 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true},
|
|
10325
|
+
// 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
|
|
10326
|
+
// 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
|
|
10327
|
+
// (COPY 最新 <sid>.jsonl 进新 cwd 桶,已在 master)接住,上下文不丢。
|
|
10328
|
+
if (msg.updateWorkingDir && lastInitConfig) {
|
|
10329
|
+
lastInitConfig.workingDir = msg.updateWorkingDir;
|
|
10330
|
+
}
|
|
10331
|
+
// restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
|
|
10332
|
+
// 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
|
|
10333
|
+
// 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
|
|
10334
|
+
// 双 spawn。workingDir 已收敛进 lastInitConfig,pending 的 spawn 展开
|
|
10335
|
+
// {...lastInitConfig} 时自然拿到新目录。
|
|
10336
|
+
//
|
|
10337
|
+
// 这里**只 break、不记任何 flag**:restart 消息不带可信来源,无法区分
|
|
10338
|
+
// 「replacement 崩溃触发的 auto-restart」与「用户重复点了一次 restart」。
|
|
10339
|
+
// replacement 真退出时 onExit 已同步把 backend 置 null,续体用 !backend 即可
|
|
10340
|
+
// 补 recovery(见 decideRestartFollowup);健康的重复 restart 就该被合并掉,
|
|
10341
|
+
// 记 flag 反而会逼健康进程再重启一轮、烧预算丢 --resume(正是合并要防的)。
|
|
10342
|
+
if (cliRestartInProgress || tmuxRestartTimer) {
|
|
10343
|
+
log(`Restart request merged into in-flight restart${msg.updateWorkingDir ? ` (workingDir → ${msg.updateWorkingDir})` : ''}`);
|
|
10344
|
+
break;
|
|
10345
|
+
}
|
|
10346
|
+
activeRestartAttemptId = msg.attemptId;
|
|
10347
|
+
codexRunnerFreshness = 'restarting_fresh';
|
|
10348
|
+
// restart 杀死 CLI,在飞的 durable turn 随之死亡。对被杀的那次投递,主动发一个
|
|
10349
|
+
// 'ambiguous' 终端回执:CLI 被中途杀掉,副作用到底发没发是**真的无法证明**
|
|
10350
|
+
// (故不能报 'cancelled'),交由 daemon 的重试策略即时对账。不发的话 receipt 会
|
|
10351
|
+
// 悬着,等 daemon 租约到期走 expire_durable_turn 的「无法证明 → 扣 ACK → 超时
|
|
10352
|
+
// fencing teardown」慢路径(还会把刚 respawn 的新 CLI 二次 teardown)。
|
|
10353
|
+
// emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、
|
|
10354
|
+
// 撤销 turn-origin relay、丢弃 bridge 尝试)并对后续 CLI-exit 终端去重;它排的
|
|
10355
|
+
// flushPending 微任务会因下面 restartCliProcess 同步置位 cliRestartInProgress 而空跑。
|
|
10356
|
+
// 结算被 restart 打断的在飞 durable turn(编排见 settleDurableTurnForRestart,
|
|
10357
|
+
// 已单测):drain 已落盘可靠终态让「刚好在 kill 前完成、watcher 未消费」的 turn
|
|
10358
|
+
// 以 completed 抢占 deduper → 复检仍在飞才补发 ambiguous(否则无条件 ambiguous
|
|
10359
|
+
// 会误标已完成投递 → 同 key 可重派 → 副作用两次)→ 兜底释放 latch 免卡 respawn。
|
|
10360
|
+
// emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、撤销
|
|
10361
|
+
// turn-origin relay、丢弃 bridge 尝试);它排的 flushPending 微任务会因下面
|
|
10362
|
+
// restartCliProcess 同步置位 cliRestartInProgress 而空跑。
|
|
10363
|
+
settleDurableTurnForRestart({
|
|
10364
|
+
hasInFlightTurn: durableTurnInFlight,
|
|
10365
|
+
hasCurrentTurnId: !!currentBotmuxTurnId,
|
|
10366
|
+
drain: () => drainReliableTerminalBeforeInterrupt(),
|
|
10367
|
+
isStillInFlight: () => durableTurnInFlight,
|
|
10368
|
+
emitAmbiguous: () => emitTurnTerminal(currentBotmuxTurnId, 'ambiguous', undefined, currentBotmuxDispatchAttempt),
|
|
10369
|
+
release: () => { durableTurnInFlight = false; inflightInputs.onTurnComplete(); },
|
|
10370
|
+
});
|
|
10371
|
+
await restartCliProcess(msg.updateWorkingDir ? `cwd-move respawn → ${msg.updateWorkingDir}` : 'daemon request',
|
|
10372
|
+
// cwd-move 是用户主动的目录迁移、不是崩溃恢复,不计入 tier-2 强制
|
|
10373
|
+
// FRESH 的重启预算;respawn 真失败仍有 claude_exit → daemon
|
|
10374
|
+
// auto-restart 那条裸 restart 的计数兜底。
|
|
10375
|
+
{ preservePending: true, skipRestartBudget: !!msg.updateWorkingDir });
|
|
10034
10376
|
break;
|
|
10035
10377
|
}
|
|
10036
10378
|
case 'expire_durable_turn': {
|
|
@@ -10156,12 +10498,10 @@ process.on('message', async (raw) => {
|
|
|
10156
10498
|
break;
|
|
10157
10499
|
}
|
|
10158
10500
|
case 'inject_command': {
|
|
10159
|
-
//
|
|
10160
|
-
//
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
}
|
|
10164
|
-
pendingInjections.push({ command: msg.command, barrier: !!msg.updateWorkingDir });
|
|
10501
|
+
// 唯一发送方是 /slash 路由的白名单 TUI 命令注入。cwd 移动不再走注入
|
|
10502
|
+
// (角色切换已改为 restart+updateWorkingDir 的 respawn),这里不接受
|
|
10503
|
+
// 任何 workingDir 改写——那会绕过 cd 路由的角色库硬校验。
|
|
10504
|
+
pendingInjections.push({ command: msg.command, barrier: false });
|
|
10165
10505
|
void flushPendingInjections();
|
|
10166
10506
|
break;
|
|
10167
10507
|
}
|
|
@@ -10294,10 +10634,11 @@ process.on('message', async (raw) => {
|
|
|
10294
10634
|
catch { /* best-effort */ }
|
|
10295
10635
|
backend = null;
|
|
10296
10636
|
isPromptReady = false;
|
|
10297
|
-
// Suspend INTENDS to resume later:
|
|
10298
|
-
//
|
|
10299
|
-
// over the SAME
|
|
10300
|
-
//
|
|
10637
|
+
// Suspend INTENDS to resume later: keep the per-session sandbox tree (the
|
|
10638
|
+
// outbox + pre-created deny-mask mountpoints) intact across the suspension
|
|
10639
|
+
// (on resume, prepareDirectSandbox re-binds over the SAME tree). So we stop
|
|
10640
|
+
// the outbox watcher (no live CLI to serve) but DO NOT run sandboxCleanup
|
|
10641
|
+
// (which would reclaim the mask mountpoints + rm the tree). We
|
|
10301
10642
|
// also disarm the exit-time teardown so process.exit(0) below can't reclaim
|
|
10302
10643
|
// it. (Crash/SIGKILL of a suspended-but-active session is still backstopped
|
|
10303
10644
|
// by the daemon's periodic sandbox reconciler.)
|
|
@@ -10308,7 +10649,7 @@ process.on('message', async (raw) => {
|
|
|
10308
10649
|
catch { /* */ }
|
|
10309
10650
|
sandboxStopWatcher = null;
|
|
10310
10651
|
}
|
|
10311
|
-
sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the
|
|
10652
|
+
sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the tree)
|
|
10312
10653
|
sandboxTeardownDone = true; // make the process.on('exit') hook a no-op
|
|
10313
10654
|
cleanup();
|
|
10314
10655
|
process.exit(0);
|
|
@@ -10384,15 +10725,15 @@ setInterval(() => {
|
|
|
10384
10725
|
}
|
|
10385
10726
|
}, 30_000).unref();
|
|
10386
10727
|
// ─── Sandbox crash-time teardown ─────────────────────────────────────────────
|
|
10387
|
-
// killCli() (which
|
|
10388
|
-
// from the SIGTERM/SIGINT/disconnect/watchdog handlers and the
|
|
10389
|
-
// cases. An UNCAUGHT exception or unhandled rejection kills
|
|
10390
|
-
// any of those firing, so without this hook a crashed
|
|
10391
|
-
//
|
|
10392
|
-
// per crash. We run a minimal,
|
|
10393
|
-
//
|
|
10394
|
-
// can't be trapped — the daemon-side
|
|
10395
|
-
// the backstop for that.)
|
|
10728
|
+
// killCli() (which reclaims the deny-mask mountpoints + rm's the per-session
|
|
10729
|
+
// tree) only runs from the SIGTERM/SIGINT/disconnect/watchdog handlers and the
|
|
10730
|
+
// close/suspend IPC cases. An UNCAUGHT exception or unhandled rejection kills
|
|
10731
|
+
// the process WITHOUT any of those firing, so without this hook a crashed
|
|
10732
|
+
// sandboxed worker would leak its pre-created host mask mountpoints (empty dirs/
|
|
10733
|
+
// files) + the per-session tree (disk leak) per crash. We run a minimal,
|
|
10734
|
+
// synchronous, best-effort sandbox teardown here so that residue is reclaimed
|
|
10735
|
+
// even on an abnormal exit. (SIGKILL still can't be trapped — the daemon-side
|
|
10736
|
+
// sweep + the periodic reconciler below are the backstop for that.)
|
|
10396
10737
|
function teardownSandboxBestEffort() {
|
|
10397
10738
|
if (sandboxTeardownDone)
|
|
10398
10739
|
return;
|