botmux 3.15.0 → 3.16.1
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 +2 -2
- package/README.md +2 -2
- package/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/destroy-result.d.ts +159 -0
- package/dist/adapters/backend/destroy-result.d.ts.map +1 -0
- package/dist/adapters/backend/destroy-result.js +223 -0
- package/dist/adapters/backend/destroy-result.js.map +1 -0
- package/dist/adapters/backend/herdr-backend.d.ts +4 -0
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +23 -1
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/mojo-backend.d.ts +577 -0
- package/dist/adapters/backend/mojo-backend.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-backend.js +2471 -0
- package/dist/adapters/backend/mojo-backend.js.map +1 -0
- package/dist/adapters/backend/mojo-budgets.d.ts +21 -0
- package/dist/adapters/backend/mojo-budgets.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-budgets.js +22 -0
- package/dist/adapters/backend/mojo-budgets.js.map +1 -0
- package/dist/adapters/backend/mojo-isolated-workspace.d.ts +69 -0
- package/dist/adapters/backend/mojo-isolated-workspace.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-isolated-workspace.js +213 -0
- package/dist/adapters/backend/mojo-isolated-workspace.js.map +1 -0
- package/dist/adapters/backend/mojo-process-tree.d.ts +292 -0
- package/dist/adapters/backend/mojo-process-tree.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-process-tree.js +486 -0
- package/dist/adapters/backend/mojo-process-tree.js.map +1 -0
- package/dist/adapters/backend/mojo-types.d.ts +542 -0
- package/dist/adapters/backend/mojo-types.d.ts.map +1 -0
- package/dist/adapters/backend/mojo-types.js +682 -0
- package/dist/adapters/backend/mojo-types.js.map +1 -0
- package/dist/adapters/backend/reproduce-command.js +3 -3
- package/dist/adapters/backend/reproduce-command.js.map +1 -1
- package/dist/adapters/backend/riff-backend.js +1 -1
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +44 -7
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +30 -7
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +14 -1
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
- package/dist/adapters/backend/session-backend-selector.js +82 -10
- package/dist/adapters/backend/session-backend-selector.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +101 -6
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zmx-backend.d.ts +11 -0
- package/dist/adapters/backend/zmx-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zmx-backend.js +16 -1
- package/dist/adapters/backend/zmx-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +2 -2
- 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 +7 -0
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/copilot.d.ts +3 -1
- package/dist/adapters/cli/copilot.d.ts.map +1 -1
- package/dist/adapters/cli/copilot.js +23 -5
- package/dist/adapters/cli/copilot.js.map +1 -1
- package/dist/adapters/cli/cursor.d.ts.map +1 -1
- package/dist/adapters/cli/cursor.js +13 -3
- package/dist/adapters/cli/cursor.js.map +1 -1
- package/dist/adapters/cli/grok.d.ts.map +1 -1
- package/dist/adapters/cli/grok.js +24 -5
- package/dist/adapters/cli/grok.js.map +1 -1
- package/dist/adapters/cli/kimi.d.ts.map +1 -1
- package/dist/adapters/cli/kimi.js +19 -1
- package/dist/adapters/cli/kimi.js.map +1 -1
- package/dist/adapters/cli/mojo.d.ts +28 -0
- package/dist/adapters/cli/mojo.d.ts.map +1 -0
- package/dist/adapters/cli/mojo.js +58 -0
- package/dist/adapters/cli/mojo.js.map +1 -0
- package/dist/adapters/cli/pi.d.ts +6 -4
- package/dist/adapters/cli/pi.d.ts.map +1 -1
- package/dist/adapters/cli/pi.js +6 -4
- package/dist/adapters/cli/pi.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +251 -1
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +254 -1
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/registry.d.ts +13 -1
- package/dist/adapters/cli/registry.d.ts.map +1 -1
- package/dist/adapters/cli/registry.js +19 -1
- package/dist/adapters/cli/registry.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +3 -3
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +4 -3
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +3 -1
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +22 -6
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +29 -4
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +20 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +30 -4
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/arg-utils.d.ts +5 -0
- package/dist/cli/arg-utils.d.ts.map +1 -1
- package/dist/cli/arg-utils.js +7 -0
- package/dist/cli/arg-utils.js.map +1 -1
- package/dist/cli/bots-list-output.d.ts +130 -2
- package/dist/cli/bots-list-output.d.ts.map +1 -1
- package/dist/cli/bots-list-output.js +173 -27
- package/dist/cli/bots-list-output.js.map +1 -1
- package/dist/cli/daemon-lifecycle-env.d.ts +10 -3
- package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
- package/dist/cli/daemon-lifecycle-env.js +38 -11
- package/dist/cli/daemon-lifecycle-env.js.map +1 -1
- package/dist/cli/dashboard-command.d.ts +15 -0
- package/dist/cli/dashboard-command.d.ts.map +1 -1
- package/dist/cli/dashboard-command.js +22 -0
- package/dist/cli/dashboard-command.js.map +1 -1
- package/dist/cli/log-tail.d.ts +54 -0
- package/dist/cli/log-tail.d.ts.map +1 -0
- package/dist/cli/log-tail.js +167 -0
- package/dist/cli/log-tail.js.map +1 -0
- package/dist/cli/pm2-env.d.ts +16 -0
- package/dist/cli/pm2-env.d.ts.map +1 -0
- package/dist/cli/pm2-env.js +63 -0
- package/dist/cli/pm2-env.js.map +1 -0
- package/dist/cli/pm2-fleet-lock.d.ts +26 -0
- package/dist/cli/pm2-fleet-lock.d.ts.map +1 -0
- package/dist/cli/pm2-fleet-lock.js +50 -0
- package/dist/cli/pm2-fleet-lock.js.map +1 -0
- package/dist/cli/pm2-god-retirement.d.ts +59 -0
- package/dist/cli/pm2-god-retirement.d.ts.map +1 -0
- package/dist/cli/pm2-god-retirement.js +95 -0
- package/dist/cli/pm2-god-retirement.js.map +1 -0
- package/dist/cli/session-list-wake.d.ts +41 -0
- package/dist/cli/session-list-wake.d.ts.map +1 -0
- package/dist/cli/session-list-wake.js +102 -0
- package/dist/cli/session-list-wake.js.map +1 -0
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +26 -0
- package/dist/cli/workflow.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +933 -115
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +261 -49
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/cli-runtime-update.d.ts +8 -0
- package/dist/core/cli-runtime-update.d.ts.map +1 -1
- package/dist/core/cli-runtime-update.js +127 -8
- package/dist/core/cli-runtime-update.js.map +1 -1
- package/dist/core/close-residual.d.ts +64 -0
- package/dist/core/close-residual.d.ts.map +1 -0
- package/dist/core/close-residual.js +93 -0
- package/dist/core/close-residual.js.map +1 -0
- package/dist/core/closed-session-card.d.ts.map +1 -1
- package/dist/core/closed-session-card.js +11 -3
- package/dist/core/closed-session-card.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +129 -27
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +3 -1
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +230 -1
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/daemon-background-close.d.ts +19 -0
- package/dist/core/daemon-background-close.d.ts.map +1 -0
- package/dist/core/daemon-background-close.js +39 -0
- package/dist/core/daemon-background-close.js.map +1 -0
- package/dist/core/dashboard-command/overview.d.ts +4 -0
- package/dist/core/dashboard-command/overview.d.ts.map +1 -1
- package/dist/core/dashboard-command/overview.js +8 -1
- package/dist/core/dashboard-command/overview.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +373 -41
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts +4 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js +3 -0
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/dashboard-url.d.ts +47 -1
- package/dist/core/dashboard-url.d.ts.map +1 -1
- package/dist/core/dashboard-url.js +83 -2
- package/dist/core/dashboard-url.js.map +1 -1
- package/dist/core/deferred-schedule-settlement.d.ts +15 -1
- package/dist/core/deferred-schedule-settlement.d.ts.map +1 -1
- package/dist/core/deferred-schedule-settlement.js +4 -1
- package/dist/core/deferred-schedule-settlement.js.map +1 -1
- package/dist/core/device-isolation-daemon.d.ts +91 -1
- package/dist/core/device-isolation-daemon.d.ts.map +1 -1
- package/dist/core/device-isolation-daemon.js +346 -2
- package/dist/core/device-isolation-daemon.js.map +1 -1
- package/dist/core/local-terminal-opener.d.ts.map +1 -1
- package/dist/core/local-terminal-opener.js +5 -1
- package/dist/core/local-terminal-opener.js.map +1 -1
- package/dist/core/loopback-target.d.ts +14 -0
- package/dist/core/loopback-target.d.ts.map +1 -0
- package/dist/core/loopback-target.js +20 -0
- package/dist/core/loopback-target.js.map +1 -0
- package/dist/core/maintenance.d.ts.map +1 -1
- package/dist/core/maintenance.js +16 -2
- package/dist/core/maintenance.js.map +1 -1
- package/dist/core/mojo-containment-command.d.ts +20 -0
- package/dist/core/mojo-containment-command.d.ts.map +1 -0
- package/dist/core/mojo-containment-command.js +266 -0
- package/dist/core/mojo-containment-command.js.map +1 -0
- package/dist/core/mojo-containment.d.ts +575 -0
- package/dist/core/mojo-containment.d.ts.map +1 -0
- package/dist/core/mojo-containment.js +1158 -0
- package/dist/core/mojo-containment.js.map +1 -0
- package/dist/core/mojo-launcher-env-quarantine.d.ts +30 -0
- package/dist/core/mojo-launcher-env-quarantine.d.ts.map +1 -0
- package/dist/core/mojo-launcher-env-quarantine.js +175 -0
- package/dist/core/mojo-launcher-env-quarantine.js.map +1 -0
- package/dist/core/mojo-session-identity.d.ts +3 -0
- package/dist/core/mojo-session-identity.d.ts.map +1 -0
- package/dist/core/mojo-session-identity.js +126 -0
- package/dist/core/mojo-session-identity.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +32 -8
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +55 -17
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.d.ts +41 -4
- package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +342 -29
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/mcp/host.d.ts +20 -0
- package/dist/core/plugins/mcp/host.d.ts.map +1 -1
- package/dist/core/plugins/mcp/host.js +82 -16
- package/dist/core/plugins/mcp/host.js.map +1 -1
- package/dist/core/plugins/mcp/launch-record.d.ts +14 -0
- package/dist/core/plugins/mcp/launch-record.d.ts.map +1 -0
- package/dist/core/plugins/mcp/launch-record.js +63 -0
- package/dist/core/plugins/mcp/launch-record.js.map +1 -0
- package/dist/core/plugins/pm2.d.ts.map +1 -1
- package/dist/core/plugins/pm2.js +16 -3
- package/dist/core/plugins/pm2.js.map +1 -1
- package/dist/core/plugins/service-manager.d.ts +9 -0
- package/dist/core/plugins/service-manager.d.ts.map +1 -1
- package/dist/core/plugins/service-manager.js +33 -16
- package/dist/core/plugins/service-manager.js.map +1 -1
- package/dist/core/preview-port-owner.d.ts +109 -0
- package/dist/core/preview-port-owner.d.ts.map +1 -0
- package/dist/core/preview-port-owner.js +320 -0
- package/dist/core/preview-port-owner.js.map +1 -0
- package/dist/core/remote-cli-ids.d.ts +5 -0
- package/dist/core/remote-cli-ids.d.ts.map +1 -0
- package/dist/core/remote-cli-ids.js +24 -0
- package/dist/core/remote-cli-ids.js.map +1 -0
- package/dist/core/{riff-shutdown-detach.d.ts → remote-shutdown-detach.d.ts} +40 -39
- package/dist/core/remote-shutdown-detach.d.ts.map +1 -0
- package/dist/core/{riff-shutdown-detach.js → remote-shutdown-detach.js} +85 -83
- package/dist/core/remote-shutdown-detach.js.map +1 -0
- package/dist/core/{riff-worker-shutdown-readiness.d.ts → remote-worker-shutdown-readiness.d.ts} +5 -4
- package/dist/core/remote-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/{riff-worker-shutdown-readiness.js → remote-worker-shutdown-readiness.js} +4 -3
- package/dist/core/remote-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/reply-target.d.ts +16 -1
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +61 -0
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/session-group-birth.d.ts +26 -0
- package/dist/core/session-group-birth.d.ts.map +1 -1
- package/dist/core/session-group-birth.js +81 -4
- package/dist/core/session-group-birth.js.map +1 -1
- package/dist/core/session-manager.d.ts +15 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +259 -5
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-model.d.ts +46 -0
- package/dist/core/session-model.d.ts.map +1 -0
- package/dist/core/session-model.js +42 -0
- package/dist/core/session-model.js.map +1 -0
- package/dist/core/session-owner-reminder.d.ts +63 -0
- package/dist/core/session-owner-reminder.d.ts.map +1 -0
- package/dist/core/session-owner-reminder.js +196 -0
- package/dist/core/session-owner-reminder.js.map +1 -0
- package/dist/core/session-preview-registry.d.ts +53 -0
- package/dist/core/session-preview-registry.d.ts.map +1 -0
- package/dist/core/session-preview-registry.js +82 -0
- package/dist/core/session-preview-registry.js.map +1 -0
- package/dist/core/session-preview.d.ts +114 -0
- package/dist/core/session-preview.d.ts.map +1 -0
- package/dist/core/session-preview.js +184 -0
- package/dist/core/session-preview.js.map +1 -0
- package/dist/core/session-rename.d.ts.map +1 -1
- package/dist/core/session-rename.js +5 -3
- package/dist/core/session-rename.js.map +1 -1
- package/dist/core/session-wake-deadline.d.ts +5 -0
- package/dist/core/session-wake-deadline.d.ts.map +1 -0
- package/dist/core/session-wake-deadline.js +13 -0
- package/dist/core/session-wake-deadline.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +7 -6
- package/dist/core/shutdown-budgets.d.ts.map +1 -1
- package/dist/core/shutdown-budgets.js +11 -10
- package/dist/core/shutdown-budgets.js.map +1 -1
- package/dist/core/terminal-control-grant.d.ts +74 -0
- package/dist/core/terminal-control-grant.d.ts.map +1 -0
- package/dist/core/terminal-control-grant.js +183 -0
- package/dist/core/terminal-control-grant.js.map +1 -0
- package/dist/core/terminal-proxy.d.ts.map +1 -1
- package/dist/core/terminal-proxy.js +4 -5
- package/dist/core/terminal-proxy.js.map +1 -1
- package/dist/core/terminal-write-auth.d.ts +4 -11
- package/dist/core/terminal-write-auth.d.ts.map +1 -1
- package/dist/core/terminal-write-auth.js +19 -16
- package/dist/core/terminal-write-auth.js.map +1 -1
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +36 -29
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +88 -10
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -7
- package/dist/core/types.js.map +1 -1
- package/dist/core/workbench-link.d.ts +81 -0
- package/dist/core/workbench-link.d.ts.map +1 -0
- package/dist/core/workbench-link.js +109 -0
- package/dist/core/workbench-link.js.map +1 -0
- package/dist/core/worker-pool.d.ts +321 -12
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2524 -212
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +443 -106
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +33 -3
- package/dist/dashboard/aggregator.d.ts.map +1 -1
- package/dist/dashboard/aggregator.js +93 -17
- package/dist/dashboard/aggregator.js.map +1 -1
- package/dist/dashboard/auth-session-connections.d.ts +59 -0
- package/dist/dashboard/auth-session-connections.d.ts.map +1 -0
- package/dist/dashboard/auth-session-connections.js +109 -0
- package/dist/dashboard/auth-session-connections.js.map +1 -0
- package/dist/dashboard/auth.d.ts +99 -1
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +176 -64
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +1 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +3 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/control-audit.d.ts +112 -0
- package/dist/dashboard/control-audit.d.ts.map +1 -0
- package/dist/dashboard/control-audit.js +195 -0
- package/dist/dashboard/control-audit.js.map +1 -0
- package/dist/dashboard/control-csrf.d.ts +123 -0
- package/dist/dashboard/control-csrf.d.ts.map +1 -0
- package/dist/dashboard/control-csrf.js +397 -0
- package/dist/dashboard/control-csrf.js.map +1 -0
- package/dist/dashboard/daemon-reconcile.d.ts +26 -0
- package/dist/dashboard/daemon-reconcile.d.ts.map +1 -0
- package/dist/dashboard/daemon-reconcile.js +46 -0
- package/dist/dashboard/daemon-reconcile.js.map +1 -0
- package/dist/dashboard/debug-terminal.d.ts.map +1 -1
- package/dist/dashboard/debug-terminal.js +18 -4
- package/dist/dashboard/debug-terminal.js.map +1 -1
- package/dist/dashboard/events-sse.d.ts +60 -0
- package/dist/dashboard/events-sse.d.ts.map +1 -0
- package/dist/dashboard/events-sse.js +63 -0
- package/dist/dashboard/events-sse.js.map +1 -0
- package/dist/dashboard/h5-auth.d.ts +256 -0
- package/dist/dashboard/h5-auth.d.ts.map +1 -0
- package/dist/dashboard/h5-auth.js +825 -0
- package/dist/dashboard/h5-auth.js.map +1 -0
- package/dist/dashboard/managed-spawn.d.ts +36 -0
- package/dist/dashboard/managed-spawn.d.ts.map +1 -0
- package/dist/dashboard/managed-spawn.js +169 -0
- package/dist/dashboard/managed-spawn.js.map +1 -0
- package/dist/dashboard/preview-content-capability.d.ts +41 -0
- package/dist/dashboard/preview-content-capability.d.ts.map +1 -0
- package/dist/dashboard/preview-content-capability.js +127 -0
- package/dist/dashboard/preview-content-capability.js.map +1 -0
- package/dist/dashboard/preview-contract.d.ts +85 -0
- package/dist/dashboard/preview-contract.d.ts.map +1 -0
- package/dist/dashboard/preview-contract.js +198 -0
- package/dist/dashboard/preview-contract.js.map +1 -0
- package/dist/dashboard/preview-guard-page.d.ts +52 -0
- package/dist/dashboard/preview-guard-page.d.ts.map +1 -0
- package/dist/dashboard/preview-guard-page.js +162 -0
- package/dist/dashboard/preview-guard-page.js.map +1 -0
- package/dist/dashboard/preview-interaction.d.ts +57 -0
- package/dist/dashboard/preview-interaction.d.ts.map +1 -0
- package/dist/dashboard/preview-interaction.js +198 -0
- package/dist/dashboard/preview-interaction.js.map +1 -0
- package/dist/dashboard/preview-proxy.d.ts +113 -0
- package/dist/dashboard/preview-proxy.d.ts.map +1 -0
- package/dist/dashboard/preview-proxy.js +543 -0
- package/dist/dashboard/preview-proxy.js.map +1 -0
- package/dist/dashboard/public-redact.d.ts +23 -0
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +90 -21
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/request-identity.d.ts +77 -0
- package/dist/dashboard/request-identity.d.ts.map +1 -0
- package/dist/dashboard/request-identity.js +72 -0
- package/dist/dashboard/request-identity.js.map +1 -0
- package/dist/dashboard/session-cleanup.d.ts +5 -0
- package/dist/dashboard/session-cleanup.d.ts.map +1 -1
- package/dist/dashboard/session-cleanup.js +4 -0
- package/dist/dashboard/session-cleanup.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +5 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +14 -0
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/standing-link.d.ts +76 -0
- package/dist/dashboard/standing-link.d.ts.map +1 -0
- package/dist/dashboard/standing-link.js +111 -0
- package/dist/dashboard/standing-link.js.map +1 -0
- package/dist/dashboard/terminal-control.d.ts +105 -0
- package/dist/dashboard/terminal-control.d.ts.map +1 -0
- package/dist/dashboard/terminal-control.js +296 -0
- package/dist/dashboard/terminal-control.js.map +1 -0
- package/dist/dashboard/terminal-front-proxy.d.ts +55 -0
- package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -0
- package/dist/dashboard/terminal-front-proxy.js +415 -0
- package/dist/dashboard/terminal-front-proxy.js.map +1 -0
- package/dist/dashboard/terminal-view-capability.d.ts +97 -0
- package/dist/dashboard/terminal-view-capability.d.ts.map +1 -0
- package/dist/dashboard/terminal-view-capability.js +145 -0
- package/dist/dashboard/terminal-view-capability.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-api.d.ts +59 -0
- package/dist/dashboard/web/agent-workbench-api.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-api.js +227 -0
- package/dist/dashboard/web/agent-workbench-api.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts +59 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.js +195 -0
- package/dist/dashboard/web/agent-workbench-appearance-menu.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance.d.ts +222 -0
- package/dist/dashboard/web/agent-workbench-appearance.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-appearance.js +499 -0
- package/dist/dashboard/web/agent-workbench-appearance.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-capabilities.d.ts +13 -0
- package/dist/dashboard/web/agent-workbench-capabilities.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-capabilities.js +24 -0
- package/dist/dashboard/web/agent-workbench-capabilities.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-chat.d.ts +90 -0
- package/dist/dashboard/web/agent-workbench-chat.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-chat.js +168 -0
- package/dist/dashboard/web/agent-workbench-chat.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-page.d.ts +3 -0
- package/dist/dashboard/web/agent-workbench-dock-page.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-page.js +23 -0
- package/dist/dashboard/web/agent-workbench-dock-page.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-view.d.ts +22 -0
- package/dist/dashboard/web/agent-workbench-dock-view.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-dock-view.js +76 -0
- package/dist/dashboard/web/agent-workbench-dock-view.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-model.d.ts +196 -0
- package/dist/dashboard/web/agent-workbench-model.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-model.js +529 -0
- package/dist/dashboard/web/agent-workbench-model.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-page.d.ts +3 -0
- package/dist/dashboard/web/agent-workbench-page.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-page.js +27 -0
- package/dist/dashboard/web/agent-workbench-page.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-panes.d.ts +49 -0
- package/dist/dashboard/web/agent-workbench-panes.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-panes.js +468 -0
- package/dist/dashboard/web/agent-workbench-panes.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-session-list.d.ts +42 -0
- package/dist/dashboard/web/agent-workbench-session-list.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-session-list.js +353 -0
- package/dist/dashboard/web/agent-workbench-session-list.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-storage.d.ts +49 -0
- package/dist/dashboard/web/agent-workbench-storage.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-storage.js +197 -0
- package/dist/dashboard/web/agent-workbench-storage.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-touch.d.ts +46 -0
- package/dist/dashboard/web/agent-workbench-touch.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-touch.js +96 -0
- package/dist/dashboard/web/agent-workbench-touch.js.map +1 -0
- package/dist/dashboard/web/agent-workbench-view.d.ts +34 -0
- package/dist/dashboard/web/agent-workbench-view.d.ts.map +1 -0
- package/dist/dashboard/web/agent-workbench-view.js +439 -0
- package/dist/dashboard/web/agent-workbench-view.js.map +1 -0
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +63 -2
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +11 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +267 -36
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +6 -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/control-csrf.d.ts +4 -0
- package/dist/dashboard/web/control-csrf.d.ts.map +1 -0
- package/dist/dashboard/web/control-csrf.js +26 -0
- package/dist/dashboard/web/control-csrf.js.map +1 -0
- package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-routes.js +2 -0
- package/dist/dashboard/web/dashboard-routes.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +11 -2
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +132 -6
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +41 -1
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +5 -1
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +3 -0
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +56 -10
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/store.d.ts +27 -2
- package/dist/dashboard/web/store.d.ts.map +1 -1
- package/dist/dashboard/web/store.js +43 -4
- package/dist/dashboard/web/store.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +3 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +11 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard/web/update-action.d.ts +12 -0
- package/dist/dashboard/web/update-action.d.ts.map +1 -1
- package/dist/dashboard/web/update-action.js +11 -0
- package/dist/dashboard/web/update-action.js.map +1 -1
- package/dist/dashboard/workbench-doctor.d.ts +114 -0
- package/dist/dashboard/workbench-doctor.d.ts.map +1 -0
- package/dist/dashboard/workbench-doctor.js +620 -0
- package/dist/dashboard/workbench-doctor.js.map +1 -0
- package/dist/dashboard/workbench-ticket.d.ts +165 -0
- package/dist/dashboard/workbench-ticket.d.ts.map +1 -0
- package/dist/dashboard/workbench-ticket.js +495 -0
- package/dist/dashboard/workbench-ticket.js.map +1 -0
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/agent-workbench-dock-page-IKAC5CFL.js +1 -0
- package/dist/dashboard-web/chunks/agent-workbench-page-M53UN5EB.js +1 -0
- package/dist/dashboard-web/chunks/bot-defaults-page-YKE5SDZF.js +10 -0
- package/dist/dashboard-web/chunks/chunk-22WIADUT.js +2 -0
- package/dist/dashboard-web/chunks/chunk-24ONYQMR.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-Z7B3E3US.js → chunk-2W6SCN3M.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-4XNJX6B3.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-VMWNR3SV.js → chunk-F6KNPF4R.js} +11 -9
- package/dist/dashboard-web/chunks/chunk-FXW3V4JR.js +1 -0
- package/dist/dashboard-web/chunks/chunk-G5ZS2YUS.js +1 -0
- package/dist/dashboard-web/chunks/chunk-KTQ7B6ZR.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-Z7DO42GJ.js → chunk-NACP7BMR.js} +1 -1
- package/dist/dashboard-web/chunks/{connectors-page-CPH6VVFY.js → connectors-page-VOISXXII.js} +1 -1
- package/dist/dashboard-web/chunks/{feedback-page-J2FKQ3LK.js → feedback-page-RN6HRZMS.js} +1 -1
- package/dist/dashboard-web/chunks/groups-page-UQT6DCB7.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-A2LWTKJD.js → insights-page-HJFDYR5N.js} +3 -3
- package/dist/dashboard-web/chunks/{monitor-room-2YR752E3.js → monitor-room-V7PCP47V.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-BGYSKZGM.js → monitoring-page-NKEKVQAP.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-ZZHTEAE4.js → office-page-GVJF73XW.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-KHLV6VTI.js +1 -0
- package/dist/dashboard-web/chunks/{plugin-page-VEGTCEAS.js → plugin-page-J3OF5IY4.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-YUC4YLJG.js → roles-page-WQIRSOLZ.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-2L2NKTDJ.js → schedules-page-J2DEY2X6.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-7BQFEA5P.js +8 -0
- package/dist/dashboard-web/chunks/settings-page-BJEIOFJH.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-DQPBBI3T.js → skills-page-HGIFR3IU.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-LQKLDV4K.js → team-federation-page-MR5ZPL4B.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-RVKQIMAP.js → v3-page-GBR5JWPE.js} +2 -2
- package/dist/dashboard-web/chunks/{whiteboards-page-S5K55HRZ.js → whiteboards-page-QAZM4BN3.js} +1 -1
- package/dist/dashboard-web/index.html +8 -1
- package/dist/dashboard-web/style.css +2006 -10
- package/dist/dashboard.js +1006 -263
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +30 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +33 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +32 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +29 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +1 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +22 -6
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +182 -27
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +15 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +30 -0
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +48 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +133 -22
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/identity-cache.d.ts +14 -6
- package/dist/im/lark/identity-cache.d.ts.map +1 -1
- package/dist/im/lark/identity-cache.js +69 -26
- package/dist/im/lark/identity-cache.js.map +1 -1
- package/dist/im/lark/lark-hosts.d.ts +13 -0
- package/dist/im/lark/lark-hosts.d.ts.map +1 -1
- package/dist/im/lark/lark-hosts.js +16 -0
- package/dist/im/lark/lark-hosts.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts +22 -0
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +53 -3
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/im/lark/sessions-card.d.ts.map +1 -1
- package/dist/im/lark/sessions-card.js +26 -3
- package/dist/im/lark/sessions-card.js.map +1 -1
- package/dist/im/lark/workflow-slash-command.d.ts +6 -0
- package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
- package/dist/im/lark/workflow-slash-command.js +7 -0
- package/dist/im/lark/workflow-slash-command.js.map +1 -1
- package/dist/index-daemon.js +27 -4
- package/dist/index-daemon.js.map +1 -1
- package/dist/index-dashboard.d.ts +3 -0
- package/dist/index-dashboard.d.ts.map +1 -0
- package/dist/index-dashboard.js +94 -0
- package/dist/index-dashboard.js.map +1 -0
- package/dist/platform/binding.d.ts +27 -0
- package/dist/platform/binding.d.ts.map +1 -1
- package/dist/platform/binding.js +40 -0
- package/dist/platform/binding.js.map +1 -1
- package/dist/platform/team-agents-client.d.ts +199 -0
- package/dist/platform/team-agents-client.d.ts.map +1 -0
- package/dist/platform/team-agents-client.js +263 -0
- package/dist/platform/team-agents-client.js.map +1 -0
- package/dist/platform/tunnel-client.d.ts +6 -0
- package/dist/platform/tunnel-client.d.ts.map +1 -1
- package/dist/platform/tunnel-client.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +7 -3
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +28 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +13 -4
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +55 -8
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bot-config-store.d.ts +1 -11
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +27 -3
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/bot-profile-store.d.ts +17 -2
- package/dist/services/bot-profile-store.d.ts.map +1 -1
- package/dist/services/bot-profile-store.js +93 -12
- package/dist/services/bot-profile-store.js.map +1 -1
- package/dist/services/bridge-turn-journal.d.ts +63 -0
- package/dist/services/bridge-turn-journal.d.ts.map +1 -0
- package/dist/services/bridge-turn-journal.js +149 -0
- package/dist/services/bridge-turn-journal.js.map +1 -0
- package/dist/services/bridge-turn-queue.d.ts +18 -2
- package/dist/services/bridge-turn-queue.d.ts.map +1 -1
- package/dist/services/bridge-turn-queue.js +31 -10
- package/dist/services/bridge-turn-queue.js.map +1 -1
- package/dist/services/claude-transcript.d.ts +19 -0
- package/dist/services/claude-transcript.d.ts.map +1 -1
- package/dist/services/claude-transcript.js +57 -5
- package/dist/services/claude-transcript.js.map +1 -1
- package/dist/services/codex-app-threads.d.ts +2 -0
- package/dist/services/codex-app-threads.d.ts.map +1 -1
- package/dist/services/codex-app-threads.js +1 -1
- package/dist/services/codex-app-threads.js.map +1 -1
- package/dist/services/codex-reasoning-effort.d.ts +8 -0
- package/dist/services/codex-reasoning-effort.d.ts.map +1 -1
- package/dist/services/codex-reasoning-effort.js +24 -0
- package/dist/services/codex-reasoning-effort.js.map +1 -1
- package/dist/services/cursor-resume-policy.d.ts.map +1 -1
- package/dist/services/cursor-resume-policy.js +15 -3
- package/dist/services/cursor-resume-policy.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +3 -2
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/ordinary-turn-recovery.d.ts +76 -0
- package/dist/services/ordinary-turn-recovery.d.ts.map +1 -0
- package/dist/services/ordinary-turn-recovery.js +349 -0
- package/dist/services/ordinary-turn-recovery.js.map +1 -0
- package/dist/services/resume-fresh-policy.d.ts +17 -0
- package/dist/services/resume-fresh-policy.d.ts.map +1 -0
- package/dist/services/resume-fresh-policy.js +24 -0
- package/dist/services/resume-fresh-policy.js.map +1 -0
- package/dist/services/session-group-title.d.ts +1 -0
- package/dist/services/session-group-title.d.ts.map +1 -1
- package/dist/services/session-group-title.js +16 -1
- package/dist/services/session-group-title.js.map +1 -1
- package/dist/services/session-groups-store.d.ts +30 -0
- package/dist/services/session-groups-store.d.ts.map +1 -1
- package/dist/services/session-groups-store.js +26 -4
- package/dist/services/session-groups-store.js.map +1 -1
- package/dist/services/session-owner-notification.d.ts +9 -0
- package/dist/services/session-owner-notification.d.ts.map +1 -0
- package/dist/services/session-owner-notification.js +10 -0
- package/dist/services/session-owner-notification.js.map +1 -0
- package/dist/services/session-owner-reminder-config-store.d.ts +9 -0
- package/dist/services/session-owner-reminder-config-store.d.ts.map +1 -0
- package/dist/services/session-owner-reminder-config-store.js +24 -0
- package/dist/services/session-owner-reminder-config-store.js.map +1 -0
- package/dist/services/session-owner-reminder-store.d.ts +5 -0
- package/dist/services/session-owner-reminder-store.d.ts.map +1 -0
- package/dist/services/session-owner-reminder-store.js +54 -0
- package/dist/services/session-owner-reminder-store.js.map +1 -0
- package/dist/services/session-store.d.ts +98 -21
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +406 -48
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/structured-bridge-clis.d.ts +9 -5
- package/dist/services/structured-bridge-clis.d.ts.map +1 -1
- package/dist/services/structured-bridge-clis.js +9 -4
- package/dist/services/structured-bridge-clis.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts +1 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +6 -1
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/setup/bot-config-editor.d.ts.map +1 -1
- package/dist/setup/bot-config-editor.js +7 -2
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/setup/ensure-herdr-integrations.d.ts +13 -0
- package/dist/setup/ensure-herdr-integrations.d.ts.map +1 -1
- package/dist/setup/ensure-herdr-integrations.js +21 -3
- package/dist/setup/ensure-herdr-integrations.js.map +1 -1
- package/dist/setup/open-platform-automation.d.ts +1 -0
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +107 -7
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/skills/definitions.d.ts +17 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +78 -8
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/injection-mode.d.ts +24 -3
- package/dist/skills/injection-mode.d.ts.map +1 -1
- package/dist/skills/injection-mode.js +64 -15
- package/dist/skills/injection-mode.js.map +1 -1
- package/dist/skills/installer.d.ts +18 -0
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +45 -1
- package/dist/skills/installer.js.map +1 -1
- package/dist/types.d.ts +221 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +92 -2
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +195 -1
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/cli-usage-limit.d.ts +25 -14
- package/dist/utils/cli-usage-limit.d.ts.map +1 -1
- package/dist/utils/cli-usage-limit.js +25 -15
- package/dist/utils/cli-usage-limit.js.map +1 -1
- package/dist/utils/daemon-rejection-guard.d.ts +35 -0
- package/dist/utils/daemon-rejection-guard.d.ts.map +1 -0
- package/dist/utils/daemon-rejection-guard.js +101 -0
- package/dist/utils/daemon-rejection-guard.js.map +1 -0
- package/dist/utils/dashboard-env.d.ts +53 -0
- package/dist/utils/dashboard-env.d.ts.map +1 -0
- package/dist/utils/dashboard-env.js +116 -0
- package/dist/utils/dashboard-env.js.map +1 -0
- package/dist/utils/global-install.d.ts.map +1 -1
- package/dist/utils/global-install.js +61 -5
- package/dist/utils/global-install.js.map +1 -1
- package/dist/utils/install-diagnostics.d.ts +14 -7
- package/dist/utils/install-diagnostics.d.ts.map +1 -1
- package/dist/utils/install-diagnostics.js +20 -11
- package/dist/utils/install-diagnostics.js.map +1 -1
- package/dist/utils/local-dev-update.d.ts +132 -0
- package/dist/utils/local-dev-update.d.ts.map +1 -0
- package/dist/utils/local-dev-update.js +176 -0
- package/dist/utils/local-dev-update.js.map +1 -0
- package/dist/utils/pending-input-queue.d.ts +12 -1
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/worker.js +1551 -241
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +68 -68
- package/dist/workflows/events/payloads.d.ts +14 -14
- package/dist/workflows/events/schema.d.ts +312 -312
- package/dist/workflows/migration/v2-run-archive-schema.d.ts +40 -40
- package/dist/workflows/v3/cli-run.d.ts.map +1 -1
- package/dist/workflows/v3/cli-run.js +7 -0
- package/dist/workflows/v3/cli-run.js.map +1 -1
- package/dist/workflows/v3/goal-cli.d.ts +3 -0
- package/dist/workflows/v3/goal-cli.d.ts.map +1 -1
- package/dist/workflows/v3/goal-cli.js +11 -0
- package/dist/workflows/v3/goal-cli.js.map +1 -1
- package/dist/workflows/v3/run-envelope.d.ts +144 -144
- package/package.json +3 -2
- package/dist/cli/pm2-god-admission.d.ts +0 -8
- package/dist/cli/pm2-god-admission.d.ts.map +0 -1
- package/dist/cli/pm2-god-admission.js +0 -25
- package/dist/cli/pm2-god-admission.js.map +0 -1
- package/dist/core/riff-shutdown-detach.d.ts.map +0 -1
- package/dist/core/riff-shutdown-detach.js.map +0 -1
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +0 -1
- package/dist/core/riff-worker-shutdown-readiness.js.map +0 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-65SMZ4QY.js +0 -10
- package/dist/dashboard-web/chunks/chunk-G5XGBCM6.js +0 -1
- package/dist/dashboard-web/chunks/chunk-IKN6WURK.js +0 -1
- package/dist/dashboard-web/chunks/chunk-WLIVBPJC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-VDOOLS4M.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-7NYPKQSB.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-DYZZMKFL.js +0 -8
- package/dist/dashboard-web/chunks/settings-page-LIULUSMH.js +0 -2
package/dist/worker.js
CHANGED
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
* 7. On 'restart', kills CLI and re-spawns with --resume
|
|
14
14
|
*/
|
|
15
15
|
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
-
import { chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
|
|
16
|
+
import { accessSync, chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, constants as fsConstants } from 'node:fs';
|
|
17
17
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
18
|
-
import { join, basename, dirname } from 'node:path';
|
|
18
|
+
import { join, basename, dirname, delimiter } from 'node:path';
|
|
19
19
|
import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
|
|
20
20
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
|
|
22
|
+
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, evaluatePersistentPaneMigration, executePersistentPaneMigration, persistentTeardownKillKind, isolationPaneMarkerPath, policyOffTombstonePath, policyOffTombstoneContent, policyOffTombstoneValid, provenancePendingContent, provenancePendingNonce, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
|
|
23
23
|
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
|
|
24
|
-
import {
|
|
24
|
+
import { buildCloseResultMessage, normalizeDestroyResult, mayRestoreWriteAdmission, interpretAbortOutcome, classifyRestartTeardown } from './adapters/backend/destroy-result.js';
|
|
25
|
+
import { isRemoteBackendType, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
|
|
25
26
|
import { finalizeRawCommandDelivery, writeRawCommandLine } from './core/raw-command-writer.js';
|
|
26
27
|
import { rawCommandWriteOptionsFor } from './core/raw-command-write-options.js';
|
|
27
28
|
import { publishCliSessionIdToDaemon } from './core/cli-session-id-publisher.js';
|
|
@@ -41,22 +42,27 @@ import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
|
|
|
41
42
|
import { EffortConfirmDialogGuard } from './utils/effort-confirm-dialog.js';
|
|
42
43
|
import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
|
|
43
44
|
import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
|
|
44
|
-
import {
|
|
45
|
+
import { remoteWorkerShutdownInputBlocker } from './core/remote-worker-shutdown-readiness.js';
|
|
45
46
|
import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
|
|
46
47
|
import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
|
|
47
48
|
import { sanitizePerBotEnv } from './core/per-bot-env.js';
|
|
48
49
|
import { resolveChildBotsConfig } from './core/config-dir.js';
|
|
49
50
|
import { evaluateVcMeetingManagedSend, } from './services/vc-meeting-send-policy.js';
|
|
50
51
|
import { TurnTerminalDeduper } from './services/turn-terminal-deduper.js';
|
|
52
|
+
import { appendBridgeTurnJournalEntry, BridgeRestoreGate, clearBridgeTurnJournal, readBridgeTurnJournal, removeBridgeTurnJournalEntry, selectRestorableBridgeTurns, writeBridgeTurnJournal, } from './services/bridge-turn-journal.js';
|
|
51
53
|
import { defaultGatewayEntry, ensureGatewayEntry } from './core/plugins/mcp/gateway-installer.js';
|
|
52
|
-
import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
|
|
54
|
+
import { sessionMcpGatewayPathRegex, sessionMcpGatewaySocketPath, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
|
|
55
|
+
import { clearMcpGatewayLaunchRecord, mcpGatewayPaneReattachSafe, readMcpGatewayLaunchRecord, writeMcpGatewayLaunchRecord, } from './core/plugins/mcp/launch-record.js';
|
|
53
56
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
54
57
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
55
58
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
56
59
|
import { loadBotConfigs, resolveBrandLabel, resolveUsageDisplay, } from './bot-registry.js';
|
|
57
|
-
import { readGlobalConfig } from './global-config.js';
|
|
58
|
-
import {
|
|
60
|
+
import { readGlobalConfig, isWorkflowFeatureEnabled } from './global-config.js';
|
|
61
|
+
import { deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
62
|
+
import { deriveWorkerViewGeneration, looksLikeTerminalControlGrant, TERMINAL_VIEW_FORWARD_HEADER, verifyTerminalControlGrant, verifyTerminalViewForward, } from './core/terminal-control-grant.js';
|
|
63
|
+
import { appendControlAudit, controlAuditRecord } from './dashboard/control-audit.js';
|
|
59
64
|
import { readPlatformBinding } from './platform/binding.js';
|
|
65
|
+
import { buildPlatformDashboardLoginUrl } from './core/dashboard-url.js';
|
|
60
66
|
import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
|
|
61
67
|
import { InflightInputTracker } from './core/inflight-input-tracker.js';
|
|
62
68
|
import { InputTurnDeduper } from './core/input-turn-dedupe.js';
|
|
@@ -115,6 +121,9 @@ import { AmbiguousSubmissionBlockedError, isObserveBackend, } from './adapters/b
|
|
|
115
121
|
import { backendGateUserMessage, backendSandboxCompatibilityError, backendSandboxCompatibilityUserMessage, decideBackendGate, retireSupersededRecordedHerdrTarget, selectSessionBackend, } from './adapters/backend/session-backend-selector.js';
|
|
116
122
|
import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
|
|
117
123
|
import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
|
|
124
|
+
import { buildEffectiveChildEnv, buildEffectiveMojoConfig, findReservedMojoCliFlags, normalizeMojoConfig, normalizeMojoLivePatch } from './adapters/backend/mojo-types.js';
|
|
125
|
+
import { builtinSkillBlockForInjectsSessionContext } from './skills/injection-mode.js';
|
|
126
|
+
import { whiteboardEnabled } from './services/whiteboard-store.js';
|
|
118
127
|
import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
|
|
119
128
|
import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
|
|
120
129
|
import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
|
|
@@ -1166,15 +1175,38 @@ async function prepareCliPluginGenerationAndGateway(cfg, adapter) {
|
|
|
1166
1175
|
const manifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
|
|
1167
1176
|
stopSessionMcpGatewayHost();
|
|
1168
1177
|
if (adapter.mcpGateway && manifest?.entries.length) {
|
|
1169
|
-
|
|
1170
|
-
sessionId: cfg.sessionId,
|
|
1171
|
-
dataDir: config.session.dataDir,
|
|
1172
|
-
onError: error => log(`[mcp-gateway] host error: ${error.message}`),
|
|
1173
|
-
});
|
|
1178
|
+
await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
|
|
1174
1179
|
log(`[mcp-gateway] trusted host listening for ${manifest.entries.length} plugin server(s)`);
|
|
1175
1180
|
}
|
|
1181
|
+
else {
|
|
1182
|
+
clearMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId);
|
|
1183
|
+
}
|
|
1176
1184
|
return manifest;
|
|
1177
1185
|
}
|
|
1186
|
+
/** Bind the trusted MCP Gateway host at the session's DETERMINISTIC socket path
|
|
1187
|
+
* and persist its launch record. Deliberately does NOT refresh the plugin/Skill
|
|
1188
|
+
* generation: prepareCliPluginGenerationAndGateway does that for fresh/resumed
|
|
1189
|
+
* spawns, whereas a warm reattach to a surviving pane keeps the CLI's existing
|
|
1190
|
+
* catalog untouched — but the host itself died with the previous worker, so the
|
|
1191
|
+
* reattach path must still re-serve it here so the pane's surviving relay can
|
|
1192
|
+
* reconnect to the same path (see spawnCli's paneRelayReattachSafe branch).
|
|
1193
|
+
* The caller owns stopping any prior host first (prepare*) or gating on absence
|
|
1194
|
+
* (reattach). Token rotation + stale-socket removal are handled inside
|
|
1195
|
+
* startSessionMcpGatewayHost. The trusted-turn-identity provider (#917) is
|
|
1196
|
+
* wired here so BOTH the fresh/resumed host and the re-served reattach host
|
|
1197
|
+
* inject the per-turn host-signed caller — the reattach path must not lose it. */
|
|
1198
|
+
async function startAndRecordSessionMcpGatewayHost(sessionId) {
|
|
1199
|
+
sessionMcpGatewayHost = await startSessionMcpGatewayHost({
|
|
1200
|
+
sessionId,
|
|
1201
|
+
dataDir: config.session.dataDir,
|
|
1202
|
+
trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
|
|
1203
|
+
onError: error => log(`[mcp-gateway] host error: ${error.message}`),
|
|
1204
|
+
});
|
|
1205
|
+
// The NEXT worker generation reads this record to decide whether a surviving
|
|
1206
|
+
// persistent pane can be reattached (relay reconnects to the same path)
|
|
1207
|
+
// instead of killed for a cold-resume.
|
|
1208
|
+
writeMcpGatewayLaunchRecord(config.session.dataDir, sessionId, sessionMcpGatewayHost.socketPath);
|
|
1209
|
+
}
|
|
1178
1210
|
/** v2 read isolation — provision a bot's PER-BOT config dir under its BOT_HOME so the
|
|
1179
1211
|
* CLI (redirected there via CLAUDE_CONFIG_DIR/CODEX_HOME) starts fully set up despite
|
|
1180
1212
|
* the global ~/.claude|~/.codex being Seatbelt-denied. Idempotent (guards on
|
|
@@ -1449,7 +1481,7 @@ function backendScreenEvidenceIsAuthoritativeForMutation() {
|
|
|
1449
1481
|
* generation. The daemon receives this over private IPC; child-writable PID
|
|
1450
1482
|
* marker files remain diagnostics only. */
|
|
1451
1483
|
let currentCliCredentialIsolated = false;
|
|
1452
|
-
/** Successful
|
|
1484
|
+
/** Successful remote close prepare awaiting durable daemon commit. */
|
|
1453
1485
|
let preparedCloseRequestId = null;
|
|
1454
1486
|
let closeRequestInFlightId = null;
|
|
1455
1487
|
let lastAbortedCloseRequestId = null;
|
|
@@ -1478,8 +1510,14 @@ const readOnlyRemoteScrollLimiter = new ReadOnlyRemoteScrollLimiter({
|
|
|
1478
1510
|
// daemon restart re-forks every worker — a per-process random token would 403
|
|
1479
1511
|
// every previously-issued operate link).
|
|
1480
1512
|
let writeToken = randomBytes(16).toString('hex');
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1513
|
+
// Per-BOOT random read capability, reported to the daemon in `ready` and
|
|
1514
|
+
// embedded in Feishu card 「打开 Web 终端」 links. Deliberately NOT the stable
|
|
1515
|
+
// per-session HMAC any more (P1-5): a stable view token could never be revoked
|
|
1516
|
+
// — a viewer who fetched it once kept terminal read access forever, across
|
|
1517
|
+
// worker restarts included. Per-boot randomness bounds every card link to this
|
|
1518
|
+
// worker generation (restart ⇒ all previously issued view tokens die), and the
|
|
1519
|
+
// dashboard view-link API mints its own short-lived signed read grants instead
|
|
1520
|
+
// of ever handing this value out (see resolveTerminalAccessForReq).
|
|
1483
1521
|
let viewToken = randomBytes(32).toString('base64url');
|
|
1484
1522
|
// Active dashboard token, persisted by the dashboard process at this stable
|
|
1485
1523
|
// path (mirrors dashboard.ts TOKEN_PATH). The platform proxy injects it as the
|
|
@@ -1487,10 +1525,30 @@ let viewToken = randomBytes(32).toString('base64url');
|
|
|
1487
1525
|
// proves a request traversed the platform's authenticated front door.
|
|
1488
1526
|
const DASHBOARD_TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token');
|
|
1489
1527
|
const DASHBOARD_SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
|
|
1490
|
-
|
|
1528
|
+
// Test-only seam (inert in production): widen the SYNCHRONOUS `.dashboard-secret`
|
|
1529
|
+
// read that happens twice inside the terminal WS handshake — once at
|
|
1530
|
+
// `verifyClient`, once at the post-upgrade `connection` re-check — so the
|
|
1531
|
+
// integration test can land a capability's expiry inside that gap and prove the
|
|
1532
|
+
// second check still fail-closes it (see worker-terminal-read-auth P1-3). A real
|
|
1533
|
+
// slow HOME (NFS/slow disk) produces the same window in production. The old test
|
|
1534
|
+
// bloated the secret file with 32MB of whitespace to force this delay, which is
|
|
1535
|
+
// incompatible with #920's strict 0600 host-authority reader (its 256-byte cap
|
|
1536
|
+
// rejects a padded file); this env-gated busy-wait reproduces the timing without
|
|
1537
|
+
// an oversized or otherwise unsafe credential file. Only ever set by that test.
|
|
1538
|
+
const HANDSHAKE_SECRET_READ_DELAY_MS = Number.isFinite(Number(process.env.BOTMUX_TEST_TERMINAL_SECRET_READ_DELAY_MS))
|
|
1539
|
+
? Math.max(0, Number(process.env.BOTMUX_TEST_TERMINAL_SECRET_READ_DELAY_MS))
|
|
1540
|
+
: 0;
|
|
1541
|
+
/** Read the dashboard secret on the terminal WS-handshake path. Identical to
|
|
1542
|
+
* {@link loadDashboardSecret} in production; adds a bounded synchronous delay
|
|
1543
|
+
* ONLY when the test seam env var is set, to make the handshake read window
|
|
1544
|
+
* observable without an oversized secret file. */
|
|
1545
|
+
function loadHandshakeSecret() {
|
|
1491
1546
|
const secret = loadDashboardSecret(DASHBOARD_SECRET_PATH);
|
|
1492
|
-
if (
|
|
1493
|
-
|
|
1547
|
+
if (HANDSHAKE_SECRET_READ_DELAY_MS > 0) {
|
|
1548
|
+
const until = Date.now() + HANDSHAKE_SECRET_READ_DELAY_MS;
|
|
1549
|
+
while (Date.now() < until) { /* busy-wait: mimic a slow synchronous fs read */ }
|
|
1550
|
+
}
|
|
1551
|
+
return secret;
|
|
1494
1552
|
}
|
|
1495
1553
|
/** Re-derive the stable write (operate) token from the host-only dashboard
|
|
1496
1554
|
* secret so a restarted worker mints the SAME token — keeping already-issued
|
|
@@ -1502,21 +1560,97 @@ function refreshTerminalWriteToken() {
|
|
|
1502
1560
|
writeToken = deriveTerminalWriteToken(secret, sessionId);
|
|
1503
1561
|
}
|
|
1504
1562
|
/**
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
* ./core/terminal-write-auth for the full rationale.
|
|
1513
|
-
*
|
|
1514
|
-
* Both the binding and the token are read PER REQUEST, never snapshotted:
|
|
1515
|
-
* `botmux bind`/unbind and dashboard token rotation are hot-reloaded without
|
|
1516
|
-
* restarting this worker, so a cached value would go stale.
|
|
1563
|
+
* Owner-login URL for THIS session's read-only web terminal, or '' when the
|
|
1564
|
+
* machine is unbound / remote access is off (banner then shows non-link text).
|
|
1565
|
+
* `next=/s/<sessionId>` is what makes the round-trip land the owner back on a
|
|
1566
|
+
* writable terminal: the platform routes a `/s/`-prefixed next to the terminal
|
|
1567
|
+
* subdomain surface and mints the host-only proxy-session cookie there, which
|
|
1568
|
+
* is the credential the owner write-check reads (the cross-subdomain SSO cookie
|
|
1569
|
+
* alone does not). See buildPlatformDashboardLoginUrl.
|
|
1517
1570
|
*/
|
|
1571
|
+
function deriveTerminalLoginUrl(sid) {
|
|
1572
|
+
return buildPlatformDashboardLoginUrl(`/s/${encodeURIComponent(sid)}`) ?? '';
|
|
1573
|
+
}
|
|
1518
1574
|
function resolveTerminalAccessForReq(req, url) {
|
|
1519
|
-
|
|
1575
|
+
// P1-6 invariant: an explicit, matching write-link `?token=` is an
|
|
1576
|
+
// independent capability with the HIGHEST priority. It wins outright — before
|
|
1577
|
+
// the internal control-grant header is even looked at — so a central proxy
|
|
1578
|
+
// that attaches a read-scope grant can never silently downgrade an explicitly
|
|
1579
|
+
// issued 「操作链接」 to read-only for a viewer the platform authenticated as
|
|
1580
|
+
// teammate/guest. (The front proxy additionally refrains from injecting a
|
|
1581
|
+
// grant next to explicit query capabilities; this ordering makes the
|
|
1582
|
+
// guarantee hold regardless of proxy behavior, not by accident of it.)
|
|
1583
|
+
if (safeTerminalTokenEqual(url.searchParams.get('token'), writeToken)) {
|
|
1584
|
+
return { hasRead: true, hasWrite: true, platformReadonly: false };
|
|
1585
|
+
}
|
|
1586
|
+
// `?viewToken=` read capability, two accepted forms (P1-5):
|
|
1587
|
+
// • this worker's per-boot random token (Feishu card links) — plain
|
|
1588
|
+
// equality; dies with the worker generation;
|
|
1589
|
+
// • a short-lived signed read grant minted by the dashboard view-link API.
|
|
1590
|
+
// The retired stable per-session HMAC matches neither form, so every
|
|
1591
|
+
// previously issued stable view token fails closed on this worker.
|
|
1592
|
+
//
|
|
1593
|
+
// The signed form is accepted ONLY when all four hold, because this worker
|
|
1594
|
+
// has no view of dashboard logout state and a URL is trivially copied:
|
|
1595
|
+
// 1. signature + expiry + session binding verify (stateless base check);
|
|
1596
|
+
// 2. scope is `read` — a leaked write-scope loopback grant must never
|
|
1597
|
+
// double as a browser-shareable capability;
|
|
1598
|
+
// 3. audience is `central` AND the central front proxy countersigned THIS
|
|
1599
|
+
// capability (X-Botmux-Terminal-View). That header is computed from the
|
|
1600
|
+
// host-only dashboard secret and is only emitted after the proxy checked
|
|
1601
|
+
// the capability's auth session is still live, so a raw view URL replayed
|
|
1602
|
+
// against this port — or against the daemon's own network-bound `/s/`
|
|
1603
|
+
// reverse proxy — cannot produce it and dies here;
|
|
1604
|
+
// 4. the pinned worker generation matches this boot. A restart re-randomizes
|
|
1605
|
+
// `viewToken`, so grants minted for the previous boot fail closed even
|
|
1606
|
+
// though `.dashboard-secret` is unchanged.
|
|
1607
|
+
// The WebSocket is additionally closed at the grant's expiresAt.
|
|
1608
|
+
const viewParam = url.searchParams.get('viewToken');
|
|
1609
|
+
let viewTokenMatches = safeTerminalTokenEqual(viewParam, viewToken);
|
|
1610
|
+
let viewGrantUser;
|
|
1611
|
+
let viewGrantExpiresAt;
|
|
1612
|
+
if (!viewTokenMatches && looksLikeTerminalControlGrant(viewParam) && sessionId) {
|
|
1613
|
+
const secret = loadHandshakeSecret();
|
|
1614
|
+
if (secret && verifyTerminalViewForward(secret, viewParam, req.headers[TERMINAL_VIEW_FORWARD_HEADER])) {
|
|
1615
|
+
const viewGrant = verifyTerminalControlGrant(secret, viewParam, sessionId);
|
|
1616
|
+
if (viewGrant.ok
|
|
1617
|
+
&& viewGrant.claims.scope === 'read'
|
|
1618
|
+
&& viewGrant.claims.audience === 'central'
|
|
1619
|
+
&& viewGrant.claims.workerGeneration === deriveWorkerViewGeneration(secret, viewToken)) {
|
|
1620
|
+
viewTokenMatches = true;
|
|
1621
|
+
viewGrantUser = viewGrant.claims.userId;
|
|
1622
|
+
viewGrantExpiresAt = viewGrant.claims.expiresAt;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
const legacy = resolveTerminalAccessForRequest(req.headers, false, viewTokenMatches, () => readPlatformBinding() !== null, () => loadPersistedToken(DASHBOARD_TOKEN_PATH));
|
|
1627
|
+
if (viewGrantExpiresAt !== undefined && legacy.hasRead && !legacy.hasWrite) {
|
|
1628
|
+
legacy.auditUser = viewGrantUser;
|
|
1629
|
+
legacy.controlExpiresAt = viewGrantExpiresAt;
|
|
1630
|
+
}
|
|
1631
|
+
// Most terminal HTTP/WS requests use the query capabilities above. Avoid a
|
|
1632
|
+
// second synchronous secret-file read on that hot path; only the central
|
|
1633
|
+
// front proxy supplies this internal header.
|
|
1634
|
+
if (req.headers['x-botmux-terminal-control'] === undefined)
|
|
1635
|
+
return legacy;
|
|
1636
|
+
const secret = loadHandshakeSecret();
|
|
1637
|
+
if (!secret || !sessionId)
|
|
1638
|
+
return legacy;
|
|
1639
|
+
const grant = verifyTerminalControlGrant(secret, req.headers['x-botmux-terminal-control'], sessionId);
|
|
1640
|
+
if (!grant.ok)
|
|
1641
|
+
return legacy;
|
|
1642
|
+
return {
|
|
1643
|
+
hasRead: true,
|
|
1644
|
+
hasWrite: grant.claims.scope === 'write',
|
|
1645
|
+
platformReadonly: grant.claims.scope === 'read',
|
|
1646
|
+
auditUser: grant.claims.userId,
|
|
1647
|
+
controlExpiresAt: grant.claims.expiresAt,
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
function auditTerminalInput(user, data) {
|
|
1651
|
+
appendControlAudit(controlAuditRecord(user ?? 'legacy-capability', sessionId, 'terminal.input', {
|
|
1652
|
+
bytes: Buffer.byteLength(data),
|
|
1653
|
+
}));
|
|
1520
1654
|
}
|
|
1521
1655
|
/** Lazily-written locked-mode zellij config for per-WS web-terminal attach
|
|
1522
1656
|
* clients: cleared keybinds + locked mode so every keystroke passes straight
|
|
@@ -1544,7 +1678,7 @@ let closeRequested = false;
|
|
|
1544
1678
|
let capturedSpawnCommand = null;
|
|
1545
1679
|
let deferredTopicOutputTail = '';
|
|
1546
1680
|
const reportedDeferredTopicRoots = new Set();
|
|
1547
|
-
const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', opencode2: 'OpenCode 2', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff', reasonix: 'Reasonix', dsh: 'DeepSeek Harness' };
|
|
1681
|
+
const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', opencode2: 'OpenCode 2', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff', reasonix: 'Reasonix', dsh: 'DeepSeek Harness', mojo: 'Mojo' };
|
|
1548
1682
|
function cliName() {
|
|
1549
1683
|
return (lastInitConfig?.cliRuntime?.source === 'configured'
|
|
1550
1684
|
? (lastInitConfig.cliRuntime.displayName?.trim() || lastInitConfig.cliRuntime.id)
|
|
@@ -1797,11 +1931,12 @@ async function runStartupCommands() {
|
|
|
1797
1931
|
return;
|
|
1798
1932
|
if (!backend)
|
|
1799
1933
|
return;
|
|
1800
|
-
//
|
|
1801
|
-
// 200ms 后 write 回车),对
|
|
1802
|
-
// riff 的初始化命令走自己的 riff.setupCommands
|
|
1803
|
-
|
|
1804
|
-
|
|
1934
|
+
// 远端后端:generic startupCommands 是 PTY 语义(sendRawCommandLine = write 文本 +
|
|
1935
|
+
// 200ms 后 write 回车),对 riff/mojo 每条会裂成两个独立远端 turn 并打乱血缘。
|
|
1936
|
+
// riff 的初始化命令走自己的 riff.setupCommands(沙箱内执行);mojo 无对应机制,
|
|
1937
|
+
// 其环境准备由 --cloud 沙箱镜像负责。两者这里都必须跳过。
|
|
1938
|
+
if (effectiveBackendType === 'riff' || effectiveBackendType === 'mojo') {
|
|
1939
|
+
log(`Skipping ${cmds.length} generic startup command(s) — ${effectiveBackendType} backend has no PTY to drive`);
|
|
1805
1940
|
return;
|
|
1806
1941
|
}
|
|
1807
1942
|
log(`Running ${cmds.length} startup command(s) before first prompt`);
|
|
@@ -1964,13 +2099,20 @@ async function deliverRawInput(msg) {
|
|
|
1964
2099
|
let recoveryFailureReason;
|
|
1965
2100
|
try {
|
|
1966
2101
|
await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
|
|
2102
|
+
// A passthrough (/compact, /model, ...) lands as a REAL mojo turn, so the
|
|
2103
|
+
// credential snapshot has to be applied here — at the write moment, which
|
|
2104
|
+
// is the same point flushPending applies it for a queued message. Without
|
|
2105
|
+
// this, a cleared or rotated JWT did not take effect on these turns.
|
|
2106
|
+
applyMojoLivePatch(msg.mojoLivePatch);
|
|
1967
2107
|
renderer?.markNewTurn();
|
|
1968
2108
|
usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
1969
2109
|
if (tmuxScrolledHalfPages > 0)
|
|
1970
2110
|
exitTmuxScrollMode();
|
|
1971
2111
|
currentBotmuxTurnId = msg.turnId;
|
|
1972
2112
|
currentBotmuxDispatchAttempt = undefined;
|
|
2113
|
+
currentGatewayTrustedCaller = undefined;
|
|
1973
2114
|
currentVcMeetingImTurnOrigin = undefined;
|
|
2115
|
+
stampMojoTurnMark(msg.turnId, undefined); // a passthrough IS a mojo turn
|
|
1974
2116
|
writeCliPidMarker();
|
|
1975
2117
|
publishSandboxRelayCapability();
|
|
1976
2118
|
});
|
|
@@ -2070,7 +2212,7 @@ async function deliverRawInput(msg) {
|
|
|
2070
2212
|
...(followUpContent
|
|
2071
2213
|
? {
|
|
2072
2214
|
writeFollowUp: async () => {
|
|
2073
|
-
const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, fence);
|
|
2215
|
+
const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, undefined, fence);
|
|
2074
2216
|
if (result === 'stale-before-write') {
|
|
2075
2217
|
send({
|
|
2076
2218
|
type: 'user_notify',
|
|
@@ -2113,8 +2255,16 @@ const inflightInputs = new InflightInputTracker();
|
|
|
2113
2255
|
let lastPtyActivityAtMs = 0;
|
|
2114
2256
|
let currentBotmuxTurnId;
|
|
2115
2257
|
let currentBotmuxDispatchAttempt;
|
|
2258
|
+
let currentGatewayTrustedCaller;
|
|
2116
2259
|
let currentVcMeetingImTurnOrigin;
|
|
2117
2260
|
let durableTurnInFlight = false;
|
|
2261
|
+
function currentGatewayTrustedTurnIdentity() {
|
|
2262
|
+
return {
|
|
2263
|
+
...(currentGatewayTrustedCaller ? { caller: currentGatewayTrustedCaller } : {}),
|
|
2264
|
+
...(currentBotmuxTurnId ? { turnId: currentBotmuxTurnId } : {}),
|
|
2265
|
+
...(currentBotmuxDispatchAttempt !== undefined ? { dispatchAttempt: currentBotmuxDispatchAttempt } : {}),
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2118
2268
|
function publishSandboxRelayCapability(opts = {}) {
|
|
2119
2269
|
const daemonIpcPort = parseDaemonIpcPort(process.env.BOTMUX_DAEMON_IPC_PORT);
|
|
2120
2270
|
const capability = {
|
|
@@ -3238,26 +3388,30 @@ function codexAppLivenessStatus(base, nowMs = Date.now()) {
|
|
|
3238
3388
|
}
|
|
3239
3389
|
/**
|
|
3240
3390
|
* True when this CLI has an authoritative STRUCTURED rate-limit signal that is
|
|
3241
|
-
* actually PUBLISHED as a `limited` screen_update
|
|
3242
|
-
* whose `bridgeIngest → maybeEmitStructuredRateLimit()` reads the
|
|
3243
|
-
* `error:"rate_limit"` record
|
|
3244
|
-
*
|
|
3245
|
-
*
|
|
3246
|
-
*
|
|
3247
|
-
* limit
|
|
3248
|
-
*
|
|
3249
|
-
*
|
|
3391
|
+
* actually PUBLISHED as a `limited` screen_update. Two families qualify: the
|
|
3392
|
+
* Claude family, whose `bridgeIngest → maybeEmitStructuredRateLimit()` reads the
|
|
3393
|
+
* transcript's `error:"rate_limit"` record; and codex, whose
|
|
3394
|
+
* `maybeEmitCodexStructuredRateLimit()` reads the rollout's `codex_rate_limited`
|
|
3395
|
+
* terminal (`isCodexRateLimitEvent`). For those CLIs the screen-text `rate`
|
|
3396
|
+
* heuristic is not just redundant but harmful: the model's own output or a dev
|
|
3397
|
+
* editing rate-limit code/tests puts phrases like "429 Too Many Requests" /
|
|
3398
|
+
* "exceeded retry limit" on screen, which the scraper cannot distinguish from a
|
|
3399
|
+
* real limit. So we suppress the screen-scan `rate` verdict and let the
|
|
3400
|
+
* structured path be the sole authority. `usage` (quota "hit your limit …") has
|
|
3401
|
+
* no structured equivalent yet, so it still comes from the screen.
|
|
3250
3402
|
*
|
|
3251
|
-
* Gate on `claudeDataDir`
|
|
3252
|
-
*
|
|
3253
|
-
* structured rate-limit EMIT only exists
|
|
3254
|
-
*
|
|
3255
|
-
*
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3259
|
-
*
|
|
3260
|
-
*
|
|
3403
|
+
* Gate on the two capability fields (`claudeDataDir` for the Claude family;
|
|
3404
|
+
* `emitsStructuredRateLimit` for codex), NOT on `reliableTurnTerminal`. The
|
|
3405
|
+
* structured rate-limit EMIT only exists where those fields are set: codex's
|
|
3406
|
+
* emit runs under the `structuredBridgeIsCodex()` gate, so among the
|
|
3407
|
+
* codexBridgeQueue CLIs only codex carries the flag. The rest (grok / traex /
|
|
3408
|
+
* pi / hermes / mtr / cursor) map an `error` terminal to a failed/ambiguous
|
|
3409
|
+
* receipt but publish NO `limited` state — suppressing their screen `rate`
|
|
3410
|
+
* verdict would silently drop the Dashboard「需要你」signal + backoff on a real
|
|
3411
|
+
* 429. Most set `reliableTurnTerminal`, so gating on that flag would wrongly
|
|
3412
|
+
* suppress them; gating on the explicit capability fields keeps the split
|
|
3413
|
+
* exact. (A future structured rate-limit emit for another CLI just sets its
|
|
3414
|
+
* `emitsStructuredRateLimit`.)
|
|
3261
3415
|
*/
|
|
3262
3416
|
function structuredRateLimitAuthoritative() {
|
|
3263
3417
|
return isStructuredRateLimitAuthoritative(cliAdapter);
|
|
@@ -3388,6 +3542,19 @@ const bridgeSecondaryPaths = new Map(); // path → offset
|
|
|
3388
3542
|
let bridgeOffset = 0;
|
|
3389
3543
|
let bridgePendingTail = '';
|
|
3390
3544
|
const bridgeQueue = new BridgeTurnQueue();
|
|
3545
|
+
/** Journal-restore of interrupted Lark turns is allowed AT MOST ONCE per worker
|
|
3546
|
+
* process, and only on the FIRST baseline this process runs. Rationale: the
|
|
3547
|
+
* journal exists to recover a turn that a *cross-process* death (daemon
|
|
3548
|
+
* restart / worker crash) orphaned — there the whole worker is new and no
|
|
3549
|
+
* other recovery owner survives. An *in-worker* CLI restart (crash-respawn,
|
|
3550
|
+
* cwd-move, durable lease expiry) keeps this process alive, so its
|
|
3551
|
+
* InflightInputTracker carryover already re-delivers the interrupted plain-IM
|
|
3552
|
+
* input (a full fresh answer) — letting the journal ALSO restore that turn
|
|
3553
|
+
* would post the recovered partial AND the fresh answer (same turnId, different
|
|
3554
|
+
* lastUuid → daemon dedupe can't collapse them). Armed on every watcher
|
|
3555
|
+
* teardown so restore is confined to the one baseline with no competing
|
|
3556
|
+
* recovery owner. */
|
|
3557
|
+
const bridgeRestoreGate = new BridgeRestoreGate();
|
|
3391
3558
|
/** uuids of structured transcript rate-limit records already turned into a
|
|
3392
3559
|
* `limited` emit. Readers may re-read from offset 0 after rotation, so the
|
|
3393
3560
|
* stable record uuid keeps the notification idempotent. */
|
|
@@ -3549,6 +3716,46 @@ function bridgeMarkerPath() {
|
|
|
3549
3716
|
return undefined;
|
|
3550
3717
|
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
3551
3718
|
}
|
|
3719
|
+
/** Durable journal of pending Lark bridge turns (services/bridge-turn-journal).
|
|
3720
|
+
* Written at mark time, cleared at every turn-terminal outcome; consulted by
|
|
3721
|
+
* bridgeAbsorbBaseline after a restart so an interrupted turn's output can
|
|
3722
|
+
* still reach Lark. Sibling of the turn-sends marker file. */
|
|
3723
|
+
function bridgeTurnJournalFilePath() {
|
|
3724
|
+
if (!process.env.SESSION_DATA_DIR || !sessionId)
|
|
3725
|
+
return undefined;
|
|
3726
|
+
return join(process.env.SESSION_DATA_DIR, 'turn-marks', `${sessionId}.json`);
|
|
3727
|
+
}
|
|
3728
|
+
function journalBridgeTurnMark(entry) {
|
|
3729
|
+
const path = bridgeTurnJournalFilePath();
|
|
3730
|
+
if (!path)
|
|
3731
|
+
return;
|
|
3732
|
+
try {
|
|
3733
|
+
appendBridgeTurnJournalEntry(path, entry);
|
|
3734
|
+
}
|
|
3735
|
+
catch (err) {
|
|
3736
|
+
log(`Bridge turn journal write failed (${err.message}) — restart recovery unavailable for turn ${entry.turnId.substring(0, 8)}`);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
function journalBridgeTurnClear(turnId, dispatchAttempt) {
|
|
3740
|
+
const path = bridgeTurnJournalFilePath();
|
|
3741
|
+
if (!path)
|
|
3742
|
+
return;
|
|
3743
|
+
try {
|
|
3744
|
+
removeBridgeTurnJournalEntry(path, turnId, dispatchAttempt);
|
|
3745
|
+
}
|
|
3746
|
+
catch (err) {
|
|
3747
|
+
log(`Bridge turn journal clear failed (${err.message}) for turn ${turnId.substring(0, 8)}`);
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
function clearBridgeTurnJournalFile() {
|
|
3751
|
+
const path = bridgeTurnJournalFilePath();
|
|
3752
|
+
if (!path)
|
|
3753
|
+
return;
|
|
3754
|
+
try {
|
|
3755
|
+
clearBridgeTurnJournal(path);
|
|
3756
|
+
}
|
|
3757
|
+
catch { /* best-effort — session is closing */ }
|
|
3758
|
+
}
|
|
3552
3759
|
function readSendMarkers() {
|
|
3553
3760
|
if (closeRequested)
|
|
3554
3761
|
return [];
|
|
@@ -3591,6 +3798,81 @@ function notifyExplicitReplyObserved(turnId, marker) {
|
|
|
3591
3798
|
...(marker.messageId ? { messageId: marker.messageId } : {}),
|
|
3592
3799
|
});
|
|
3593
3800
|
}
|
|
3801
|
+
// ─── Mojo final-answer bridge ───────────────────────────────────────────────
|
|
3802
|
+
// A headless backend has no terminal for the user to fall back on: if the agent
|
|
3803
|
+
// answers without calling `botmux send`, the answer exists only inside the
|
|
3804
|
+
// worker's synthesised screen (the streaming card) and the thread stays silent.
|
|
3805
|
+
// So mojo's per-turn `result` text is bridged into the thread through the SAME
|
|
3806
|
+
// gate the transcript-driven CLIs use — no second dedup policy: the sentinel and
|
|
3807
|
+
// the "already sent this turn" window rules live in shouldSuppressBridgeEmit.
|
|
3808
|
+
//
|
|
3809
|
+
// The window's lower bound is this record, stamped immediately before the turn's
|
|
3810
|
+
// literal write (see prepareNormalWrite) so a still-running previous turn's send
|
|
3811
|
+
// cannot land inside it. There is no upper bound to compute: mojo serialises its
|
|
3812
|
+
// turns (one child per turn; queued follow-ups wait for prompt-ready), so at the
|
|
3813
|
+
// moment the gate runs no later turn has started and no later send can exist.
|
|
3814
|
+
let mojoTurnMark = null;
|
|
3815
|
+
/** Stamp the send window at a mojo turn's literal write. Both write paths call
|
|
3816
|
+
* it (queued message flush + passthrough slash command), each right where it
|
|
3817
|
+
* rotates currentBotmuxTurnId — that pairing is what the staleness check in
|
|
3818
|
+
* deliverMojoTurnFinal relies on. */
|
|
3819
|
+
function stampMojoTurnMark(turnId, dispatchAttempt) {
|
|
3820
|
+
if (effectiveBackendType !== 'mojo')
|
|
3821
|
+
return;
|
|
3822
|
+
mojoTurnMark = { turnId, dispatchAttempt, markTimeMs: Date.now() };
|
|
3823
|
+
}
|
|
3824
|
+
function deliverMojoTurnFinal(text) {
|
|
3825
|
+
if (!text.trim())
|
|
3826
|
+
return;
|
|
3827
|
+
// A mark from an EARLIER turn must not gate this one: its window opened
|
|
3828
|
+
// before the previous turn's sends, which would suppress this answer for the
|
|
3829
|
+
// wrong reason. Falling back to an unmarked window degrades to "always
|
|
3830
|
+
// deliver" (shouldSuppressBridgeEmit needs markTimeMs to suppress on sends),
|
|
3831
|
+
// which is the safe direction for a bug whose symptom is silence.
|
|
3832
|
+
const mark = mojoTurnMark && mojoTurnMark.turnId === currentBotmuxTurnId ? mojoTurnMark : null;
|
|
3833
|
+
const turnId = mark?.turnId ?? currentBotmuxTurnId;
|
|
3834
|
+
// Without a turn id the daemon cannot resolve a reply target, and lastUuid
|
|
3835
|
+
// would not dedupe a retry. Dropping is right: this can only be output that
|
|
3836
|
+
// belongs to no Lark turn.
|
|
3837
|
+
if (!turnId) {
|
|
3838
|
+
log('Mojo final bridge skipped: no turn id for this answer');
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
const dispatchAttempt = mark?.dispatchAttempt ?? currentBotmuxDispatchAttempt;
|
|
3842
|
+
// adoptMode is structurally impossible for mojo (no pane to adopt); passing
|
|
3843
|
+
// the real flag keeps this call identical in shape to the other gate callers.
|
|
3844
|
+
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
3845
|
+
const markers = adoptMode ? [] : readSendMarkers();
|
|
3846
|
+
const gateInput = {
|
|
3847
|
+
markTimeMs: mark?.markTimeMs,
|
|
3848
|
+
isLocal: false,
|
|
3849
|
+
finalText: text,
|
|
3850
|
+
};
|
|
3851
|
+
if (shouldSuppressBridgeEmit(gateInput, undefined, markers, adoptMode)) {
|
|
3852
|
+
log(`Mojo final bridge suppressed for turn ${turnId.substring(0, 12)} `
|
|
3853
|
+
+ `(${isBridgeNothingToSendFinal(text) ? 'nothing-to-send sentinel' : 'model already called botmux send'})`);
|
|
3854
|
+
// Same as the structured bridge: an explicit send IS this turn's reply, so
|
|
3855
|
+
// tell observers rather than leaving them waiting on a final that the gate
|
|
3856
|
+
// deliberately swallowed.
|
|
3857
|
+
notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, undefined, markers, adoptMode));
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
// Strip a trailing sentinel line: "prose + sentinel" with no send is the
|
|
3861
|
+
// "did the work, forgot to send" shape — post the prose, never the token.
|
|
3862
|
+
const postContent = bridgePostText(text, adoptMode);
|
|
3863
|
+
if (!postContent.trim())
|
|
3864
|
+
return;
|
|
3865
|
+
send({
|
|
3866
|
+
type: 'final_output',
|
|
3867
|
+
content: postContent,
|
|
3868
|
+
// Stable per turn: the daemon derives both its dedupe key and the Lark
|
|
3869
|
+
// idempotency uuid from this, so a retry collapses into one message.
|
|
3870
|
+
lastUuid: turnId,
|
|
3871
|
+
turnId,
|
|
3872
|
+
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
3873
|
+
});
|
|
3874
|
+
log(`Mojo final bridge delivered ${postContent.length} chars for turn ${turnId.substring(0, 12)}`);
|
|
3875
|
+
}
|
|
3594
3876
|
function submitActivityEvidenceSince(sinceMs) {
|
|
3595
3877
|
if (lastPtyActivityAtMs > sinceMs)
|
|
3596
3878
|
return 'pty-output';
|
|
@@ -3724,6 +4006,13 @@ function bridgeAbsorbBaseline() {
|
|
|
3724
4006
|
if (!bridgeJsonlPath)
|
|
3725
4007
|
return;
|
|
3726
4008
|
if (!lastInitConfig?.adoptMode) {
|
|
4009
|
+
// Restart recovery: if the previous generation left pending Lark turns in
|
|
4010
|
+
// the durable journal (worker/daemon died mid-turn), re-mark them and
|
|
4011
|
+
// baseline BEHIND their recorded offsets instead of skipping to EOF — the
|
|
4012
|
+
// interrupted turn's user line and partial assistant output are then
|
|
4013
|
+
// re-attributed and delivered through the normal fallback gate.
|
|
4014
|
+
if (tryRestoreInterruptedBridgeTurns())
|
|
4015
|
+
return;
|
|
3727
4016
|
const cursor = baselineJsonlCursor(bridgeJsonlPath);
|
|
3728
4017
|
bridgeOffset = cursor.newOffset;
|
|
3729
4018
|
bridgePendingTail = cursor.pendingTail;
|
|
@@ -3743,6 +4032,72 @@ function bridgeAbsorbBaseline() {
|
|
|
3743
4032
|
if (lastInitConfig?.adoptMode)
|
|
3744
4033
|
maybeEmitAdoptPreamble(result.events);
|
|
3745
4034
|
}
|
|
4035
|
+
/** Restore journal-persisted pending turns after a restart interrupted them.
|
|
4036
|
+
*
|
|
4037
|
+
* Returns true when it established the bridge cursor itself (restore mode);
|
|
4038
|
+
* false when there is nothing to restore and the caller should run the
|
|
4039
|
+
* normal baseline-to-EOF.
|
|
4040
|
+
*
|
|
4041
|
+
* Scope guards (all deliberate):
|
|
4042
|
+
* - non-adopt only (caller gates);
|
|
4043
|
+
* - same transcript file only — a sessionId rotation between generations
|
|
4044
|
+
* invalidates offsets and risks cross-file mis-attribution;
|
|
4045
|
+
* - bounded age (BRIDGE_TURN_RESTORE_MAX_AGE_MS) — a partial answer from
|
|
4046
|
+
* days ago is noise, not recovery.
|
|
4047
|
+
*
|
|
4048
|
+
* Safety properties:
|
|
4049
|
+
* - events before the earliest mark (minus the same 5s guard the
|
|
4050
|
+
* fingerprint switch uses) are ABSORBED as history, so old turns are
|
|
4051
|
+
* never re-emitted;
|
|
4052
|
+
* - a stray prior-turn assistant tail after the cutoff can only synthesize
|
|
4053
|
+
* a local turn, which the non-adopt emit gate always suppresses;
|
|
4054
|
+
* - a restored mark whose user line never landed in the jsonl simply
|
|
4055
|
+
* never starts, and pruneExpired clears it (journal entry included) on
|
|
4056
|
+
* the first idle tick;
|
|
4057
|
+
* - the send-marker gate still applies at emit — turn-sends markers
|
|
4058
|
+
* persist across restarts, so an answer the model already `botmux
|
|
4059
|
+
* send`-ed before the kill stays suppressed. */
|
|
4060
|
+
function tryRestoreInterruptedBridgeTurns() {
|
|
4061
|
+
// At-most-once, and only on this worker process's FIRST bridge baseline. Any
|
|
4062
|
+
// baseline that runs after a watcher teardown is an in-worker CLI restart
|
|
4063
|
+
// whose InflightInputTracker carryover already re-delivers the interrupted
|
|
4064
|
+
// turn — restoring here too would double-deliver (see bridgeRestoreGate,
|
|
4065
|
+
// armed in stopBridgeWatcher).
|
|
4066
|
+
if (!bridgeRestoreGate.mayRestoreOnThisBaseline())
|
|
4067
|
+
return false;
|
|
4068
|
+
const journalPath = bridgeTurnJournalFilePath();
|
|
4069
|
+
if (!journalPath || !bridgeJsonlPath)
|
|
4070
|
+
return false;
|
|
4071
|
+
const entries = readBridgeTurnJournal(journalPath);
|
|
4072
|
+
if (entries.length === 0)
|
|
4073
|
+
return false;
|
|
4074
|
+
const restorable = selectRestorableBridgeTurns(entries, { currentJsonlPath: bridgeJsonlPath });
|
|
4075
|
+
if (restorable.length !== entries.length) {
|
|
4076
|
+
// Entries for rotated-away files or beyond the age cap can never restore —
|
|
4077
|
+
// drop them now so they don't linger across future generations.
|
|
4078
|
+
try {
|
|
4079
|
+
writeBridgeTurnJournal(journalPath, restorable);
|
|
4080
|
+
}
|
|
4081
|
+
catch { /* best-effort */ }
|
|
4082
|
+
}
|
|
4083
|
+
if (restorable.length === 0)
|
|
4084
|
+
return false;
|
|
4085
|
+
for (const entry of restorable) {
|
|
4086
|
+
bridgeQueue.mark(entry.turnId, entry.fingerprint, entry.markTimeMs, entry.contentNormalized, entry.dispatchAttempt, { restoredFromJournal: true });
|
|
4087
|
+
}
|
|
4088
|
+
const fromOffset = Math.min(...restorable.map(entry => entry.offsetAtMark));
|
|
4089
|
+
const drained = drainTranscript(bridgeJsonlPath, fromOffset);
|
|
4090
|
+
bridgeOffset = drained.newOffset;
|
|
4091
|
+
bridgePendingTail = drained.pendingTail;
|
|
4092
|
+
const cutoffMs = Math.min(...restorable.map(entry => entry.markTimeMs)) - 5_000;
|
|
4093
|
+
const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
|
|
4094
|
+
bridgeQueue.absorb(history);
|
|
4095
|
+
if (live.length > 0)
|
|
4096
|
+
bridgeQueue.ingest(live, bridgeJsonlPath);
|
|
4097
|
+
bridgeBaselineDone = true;
|
|
4098
|
+
log(`Bridge restored ${restorable.length} interrupted turn(s) from journal (drain ${fromOffset}→${bridgeOffset}, absorbed=${history.length}, live=${live.length})`);
|
|
4099
|
+
return true;
|
|
4100
|
+
}
|
|
3746
4101
|
/** Record `bridgeStalePidStateSessionId` if the pid file's current sid
|
|
3747
4102
|
* disagrees with the just-accepted candidate's sid. Stops the next pid
|
|
3748
4103
|
* resolver tick from pulling the watcher back to the stale spawn-time
|
|
@@ -4309,6 +4664,7 @@ function bridgeIngest() {
|
|
|
4309
4664
|
for (const t of expired) {
|
|
4310
4665
|
if (t.contentFingerprint)
|
|
4311
4666
|
bridgeFingerprintScanLastMs.delete(t.contentFingerprint);
|
|
4667
|
+
journalBridgeTurnClear(t.turnId, t.dispatchAttempt);
|
|
4312
4668
|
log(`Bridge mark expired after ${Math.round(BRIDGE_PENDING_TURN_TTL_MS / 1000)}s without matching a jsonl user line (turnId=${t.turnId}) — dropped to prevent rotation-fallback scan loop.`);
|
|
4313
4669
|
}
|
|
4314
4670
|
// Drain secondary paths first so any trailing assistant text on an old
|
|
@@ -4398,7 +4754,8 @@ function bridgeIngest() {
|
|
|
4398
4754
|
* session surfaces in Dashboard「需要你」with a retry countdown — identical
|
|
4399
4755
|
* wire shape to the screen-text detector's classify() output, so the daemon /
|
|
4400
4756
|
* card / persistence paths need no change. Claude-only (bridgeQueue is the
|
|
4401
|
-
* Claude bridge; Codex
|
|
4757
|
+
* Claude bridge; Codex has its own structured 429 via
|
|
4758
|
+
* maybeEmitCodexStructuredRateLimit on the codexBridgeQueue path). */
|
|
4402
4759
|
function maybeEmitStructuredRateLimit(events) {
|
|
4403
4760
|
for (const ev of events) {
|
|
4404
4761
|
if (!ev.uuid || emittedRateLimitUuids.has(ev.uuid))
|
|
@@ -4562,6 +4919,12 @@ function stopBridgeWatcher() {
|
|
|
4562
4919
|
// A fresh backend must not inherit a terminal/fingerprint from the exited
|
|
4563
4920
|
// process; durable replay is owned by the receiver with a new attempt.
|
|
4564
4921
|
bridgeQueue.clearPending();
|
|
4922
|
+
// A watcher teardown means this worker process has now had at least one CLI
|
|
4923
|
+
// generation, so any FUTURE baseline is an in-worker restart — never the
|
|
4924
|
+
// first-baseline cross-process recovery the journal is for. Disarm journal
|
|
4925
|
+
// restore: the restart's InflightInputTracker carryover already re-delivers
|
|
4926
|
+
// the interrupted turn, and restoring on top would double-deliver.
|
|
4927
|
+
bridgeRestoreGate.markGenerationRetired();
|
|
4565
4928
|
bridgePreambleSent = false;
|
|
4566
4929
|
}
|
|
4567
4930
|
/**
|
|
@@ -4623,7 +4986,30 @@ function bridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
|
|
|
4623
4986
|
const normalised = normaliseForFingerprint(messageText);
|
|
4624
4987
|
const contentNormalized = normalised.length > 0 ? normalised : undefined;
|
|
4625
4988
|
const turnId = preferredTurnId ?? randomBytes(8).toString('hex');
|
|
4626
|
-
|
|
4989
|
+
const markTimeMs = Date.now();
|
|
4990
|
+
bridgeQueue.mark(turnId, fingerprint, markTimeMs, contentNormalized, dispatchAttempt);
|
|
4991
|
+
// Journal the mark so a worker/daemon restart mid-turn can restore it
|
|
4992
|
+
// (bridgeAbsorbBaseline). Excluded:
|
|
4993
|
+
// - adopt mode: its baseline absorbs the whole transcript with different
|
|
4994
|
+
// semantics and never suppresses — restore marks would double-attribute;
|
|
4995
|
+
// - durable turns (dispatchAttempt !== undefined): a durable delivery has
|
|
4996
|
+
// its OWN cross-restart recovery owner (the daemon's persisted
|
|
4997
|
+
// queuedActivation* write-ahead + receipt auto-redispatch), so a journal
|
|
4998
|
+
// restore is always redundant for it and — because the recovered partial
|
|
4999
|
+
// and the re-dispatched attempt carry the same turnId but different
|
|
5000
|
+
// lastUuid — would double-deliver. Only PLAIN Lark IM turns (no other
|
|
5001
|
+
// recovery owner) actually need the journal.
|
|
5002
|
+
if (!lastInitConfig?.adoptMode && bridgeJsonlPath && dispatchAttempt === undefined) {
|
|
5003
|
+
journalBridgeTurnMark({
|
|
5004
|
+
turnId,
|
|
5005
|
+
dispatchAttempt,
|
|
5006
|
+
markTimeMs,
|
|
5007
|
+
fingerprint,
|
|
5008
|
+
contentNormalized,
|
|
5009
|
+
jsonlPath: bridgeJsonlPath,
|
|
5010
|
+
offsetAtMark: bridgeOffset,
|
|
5011
|
+
});
|
|
5012
|
+
}
|
|
4627
5013
|
return turnId;
|
|
4628
5014
|
}
|
|
4629
5015
|
function bridgeDrainAndMaybeEmit() {
|
|
@@ -4653,6 +5039,14 @@ function emitReadyTurns(opts = {}) {
|
|
|
4653
5039
|
});
|
|
4654
5040
|
if (ready.length === 0)
|
|
4655
5041
|
return;
|
|
5042
|
+
// Every popped turn is terminal (emitted or suppressed below) — retire its
|
|
5043
|
+
// durable journal entry NOW, before the final_output IPC. Clear-before-send:
|
|
5044
|
+
// if the worker dies in between, the answer is lost rather than duplicated
|
|
5045
|
+
// (the daemon-side dedupe key does not survive restarts).
|
|
5046
|
+
for (const turn of ready) {
|
|
5047
|
+
if (!turn.isLocal)
|
|
5048
|
+
journalBridgeTurnClear(turn.turnId, turn.dispatchAttempt);
|
|
5049
|
+
}
|
|
4656
5050
|
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
4657
5051
|
// Send markers (`botmux send` landed in own thread) + the queue's first
|
|
4658
5052
|
// still-unready turn. The latter caps the LAST ready turn's window —
|
|
@@ -4668,6 +5062,11 @@ function emitReadyTurns(opts = {}) {
|
|
|
4668
5062
|
const nothingToSendTurns = new Set();
|
|
4669
5063
|
for (let i = 0; i < ready.length; i++) {
|
|
4670
5064
|
const turn = ready[i];
|
|
5065
|
+
// Claude API-error text is execution metadata, not a model answer. The
|
|
5066
|
+
// structured terminal below owns retry/attention; never leak the raw
|
|
5067
|
+
// provider error through transcript fallback (regardless of send markers).
|
|
5068
|
+
if (turn.terminalOutcome && turn.terminalOutcome.status !== 'completed')
|
|
5069
|
+
continue;
|
|
4671
5070
|
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
4672
5071
|
if (turn.isLocal && shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
|
|
4673
5072
|
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
|
|
@@ -4758,9 +5157,16 @@ function emitReadyTurns(opts = {}) {
|
|
|
4758
5157
|
});
|
|
4759
5158
|
continue;
|
|
4760
5159
|
}
|
|
5160
|
+
// A restored turn's fallback is a RECOVERED partial/final produced around
|
|
5161
|
+
// a worker restart — label it so the user can tell it from a live answer.
|
|
5162
|
+
// Prefix is applied AFTER the suppress gate so dedup compares the model's
|
|
5163
|
+
// own text, never the notice.
|
|
5164
|
+
const deliveredText = turn.restoredFromJournal
|
|
5165
|
+
? `${t('worker.bridge_restored_turn_notice')}\n\n${postText}`
|
|
5166
|
+
: postText;
|
|
4761
5167
|
send({
|
|
4762
5168
|
type: 'final_output',
|
|
4763
|
-
content:
|
|
5169
|
+
content: deliveredText,
|
|
4764
5170
|
lastUuid,
|
|
4765
5171
|
turnId: turn.turnId,
|
|
4766
5172
|
...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
|
|
@@ -4770,8 +5176,12 @@ function emitReadyTurns(opts = {}) {
|
|
|
4770
5176
|
// after the model used `botmux send`. Completion is not optional. Emit it
|
|
4771
5177
|
// only after all corresponding final_output IPC messages have been queued so
|
|
4772
5178
|
// the daemon observes a stable per-turn ordering.
|
|
4773
|
-
for (const turn of ready)
|
|
4774
|
-
|
|
5179
|
+
for (const turn of ready) {
|
|
5180
|
+
if (turn.rateLimited)
|
|
5181
|
+
continue;
|
|
5182
|
+
const outcome = turn.terminalOutcome;
|
|
5183
|
+
emitTurnTerminal(turn.turnId, outcome?.status ?? 'completed', outcome && outcome.status !== 'completed' ? outcome.errorCode : undefined, turn.dispatchAttempt, nothingToSendTurns.has(turn) ? 'nothing_to_send' : undefined, outcome && outcome.status !== 'completed' ? outcome.retryable : undefined);
|
|
5184
|
+
}
|
|
4775
5185
|
}
|
|
4776
5186
|
/** Drain `path` from `fromOffset` and feed the events to the bridge queue
|
|
4777
5187
|
* with that path as the source stamp. Pure side-effects on bridgeQueue +
|
|
@@ -6540,7 +6950,7 @@ function beginCliWriteCycle() {
|
|
|
6540
6950
|
resetIdleDetector: () => { idleDetector?.reset(); },
|
|
6541
6951
|
});
|
|
6542
6952
|
}
|
|
6543
|
-
async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, fence) {
|
|
6953
|
+
async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, trustedCaller, fence) {
|
|
6544
6954
|
const executionFence = fence ?? captureAdoptWriteFence();
|
|
6545
6955
|
if (!executionFence || !adoptWriteFenceIsCurrent(executionFence)) {
|
|
6546
6956
|
return 'stale-before-write';
|
|
@@ -6550,6 +6960,7 @@ async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTu
|
|
|
6550
6960
|
const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
6551
6961
|
currentBotmuxTurnId = turnId;
|
|
6552
6962
|
currentBotmuxDispatchAttempt = dispatchAttempt;
|
|
6963
|
+
currentGatewayTrustedCaller = trustedCaller;
|
|
6553
6964
|
currentVcMeetingImTurnOrigin = vcMeetingImTurnOrigin;
|
|
6554
6965
|
if (dispatchAttempt !== undefined)
|
|
6555
6966
|
durableTurnInFlight = true;
|
|
@@ -6764,7 +7175,7 @@ async function runAdoptMessageForCapturedGeneration(item, requeue) {
|
|
|
6764
7175
|
queue: adoptWriteQueue,
|
|
6765
7176
|
isCurrent: () => adoptWriteFenceIsCurrent(fence),
|
|
6766
7177
|
onStale: requeueOnce,
|
|
6767
|
-
write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, fence),
|
|
7178
|
+
write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, item.trustedCaller, fence),
|
|
6768
7179
|
});
|
|
6769
7180
|
if (queued.status === 'stale-before-write')
|
|
6770
7181
|
return 'stale-before-write';
|
|
@@ -6910,6 +7321,8 @@ let displayMode = 'hidden';
|
|
|
6910
7321
|
let screenshotTimer = null;
|
|
6911
7322
|
let pendingShotTimer = null;
|
|
6912
7323
|
let lastShotHash = '';
|
|
7324
|
+
// Throttle for the happy-path upload log in captureAndUpload (one line/min).
|
|
7325
|
+
let lastUploadLogAtMs = 0;
|
|
6913
7326
|
let larkAppIdForUpload = '';
|
|
6914
7327
|
let larkAppSecretForUpload = '';
|
|
6915
7328
|
let larkBrandForUpload = 'feishu';
|
|
@@ -7038,6 +7451,14 @@ async function captureAndUpload() {
|
|
|
7038
7451
|
return;
|
|
7039
7452
|
}
|
|
7040
7453
|
const status = projectedRuntimeScreenStatus();
|
|
7454
|
+
// Success is otherwise completely silent (skips and failures log above), which
|
|
7455
|
+
// made "loop alive and uploading" indistinguishable from "loop never started"
|
|
7456
|
+
// in the daemon log. One throttled line keeps the happy path observable.
|
|
7457
|
+
const nowMs = Date.now();
|
|
7458
|
+
if (nowMs - lastUploadLogAtMs >= 60_000) {
|
|
7459
|
+
lastUploadLogAtMs = nowMs;
|
|
7460
|
+
log(`Screenshot uploaded (${png.length}B, key=${imageKey.slice(0, 12)}…)`);
|
|
7461
|
+
}
|
|
7041
7462
|
send({
|
|
7042
7463
|
type: 'screenshot_uploaded',
|
|
7043
7464
|
imageKey,
|
|
@@ -7092,10 +7513,10 @@ function sendTermActionOnce(target, key) {
|
|
|
7092
7513
|
return false;
|
|
7093
7514
|
}
|
|
7094
7515
|
async function handleTermAction(key) {
|
|
7095
|
-
//
|
|
7096
|
-
// ANSI 序列的 follow-up
|
|
7097
|
-
if (effectiveBackendType === 'riff') {
|
|
7098
|
-
log(`term_action '${key}' ignored —
|
|
7516
|
+
// 远端后端:没有终端可驱动——把控制字符 write 进 riff/mojo 会变成一个内容为
|
|
7517
|
+
// ANSI 序列的 follow-up turn(^C 也不会 cancel 远端执行),必须整体拒绝。
|
|
7518
|
+
if (effectiveBackendType === 'riff' || effectiveBackendType === 'mojo') {
|
|
7519
|
+
log(`term_action '${key}' ignored — ${effectiveBackendType} backend has no local terminal to drive`);
|
|
7099
7520
|
return;
|
|
7100
7521
|
}
|
|
7101
7522
|
if (!backend)
|
|
@@ -8866,7 +9287,12 @@ function markPromptReady() {
|
|
|
8866
9287
|
// so a dropped Enter cannot create permanent false-busy.
|
|
8867
9288
|
// Reject the heuristic and re-arm IdleDetector so the later transcript-final
|
|
8868
9289
|
// fireIdle() can drive the real ready edge.
|
|
8869
|
-
if (hasStructuredLifecycleBlock()) {
|
|
9290
|
+
if (hasStructuredLifecycleBlock() && !spawnArgvInitialPromptBusy) {
|
|
9291
|
+
// Grok is lifecycle-blocking, but its FIRST ready is still a pre-execution
|
|
9292
|
+
// SessionStart / idle-detector edge while the argv-baked prompt is running.
|
|
9293
|
+
// The spawnArgvInitialPromptBusy arm below must publish working and consume
|
|
9294
|
+
// itself on that first edge. After the arm is gone, later TUI redraws stay
|
|
9295
|
+
// blocked until turn_completed so they cannot publish idle mid-turn.
|
|
8870
9296
|
const remainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
|
|
8871
9297
|
structuredRejectedReadyEvidenceGeneration = ptyOutputGeneration.snapshot();
|
|
8872
9298
|
log('Ignoring prompt-ready heuristic while a structured turn is unfinished or submit verification/start is pending');
|
|
@@ -9111,6 +9537,7 @@ function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
|
|
|
9111
9537
|
if (dropped) {
|
|
9112
9538
|
if (dropped.contentFingerprint)
|
|
9113
9539
|
bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
|
|
9540
|
+
journalBridgeTurnClear(bridgeTurnId, dispatchAttempt);
|
|
9114
9541
|
log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
|
|
9115
9542
|
}
|
|
9116
9543
|
if (droppedStructured) {
|
|
@@ -9477,10 +9904,11 @@ async function flushPending() {
|
|
|
9477
9904
|
const rawInputReady = isPromptReady && pendingRawInputs.length > 0;
|
|
9478
9905
|
const adoptInputReady = isPromptReady && lastInitConfig?.adoptMode === true && pendingAdoptMessages.length > 0;
|
|
9479
9906
|
let supportedSessionRenameReady = sessionRenameReady;
|
|
9480
|
-
|
|
9907
|
+
const renameOnRemoteBackend = effectiveBackendType === 'riff' || effectiveBackendType === 'mojo';
|
|
9908
|
+
if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || renameOnRemoteBackend)) {
|
|
9481
9909
|
pendingSessionRename = null;
|
|
9482
9910
|
supportedSessionRenameReady = false;
|
|
9483
|
-
log(`Ignoring native session rename — unsupported by ${cliName()}${
|
|
9911
|
+
log(`Ignoring native session rename — unsupported by ${cliName()}${renameOnRemoteBackend ? ` on ${effectiveBackendType} backend` : ''}`);
|
|
9484
9912
|
if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0)
|
|
9485
9913
|
return;
|
|
9486
9914
|
}
|
|
@@ -9623,6 +10051,10 @@ async function flushPending() {
|
|
|
9623
10051
|
const item = freshnessInputQueue.takeNormal();
|
|
9624
10052
|
if (!item)
|
|
9625
10053
|
break;
|
|
10054
|
+
// Apply the credential snapshot that arrived WITH this turn, immediately
|
|
10055
|
+
// before the turn is written. Doing it at IPC-receive time collapsed two
|
|
10056
|
+
// queued credential turns (A/B/C ran as A/C/C).
|
|
10057
|
+
applyMojoLivePatch(item.mojoLivePatch);
|
|
9626
10058
|
// Type-ahead can drain several items in one flush. Each logical submit
|
|
9627
10059
|
// starts its own readiness generation before transcript marking/writing.
|
|
9628
10060
|
beginCliWriteCycle();
|
|
@@ -9649,6 +10081,7 @@ async function flushPending() {
|
|
|
9649
10081
|
renderer?.markNewTurn();
|
|
9650
10082
|
currentBotmuxTurnId = item.turnId;
|
|
9651
10083
|
currentBotmuxDispatchAttempt = item.dispatchAttempt;
|
|
10084
|
+
currentGatewayTrustedCaller = item.trustedCaller;
|
|
9652
10085
|
currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
|
|
9653
10086
|
// Acquire durable HOL ownership only after this turn owns the backend
|
|
9654
10087
|
// submission mutex. If an older ZMX recovery debt rejects capture,
|
|
@@ -9683,6 +10116,13 @@ async function flushPending() {
|
|
|
9683
10116
|
codexBridgeQueue.beginSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
|
|
9684
10117
|
}
|
|
9685
10118
|
}
|
|
10119
|
+
else {
|
|
10120
|
+
// Same anchoring rule as the transcript bridges above: stamp the send
|
|
10121
|
+
// window's lower bound at the literal write, not on IPC arrival, so a
|
|
10122
|
+
// previous turn's `botmux send` cannot fall inside this turn's window
|
|
10123
|
+
// and falsely suppress its final. No-op for non-mojo backends.
|
|
10124
|
+
stampMojoTurnMark(item.turnId, item.dispatchAttempt);
|
|
10125
|
+
}
|
|
9686
10126
|
if (durableWrite
|
|
9687
10127
|
&& cliAdapter.reliableTurnTerminal === true
|
|
9688
10128
|
&& lastInitConfig?.cliId === 'claude-code'
|
|
@@ -9814,13 +10254,21 @@ async function flushPending() {
|
|
|
9814
10254
|
}
|
|
9815
10255
|
else if (item.codexAppInput && writeAdapter.writeStructuredInput) {
|
|
9816
10256
|
submissionBackend = writeBackend;
|
|
9817
|
-
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, {
|
|
10257
|
+
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, {
|
|
10258
|
+
turnId: item.turnId,
|
|
10259
|
+
...(item.trustedCaller ? { trustedCaller: item.trustedCaller } : {}),
|
|
10260
|
+
...(item.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
10261
|
+
}), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
9818
10262
|
result = transaction.result;
|
|
9819
10263
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
9820
10264
|
}
|
|
9821
10265
|
else {
|
|
9822
10266
|
submissionBackend = writeBackend;
|
|
9823
|
-
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, {
|
|
10267
|
+
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, {
|
|
10268
|
+
turnId: item.turnId,
|
|
10269
|
+
...(item.trustedCaller ? { trustedCaller: item.trustedCaller } : {}),
|
|
10270
|
+
...(item.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
10271
|
+
}), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
9824
10272
|
result = transaction.result;
|
|
9825
10273
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
9826
10274
|
}
|
|
@@ -10081,6 +10529,8 @@ async function flushPending() {
|
|
|
10081
10529
|
// HOL-dropped or steered into the other.
|
|
10082
10530
|
if (rpcLifecycleFailClosedOwners.size > 0)
|
|
10083
10531
|
break;
|
|
10532
|
+
if (item.trustedCaller && lastInitConfig?.cliId === 'codex')
|
|
10533
|
+
break;
|
|
10084
10534
|
if (shouldStopPendingBatch(item, pendingMessages[0]))
|
|
10085
10535
|
break;
|
|
10086
10536
|
}
|
|
@@ -10097,6 +10547,30 @@ async function flushPending() {
|
|
|
10097
10547
|
}
|
|
10098
10548
|
}
|
|
10099
10549
|
}
|
|
10550
|
+
/**
|
|
10551
|
+
* Find an executable using the PATH the CHILD will actually see.
|
|
10552
|
+
*
|
|
10553
|
+
* The layered env (worker env → per-bot `env`) is authoritative: when it names a
|
|
10554
|
+
* PATH, that is the ONLY place searched, because falling back to the daemon's own
|
|
10555
|
+
* PATH is exactly how an ambient install shadowed a per-bot one.
|
|
10556
|
+
*/
|
|
10557
|
+
function locateOnEffectiveChildPath(cmd, childEnvForPath) {
|
|
10558
|
+
const childPath = childEnvForPath.PATH;
|
|
10559
|
+
if (!childPath)
|
|
10560
|
+
return locateOnPath(cmd);
|
|
10561
|
+
for (const dir of childPath.split(delimiter)) {
|
|
10562
|
+
if (!dir)
|
|
10563
|
+
continue;
|
|
10564
|
+
const candidate = join(dir, cmd);
|
|
10565
|
+
try {
|
|
10566
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
10567
|
+
return candidate;
|
|
10568
|
+
}
|
|
10569
|
+
catch { /* not here */ }
|
|
10570
|
+
}
|
|
10571
|
+
// Explicit child PATH is authoritative — do NOT fall back to the ambient one.
|
|
10572
|
+
return null;
|
|
10573
|
+
}
|
|
10100
10574
|
function sendToPty(content, turnId, opts = {}) {
|
|
10101
10575
|
const next = {
|
|
10102
10576
|
content,
|
|
@@ -10105,7 +10579,9 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
10105
10579
|
...(opts.codexAppDispatchId ? { codexAppDispatchId: opts.codexAppDispatchId } : {}),
|
|
10106
10580
|
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
10107
10581
|
...(opts.queuedActivationToken ? { queuedActivationToken: opts.queuedActivationToken } : {}),
|
|
10582
|
+
...(opts.mojoLivePatch ? { mojoLivePatch: opts.mojoLivePatch } : {}),
|
|
10108
10583
|
...(opts.codexAppInput ? { codexAppInput: opts.codexAppInput } : {}),
|
|
10584
|
+
...(opts.trustedCaller ? { trustedCaller: opts.trustedCaller } : {}),
|
|
10109
10585
|
...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
|
|
10110
10586
|
...(opts.atMostOnce ? { noReplay: true } : {}),
|
|
10111
10587
|
...(opts.vcMeetingImTurnOrigin
|
|
@@ -10951,18 +11427,82 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
10951
11427
|
// PTY/tmux backends inject these into the child process env directly; riff
|
|
10952
11428
|
// has no local process, so they go via config.env → the riff API's config.env.
|
|
10953
11429
|
let riffBackendConfig = cfg.backendConfig;
|
|
11430
|
+
// mojo: combine the user's `mojo` block with the platform-owned launch identity
|
|
11431
|
+
// into the ONE EffectiveMojoConfig the backend runs on. Without this the backend
|
|
11432
|
+
// silently ignores everything botmux already resolved — repo selection
|
|
11433
|
+
// (workingDir), the dashboard/setup `model`, `disableCliBypass`,
|
|
11434
|
+
// `cliPathOverride` and the persisted session lineage.
|
|
11435
|
+
//
|
|
11436
|
+
// There is NO precedence question: the launch identity has exactly one source
|
|
11437
|
+
// (the top-level bot fields, frozen onto the session). The `mojo` block is not
|
|
11438
|
+
// a fallback for those keys — both config entry points reject them, and the
|
|
11439
|
+
// builder strips any that slip through. Do not reintroduce a `block ?? generic`
|
|
11440
|
+
// merge here; that was the double-entry-point bug.
|
|
11441
|
+
if (effectiveBackendType === 'mojo') {
|
|
11442
|
+
// Defensive third gate. Both config doors already validate, but the IPC
|
|
11443
|
+
// payload could be stale (written by an older daemon) or malformed, and the
|
|
11444
|
+
// failure mode is a security one: `localDaemon: "false"` satisfies the
|
|
11445
|
+
// sandbox check's `!== true` while being truthy when building the child env,
|
|
11446
|
+
// i.e. isolation skipped AND host execution enabled. Refuse to launch rather
|
|
11447
|
+
// than run in that state.
|
|
11448
|
+
const validated = normalizeMojoConfig(cfg.backendConfig);
|
|
11449
|
+
if (!validated.ok) {
|
|
11450
|
+
throw new Error(`mojo config is invalid: ${validated.errors.join('; ')}`);
|
|
11451
|
+
}
|
|
11452
|
+
// Shared with the daemon's workerless `/close` path (see
|
|
11453
|
+
// destroyOrphanedBackingSession) so a session that runs on a custom binary /
|
|
11454
|
+
// per-bot JWT can still be cancelled after its worker is gone.
|
|
11455
|
+
riffBackendConfig = buildEffectiveMojoConfig(validated.value, {
|
|
11456
|
+
cliPathOverride: cfg.cliPathOverride,
|
|
11457
|
+
workingDir: cfg.workingDir,
|
|
11458
|
+
model: cfg.model,
|
|
11459
|
+
disableCliBypass: cfg.disableCliBypass,
|
|
11460
|
+
env: cfg.env ? sanitizePerBotEnv(cfg.env) : undefined,
|
|
11461
|
+
// Resume the persisted lineage: daemon restart, /relay and worker rebuilds
|
|
11462
|
+
// all arrive with riffParentTaskId set (the shared remote-lineage channel —
|
|
11463
|
+
// see the riff_task_id IPC message, emitted by the generic
|
|
11464
|
+
// backend.onTaskId hook and therefore already carrying mojo ids).
|
|
11465
|
+
resumeCliSessionId: cfg.riffParentTaskId,
|
|
11466
|
+
wrapperCli: cfg.wrapperCli,
|
|
11467
|
+
// mojo is injectsSessionContext + global skillsDir (same shape as
|
|
11468
|
+
// genius/grok), so session-manager does NOT wrap the per-message skill
|
|
11469
|
+
// envelope for it — the catalog for `prompt` mode, and the help pointer for
|
|
11470
|
+
// `off`, must ride on the prompt MojoBackend builds. Resolved here because
|
|
11471
|
+
// the mode lookup needs larkAppId and the backend has no bot context.
|
|
11472
|
+
// `global` resolves to '' (files already installed under ~/.mojo/skills).
|
|
11473
|
+
builtinSkillBlock: builtinSkillBlockForInjectsSessionContext(cfg.larkAppId, cfg.locale, {
|
|
11474
|
+
asksViaHook: false,
|
|
11475
|
+
whiteboardEnabled: whiteboardEnabled(),
|
|
11476
|
+
// mojo emits NO <botmux_routing> block (unlike genius/grok, which build one
|
|
11477
|
+
// via buildBotmuxSystemPromptText). So the catalog must keep the
|
|
11478
|
+
// routing-covered skills (history/quoted/bots) — otherwise they are
|
|
11479
|
+
// documented nowhere — and must not cite a routing block that is absent.
|
|
11480
|
+
// Full routing/identity injection for mojo is deliberately out of scope
|
|
11481
|
+
// here: a sandboxed remote session cannot reuse the normal CLI's routing
|
|
11482
|
+
// semantics verbatim (see the --mention-back note in adapters/cli/mojo.ts).
|
|
11483
|
+
hasRoutingBlock: false,
|
|
11484
|
+
}),
|
|
11485
|
+
});
|
|
11486
|
+
const resumed = riffBackendConfig.resumeCliSessionId;
|
|
11487
|
+
if (resumed)
|
|
11488
|
+
log(`mojo resuming session lineage ${resumed}`);
|
|
11489
|
+
}
|
|
10954
11490
|
if (effectiveBackendType === 'riff') {
|
|
10955
11491
|
if (!cfg.backendConfig) {
|
|
10956
11492
|
throw new Error('riff backend requires backendConfig (baseUrl, etc.)');
|
|
10957
11493
|
}
|
|
11494
|
+
// backendConfig is a union shared with the mojo backend; inside this
|
|
11495
|
+
// riff-only branch the shape is known, so narrow once instead of casting at
|
|
11496
|
+
// each riff-specific field access below.
|
|
11497
|
+
const riffCfg = cfg.backendConfig;
|
|
10958
11498
|
// Fail fast on a missing/invalid baseUrl — every config entry point funnels
|
|
10959
11499
|
// through this spawn gate, and a late `fetch("undefined/api/…")` error is
|
|
10960
11500
|
// far harder to diagnose than an explicit spawn refusal.
|
|
10961
|
-
if (!isValidRiffBaseUrl(
|
|
10962
|
-
throw new Error(`riff baseUrl 未配置或非法(需 http(s) URL,当前: ${JSON.stringify(
|
|
11501
|
+
if (!isValidRiffBaseUrl(riffCfg.baseUrl)) {
|
|
11502
|
+
throw new Error(`riff baseUrl 未配置或非法(需 http(s) URL,当前: ${JSON.stringify(riffCfg.baseUrl ?? null)})——请在 dashboard 的 Riff 配置中填写`);
|
|
10963
11503
|
}
|
|
10964
|
-
if (
|
|
10965
|
-
throw new Error(`riff sandboxCluster 非法(仅支持 boe/cn,当前: ${JSON.stringify(
|
|
11504
|
+
if (riffCfg.sandboxCluster !== undefined && !isValidRiffSandboxCluster(riffCfg.sandboxCluster)) {
|
|
11505
|
+
throw new Error(`riff sandboxCluster 非法(仅支持 boe/cn,当前: ${JSON.stringify(riffCfg.sandboxCluster)})——请在 dashboard 的 Riff 配置中重新选择`);
|
|
10966
11506
|
}
|
|
10967
11507
|
const sessionEnv = {
|
|
10968
11508
|
BOTMUX_SESSION_ID: cfg.sessionId,
|
|
@@ -11012,14 +11552,24 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11012
11552
|
sessionEnv.BOTMUX_LARK_LIST_BOTS_API_ENABLED = chatBotDiscovery.listBotsApiEnabled ? 'true' : 'false';
|
|
11013
11553
|
sessionEnv.BOTMUX_LARK_LIST_BOTS_API_TIMEOUT_MS = String(chatBotDiscovery.listBotsApiTimeoutMs);
|
|
11014
11554
|
// Per-bot env (bots.json `env`) takes precedence over session context;
|
|
11015
|
-
// explicit riff config.env takes precedence over both.
|
|
11016
|
-
|
|
11555
|
+
// explicit riff config.env takes precedence over both. The workflow
|
|
11556
|
+
// kill-switch is a host-resolved snapshot and is re-frozen AFTER this merge
|
|
11557
|
+
// (see below), so it is intentionally NOT set in sessionEnv here.
|
|
11558
|
+
const mergedEnv = { ...sessionEnv, ...sanitizePerBotEnv(cfg.env), ...riffCfg.env };
|
|
11017
11559
|
// The effective policy is a host-resolved snapshot, not a user-overridable
|
|
11018
11560
|
// backend env knob. Re-freeze it after config.env/per-bot env merge.
|
|
11019
11561
|
if (cfg.feedback)
|
|
11020
11562
|
mergedEnv.BOTMUX_FEEDBACK_POLICY = JSON.stringify(cfg.feedback);
|
|
11021
11563
|
else
|
|
11022
11564
|
delete mergedEnv.BOTMUX_FEEDBACK_POLICY;
|
|
11565
|
+
// The workflow kill-switch is likewise a host-resolved snapshot. Re-freeze it
|
|
11566
|
+
// AFTER the merge: unlike per-bot `env` (sanitizePerBotEnv strips the BOTMUX*
|
|
11567
|
+
// prefix), `riffCfg.env` merges LAST and is NOT sanitized, so a stale or
|
|
11568
|
+
// user-shaped backendConfig.env could otherwise flip the pane's
|
|
11569
|
+
// BOTMUX_WORKFLOW_ENABLED and desync the remote CLI-side gate from the
|
|
11570
|
+
// daemon's authoritative decision. The daemon-side gate is unaffected either
|
|
11571
|
+
// way; this keeps the in-pane defense-in-depth honest on the riff path too.
|
|
11572
|
+
mergedEnv.BOTMUX_WORKFLOW_ENABLED = isWorkflowFeatureEnabled() ? 'true' : 'false';
|
|
11023
11573
|
// Re-freeze the no-transport capability keys AFTER the merge: a stale or
|
|
11024
11574
|
// attacker-shaped backendConfig.env / per-bot env merges LAST and would
|
|
11025
11575
|
// otherwise override the frozen values, restoring send capability for a
|
|
@@ -11037,11 +11587,14 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11037
11587
|
// merge so a stale or user-supplied backend env cannot impersonate another
|
|
11038
11588
|
// owner (or resurrect an owner on an ownerless session).
|
|
11039
11589
|
applySessionOwnerEnv(mergedEnv, cfg.ownerOpenId);
|
|
11040
|
-
|
|
11590
|
+
// `riffCfg` is `cfg.backendConfig` already narrowed once at the top of this
|
|
11591
|
+
// riff-only branch — same object, so master's owner re-freeze above is
|
|
11592
|
+
// unaffected; keeping the typed alias avoids re-widening to the shared union.
|
|
11593
|
+
riffBackendConfig = Object.assign({}, riffCfg, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
|
|
11041
11594
|
// 复用本地仓库+分支:多仓只认会话上的显式 stamp(仓库选择卡多选流按用户
|
|
11042
11595
|
// 顺序写入 cfg.riffRepoDirs,首仓=primary);否则仅对 workingDir 本身做单仓
|
|
11043
11596
|
// 推导——绝不扫描任意非 git 目录的子目录(home/仓库集合目录会乱带仓库)。
|
|
11044
|
-
if (!
|
|
11597
|
+
if (!riffCfg.repos || riffCfg.repos.length === 0) {
|
|
11045
11598
|
const derived = cfg.riffRepoDirs && cfg.riffRepoDirs.length > 0
|
|
11046
11599
|
? deriveRiffReposFromDirs(cfg.riffRepoDirs)
|
|
11047
11600
|
: (() => { const one = deriveRiffRepoFromWorkingDir(cfg.workingDir); return one ? { repos: [one.repo], warnings: one.warnings } : null; })();
|
|
@@ -11077,15 +11630,29 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11077
11630
|
// BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process —
|
|
11078
11631
|
// local confinement is meaningless there and must be bypassed on ALL
|
|
11079
11632
|
// platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
|
|
11080
|
-
|
|
11633
|
+
// mojo only counts as remote when it provably runs nothing locally
|
|
11634
|
+
// (cloud on, localDaemon off) — otherwise the local sandbox must stay engaged.
|
|
11635
|
+
const remoteBackendFullyOffBox = !localSandboxApplies(effectiveBackendType, effectiveBackendType === 'mojo'
|
|
11636
|
+
? riffBackendConfig
|
|
11637
|
+
: undefined);
|
|
11638
|
+
const riffRemoteBackend = remoteBackendFullyOffBox;
|
|
11081
11639
|
if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
|
|
11082
|
-
log(
|
|
11640
|
+
log(`Sandbox flag set but backend is ${effectiveBackendType} (remote sandbox, no local process) — local sandbox bypassed`);
|
|
11641
|
+
}
|
|
11642
|
+
if (effectiveBackendType === 'mojo' && !remoteBackendFullyOffBox
|
|
11643
|
+
&& (cfg.sandbox === true || cfg.readIsolation === true)) {
|
|
11644
|
+
// Loud on purpose: this is the combination where a mojo bot DOES execute
|
|
11645
|
+
// locally, so the local sandbox stays on rather than being skipped.
|
|
11646
|
+
log('mojo runs tools locally (cloud not enabled, or localDaemon set) — keeping the local sandbox engaged');
|
|
11083
11647
|
}
|
|
11084
11648
|
const sandboxRequested = !riffRemoteBackend
|
|
11085
11649
|
&& (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
|
|
11086
11650
|
const backendIsolationGate = backendSandboxCompatibilityError({
|
|
11087
11651
|
backendType: effectiveBackendType,
|
|
11088
11652
|
fileSandboxRequested: sandboxRequested,
|
|
11653
|
+
...(effectiveBackendType === 'mojo'
|
|
11654
|
+
? { mojoConfig: (riffBackendConfig ?? {}) }
|
|
11655
|
+
: {}),
|
|
11089
11656
|
// The unified sandbox request above already includes legacy readIsolation.
|
|
11090
11657
|
effectiveReadIsolationRequested: false,
|
|
11091
11658
|
});
|
|
@@ -11314,17 +11881,65 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11314
11881
|
// so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
|
|
11315
11882
|
// this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
|
|
11316
11883
|
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
11884
|
+
//
|
|
11885
|
+
// The MIRROR case is just as load-bearing: policy is now OFF (no sandbox, not
|
|
11886
|
+
// enrolled → appliedIsolationCapabilities empty), but a pane spawned by an OLDER
|
|
11887
|
+
// build under the previous FORCED no-transport isolation is still alive AND still
|
|
11888
|
+
// stamped. That pane runs confined against a policy we no longer want; a bare
|
|
11889
|
+
// `capabilities.length > 0` gate would skip the check entirely and warm-reattach
|
|
11890
|
+
// the still-isolated process, silently contradicting "read scope follows local
|
|
11891
|
+
// config" on resume/restart (the 2026-08 no-transport放宽 upgrade path). So we
|
|
11892
|
+
// also enter when a boot marker is present on disk for THIS session — then the
|
|
11893
|
+
// policy-off arm below (no expected capabilities) demands the marker be truly
|
|
11894
|
+
// absent to reattach, else kills + cold-spawns unconfined. Presence is checked
|
|
11895
|
+
// by the no-follow existence probe (a planted/tampered leaf that reads as null
|
|
11896
|
+
// still counts as present, so it cannot be used to force a silent reattach).
|
|
11317
11897
|
let persistentPaneOriginChannelId;
|
|
11318
|
-
|
|
11898
|
+
const stalePaneMarkerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId);
|
|
11899
|
+
const policyOffTombstoneFilePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId);
|
|
11900
|
+
// no-transport (apiOnly bot OR HTTP virtual chat) is the ONLY session shape the
|
|
11901
|
+
// removed force-isolation rule ever confined; the policy-off migration arm is
|
|
11902
|
+
// scoped to it so an ordinary transport-enabled chat is never subjected to the
|
|
11903
|
+
// tombstone requirement (no false kills). Computed locally — the merge-scoped
|
|
11904
|
+
// `noTransport` above is out of scope here.
|
|
11905
|
+
const noTransportSession = cfg.apiOnly === true
|
|
11906
|
+
|| cfg.chatId?.startsWith('http_async_') === true
|
|
11907
|
+
|| cfg.chatId?.startsWith('http_wait_') === true;
|
|
11908
|
+
const isolationCapableBackend = effectiveBackendType === 'tmux';
|
|
11909
|
+
// Existence via no-follow probes so a planted/tampered leaf still counts as
|
|
11910
|
+
// present (→ triggers cleanup / conservative kill) and can never be used to
|
|
11911
|
+
// force a silent reattach.
|
|
11912
|
+
const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath);
|
|
11913
|
+
const policyOffTombstonePresent = hostEntryExistsNoFollow(policyOffTombstoneFilePath);
|
|
11914
|
+
// The guard must ENTER the state machine whenever it could have anything to
|
|
11915
|
+
// decide, WITHOUT depending on provenance already being present for the live-
|
|
11916
|
+
// pane arms — else a no-transport pane whose best-effort isolation marker write
|
|
11917
|
+
// was lost would (NEITHER file) skip the guard and warm-reattach still confined.
|
|
11918
|
+
// Enter for:
|
|
11919
|
+
// · any policy-ON spawn (capability check runs on every persistent backend,
|
|
11920
|
+
// incl. credential-only zellij/herdr/zmx), OR
|
|
11921
|
+
// · a policy-OFF no-transport tmux session (the file-sandbox migration scope), OR
|
|
11922
|
+
// · ANY session (incl. transport-enabled, any backend) that has stale
|
|
11923
|
+
// provenance on disk — so a dead pane's leftover marker/tombstone is cleared
|
|
11924
|
+
// before cold-spawn on EVERY backend. Otherwise a transport chat that turned
|
|
11925
|
+
// sandbox OFF leaves a matching marker that would later warm-reattach a fresh
|
|
11926
|
+
// UNisolated pane as "isolated" when sandbox is re-enabled.
|
|
11927
|
+
const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0
|
|
11928
|
+
|| (noTransportSession && isolationCapableBackend)
|
|
11929
|
+
|| stalePaneMarkerPresent || policyOffTombstonePresent;
|
|
11930
|
+
if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) {
|
|
11319
11931
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
11320
11932
|
// ZMX ownership is verified against the frozen PID, not just the name — a
|
|
11321
11933
|
// same-named session may belong to the user or to a newer generation.
|
|
11322
11934
|
const zmxOwnedProbe = effectiveBackendType === 'zmx'
|
|
11323
11935
|
? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid)
|
|
11324
11936
|
: undefined;
|
|
11325
|
-
// ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe
|
|
11326
|
-
//
|
|
11327
|
-
//
|
|
11937
|
+
// ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe is not
|
|
11938
|
+
// proof of anything. Other persistent backends: their target probe returning
|
|
11939
|
+
// unknown is likewise not proof that a pane exists. Liveness is passed TRI-STATE
|
|
11940
|
+
// (paneProbe) into the state machine, which fail-closes on `unknown` for EVERY
|
|
11941
|
+
// backend (refuse-inconclusive-probe) — no longer only ZMX, and no longer
|
|
11942
|
+
// collapsed into "dead" (which would clear a still-confined pane's provenance).
|
|
11328
11943
|
const paneProbe = zmxOwnedProbe?.probe
|
|
11329
11944
|
?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
|
|
11330
11945
|
if (effectiveBackendType === 'zmx'
|
|
@@ -11333,80 +11948,122 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11333
11948
|
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
|
|
11334
11949
|
'ZMX session appeared after the frozen launch probe');
|
|
11335
11950
|
}
|
|
11336
|
-
if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
|
|
11337
|
-
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
|
|
11338
|
-
`could not verify existing ${effectiveBackendType} pane`);
|
|
11339
|
-
}
|
|
11340
11951
|
const paneLive = paneProbe === 'exists';
|
|
11341
|
-
|
|
11342
|
-
|
|
11343
|
-
|
|
11344
|
-
|
|
11345
|
-
|
|
11346
|
-
|
|
11347
|
-
|
|
11348
|
-
|
|
11349
|
-
|
|
11350
|
-
?
|
|
11351
|
-
|
|
11352
|
-
|
|
11353
|
-
|
|
11354
|
-
|
|
11355
|
-
|
|
11356
|
-
|
|
11357
|
-
|
|
11358
|
-
|
|
11359
|
-
|
|
11360
|
-
|
|
11361
|
-
|
|
11362
|
-
|
|
11363
|
-
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
|
|
11367
|
-
|
|
11368
|
-
|
|
11952
|
+
const markerPath = stalePaneMarkerPath;
|
|
11953
|
+
const marker = paneLive ? readManagedOriginAuthorityFile(markerPath) : null;
|
|
11954
|
+
const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest;
|
|
11955
|
+
// isolatedPaneReattachSafe only means anything under a policy-ON spawn; the
|
|
11956
|
+
// state machine consults it only in that arm.
|
|
11957
|
+
const isolationMarkerReattachSafe = appliedIsolationCapabilities.length > 0
|
|
11958
|
+
&& isolatedPaneReattachSafe(marker, {
|
|
11959
|
+
requiredCapabilities: appliedIsolationCapabilities,
|
|
11960
|
+
exactCapabilities: true,
|
|
11961
|
+
...(originChannelPolicyExpected ? {
|
|
11962
|
+
readIsolation: willReadIsolate,
|
|
11963
|
+
writeSandbox: willWriteSandbox,
|
|
11964
|
+
requireOriginChannel: true,
|
|
11965
|
+
policyDigest: managedOriginChannelPolicyDigest,
|
|
11966
|
+
} : {}),
|
|
11967
|
+
});
|
|
11968
|
+
// Tombstone authorizes a policy-off warm reattach ONLY when it passes a SECURE
|
|
11969
|
+
// read (real 0600 regular file, right owner) + schema/version validation — a
|
|
11970
|
+
// bare lstat "present" (empty / dir / symlink / garbage) must NOT authorize.
|
|
11971
|
+
// Presence (above) still drives cleanup; validity drives authorization.
|
|
11972
|
+
const policyOffTombstoneIsValid = paneLive && policyOffTombstonePresent
|
|
11973
|
+
&& policyOffTombstoneValid(readManagedOriginAuthorityFile(policyOffTombstoneFilePath));
|
|
11974
|
+
// PENDING provenance: a present marker OR tombstone whose secure-read body is a
|
|
11975
|
+
// `state:'pending'` record. This is a generation whose fresh-attribution never
|
|
11976
|
+
// completed (crash between pending-write and commit, or an uncommitted
|
|
11977
|
+
// late-flip/collision). It DOMINATES the state machine (all backends, both
|
|
11978
|
+
// policy directions) — see evaluatePersistentPaneMigration. Secure-read (not
|
|
11979
|
+
// lstat) because only a real 0600 file we wrote can be a trusted pending
|
|
11980
|
+
// record; a planted/garbage leaf reads as null → not pending → falls through
|
|
11981
|
+
// to the normal presence-but-invalid handling (still conservative).
|
|
11982
|
+
const pendingProvenancePresent = (stalePaneMarkerPresent
|
|
11983
|
+
&& provenancePendingNonce(readManagedOriginAuthorityFile(stalePaneMarkerPath)) !== null)
|
|
11984
|
+
|| (policyOffTombstonePresent
|
|
11985
|
+
&& provenancePendingNonce(readManagedOriginAuthorityFile(policyOffTombstoneFilePath)) !== null);
|
|
11986
|
+
const migration = evaluatePersistentPaneMigration({
|
|
11987
|
+
appliedIsolationCapabilities,
|
|
11988
|
+
isolationCapableBackend,
|
|
11989
|
+
noTransport: noTransportSession,
|
|
11990
|
+
isolationMarkerPresent: stalePaneMarkerPresent,
|
|
11991
|
+
policyOffTombstonePresent,
|
|
11992
|
+
policyOffTombstoneValid: policyOffTombstoneIsValid,
|
|
11993
|
+
paneProbe,
|
|
11994
|
+
pendingProvenancePresent,
|
|
11995
|
+
isolationMarkerReattachSafe,
|
|
11996
|
+
});
|
|
11997
|
+
// Verified removal of a provenance file: unlink then confirm it is truly gone
|
|
11998
|
+
// (no-follow). A leaf we cannot remove (directory / planted / perm) must FAIL
|
|
11999
|
+
// CLOSED — never fall through to publish a new generation, or a later restart
|
|
12000
|
+
// re-reads the stale proof and mis-kills the fresh pane in a loop.
|
|
12001
|
+
const removeProvenanceOrThrow = (path, label) => {
|
|
12002
|
+
try {
|
|
12003
|
+
unlinkSync(path);
|
|
11369
12004
|
}
|
|
11370
|
-
|
|
11371
|
-
|
|
11372
|
-
|
|
11373
|
-
|
|
11374
|
-
|
|
11375
|
-
|
|
11376
|
-
|
|
11377
|
-
|
|
11378
|
-
|
|
11379
|
-
|
|
12005
|
+
catch { /* may already be absent — verified below */ }
|
|
12006
|
+
if (hostEntryExistsNoFollow(path)) {
|
|
12007
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
12008
|
+
+ `could not remove stale ${label} at ${path}`);
|
|
12009
|
+
}
|
|
12010
|
+
};
|
|
12011
|
+
// Capture the stale name/target BEFORE any re-selection below (reselect
|
|
12012
|
+
// reassigns persistentSessionName and widens it back to string | undefined).
|
|
12013
|
+
const staleSessionName = persistentSessionName;
|
|
12014
|
+
const stalePersistentTarget = selectedBackend.persistentBackendTarget;
|
|
12015
|
+
const migrationEffects = {
|
|
12016
|
+
killStalePane: () => {
|
|
11380
12017
|
try {
|
|
11381
12018
|
// ZMX keeps its own call here rather than going through the target
|
|
11382
12019
|
// helper: only this path holds the frozen PID, which makes the
|
|
11383
12020
|
// ownership check stricter than the name+label check.
|
|
11384
12021
|
if (effectiveBackendType === 'zmx') {
|
|
11385
|
-
ZmxBackend.killManagedSession(
|
|
12022
|
+
ZmxBackend.killManagedSession(staleSessionName, cfg.sessionId, resolvedZmxSessionPid);
|
|
12023
|
+
}
|
|
12024
|
+
else if (stalePersistentTarget) {
|
|
12025
|
+
killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
|
|
11386
12026
|
}
|
|
11387
12027
|
else {
|
|
11388
|
-
|
|
11389
|
-
killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
|
|
11390
|
-
else
|
|
11391
|
-
killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
|
|
12028
|
+
killPersistentSession(effectiveBackendType, staleSessionName, cfg.sessionId);
|
|
11392
12029
|
}
|
|
11393
12030
|
}
|
|
11394
12031
|
catch (e) {
|
|
11395
12032
|
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
|
|
11396
12033
|
}
|
|
12034
|
+
},
|
|
12035
|
+
confirmPaneGone: () => {
|
|
11397
12036
|
const postKillProbe = effectiveBackendType === 'zmx'
|
|
11398
12037
|
? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe
|
|
11399
12038
|
: (stalePersistentTarget
|
|
11400
12039
|
? probePersistentBackendTarget(stalePersistentTarget)
|
|
11401
12040
|
: probePersistentSession(effectiveBackendType, staleSessionName));
|
|
11402
|
-
|
|
11403
|
-
|
|
11404
|
-
|
|
12041
|
+
// Migration teardown fail-closes on ANY non-`missing` post-kill probe for
|
|
12042
|
+
// EVERY backend — not just ZMX. `exists` (kill didn't take) and `unknown`
|
|
12043
|
+
// (kill unconfirmed: tmux swallows kill errors incl. timeout, zellij does
|
|
12044
|
+
// not check its spawnSync exit) both mean "the confined pane may still be
|
|
12045
|
+
// alive", so publishing a new generation around it would silently keep the
|
|
12046
|
+
// old confinement. Only an authoritative `missing` confirms termination.
|
|
12047
|
+
// (This is STRICTER than the shared shouldRejectPersistentPostKillProbe,
|
|
12048
|
+
// which the separate mcp-gateway gate still uses with its own semantics.)
|
|
12049
|
+
if (postKillProbe !== 'missing') {
|
|
12050
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
12051
|
+
+ `could not confirm stale ${effectiveBackendType} pane termination `
|
|
12052
|
+
+ `(post-kill probe: ${postKillProbe})`);
|
|
11405
12053
|
}
|
|
11406
12054
|
if (effectiveBackendType === 'zmx') {
|
|
11407
12055
|
resolvedZmxSessionProbe = postKillProbe;
|
|
11408
12056
|
resolvedZmxSessionPid = undefined;
|
|
11409
12057
|
}
|
|
12058
|
+
},
|
|
12059
|
+
clearProvenanceVerified: () => {
|
|
12060
|
+
// Clear BOTH files (verified). Order-independent — both must end absent.
|
|
12061
|
+
if (stalePaneMarkerPresent)
|
|
12062
|
+
removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker');
|
|
12063
|
+
if (policyOffTombstonePresent)
|
|
12064
|
+
removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone');
|
|
12065
|
+
},
|
|
12066
|
+
reselectBackend: () => {
|
|
11410
12067
|
// ZMX backend selection consumes the frozen probe. Refresh it before
|
|
11411
12068
|
// re-selecting or the replacement keeps isReattach=true for the pane
|
|
11412
12069
|
// that this gate just proved was removed.
|
|
@@ -11417,8 +12074,40 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11417
12074
|
backend = selectedBackend.backend;
|
|
11418
12075
|
cliLifetimeNonce++;
|
|
11419
12076
|
persistentSessionName = selectedBackend.persistentSessionName;
|
|
12077
|
+
},
|
|
12078
|
+
refuseInconclusiveProbe: () => {
|
|
12079
|
+
// The liveness probe was `unknown` where acting would be unsafe (the pane
|
|
12080
|
+
// may still be alive AND still confined under an obsolete policy). No
|
|
12081
|
+
// provenance is touched, no reselect — fail closed and let the next launch
|
|
12082
|
+
// re-probe once the backend is answering again.
|
|
12083
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
12084
|
+
+ `could not verify existing ${effectiveBackendType} pane `
|
|
12085
|
+
+ `(liveness probe: ${paneProbe})`);
|
|
12086
|
+
},
|
|
12087
|
+
};
|
|
12088
|
+
if (migration.action === 'reattach') {
|
|
12089
|
+
if (originChannelPolicyExpected) {
|
|
12090
|
+
persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker);
|
|
11420
12091
|
}
|
|
12092
|
+
// Pane matches the current policy (isolated pane stamped under it, or a
|
|
12093
|
+
// no-transport pane with a VALIDATED policy-off tombstone) → still valid on
|
|
12094
|
+
// the running process across daemon restarts; warm reattach preserves
|
|
12095
|
+
// resume/context + tmux idle-suspend.
|
|
12096
|
+
log(`[read-isolation] reattaching persistent pane under current policy (${cfg.sessionId})`);
|
|
12097
|
+
}
|
|
12098
|
+
else if (migration.action === 'clear-stale-then-cold-spawn') {
|
|
12099
|
+
log(`[read-isolation] clearing stale provenance for dead pane before cold-spawn (${cfg.sessionId})`);
|
|
11421
12100
|
}
|
|
12101
|
+
else if (migration.action === 'kill-then-cold-spawn') {
|
|
12102
|
+
log(`[read-isolation] persistent pane provenance mismatch for ${cfg.sessionId} — killing + cold-spawning with current policy`);
|
|
12103
|
+
}
|
|
12104
|
+
else if (migration.action === 'refuse-inconclusive-probe') {
|
|
12105
|
+
log(`[read-isolation] inconclusive liveness probe (${paneProbe}) for ${cfg.sessionId} — refusing to start rather than clear/cold-spawn around a possibly-live confined pane`);
|
|
12106
|
+
}
|
|
12107
|
+
// Ordered, fail-closed side effects (kill → confirm → clear → reselect) live
|
|
12108
|
+
// in executePersistentPaneMigration so the ordering + stop-on-failure
|
|
12109
|
+
// guarantees are unit-testable with injected mocks.
|
|
12110
|
+
executePersistentPaneMigration(migration, migrationEffects);
|
|
11422
12111
|
}
|
|
11423
12112
|
readIsolationOriginChannelId = managedOriginChannelRequired
|
|
11424
12113
|
? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex'))
|
|
@@ -11447,11 +12136,35 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11447
12136
|
if (effectiveBackendType === 'zmx') {
|
|
11448
12137
|
resolvedZmxSessionProbe = paneProbe;
|
|
11449
12138
|
}
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
//
|
|
11454
|
-
|
|
12139
|
+
const paneRelayReattachSafe = paneProbe === 'exists'
|
|
12140
|
+
&& mcpGatewayPaneReattachSafe(readMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId), sessionMcpGatewaySocketPath(cfg.sessionId, config.session.dataDir));
|
|
12141
|
+
if (paneRelayReattachSafe) {
|
|
12142
|
+
// The pane's CLI was launched against the deterministic Gateway socket
|
|
12143
|
+
// path by a reconnect-capable relay. Reattaching preserves whatever turn
|
|
12144
|
+
// the CLI is executing — the whole point of the persistent backend — but
|
|
12145
|
+
// the trusted host died with the previous worker, so we must RE-SERVE it
|
|
12146
|
+
// here at the same deterministic path for the pane's surviving relay to
|
|
12147
|
+
// reconnect to. This reattach path skips prepareCliPluginGenerationAndGateway
|
|
12148
|
+
// (it must not refresh the catalog on a warm reattach), which is the only
|
|
12149
|
+
// other host starter; without this the relay would reconnect forever to a
|
|
12150
|
+
// socket nothing binds. Start only when this fresh worker has no live host
|
|
12151
|
+
// yet — never stomp a host an in-worker restart already brought up.
|
|
12152
|
+
if (!sessionMcpGatewayHost) {
|
|
12153
|
+
await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
|
|
12154
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
12155
|
+
throw new CliSpawnSupersededError();
|
|
12156
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — re-served trusted host at the same path; relay reconnects on its own`);
|
|
12157
|
+
}
|
|
12158
|
+
else {
|
|
12159
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — reattaching; live host already bound, relay stays connected`);
|
|
12160
|
+
}
|
|
12161
|
+
}
|
|
12162
|
+
else if (paneProbe === 'exists') {
|
|
12163
|
+
// Legacy pane (mkdtemp-random socket path, or a relay predating
|
|
12164
|
+
// reconnect support): its MCP client can never reach the replacement
|
|
12165
|
+
// host. Cold-resume the CLI so it gets a fresh relay socket instead of
|
|
12166
|
+
// reattaching to a dead connection.
|
|
12167
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state without a reconnect-capable relay — cold-resuming with a fresh host`);
|
|
11455
12168
|
const persistentBackendType = effectiveBackendType;
|
|
11456
12169
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
11457
12170
|
if (effectiveBackendType === 'zmx') {
|
|
@@ -11571,6 +12284,13 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11571
12284
|
bareShellChecked = false;
|
|
11572
12285
|
bareShellCheckInProgress = false;
|
|
11573
12286
|
// ── Resume pre-flight check + two-tier fallback ──────────────────────────
|
|
12287
|
+
// Tier 0 (adapter capability): adapters whose buildArgs can only resume a
|
|
12288
|
+
// PRECISE cliSessionId (cursor/copilot/kimi — no --continue/latest fallback,
|
|
12289
|
+
// which would risk resuming a SIBLING session's conversation) start FRESH
|
|
12290
|
+
// when no id was persisted. Demote here so the user-visible fresh notice
|
|
12291
|
+
// fires — leaving effectiveResume=true would have the adapter silently
|
|
12292
|
+
// launch a blank session while the closed card / resume receipt still claim
|
|
12293
|
+
// history was restored.
|
|
11574
12294
|
// Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false
|
|
11575
12295
|
// → skip --resume, spawn FRESH.
|
|
11576
12296
|
// Tier 2 (restart count): 2nd consecutive in-worker restart → force FRESH,
|
|
@@ -11615,8 +12335,15 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11615
12335
|
? resolveHermesStateDbPath({ ...process.env, ...sanitizePerBotEnv(cfg.env), BOTMUX_SESSION_ID: cfg.sessionId }, { botmuxSessionProfile: basename(cfg.cliPathOverride ?? '') === 'hermes-botmux-session' })
|
|
11616
12336
|
: undefined;
|
|
11617
12337
|
const tier2ForceFresh = effectiveResume && consecutiveInWorkerRestarts >= 2;
|
|
12338
|
+
// Tier 0: see block comment above. The adapter itself drops --resume when
|
|
12339
|
+
// the id is missing (its buildArgs starts fresh); demoting HERE keeps
|
|
12340
|
+
// effectiveResume honest so the fresh-demotion notice below fires.
|
|
12341
|
+
const missingExactResumeId = effectiveResume
|
|
12342
|
+
&& !willReattachPersistent
|
|
12343
|
+
&& !effectiveCliSessionId
|
|
12344
|
+
&& cliAdapter.resumeRequiresCliSessionId === true;
|
|
11618
12345
|
let tier1ProbeFalse = false;
|
|
11619
|
-
if (effectiveResume && !tier2ForceFresh && !willReattachPersistent) {
|
|
12346
|
+
if (effectiveResume && !tier2ForceFresh && !willReattachPersistent && !missingExactResumeId) {
|
|
11620
12347
|
const probe = cliAdapter.checkResumeTargetExists?.({
|
|
11621
12348
|
sessionId: effectiveAdapterSessionId,
|
|
11622
12349
|
cliSessionId: effectiveCliSessionId,
|
|
@@ -11627,11 +12354,13 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11627
12354
|
if (probe === false)
|
|
11628
12355
|
tier1ProbeFalse = true;
|
|
11629
12356
|
}
|
|
11630
|
-
const fallBackToFresh = effectiveResume && !willReattachPersistent && (tier1ProbeFalse || tier2ForceFresh);
|
|
12357
|
+
const fallBackToFresh = effectiveResume && !willReattachPersistent && (tier1ProbeFalse || tier2ForceFresh || missingExactResumeId);
|
|
11631
12358
|
if (fallBackToFresh) {
|
|
11632
12359
|
const reason = tier2ForceFresh
|
|
11633
12360
|
? `consecutive restart x${consecutiveInWorkerRestarts} — 2nd failed resume attempt`
|
|
11634
|
-
:
|
|
12361
|
+
: missingExactResumeId
|
|
12362
|
+
? 'no persisted CLI session id — this CLI can only resume a precise session (no --continue fallback)'
|
|
12363
|
+
: 'adapter confirmed resume target does not exist on disk';
|
|
11635
12364
|
log(`Resume fallback: dropping --resume (${reason}) → fresh session ${cfg.sessionId}`);
|
|
11636
12365
|
effectiveResume = false;
|
|
11637
12366
|
effectiveCliSessionId = undefined;
|
|
@@ -12011,6 +12740,10 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
12011
12740
|
const chatBotDiscovery = resolveChatBotDiscoveryConfig();
|
|
12012
12741
|
childEnv.BOTMUX_LARK_LIST_BOTS_API_ENABLED = chatBotDiscovery.listBotsApiEnabled ? 'true' : 'false';
|
|
12013
12742
|
childEnv.BOTMUX_LARK_LIST_BOTS_API_TIMEOUT_MS = String(chatBotDiscovery.listBotsApiTimeoutMs);
|
|
12743
|
+
// Inject the resolved workflow kill-switch so a pane's `botmux workflow …`
|
|
12744
|
+
// subcommand agrees with the daemon that spawned it, independent of stale
|
|
12745
|
+
// rcfile/tmux env (mirrors the chatBotDiscovery injection above).
|
|
12746
|
+
childEnv.BOTMUX_WORKFLOW_ENABLED = isWorkflowFeatureEnabled() ? 'true' : 'false';
|
|
12014
12747
|
if (cliAdapter.injectsReadyHook)
|
|
12015
12748
|
childEnv.BOTMUX_READY_COMMAND = sessionReadyHookCommand();
|
|
12016
12749
|
// Initial value only; long-lived panes get the latest turn via the JSON pid marker.
|
|
@@ -12595,23 +13328,69 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
12595
13328
|
log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
|
|
12596
13329
|
}
|
|
12597
13330
|
}
|
|
12598
|
-
|
|
12599
|
-
// daemon's boot id so a later reattach can be trusted (see the stale-pane
|
|
12600
|
-
// guard above). pty needs no marker (never reattached).
|
|
13331
|
+
let pendingProvenanceCommit = null;
|
|
12601
13332
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
|
|
13333
|
+
// Policy-ON isolation marker. The PENDING write is a spawn-time ADMISSION
|
|
13334
|
+
// PRECONDITION, NOT best-effort: if we cannot durably record the pending proof
|
|
13335
|
+
// and then backend.spawn() dynamically reattaches a late-arriving same-named
|
|
13336
|
+
// pane (zellij/TmuxBackend flip), the whole commit/teardown block below —
|
|
13337
|
+
// gated on pendingProvenanceCommit — would be SKIPPED, so the late-flip
|
|
13338
|
+
// teardown never runs and this launch keeps running attached to an
|
|
13339
|
+
// unattributed generation. "No committed proof → next launch cold-spawns" only
|
|
13340
|
+
// protects the NEXT launch, not THIS one. So a write failure must FAIL CLOSED
|
|
13341
|
+
// here (throw before spawn), exactly like the policy-off arm.
|
|
12602
13342
|
try {
|
|
12603
|
-
|
|
12604
|
-
|
|
12605
|
-
|
|
12606
|
-
|
|
12607
|
-
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
12611
|
-
|
|
12612
|
-
|
|
13343
|
+
mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true });
|
|
13344
|
+
const nonce = randomBytes(32).toString('hex');
|
|
13345
|
+
const markerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId);
|
|
13346
|
+
replaceManagedOriginCapabilityFile(markerPath, provenancePendingContent(nonce));
|
|
13347
|
+
pendingProvenanceCommit = {
|
|
13348
|
+
path: markerPath,
|
|
13349
|
+
nonce,
|
|
13350
|
+
committedContent: isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities, managedOriginChannelPolicyDigest
|
|
13351
|
+
? {
|
|
13352
|
+
originChannelId: persistentPaneOriginChannelId,
|
|
13353
|
+
readIsolation: willReadIsolate,
|
|
13354
|
+
writeSandbox: willWriteSandbox,
|
|
13355
|
+
policyDigest: managedOriginChannelPolicyDigest,
|
|
13356
|
+
}
|
|
13357
|
+
: undefined),
|
|
13358
|
+
// A committed policy-ON generation must not carry a stale policy-off
|
|
13359
|
+
// tombstone (a later flip to policy-off could read it as a no-sandbox gen).
|
|
13360
|
+
clearSiblingPath: policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId),
|
|
13361
|
+
label: 'isolation marker',
|
|
13362
|
+
};
|
|
13363
|
+
}
|
|
13364
|
+
catch (e) {
|
|
13365
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
13366
|
+
+ `could not record pending isolation-marker generation proof (${e.message})`);
|
|
13367
|
+
}
|
|
13368
|
+
}
|
|
13369
|
+
else if (appliedIsolationCapabilities.length === 0 && persistentSessionName
|
|
13370
|
+
&& !willReattachPersistent && noTransportSession && isolationCapableBackend) {
|
|
13371
|
+
// Policy-OFF generation proof (tmux-only: isolationCapableBackend === tmux).
|
|
13372
|
+
// NOT best-effort: if we cannot durably record the PENDING proof we must fail
|
|
13373
|
+
// closed rather than spawn a pane we can never prove. The committed tombstone
|
|
13374
|
+
// is written post-spawn only on a confirmed fresh generation.
|
|
13375
|
+
try {
|
|
13376
|
+
mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true });
|
|
13377
|
+
const nonce = randomBytes(32).toString('hex');
|
|
13378
|
+
const tombstonePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId);
|
|
13379
|
+
replaceManagedOriginCapabilityFile(tombstonePath, provenancePendingContent(nonce));
|
|
13380
|
+
pendingProvenanceCommit = {
|
|
13381
|
+
path: tombstonePath,
|
|
13382
|
+
nonce,
|
|
13383
|
+
committedContent: policyOffTombstoneContent(cfg.daemonBootId ?? ''),
|
|
13384
|
+
// A committed policy-off generation must not be shadowed by a stale
|
|
13385
|
+
// isolation marker (which DOMINATES the tombstone in the guard).
|
|
13386
|
+
clearSiblingPath: isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId),
|
|
13387
|
+
label: 'policy-off tombstone',
|
|
13388
|
+
};
|
|
13389
|
+
}
|
|
13390
|
+
catch (e) {
|
|
13391
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
13392
|
+
+ `could not record pending policy-off generation proof (${e.message})`);
|
|
12613
13393
|
}
|
|
12614
|
-
catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
|
|
12615
13394
|
}
|
|
12616
13395
|
// 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
|
|
12617
13396
|
// bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
|
|
@@ -12624,12 +13403,53 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
12624
13403
|
// ever (re)set it. Harmless for non-cjadk CLIs (they don't read it), but this
|
|
12625
13404
|
// keeps the behaviour intentional rather than ambient. (Codex review note.)
|
|
12626
13405
|
delete childEnv.CJADK_INTERACTIVE;
|
|
13406
|
+
// mojo: hand the generic extra args (CLI_EXTRA_ARGS) to the backend through the
|
|
13407
|
+
// config instead of through spawn args, and keep them OUT of the wrapper prefix.
|
|
13408
|
+
//
|
|
13409
|
+
// buildWrappedLaunch folds whatever it is given into the prefix, i.e. BEFORE the
|
|
13410
|
+
// per-turn flags the backend appends. With last-value-wins parsing that
|
|
13411
|
+
// silently inverted precedence: `CLI_EXTRA_ARGS=--idle-timeout 77` plus
|
|
13412
|
+
// `mojo.idleTimeoutSec=12` produced `--idle-timeout 77 … --idle-timeout 12` and
|
|
13413
|
+
// 12 won, contradicting the documented "extra args can override built-ins".
|
|
13414
|
+
// Carrying them separately lets the backend append them AFTER its own flags on
|
|
13415
|
+
// every turn, which is the same order the no-wrapper path already produced.
|
|
13416
|
+
if (effectiveBackendType === 'mojo' && spawnArgs.length > 0) {
|
|
13417
|
+
// Refuse platform-owned flags. Extra args are appended AFTER the backend's
|
|
13418
|
+
// own flags so an operator can override behaviour knobs — but with
|
|
13419
|
+
// last-value-wins that also let CLI_EXTRA_ARGS override the control-plane
|
|
13420
|
+
// identity frozen on the session (`--workspace-id`, `--agent-id`, `--cloud`),
|
|
13421
|
+
// the approval bypass (`--yolo`) or the resume lineage (`-r`). Fail closed
|
|
13422
|
+
// rather than silently dropping them: a dropped flag reads as applied.
|
|
13423
|
+
const reserved = findReservedMojoCliFlags(spawnArgs);
|
|
13424
|
+
if (reserved.length > 0) {
|
|
13425
|
+
throw new Error(`CLI_EXTRA_ARGS may not set ${reserved.join(' / ')} for a mojo bot — `
|
|
13426
|
+
+ 'these are platform-owned (control plane frozen per session, approval '
|
|
13427
|
+
+ 'bypass, resume lineage). Configure them through the bot settings instead.');
|
|
13428
|
+
}
|
|
13429
|
+
riffBackendConfig.extraCliArgs = [...spawnArgs];
|
|
13430
|
+
log(`mojo extra CLI args deferred to per-turn append: ${spawnArgs.join(' ')}`);
|
|
13431
|
+
spawnArgs = [];
|
|
13432
|
+
}
|
|
12627
13433
|
if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
|
|
12628
13434
|
if (sandboxRequested) {
|
|
12629
13435
|
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
|
|
12630
13436
|
}
|
|
12631
13437
|
else {
|
|
12632
|
-
|
|
13438
|
+
// Resolve wrapper binaries against the EFFECTIVE child PATH — the exact env
|
|
13439
|
+
// the child will really run with — not the daemon's own. `locateOnPath`
|
|
13440
|
+
// reads this process's env, so an ambient install shadowed a per-bot one.
|
|
13441
|
+
// The layering MUST come from the shared buildEffectiveChildEnv: this site
|
|
13442
|
+
// previously merged only childEnv + bot env and omitted the
|
|
13443
|
+
// highest-precedence `mojo.env`, so a wrapper pinned via `mojo.env.PATH`
|
|
13444
|
+
// lost to a same-named binary on the bot-level PATH.
|
|
13445
|
+
const effectiveChildEnv = buildEffectiveChildEnv({
|
|
13446
|
+
base: childEnv,
|
|
13447
|
+
botEnv: perBotInjectEnv,
|
|
13448
|
+
mojoEnv: effectiveBackendType === 'mojo'
|
|
13449
|
+
? riffBackendConfig?.env
|
|
13450
|
+
: undefined,
|
|
13451
|
+
});
|
|
13452
|
+
const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnEffectiveChildPath(b, effectiveChildEnv) ?? b, {
|
|
12633
13453
|
ttadkModel: cfg.model,
|
|
12634
13454
|
});
|
|
12635
13455
|
if (launch.bin) {
|
|
@@ -12872,6 +13692,123 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
12872
13692
|
}
|
|
12873
13693
|
const actuallyReattachedPersistent = 'isReattach' in backend
|
|
12874
13694
|
&& backend.isReattach === true;
|
|
13695
|
+
// ── Generational-race commit/teardown for the read-isolation provenance proof ──
|
|
13696
|
+
// We wrote a PENDING proof before spawn (pendingProvenanceCommit). Now that spawn
|
|
13697
|
+
// has returned we know whether a FRESH generation was actually established.
|
|
13698
|
+
if (pendingProvenanceCommit) {
|
|
13699
|
+
const commit = pendingProvenanceCommit;
|
|
13700
|
+
// Verified removal of a proof file: unlink then confirm truly gone (no-follow).
|
|
13701
|
+
// A leaf we cannot remove must FAIL CLOSED — never leave an ambiguous proof.
|
|
13702
|
+
const removeProofOrThrow = (path, label) => {
|
|
13703
|
+
try {
|
|
13704
|
+
unlinkSync(path);
|
|
13705
|
+
}
|
|
13706
|
+
catch { /* may already be absent — verified below */ }
|
|
13707
|
+
if (hostEntryExistsNoFollow(path)) {
|
|
13708
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
|
|
13709
|
+
+ `could not remove ${label} at ${path}`);
|
|
13710
|
+
}
|
|
13711
|
+
};
|
|
13712
|
+
// Teardown the exact just-launched target, confirm authoritative `missing`,
|
|
13713
|
+
// and only THEN keep/clear the pending proof per the tri-state result. On a
|
|
13714
|
+
// non-missing (exists/unknown) post-kill probe we KEEP the pending proof and
|
|
13715
|
+
// refuse — never erase evidence of a possibly-live pane.
|
|
13716
|
+
//
|
|
13717
|
+
// CRITICAL: kill the EXACT backend target, NOT the session name. An isolated /
|
|
13718
|
+
// MCP herdr task lives as an agent on the SHARED host session `botmux`
|
|
13719
|
+
// (target = {sessionName:'botmux', agentName:<this topic>}); a name-only
|
|
13720
|
+
// killPersistentSession('herdr','botmux') would tear down the whole shared host
|
|
13721
|
+
// (every bot/topic agent). Mirror the migration effects' killStalePane /
|
|
13722
|
+
// confirmPaneGone: target helper for herdr's agent scope, frozen-PID path for
|
|
13723
|
+
// ZMX identity, name only as the last-resort fallback when no target exists.
|
|
13724
|
+
const teardownTarget = selectedBackend.persistentBackendTarget;
|
|
13725
|
+
const tearDownAndRefuse = (why) => {
|
|
13726
|
+
if (persistentSessionName) {
|
|
13727
|
+
// Pure policy (behaviorally tested): herdr's shared-host agent MUST be
|
|
13728
|
+
// killed via its target, never by the 'botmux' session name.
|
|
13729
|
+
const killKind = persistentTeardownKillKind({
|
|
13730
|
+
backendType: effectiveBackendType,
|
|
13731
|
+
hasBackendTarget: !!teardownTarget,
|
|
13732
|
+
});
|
|
13733
|
+
try {
|
|
13734
|
+
if (killKind === 'zmx') {
|
|
13735
|
+
ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
|
|
13736
|
+
}
|
|
13737
|
+
else if (killKind === 'target') {
|
|
13738
|
+
killPersistentBackendTarget(teardownTarget, cfg.sessionId);
|
|
13739
|
+
}
|
|
13740
|
+
else {
|
|
13741
|
+
killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
|
|
13742
|
+
}
|
|
13743
|
+
}
|
|
13744
|
+
catch (killErr) {
|
|
13745
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; `
|
|
13746
|
+
+ `could not kill the exact target (${killErr?.message ?? killErr}) — pending proof retained`);
|
|
13747
|
+
}
|
|
13748
|
+
const postKill = killKind === 'zmx'
|
|
13749
|
+
? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe
|
|
13750
|
+
: (killKind === 'target'
|
|
13751
|
+
? probePersistentBackendTarget(teardownTarget)
|
|
13752
|
+
: probePersistentSession(effectiveBackendType, persistentSessionName));
|
|
13753
|
+
if (postKill !== 'missing') {
|
|
13754
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; `
|
|
13755
|
+
+ `post-kill probe ${postKill} (not missing) — pending proof retained`);
|
|
13756
|
+
}
|
|
13757
|
+
}
|
|
13758
|
+
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}`);
|
|
13759
|
+
};
|
|
13760
|
+
// Condition #2: a predicted-fresh launch that backend.spawn() dynamically
|
|
13761
|
+
// flipped into a reattach (a same-named pane arrived before the in-spawn probe)
|
|
13762
|
+
// must NOT keep running — the live pane is a foreign/unknown generation. Tear
|
|
13763
|
+
// it down and refuse rather than "don't commit but keep running".
|
|
13764
|
+
if (actuallyReattachedPersistent) {
|
|
13765
|
+
tearDownAndRefuse('predicted-fresh persistent launch dynamically reattached a late-arriving pane');
|
|
13766
|
+
}
|
|
13767
|
+
else if (effectiveBackendType === 'zellij') {
|
|
13768
|
+
// Option B: zellij's session is created ASYNCHRONOUSLY inside the pty child,
|
|
13769
|
+
// so a synchronous `!isReattach` here does NOT prove a fresh generation
|
|
13770
|
+
// attributable to THIS launch (a late collision can still occur after the
|
|
13771
|
+
// last in-spawn hasSession probe). We have no synchronous attributable
|
|
13772
|
+
// signal, so we DELIBERATELY do not commit: the pending proof is left on
|
|
13773
|
+
// disk, and this isolation-capable zellij pane will always cold-spawn on the
|
|
13774
|
+
// next daemon restart/suspend-resume (no warm-reattach). The attributable-ack
|
|
13775
|
+
// protocol that would restore zellij warm-reattach is a separate follow-up.
|
|
13776
|
+
log(`[read-isolation] zellij persistent pane ${cfg.sessionId}: leaving PENDING proof (isolation-capable zellij does not warm-reattach; cold-spawn on next launch)`);
|
|
13777
|
+
}
|
|
13778
|
+
else {
|
|
13779
|
+
// tmux(-pipe) / herdr / zmx: attributable-fresh at the synchronous spawn
|
|
13780
|
+
// return — TmuxPipe throws on new-session collision, herdr/zmx throw on a
|
|
13781
|
+
// name collision / verify their own bootstrap+launchPid handshake. So a
|
|
13782
|
+
// returned spawn + !isReattach here IS a fresh generation created by THIS
|
|
13783
|
+
// launch. COMMIT with compare-before-replace + generation fence.
|
|
13784
|
+
try {
|
|
13785
|
+
// F1 generation fence: a superseded spawn must not let this commit run.
|
|
13786
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
13787
|
+
throw new CliSpawnSupersededError();
|
|
13788
|
+
// F2 compare-before-replace: the pending file on disk must STILL be our
|
|
13789
|
+
// nonce (a same-worker restart / backend replacement between the pending
|
|
13790
|
+
// write and here could have replaced it with a newer generation's pending).
|
|
13791
|
+
const current = provenancePendingNonce(readManagedOriginAuthorityFile(commit.path));
|
|
13792
|
+
if (current !== commit.nonce) {
|
|
13793
|
+
throw new Error(`pending proof nonce mismatch (superseded generation) at ${commit.path}`);
|
|
13794
|
+
}
|
|
13795
|
+
// Mutual exclusivity: clear the sibling proof (verified) BEFORE committing,
|
|
13796
|
+
// so the committed generation is never shadowed by a stale sibling.
|
|
13797
|
+
if (commit.clearSiblingPath)
|
|
13798
|
+
removeProofOrThrow(commit.clearSiblingPath, `stale ${commit.label} sibling`);
|
|
13799
|
+
// Atomic replace pending → committed.
|
|
13800
|
+
replaceManagedOriginCapabilityFile(commit.path, commit.committedContent);
|
|
13801
|
+
}
|
|
13802
|
+
catch (err) {
|
|
13803
|
+
if (err instanceof CliSpawnSupersededError)
|
|
13804
|
+
throw err;
|
|
13805
|
+
// Condition #3: a commit-write failure must not leave an ambiguous started
|
|
13806
|
+
// pane running as if successful — tear down the exact target and refuse.
|
|
13807
|
+
// (The pending proof, if it survived, keeps the next launch fail-closed.)
|
|
13808
|
+
tearDownAndRefuse(`could not commit ${commit.label} generation proof (${err.message})`);
|
|
13809
|
+
}
|
|
13810
|
+
}
|
|
13811
|
+
}
|
|
12875
13812
|
try {
|
|
12876
13813
|
finalizeCodexAppControlGeneration(cfg, actuallyReattachedPersistent, !!persistentSessionName);
|
|
12877
13814
|
}
|
|
@@ -13272,11 +14209,12 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
13272
14209
|
else
|
|
13273
14210
|
markPromptReady();
|
|
13274
14211
|
};
|
|
13275
|
-
// Set up idle detection.
|
|
13276
|
-
//
|
|
14212
|
+
// Set up idle detection. Remote backends (riff / mojo) have no PTY output and
|
|
14213
|
+
// are marked ready immediately after spawn (see below), so the idle detector
|
|
13277
14214
|
// is unnecessary — and without a readyPattern it would fire on every
|
|
13278
14215
|
// quiescence, repeatedly triggering markPromptReady() and duplicate cards.
|
|
13279
|
-
|
|
14216
|
+
// (For mojo it is also actively harmful — see MojoBackend.settleTurn.)
|
|
14217
|
+
if (!isRemoteBackendType(effectiveBackendType)) {
|
|
13280
14218
|
idleDetector = new IdleDetector(cliAdapter);
|
|
13281
14219
|
wireIdleDetectorBusyTransition(idleDetector, `${cliName()} PTY`);
|
|
13282
14220
|
idleDetector.onIdle(async (evidenceSource) => {
|
|
@@ -13367,6 +14305,22 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
13367
14305
|
log(`${cliName()} task finished — re-arming prompt-ready for queued follow-ups`);
|
|
13368
14306
|
markPromptReady();
|
|
13369
14307
|
});
|
|
14308
|
+
// Headless final-answer bridge (mojo): deliver the turn's answer to the
|
|
14309
|
+
// thread when the agent produced one but never called `botmux send`. Same
|
|
14310
|
+
// generation fence as onTaskDone above — a late callback from a backend the
|
|
14311
|
+
// worker has already replaced must not post into the current turn.
|
|
14312
|
+
backend.onTurnFinal?.((text) => {
|
|
14313
|
+
if (fatalWorkerErrorPending)
|
|
14314
|
+
return;
|
|
14315
|
+
if (backend !== observedBackend)
|
|
14316
|
+
return;
|
|
14317
|
+
try {
|
|
14318
|
+
deliverMojoTurnFinal(text);
|
|
14319
|
+
}
|
|
14320
|
+
catch (err) {
|
|
14321
|
+
log(`Mojo final bridge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
14322
|
+
}
|
|
14323
|
+
});
|
|
13370
14324
|
// riff:任务 id 变更同步给 daemon 持久化,daemon 重启后 follow-up 血缘不断。
|
|
13371
14325
|
backend.onTaskId?.((taskId) => {
|
|
13372
14326
|
send({ type: 'riff_task_id', taskId });
|
|
@@ -13517,6 +14471,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
13517
14471
|
isPromptReady = false;
|
|
13518
14472
|
currentBotmuxTurnId = undefined;
|
|
13519
14473
|
currentBotmuxDispatchAttempt = undefined;
|
|
14474
|
+
currentGatewayTrustedCaller = undefined;
|
|
13520
14475
|
if (!intentionalRestart && activeRestartAttemptId) {
|
|
13521
14476
|
send({
|
|
13522
14477
|
type: 'restart_result',
|
|
@@ -13606,15 +14561,79 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
13606
14561
|
markPromptReady();
|
|
13607
14562
|
};
|
|
13608
14563
|
setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_TIMEOUT_MS, false), FIRST_PROMPT_TIMEOUT_MS);
|
|
13609
|
-
//
|
|
13610
|
-
//
|
|
13611
|
-
//
|
|
13612
|
-
//
|
|
13613
|
-
// and
|
|
13614
|
-
if (effectiveBackendType
|
|
14564
|
+
// Remote backends (riff / mojo) have no local boot process — the backend is
|
|
14565
|
+
// ready immediately after spawn(). The idle detector never fires for them (no
|
|
14566
|
+
// PTY output), and the first-prompt timeout only flushes for type-ahead
|
|
14567
|
+
// adapters, so isPromptReady would otherwise stay false until the ~15s
|
|
14568
|
+
// fallback and every first message would be needlessly delayed.
|
|
14569
|
+
if (isRemoteBackendType(effectiveBackendType)) {
|
|
14570
|
+
// BEFORE markPromptReady, which can flush a queued turn: a replacement
|
|
14571
|
+
// backend is built from the original init config, so a rotated — or cleared —
|
|
14572
|
+
// credential must be re-applied first or the queued turn runs against the
|
|
14573
|
+
// revived token.
|
|
14574
|
+
restoreMojoLivePatchAfterRespawn();
|
|
13615
14575
|
markPromptReady();
|
|
13616
14576
|
}
|
|
13617
14577
|
}
|
|
14578
|
+
/**
|
|
14579
|
+
* Hand a credential/behaviour patch to a live MojoBackend.
|
|
14580
|
+
*
|
|
14581
|
+
* Validated first: the daemon is trusted, but this arrives over IPC and a stale
|
|
14582
|
+
* or malformed payload must not reach the backend. Non-mojo backends ignore it.
|
|
14583
|
+
*/
|
|
14584
|
+
/**
|
|
14585
|
+
* Last VALIDATED credential snapshot applied to a backend in this worker.
|
|
14586
|
+
*
|
|
14587
|
+
* Survives a backend replacement (`/restart`, crash respawn, lease-fenced
|
|
14588
|
+
* restart), which is the only place it matters: a replacement backend is
|
|
14589
|
+
* constructed from the ORIGINAL init config, so a credential that had since been
|
|
14590
|
+
* rotated — or, worse, cleared — silently reverted. A queued clear turn then ran
|
|
14591
|
+
* against the revived token. Restart IPC deliberately does NOT need to carry the
|
|
14592
|
+
* snapshot; the worker already knows it.
|
|
14593
|
+
*/
|
|
14594
|
+
let lastAppliedMojoLivePatch;
|
|
14595
|
+
function applyMojoLivePatch(patch) {
|
|
14596
|
+
if (!patch || effectiveBackendType !== 'mojo')
|
|
14597
|
+
return;
|
|
14598
|
+
// Patch-specific validator. The CONFIG validator was wrong here: it requires a
|
|
14599
|
+
// non-empty `jwt` string, so it rejected every `null` tombstone and the backend
|
|
14600
|
+
// never received a clear request — the credential stayed live for the worker's
|
|
14601
|
+
// whole lifetime. This one accepts exactly `{ jwt: string | null }`.
|
|
14602
|
+
const validated = normalizeMojoLivePatch(patch);
|
|
14603
|
+
if (!validated.ok) {
|
|
14604
|
+
log(`Ignoring invalid mojo live patch: ${validated.errors.join('; ')}`);
|
|
14605
|
+
return;
|
|
14606
|
+
}
|
|
14607
|
+
if (validated.value.jwt !== undefined)
|
|
14608
|
+
lastAppliedMojoLivePatch = validated.value;
|
|
14609
|
+
pushMojoLivePatchToBackend(validated.value);
|
|
14610
|
+
}
|
|
14611
|
+
/** Hand an already-validated snapshot to the live backend, if it can take one. */
|
|
14612
|
+
function pushMojoLivePatchToBackend(patch) {
|
|
14613
|
+
// Duck-typed rather than `instanceof`: importing MojoBackend as a value here
|
|
14614
|
+
// would pull the backend module into the worker's eager import graph for every
|
|
14615
|
+
// CLI, and the capability check is what actually matters.
|
|
14616
|
+
const current = backend;
|
|
14617
|
+
if (typeof current?.applyLivePatch !== 'function')
|
|
14618
|
+
return;
|
|
14619
|
+
current.applyLivePatch(patch);
|
|
14620
|
+
}
|
|
14621
|
+
/**
|
|
14622
|
+
* Re-apply the remembered snapshot onto a freshly installed backend.
|
|
14623
|
+
*
|
|
14624
|
+
* Must run AFTER the replacement backend is installed and BEFORE any pending turn
|
|
14625
|
+
* is flushed into it, or a queued clear would execute against the reverted
|
|
14626
|
+
* credential.
|
|
14627
|
+
*/
|
|
14628
|
+
function restoreMojoLivePatchAfterRespawn() {
|
|
14629
|
+
if (effectiveBackendType !== 'mojo')
|
|
14630
|
+
return;
|
|
14631
|
+
const remembered = lastAppliedMojoLivePatch;
|
|
14632
|
+
if (!remembered || remembered.jwt === undefined)
|
|
14633
|
+
return;
|
|
14634
|
+
pushMojoLivePatchToBackend(remembered);
|
|
14635
|
+
log(`Restored live mojo JWT state after backend replacement (${remembered.jwt === null ? 'cleared' : 'rotated'})`);
|
|
14636
|
+
}
|
|
13618
14637
|
function killCli(opts = {}) {
|
|
13619
14638
|
// Invalidate an in-flight async spawn before any other teardown hook runs. The
|
|
13620
14639
|
// remaining cleanup is synchronous today, but generation ownership must not
|
|
@@ -13692,6 +14711,7 @@ function killCli(opts = {}) {
|
|
|
13692
14711
|
readIsolationOriginChannelId = null;
|
|
13693
14712
|
currentBotmuxTurnId = undefined;
|
|
13694
14713
|
currentBotmuxDispatchAttempt = undefined;
|
|
14714
|
+
currentGatewayTrustedCaller = undefined;
|
|
13695
14715
|
currentVcMeetingImTurnOrigin = undefined;
|
|
13696
14716
|
submittedCodexAppReplyTurnIds.clear();
|
|
13697
14717
|
pendingCodexAppSteerAckIds.clear();
|
|
@@ -13778,14 +14798,39 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
13778
14798
|
if (restartingBackend)
|
|
13779
14799
|
intentionalRestartBackend = restartingBackend;
|
|
13780
14800
|
const teardown = restartingBackend?.destroySession?.();
|
|
14801
|
+
let teardownOutcome = { kind: 'absent' };
|
|
13781
14802
|
if (teardown && typeof teardown.then === 'function') {
|
|
13782
|
-
|
|
13783
|
-
|
|
13784
|
-
|
|
13785
|
-
|
|
13786
|
-
|
|
13787
|
-
|
|
13788
|
-
|
|
14803
|
+
// Every branch is CAPTURED rather than discarded. The old code raced the
|
|
14804
|
+
// promise against a 22s sleep inside `try { } catch {}`, so a resolved
|
|
14805
|
+
// ok:false, a won timeout and a rejection were all indistinguishable from a
|
|
14806
|
+
// clean teardown — and all three fell through to killCli() + respawn.
|
|
14807
|
+
teardownOutcome = await Promise.race([
|
|
14808
|
+
Promise.resolve(teardown).then((raw) => ({ kind: 'settled', raw }), (error) => ({ kind: 'rejected', error })),
|
|
14809
|
+
new Promise(resolve => setTimeout(() => resolve({ kind: 'timeout' }), 22_000)),
|
|
14810
|
+
]);
|
|
14811
|
+
}
|
|
14812
|
+
const teardownVerdict = classifyRestartTeardown(teardownOutcome, {
|
|
14813
|
+
remote: isRemoteBackendType(effectiveBackendType),
|
|
14814
|
+
});
|
|
14815
|
+
if (!teardownVerdict.mayRespawn) {
|
|
14816
|
+
// Fail closed instead of respawning. The old subtree may still hold the
|
|
14817
|
+
// injected credential, so a replacement lineage must not be layered on top
|
|
14818
|
+
// of it: that is exactly how a second credentialed tree used to appear
|
|
14819
|
+
// while the first became unenumerable under a fresh nonce.
|
|
14820
|
+
//
|
|
14821
|
+
// Deliberately NOT calling abortDestroySession(): rollback is not the
|
|
14822
|
+
// legitimate exit from an uncertain teardown (see destroy-result.ts), and
|
|
14823
|
+
// for a latched fence the backend would refuse anyway. Admission therefore
|
|
14824
|
+
// stays fenced, and the persisted containment handle keeps the
|
|
14825
|
+
// device-isolation blocker in place for the next generation to resolve.
|
|
14826
|
+
log(`Restart ABORTED (${reason}): teardown not proven `
|
|
14827
|
+
+ `(${teardownVerdict.reason ?? 'unknown'}, `
|
|
14828
|
+
+ `${teardownVerdict.recovery ?? 'retryable'}); refusing to respawn a second `
|
|
14829
|
+
+ 'lineage over a possibly-live credentialed subtree');
|
|
14830
|
+
// cliRestartInProgress / rawInputRestartGate deliberately stay armed so no
|
|
14831
|
+
// input is accepted while the outcome is unknown.
|
|
14832
|
+
await sendFatalWorkerErrorAndExit(new Error(`restart teardown not proven: ${teardownVerdict.reason ?? 'unknown'}`));
|
|
14833
|
+
return;
|
|
13789
14834
|
}
|
|
13790
14835
|
killCli({ preservePending: opts.preservePending });
|
|
13791
14836
|
awaitingFirstPrompt = true;
|
|
@@ -13853,10 +14898,11 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
13853
14898
|
void restartCliProcess(reason, { preservePending: true, skipRestartBudget: followup.skipRestartBudget });
|
|
13854
14899
|
return;
|
|
13855
14900
|
}
|
|
13856
|
-
//
|
|
13857
|
-
// intentionally held by the restart gate above. Release
|
|
13858
|
-
// fence now;
|
|
13859
|
-
|
|
14901
|
+
// Remote backends mark themselves prompt-ready inside spawnCli(); that
|
|
14902
|
+
// early flush is intentionally held by the restart gate above. Release
|
|
14903
|
+
// the raw-input fence now; local backends keep it until their later
|
|
14904
|
+
// markPromptReady().
|
|
14905
|
+
if (isRemoteBackendType(effectiveBackendType) && isPromptReady)
|
|
13860
14906
|
releaseRawInputRestartGate();
|
|
13861
14907
|
// A local replacement process can exist before its TUI input box does.
|
|
13862
14908
|
// Only re-kick a prompt that became ready while the restart fence was
|
|
@@ -13906,7 +14952,22 @@ function startWebServer(host, preferredPort) {
|
|
|
13906
14952
|
return;
|
|
13907
14953
|
}
|
|
13908
14954
|
const loginHdr = req.headers['x-botmux-login-url'];
|
|
13909
|
-
|
|
14955
|
+
// The central front proxy only injects X-Botmux-Login-Url on the SPA 401
|
|
14956
|
+
// path, never on `/s/` terminal requests — so a read-only web terminal
|
|
14957
|
+
// showed an owner-login banner that was a dead <div> (no href). When the
|
|
14958
|
+
// header is absent, self-derive the platform owner-login URL for THIS
|
|
14959
|
+
// session's terminal path: `/open/<machineId>?next=/s/<sessionId>`. The
|
|
14960
|
+
// `/s/` next routes the platform to the terminal subdomain surface and,
|
|
14961
|
+
// after login, lands the browser back on this terminal WITH the host-only
|
|
14962
|
+
// proxy-session cookie the owner check requires (the cross-subdomain SSO
|
|
14963
|
+
// cookie alone does not make the request writable). Returns '' when this
|
|
14964
|
+
// machine is unbound or remote access is off, so the banner falls back to
|
|
14965
|
+
// its non-link text form rather than a broken link.
|
|
14966
|
+
const headerLoginUrl = typeof loginHdr === 'string' && /^https?:\/\/[^"'<>\s]+$/.test(loginHdr)
|
|
14967
|
+
? loginHdr
|
|
14968
|
+
: '';
|
|
14969
|
+
const loginUrl = headerLoginUrl
|
|
14970
|
+
|| (sessionId ? deriveTerminalLoginUrl(sessionId) : '');
|
|
13910
14971
|
// Herdr snapshots contain screen cells but not terminal mode state. On a
|
|
13911
14972
|
// refreshed page an alt-screen CLI would otherwise look like an empty
|
|
13912
14973
|
// normal buffer until resize causes a redraw. Preserve that mode so
|
|
@@ -13951,21 +15012,62 @@ function startWebServer(host, preferredPort) {
|
|
|
13951
15012
|
},
|
|
13952
15013
|
});
|
|
13953
15014
|
wss.on('connection', (ws, req) => {
|
|
13954
|
-
|
|
13955
|
-
//
|
|
13956
|
-
//
|
|
15015
|
+
// P1-3 — the pre-handshake verdict is a SNAPSHOT, and this callback runs
|
|
15016
|
+
// strictly after it: ws only completes the upgrade once verifyClient
|
|
15017
|
+
// called back, and re-resolving access here re-reads `.dashboard-secret`
|
|
15018
|
+
// synchronously. A capability whose expiry — or the worker generation it
|
|
15019
|
+
// is pinned to, or the host secret it is signed under — turns over inside
|
|
15020
|
+
// that window was authorized by the FIRST check and is already dead by
|
|
15021
|
+
// this one. This callback used to recompute `access` but consume only
|
|
15022
|
+
// `hasWrite`, so such a socket was registered in `wsClients`, seeded with
|
|
15023
|
+
// the terminal scrollback, and — the second resolution carrying no
|
|
15024
|
+
// controlExpiresAt — never given an expiry timer at all: an unbounded
|
|
15025
|
+
// read-only stream outliving its own credential.
|
|
15026
|
+
//
|
|
15027
|
+
// FAIL CLOSED on the SECOND result instead. A socket that no longer
|
|
15028
|
+
// resolves to read authority is closed before any registration, so it
|
|
15029
|
+
// never joins a broadcast set and never receives the history seed; and
|
|
15030
|
+
// the expiry timer below is armed from THIS resolution's expiresAt —
|
|
15031
|
+
// never inherited from the handshake and never skipped.
|
|
13957
15032
|
const url = parseWorkerRequestUrl(req);
|
|
13958
15033
|
if (!url) {
|
|
13959
15034
|
log(`Bad worker WS URL rejected: ${JSON.stringify(req.url ?? '')}`);
|
|
13960
|
-
wsClients.delete(ws);
|
|
13961
15035
|
ws.close(1008, 'Bad Request');
|
|
13962
15036
|
return;
|
|
13963
15037
|
}
|
|
13964
|
-
const
|
|
15038
|
+
const access = resolveTerminalAccessForReq(req, url);
|
|
15039
|
+
const { hasRead, hasWrite, auditUser, controlExpiresAt } = access;
|
|
15040
|
+
if (!hasRead || (controlExpiresAt !== undefined && controlExpiresAt <= Date.now())) {
|
|
15041
|
+
log('WS client refused at the connection re-check: capability no longer valid');
|
|
15042
|
+
ws.close(4003, 'authorization expired');
|
|
15043
|
+
return;
|
|
15044
|
+
}
|
|
15045
|
+
wsClients.add(ws);
|
|
13965
15046
|
const allowReadOnlyRemoteScroll = canHandleReadOnlyRemoteScroll();
|
|
13966
15047
|
if (hasWrite)
|
|
13967
15048
|
authedClients.add(ws);
|
|
13968
15049
|
log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
|
|
15050
|
+
// A signed Dashboard grant is fixed-expiry — for READ scope as much as
|
|
15051
|
+
// for write (P1-5). Even if the central proxy's socket invalidation is
|
|
15052
|
+
// delayed or bypassed, the worker independently removes any granted
|
|
15053
|
+
// authority and closes this connection at the signed boundary; a
|
|
15054
|
+
// reconnect must then present a still-valid credential. Read-only
|
|
15055
|
+
// sockets used to outlive their authentication here; now they cannot.
|
|
15056
|
+
let controlExpiryTimer;
|
|
15057
|
+
if (controlExpiresAt !== undefined) {
|
|
15058
|
+
const expiredReason = hasWrite ? 'control expired' : 'view expired';
|
|
15059
|
+
controlExpiryTimer = setTimeout(() => {
|
|
15060
|
+
authedClients.delete(ws);
|
|
15061
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
15062
|
+
ws.close(4003, expiredReason);
|
|
15063
|
+
}, Math.max(0, controlExpiresAt - Date.now()));
|
|
15064
|
+
controlExpiryTimer.unref();
|
|
15065
|
+
}
|
|
15066
|
+
ws.once('close', () => {
|
|
15067
|
+
if (controlExpiryTimer)
|
|
15068
|
+
clearTimeout(controlExpiryTimer);
|
|
15069
|
+
authedClients.delete(ws);
|
|
15070
|
+
});
|
|
13969
15071
|
if (isTmuxMode && !isPipeMode && sessionId) {
|
|
13970
15072
|
// ── Tmux-attach mode: per-client attach ──
|
|
13971
15073
|
// Each WS client gets its own `tmux attach-session` PTY.
|
|
@@ -14047,6 +15149,7 @@ function startWebServer(host, preferredPort) {
|
|
|
14047
15149
|
// A view capability therefore forwards no input bytes at all.
|
|
14048
15150
|
if (!authedClients.has(ws))
|
|
14049
15151
|
return;
|
|
15152
|
+
auditTerminalInput(auditUser, msg.data);
|
|
14050
15153
|
if (cp)
|
|
14051
15154
|
cp.write(msg.data);
|
|
14052
15155
|
else
|
|
@@ -14097,7 +15200,12 @@ function startWebServer(host, preferredPort) {
|
|
|
14097
15200
|
name: 'xterm-256color',
|
|
14098
15201
|
cols,
|
|
14099
15202
|
rows,
|
|
14100
|
-
|
|
15203
|
+
// redactChildEnv first, matching the tmux viewer above (tmuxEnv()
|
|
15204
|
+
// folds REDACTED_CHILD_ENV_KEYS into its own strip set, zellijEnv()
|
|
15205
|
+
// only drops ZELLIJ*). A viewer client needs none of the daemon's
|
|
15206
|
+
// secrets — bare IM-app creds, GitHub tokens, or the Dashboard H5
|
|
15207
|
+
// credentials — so it must not carry them into a pty it owns.
|
|
15208
|
+
env: zellijEnv(redactChildEnv(process.env)),
|
|
14101
15209
|
});
|
|
14102
15210
|
attachStarted = true;
|
|
14103
15211
|
observeBe?.setLiveAttach(true);
|
|
@@ -14130,6 +15238,7 @@ function startWebServer(host, preferredPort) {
|
|
|
14130
15238
|
else if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
14131
15239
|
if (!authedClients.has(ws))
|
|
14132
15240
|
return;
|
|
15241
|
+
auditTerminalInput(auditUser, msg.data);
|
|
14133
15242
|
if (cp)
|
|
14134
15243
|
cp.write(msg.data);
|
|
14135
15244
|
else
|
|
@@ -14215,6 +15324,7 @@ function startWebServer(host, preferredPort) {
|
|
|
14215
15324
|
// A read-only view capability must never forward bytes to the backend.
|
|
14216
15325
|
if (!authedClients.has(ws))
|
|
14217
15326
|
return;
|
|
15327
|
+
auditTerminalInput(auditUser, msg.data);
|
|
14218
15328
|
if (usesHerdrSnapshotWebHistory()) {
|
|
14219
15329
|
if (msg.data.includes('\x1b[<64;'))
|
|
14220
15330
|
herdrWebScrollDirection = 'up';
|
|
@@ -14401,12 +15511,46 @@ if(!hasToken){
|
|
|
14401
15511
|
else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
|
|
14402
15512
|
}
|
|
14403
15513
|
|
|
15514
|
+
// ── 终端字号按容器宽度自适应 ─────────────────────────────────────────────────
|
|
15515
|
+
// xterm 的 fontSize 是这一页唯一的几何输入:字号写死,列数就只能被容器宽度被动
|
|
15516
|
+
// 决定。14px 是照桌面宽度调的,手机把这一页直嵌进 390px 宽的 iframe 之后一行只
|
|
15517
|
+
// 剩约 44 列 —— 字大得离谱,CLI 的 TUI 还被迫在 44 列上硬折行,逻辑行换行稀碎。
|
|
15518
|
+
// 这里把因果反过来:先定一个可读的目标列数,再由容器实际宽度反推字号。
|
|
15519
|
+
//
|
|
15520
|
+
// _WB_FONT_ADVANCE 是等宽字体的标称「字宽 / 字号」比(JetBrains Mono、Fira Code、
|
|
15521
|
+
// Menlo、DejaVu Sans Mono 都是 0.6),只用于估算;真实列数仍由 xterm 量完字体后
|
|
15522
|
+
// 自己 fit() 出来,估算偏一点最多让列数在目标附近浮动,不会错位。
|
|
15523
|
+
// 只缩不放:算出来比 _WB_FONT_BASE 大一律按 base 走,所以宽度够(约 521px 以上)
|
|
15524
|
+
// 的桌面一律还是 14px,存量渲染零变化;窄容器才往下缩,最低到 _WB_FONT_MIN。
|
|
15525
|
+
//
|
|
15526
|
+
// 注意:这一整段在 worker.ts 的模板字符串里,注释和代码都不能出现反引号,
|
|
15527
|
+
// 也不能出现「美元号 + 左花括号」的插值起始序列,否则会把模板字符串截断。
|
|
15528
|
+
var _WB_FONT_MIN=9,_WB_FONT_MAX=15,_WB_FONT_BASE=14,_WB_FONT_ADVANCE=.6,_WB_TARGET_COLS=62;
|
|
15529
|
+
function _wbAutoFontSize(width){
|
|
15530
|
+
if(!(width>0))return _WB_FONT_BASE;
|
|
15531
|
+
var ideal=width/(_WB_TARGET_COLS*_WB_FONT_ADVANCE);
|
|
15532
|
+
var capped=ideal>_WB_FONT_BASE?_WB_FONT_BASE:ideal;
|
|
15533
|
+
// 半档取整:比整数细一档,又不会停在容易糊掉的亚像素字号上。
|
|
15534
|
+
var stepped=Math.round(capped*2)/2;
|
|
15535
|
+
// 硬边界:字号是这一页唯一的几何输入,任何异常宽度都不许把它推到读不了的档位。
|
|
15536
|
+
if(stepped<_WB_FONT_MIN)return _WB_FONT_MIN;
|
|
15537
|
+
if(stepped>_WB_FONT_MAX)return _WB_FONT_MAX;
|
|
15538
|
+
return stepped;
|
|
15539
|
+
}
|
|
15540
|
+
function _wbTerminalWidth(){
|
|
15541
|
+
var host=document.getElementById('terminal');
|
|
15542
|
+
var w=host?host.clientWidth:0;
|
|
15543
|
+
if(w>0)return w;
|
|
15544
|
+
// 容器还没布局出宽度(隐藏面板、刚插进 DOM 的 iframe)时退回视口宽;再量不到就
|
|
15545
|
+
// 返回 0,_wbAutoFontSize 保持基准字号,等尺寸事件到了再纠正。
|
|
15546
|
+
return window.innerWidth||0;
|
|
15547
|
+
}
|
|
14404
15548
|
var term=new Terminal({
|
|
14405
15549
|
theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
|
|
14406
15550
|
selectionBackground:'#33467c',black:'#15161e',red:'#f7768e',
|
|
14407
15551
|
green:'#9ece6a',yellow:'#e0af68',blue:'#7aa2f7',magenta:'#bb9af7',
|
|
14408
15552
|
cyan:'#7dcfff',white:'#a9b1d6'},
|
|
14409
|
-
fontSize:
|
|
15553
|
+
fontSize:_wbAutoFontSize(_wbTerminalWidth()),fontFamily:"'JetBrains Mono','Fira Code',monospace",
|
|
14410
15554
|
cursorBlink:!isTouch,scrollback:50000,allowProposedApi:true
|
|
14411
15555
|
});
|
|
14412
15556
|
var fit=new FitAddon.FitAddon();
|
|
@@ -14426,7 +15570,53 @@ try{
|
|
|
14426
15570
|
}catch(_e){
|
|
14427
15571
|
try{term.loadAddon(new CanvasAddon.CanvasAddon())}catch(_e2){}
|
|
14428
15572
|
}
|
|
15573
|
+
// 首帧字号已经在 new Terminal 里按容器宽度定好了;这个函数负责后续尺寸变化
|
|
15574
|
+
// (转屏、iframe 改宽、分屏、地址栏收放)时复算。返回 true 表示字号真的换了档。
|
|
15575
|
+
function _wbApplyAutoFontSize(){
|
|
15576
|
+
var next=_wbAutoFontSize(_wbTerminalWidth());
|
|
15577
|
+
if(term.options.fontSize===next)return false;
|
|
15578
|
+
term.options.fontSize=next;
|
|
15579
|
+
return true;
|
|
15580
|
+
}
|
|
14429
15581
|
fit.fit();
|
|
15582
|
+
// 工作台下发的终端外观(消息类型 botmux:wb-appearance)。终端画布住在这个跨文档
|
|
15583
|
+
// iframe 里,父页换 class / 换 CSS 变量都传不进来,配色只能靠 postMessage 递过来。
|
|
15584
|
+
// 注意:这一整段在 worker.ts 的模板字符串里,注释和代码都不能出现反引号,
|
|
15585
|
+
// 也不能出现「美元号 + 左花括号」的插值起始序列,否则会把模板字符串截断。
|
|
15586
|
+
// 安全边界:这个页面会被跨 origin 嵌入,因此只认 window.parent 发来的、结构完全
|
|
15587
|
+
// 对得上的消息,且每个颜色都必须是 #rgb / #rrggbb 十六进制字面量;少一个键、多一个
|
|
15588
|
+
// 非法值,整条消息丢弃(宁可保持现状,也不要把半套主题刷到画布上)。
|
|
15589
|
+
var _WB_THEME_KEYS=['background','foreground','cursor','selectionBackground',
|
|
15590
|
+
'black','red','green','yellow','blue','magenta','cyan','white'];
|
|
15591
|
+
function _wbSanitizeTheme(raw){
|
|
15592
|
+
if(!raw||typeof raw!=='object')return null;
|
|
15593
|
+
var out={};
|
|
15594
|
+
for(var _i=0;_i<_WB_THEME_KEYS.length;_i++){
|
|
15595
|
+
var _k=_WB_THEME_KEYS[_i],_v=raw[_k];
|
|
15596
|
+
if(typeof _v!=='string'||!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(_v))return null;
|
|
15597
|
+
out[_k]=_v;
|
|
15598
|
+
}
|
|
15599
|
+
return out;
|
|
15600
|
+
}
|
|
15601
|
+
window.addEventListener('message',function(_ev){
|
|
15602
|
+
if(_ev.source===window||_ev.source!==window.parent)return;
|
|
15603
|
+
var _d=_ev.data;
|
|
15604
|
+
if(!_d||_d.type!=='botmux:wb-appearance')return;
|
|
15605
|
+
var _theme=_wbSanitizeTheme(_d.theme);
|
|
15606
|
+
if(!_theme)return;
|
|
15607
|
+
try{
|
|
15608
|
+
term.options.theme=_theme;
|
|
15609
|
+
// 阅读风是大行距 1.3;经典保持 xterm 默认的 1,「经典 = 原样」的一部分。
|
|
15610
|
+
// 经典那套色值与上面写死的字面量逐色相同,所以没开阅读风的用户零变化。
|
|
15611
|
+
// 这两个数字的唯一出处是 WORKBENCH_TERM_LINE_HEIGHTS(agent-workbench-appearance.ts),
|
|
15612
|
+
// 接缝测试按那张表比对这一行的字面量:改一处必须两处一起改。
|
|
15613
|
+
// 曾经是 1.55,单元格高约 24.5px,CLI 底部那块固定 8 行的 chrome(提示 + 输入框 +
|
|
15614
|
+
// 状态条)就要占掉约 196px —— 一屏四分之一全是 chrome,正文被挤走。
|
|
15615
|
+
term.options.lineHeight=_d.termStyle==='reader'?1.3:1;
|
|
15616
|
+
// 行距变了可视行数就变,必须复算一次。几何契约不动:只有画布内重绘。
|
|
15617
|
+
fit.fit();
|
|
15618
|
+
}catch(_e){}
|
|
15619
|
+
});
|
|
14430
15620
|
// xterm parses writes asynchronously. On a brand-new page the first tmux /
|
|
14431
15621
|
// zellij frame (or relay history seed) can therefore finish after the browser
|
|
14432
15622
|
// has initialised the viewport scrollbar, leaving that viewport at scrollTop=0
|
|
@@ -14643,9 +15833,25 @@ function sendResize(){
|
|
|
14643
15833
|
// text re-wraps, i.e. the reported flicker. Coalesce to the settled size.
|
|
14644
15834
|
function onViewportResize(){
|
|
14645
15835
|
clearTimeout(_rzT);
|
|
14646
|
-
_rzT=setTimeout(function(){
|
|
15836
|
+
_rzT=setTimeout(function(){
|
|
15837
|
+
// 先复算字号再 fit:字号换档字宽就变,反过来 fit 出来的是旧字号下的列数。
|
|
15838
|
+
// 只动画布内的渲染几何,与后端的 resize 语义一如既往由下面的 sendResize 决定。
|
|
15839
|
+
try{_wbApplyAutoFontSize()}catch(e){}
|
|
15840
|
+
if(!fixedSize){try{fit.fit()}catch(e){}}
|
|
15841
|
+
sendResize();
|
|
15842
|
+
},250);
|
|
14647
15843
|
}
|
|
14648
15844
|
window.addEventListener('resize',onViewportResize);
|
|
15845
|
+
// 转屏:部分 iOS 版本只发 orientationchange 或先于 resize 发,重复触发被防抖吃掉。
|
|
15846
|
+
window.addEventListener('orientationchange',onViewportResize);
|
|
15847
|
+
// 嵌入方(工作台终端面板、会话坞)单独改 iframe 宽度时,窗口 resize 未必可靠地传
|
|
15848
|
+
// 进来。直接盯容器自己的尺寸,这一页才真正自包含 —— 任何嵌入方都跟着受益。
|
|
15849
|
+
if(typeof ResizeObserver!=='undefined'){
|
|
15850
|
+
try{
|
|
15851
|
+
var _wbHost=document.getElementById('terminal');
|
|
15852
|
+
if(_wbHost)new ResizeObserver(onViewportResize).observe(_wbHost);
|
|
15853
|
+
}catch(_e){}
|
|
15854
|
+
}
|
|
14649
15855
|
(function connect(){
|
|
14650
15856
|
// Derive base from the current path so the WS connects to the same prefix the
|
|
14651
15857
|
// page was served under — works both directly (path '/') and behind the
|
|
@@ -15149,7 +16355,7 @@ const emittedTurnTerminals = new TurnTerminalDeduper();
|
|
|
15149
16355
|
/** Report CLI processing completion independently from user-visible output.
|
|
15150
16356
|
* Keep a bounded worker-local dedup set because transcript watchers and app
|
|
15151
16357
|
* runners may surface the same final boundary more than once. */
|
|
15152
|
-
function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisposition) {
|
|
16358
|
+
function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisposition, retryable) {
|
|
15153
16359
|
if (!sessionId || !turnId)
|
|
15154
16360
|
return;
|
|
15155
16361
|
if (!emittedTurnTerminals.claim(sessionId, turnId, dispatchAttempt))
|
|
@@ -15171,6 +16377,7 @@ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisp
|
|
|
15171
16377
|
...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
|
|
15172
16378
|
...(errorCode ? { errorCode } : {}),
|
|
15173
16379
|
...(outputDisposition ? { outputDisposition } : {}),
|
|
16380
|
+
...(retryable !== undefined ? { retryable } : {}),
|
|
15174
16381
|
});
|
|
15175
16382
|
if (terminalReleasesDurableTurn({ turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }, { turnId, dispatchAttempt })) {
|
|
15176
16383
|
// Durable replay ownership moves back to the receiver/hub at terminal.
|
|
@@ -15344,7 +16551,7 @@ process.on('message', async (raw) => {
|
|
|
15344
16551
|
const ordinaryImTurnId = !msg.adoptMode
|
|
15345
16552
|
&& msg.dispatchAttempt === undefined
|
|
15346
16553
|
&& !!msg.prompt
|
|
15347
|
-
&& msg.turnId?.startsWith('om_')
|
|
16554
|
+
&& (msg.turnId?.startsWith('om_') || msg.turnId?.startsWith('bmx-recovery-'))
|
|
15348
16555
|
? msg.turnId
|
|
15349
16556
|
: undefined;
|
|
15350
16557
|
if (lastInitConfig) {
|
|
@@ -15381,7 +16588,8 @@ process.on('message', async (raw) => {
|
|
|
15381
16588
|
initialInputOwnershipPending = !!msg.prompt;
|
|
15382
16589
|
activeRestartAttemptId = msg.restartAttemptId;
|
|
15383
16590
|
sessionId = msg.sessionId;
|
|
15384
|
-
|
|
16591
|
+
// The view token intentionally stays per-boot random (no refresh): a
|
|
16592
|
+
// worker restart must invalidate every previously issued read link.
|
|
15385
16593
|
refreshTerminalWriteToken();
|
|
15386
16594
|
applySessionOwnerEnv(process.env, msg.ownerOpenId);
|
|
15387
16595
|
// Pin this worker's i18n locale early so every t() call below resolves
|
|
@@ -15451,6 +16659,7 @@ process.on('message', async (raw) => {
|
|
|
15451
16659
|
if (msg.turnId) {
|
|
15452
16660
|
currentBotmuxTurnId = msg.turnId;
|
|
15453
16661
|
currentBotmuxDispatchAttempt = msg.dispatchAttempt;
|
|
16662
|
+
currentGatewayTrustedCaller = msg.trustedCaller;
|
|
15454
16663
|
currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
|
|
15455
16664
|
writeCliPidMarker();
|
|
15456
16665
|
publishSandboxRelayCapability();
|
|
@@ -15570,6 +16779,7 @@ process.on('message', async (raw) => {
|
|
|
15570
16779
|
: undefined),
|
|
15571
16780
|
codexAppInput: entry.codexAppInput,
|
|
15572
16781
|
vcMeetingImTurnOrigin: entry.vcMeetingImTurnOrigin,
|
|
16782
|
+
trustedCaller: msg.trustedCaller,
|
|
15573
16783
|
}));
|
|
15574
16784
|
let initialInputCommitted = false;
|
|
15575
16785
|
if (shouldQueueInitialPrompt({
|
|
@@ -15597,6 +16807,7 @@ process.on('message', async (raw) => {
|
|
|
15597
16807
|
codexAppDispatchId: msg.codexAppDispatchId,
|
|
15598
16808
|
queuedActivationToken: msg.queuedActivationToken,
|
|
15599
16809
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
16810
|
+
trustedCaller: msg.trustedCaller,
|
|
15600
16811
|
codexAppInput: msg.promptCodexAppInput,
|
|
15601
16812
|
// Thread the root's steer authorization so ordered pre-final steer can
|
|
15602
16813
|
// fold follow-ups into THIS turn (the runner's canSteer requires the
|
|
@@ -15671,10 +16882,22 @@ process.on('message', async (raw) => {
|
|
|
15671
16882
|
break;
|
|
15672
16883
|
}
|
|
15673
16884
|
case 'message': {
|
|
16885
|
+
// 每轮消息都捎带 daemon 侧当前解析出的模型,覆盖 respawn 快照——理由与
|
|
16886
|
+
// restart 那条通道相同,但这里管的是**崩溃 park 后由消息触发的恢复重启**:
|
|
16887
|
+
// 那条路在 worker 内部直接 `{...lastInitConfig, resume:true}` 起 CLI,没有
|
|
16888
|
+
// restart IPC 可携带新值。三分态同 restart:undefined = 不携带(旧 daemon)
|
|
16889
|
+
// 保持快照;null = 明确不传模型。对已经在跑的 CLI 无影响(只在下次 spawn 用)。
|
|
16890
|
+
if (msg.model !== undefined && lastInitConfig) {
|
|
16891
|
+
lastInitConfig.model = msg.model === null ? undefined : msg.model;
|
|
16892
|
+
}
|
|
16893
|
+
// NOTE: the mojo credential snapshot is deliberately NOT applied here. It
|
|
16894
|
+
// rides on the queue item and is applied when THIS turn is actually written
|
|
16895
|
+
// to the backend (see flushPending) — applying at receive time made two
|
|
16896
|
+
// queued credential turns collapse to the newest value for both.
|
|
15674
16897
|
const messageAdoptMode = lastInitConfig?.adoptMode === true;
|
|
15675
16898
|
const ordinaryImTurnId = !messageAdoptMode
|
|
15676
16899
|
&& msg.dispatchAttempt === undefined
|
|
15677
|
-
&& msg.turnId?.startsWith('om_')
|
|
16900
|
+
&& (msg.turnId?.startsWith('om_') || msg.turnId?.startsWith('bmx-recovery-'))
|
|
15678
16901
|
? msg.turnId
|
|
15679
16902
|
: undefined;
|
|
15680
16903
|
if (ordinaryImTurnId) {
|
|
@@ -15765,6 +16988,7 @@ process.on('message', async (raw) => {
|
|
|
15765
16988
|
codexAppDispatchId: msg.codexAppDispatchId,
|
|
15766
16989
|
queuedActivationToken: msg.queuedActivationToken,
|
|
15767
16990
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
16991
|
+
trustedCaller: msg.trustedCaller,
|
|
15768
16992
|
codexAppInput,
|
|
15769
16993
|
};
|
|
15770
16994
|
// process.on('message') does not serialize async listeners. Hold the
|
|
@@ -15796,6 +17020,9 @@ process.on('message', async (raw) => {
|
|
|
15796
17020
|
queuedActivationToken: msg.queuedActivationToken,
|
|
15797
17021
|
replyTurnId: msg.replyTurnId,
|
|
15798
17022
|
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
17023
|
+
trustedCaller: msg.trustedCaller,
|
|
17024
|
+
// Applied when THIS item is written, not on receipt.
|
|
17025
|
+
...(msg.mojoLivePatch ? { mojoLivePatch: msg.mojoLivePatch } : {}),
|
|
15799
17026
|
});
|
|
15800
17027
|
if (inputCommitted)
|
|
15801
17028
|
acknowledgeTurnInputCommitted(msg.turnId);
|
|
@@ -15864,6 +17091,14 @@ process.on('message', async (raw) => {
|
|
|
15864
17091
|
log('Refused Riff generation restart; the existing lineage-owning worker is retained');
|
|
15865
17092
|
break;
|
|
15866
17093
|
}
|
|
17094
|
+
// Mojo is deliberately NOT refused here (unlike riff): the mojo restart
|
|
17095
|
+
// teardown cancels the remote session and cold-boots a replacement, and
|
|
17096
|
+
// internal machinery (per-bot env hot updates, backend-replacement tests)
|
|
17097
|
+
// relies on that. USER-facing restart for every remote backend is instead
|
|
17098
|
+
// rejected at the daemon boundary — /restart in command-handler and the
|
|
17099
|
+
// dashboard restart route both gate on isRemoteBackendSession — so a
|
|
17100
|
+
// restart IPC that reaches a mojo worker is a daemon-internal decision,
|
|
17101
|
+
// not a user command that would silently destroy remote context.
|
|
15867
17102
|
// 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true},
|
|
15868
17103
|
// 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
|
|
15869
17104
|
// 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
|
|
@@ -15881,6 +17116,14 @@ process.on('message', async (raw) => {
|
|
|
15881
17116
|
if (msg.env !== undefined && lastInitConfig) {
|
|
15882
17117
|
lastInitConfig.env = msg.env === null ? undefined : msg.env;
|
|
15883
17118
|
}
|
|
17119
|
+
// model 热更,与 env 同一条通道、同一套三分态。model 不进冻结集合、每次
|
|
17120
|
+
// spawn 按当前 bot 配置解析(见 resolveSessionLaunchModel),但 live-worker
|
|
17121
|
+
// restart 不 refork,没有 init 重发这条通道——不覆盖的话,改完模型再重启
|
|
17122
|
+
// 起来的还是快照里的旧模型。undefined=不携带(旧 daemon / 取不到)保持快照;
|
|
17123
|
+
// null=当前不该传模型(bot 未配 / 已清空)→ 移除快照。同样放在合并守卫之前。
|
|
17124
|
+
if (msg.model !== undefined && lastInitConfig) {
|
|
17125
|
+
lastInitConfig.model = msg.model === null ? undefined : msg.model;
|
|
17126
|
+
}
|
|
15884
17127
|
// restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
|
|
15885
17128
|
// 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
|
|
15886
17129
|
// 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
|
|
@@ -16226,8 +17469,13 @@ process.on('message', async (raw) => {
|
|
|
16226
17469
|
break;
|
|
16227
17470
|
}
|
|
16228
17471
|
case 'refresh_screen': {
|
|
16229
|
-
|
|
17472
|
+
// The daemon only offers manual refresh on a screenshot-mode card, so
|
|
17473
|
+
// landing here in another mode means the display-mode re-sync was lost —
|
|
17474
|
+
// say so instead of eating the click silently.
|
|
17475
|
+
if (displayMode !== 'screenshot') {
|
|
17476
|
+
log(`Manual screenshot refresh ignored: displayMode=${displayMode}`);
|
|
16230
17477
|
break;
|
|
17478
|
+
}
|
|
16231
17479
|
lastShotHash = '';
|
|
16232
17480
|
if (screenshotTimer) {
|
|
16233
17481
|
clearInterval(screenshotTimer);
|
|
@@ -16240,11 +17488,17 @@ process.on('message', async (raw) => {
|
|
|
16240
17488
|
case 'close': {
|
|
16241
17489
|
log('Close requested');
|
|
16242
17490
|
// destroySession kills tmux session permanently; kill() only detaches.
|
|
16243
|
-
//
|
|
16244
|
-
// process.exit
|
|
16245
|
-
|
|
17491
|
+
// Remote destroySession is an asynchronous cancellation. Explicit close
|
|
17492
|
+
// must therefore use prepare/commit: an immediate process.exit would cut
|
|
17493
|
+
// off the request and leave the remote agent running. A request-less
|
|
17494
|
+
// close on ANY remote backend is refused outright: the legacy path below
|
|
17495
|
+
// calls destroySession() — for Mojo that is `mojo session cancel` — so
|
|
17496
|
+
// letting a generic lifecycle teardown fall through there irreversibly
|
|
17497
|
+
// cancelled the remote session with no close_result to report ok:false
|
|
17498
|
+
// or residuals. Remote retirement must always carry a requestId.
|
|
17499
|
+
if (isRemoteBackendType(effectiveBackendType)) {
|
|
16246
17500
|
if (!msg.requestId) {
|
|
16247
|
-
log(
|
|
17501
|
+
log(`Refused unsafe request-less ${effectiveBackendType} close; explicit close requires prepare/commit`);
|
|
16248
17502
|
break;
|
|
16249
17503
|
}
|
|
16250
17504
|
let result;
|
|
@@ -16272,33 +17526,57 @@ process.on('message', async (raw) => {
|
|
|
16272
17526
|
lastAbortedCloseRequestId = null;
|
|
16273
17527
|
closeRequestInFlightId = msg.requestId;
|
|
16274
17528
|
try {
|
|
16275
|
-
|
|
16276
|
-
|
|
16277
|
-
|
|
16278
|
-
|
|
17529
|
+
if (!backend?.destroySession) {
|
|
17530
|
+
result = { ok: false, error: 'remote_close_unsupported' };
|
|
17531
|
+
}
|
|
17532
|
+
else {
|
|
17533
|
+
result = normalizeDestroyResult(await backend.destroySession(), { remote: isRemoteBackendType(effectiveBackendType) });
|
|
17534
|
+
}
|
|
16279
17535
|
}
|
|
16280
17536
|
catch (err) {
|
|
16281
|
-
|
|
17537
|
+
// A THROWN destroySession leaves the outcome unknown: it may already
|
|
17538
|
+
// have cancelled the remote session or spawned side effects. Defaulting
|
|
17539
|
+
// this to retryable re-opened write admission on a possibly-dead
|
|
17540
|
+
// lineage, so an unknown outcome must fence instead.
|
|
17541
|
+
result = {
|
|
17542
|
+
ok: false,
|
|
17543
|
+
error: err instanceof Error ? err.message : String(err),
|
|
17544
|
+
recovery: 'uncertain',
|
|
17545
|
+
// Stated explicitly: an unknown outcome must fence writes even if a
|
|
17546
|
+
// later reader decides the close itself may be retried.
|
|
17547
|
+
admission: 'fenced',
|
|
17548
|
+
};
|
|
16282
17549
|
}
|
|
16283
17550
|
}
|
|
16284
17551
|
if (result.ok) {
|
|
16285
17552
|
preparedCloseRequestId = msg.requestId;
|
|
16286
17553
|
}
|
|
16287
17554
|
else if (attemptedPrepare) {
|
|
16288
|
-
|
|
16289
|
-
|
|
17555
|
+
// Two separate questions, deliberately not collapsed: `recovery` says
|
|
17556
|
+
// whether the CLOSE may be retried, `admission` says whether WRITES may
|
|
17557
|
+
// be admitted again — mayRestoreWriteAdmission answers only the second.
|
|
17558
|
+
// Keying this on `recovery` re-opened writes on an unproven-local-child
|
|
17559
|
+
// close (retryable, yet a credentialed process may still be alive), and
|
|
17560
|
+
// aborting on every ok:false additionally re-opened writes on a lineage
|
|
17561
|
+
// already cancelled remotely.
|
|
17562
|
+
if (mayRestoreWriteAdmission(result)) {
|
|
17563
|
+
await backend?.abortDestroySession?.();
|
|
17564
|
+
lastAbortedCloseRequestId = msg.requestId;
|
|
17565
|
+
}
|
|
17566
|
+
else {
|
|
17567
|
+
log(`${effectiveBackendType} close prepare failed as `
|
|
17568
|
+
+ `${result.recovery ?? 'retryable'}/`
|
|
17569
|
+
+ `${result.admission ?? 'derived'}; write admission stays fenced `
|
|
17570
|
+
+ '(session remains active for retry)');
|
|
17571
|
+
}
|
|
16290
17572
|
}
|
|
16291
17573
|
if (closeRequestInFlightId === msg.requestId)
|
|
16292
17574
|
closeRequestInFlightId = null;
|
|
16293
|
-
|
|
16294
|
-
|
|
16295
|
-
|
|
16296
|
-
ok: result.ok,
|
|
16297
|
-
...(result.taskId ? { taskId: result.taskId } : {}),
|
|
16298
|
-
...(result.error ? { error: result.error } : {}),
|
|
16299
|
-
});
|
|
17575
|
+
// Built by the shared helper so the payload (notably `recovery`, which the
|
|
17576
|
+
// daemon needs to decide whether a rollback is legal) is covered by tests.
|
|
17577
|
+
send(buildCloseResultMessage(msg.requestId, result));
|
|
16300
17578
|
if (!result.ok) {
|
|
16301
|
-
log(
|
|
17579
|
+
log(`${effectiveBackendType} close prepare failed (${result.error ?? 'cancel failed'}); session stays active for retry`);
|
|
16302
17580
|
}
|
|
16303
17581
|
break;
|
|
16304
17582
|
}
|
|
@@ -16317,8 +17595,10 @@ process.on('message', async (raw) => {
|
|
|
16317
17595
|
stopScreenshotLoop();
|
|
16318
17596
|
stopBridgeWatcher();
|
|
16319
17597
|
stopCodexBridge();
|
|
16320
|
-
// Local close
|
|
16321
|
-
//
|
|
17598
|
+
// Local close destroys persistent owned sessions. Remote backends never
|
|
17599
|
+
// reach here: the branch above fences them all (request-less remote close
|
|
17600
|
+
// is refused; with a requestId it goes through prepare/commit), so
|
|
17601
|
+
// destroySession() below only ever tears down local backing sessions.
|
|
16322
17602
|
const closeTeardown = backend?.destroySession?.();
|
|
16323
17603
|
if (closeTeardown && typeof closeTeardown.then === 'function') {
|
|
16324
17604
|
try {
|
|
@@ -16327,11 +17607,13 @@ process.on('message', async (raw) => {
|
|
|
16327
17607
|
catch { /* logged by backend */ }
|
|
16328
17608
|
}
|
|
16329
17609
|
killCli();
|
|
16330
|
-
// Bridge marker
|
|
16331
|
-
// restarts so a mid-flight send is still credited
|
|
16332
|
-
//
|
|
16333
|
-
// same sessionId starts
|
|
17610
|
+
// Bridge marker + turn-journal files outlive a single CLI process (kept
|
|
17611
|
+
// across restarts so a mid-flight send is still credited and an
|
|
17612
|
+
// interrupted turn can be restored), but a real close tears down the
|
|
17613
|
+
// session — purge both so a future re-use of the same sessionId starts
|
|
17614
|
+
// clean.
|
|
16334
17615
|
clearSendMarkers();
|
|
17616
|
+
clearBridgeTurnJournalFile();
|
|
16335
17617
|
cleanup();
|
|
16336
17618
|
process.exit(0);
|
|
16337
17619
|
}
|
|
@@ -16356,28 +17638,28 @@ process.on('message', async (raw) => {
|
|
|
16356
17638
|
// We still request a clean exit; the daemon SIGKILL is the hard backstop.
|
|
16357
17639
|
process.exit(0);
|
|
16358
17640
|
}
|
|
16359
|
-
case '
|
|
16360
|
-
if (effectiveBackendType
|
|
17641
|
+
case 'remote_shutdown_prepare': {
|
|
17642
|
+
if (!isRemoteBackendType(effectiveBackendType)) {
|
|
16361
17643
|
send({
|
|
16362
|
-
type: '
|
|
17644
|
+
type: 'remote_shutdown_result',
|
|
16363
17645
|
requestId: msg.requestId,
|
|
16364
17646
|
phase: 'prepare',
|
|
16365
17647
|
ok: false,
|
|
16366
17648
|
taskId: null,
|
|
16367
|
-
error: '
|
|
17649
|
+
error: 'not_remote_backend',
|
|
16368
17650
|
});
|
|
16369
17651
|
break;
|
|
16370
17652
|
}
|
|
16371
17653
|
let result;
|
|
16372
|
-
const inputBlocker =
|
|
17654
|
+
const inputBlocker = remoteWorkerShutdownInputBlocker({
|
|
16373
17655
|
initPromptMaterialized,
|
|
16374
17656
|
isFlushing,
|
|
16375
17657
|
pendingMessages: pendingMessages.length,
|
|
16376
17658
|
pendingRawInputs: pendingRawInputs.length,
|
|
16377
17659
|
pendingSessionRename: pendingSessionRename !== null,
|
|
16378
17660
|
// sessionRenameInFlight became a function in this branch's rename
|
|
16379
|
-
// lifecycle rework;
|
|
16380
|
-
// the
|
|
17661
|
+
// lifecycle rework; remoteWorkerShutdownInputBlocker (added on master by
|
|
17662
|
+
// the original Riff two-phase shutdown) still consumes it as a boolean field.
|
|
16381
17663
|
sessionRenameInFlight: sessionRenameInFlight(),
|
|
16382
17664
|
commandLineWritesPending,
|
|
16383
17665
|
});
|
|
@@ -16417,7 +17699,7 @@ process.on('message', async (raw) => {
|
|
|
16417
17699
|
}
|
|
16418
17700
|
}
|
|
16419
17701
|
send({
|
|
16420
|
-
type: '
|
|
17702
|
+
type: 'remote_shutdown_result',
|
|
16421
17703
|
requestId: msg.requestId,
|
|
16422
17704
|
phase: 'prepare',
|
|
16423
17705
|
ok: result.ok,
|
|
@@ -16426,14 +17708,14 @@ process.on('message', async (raw) => {
|
|
|
16426
17708
|
});
|
|
16427
17709
|
break;
|
|
16428
17710
|
}
|
|
16429
|
-
case '
|
|
16430
|
-
if (effectiveBackendType
|
|
17711
|
+
case 'remote_shutdown_commit': {
|
|
17712
|
+
if (!isRemoteBackendType(effectiveBackendType)
|
|
16431
17713
|
|| shutdownDetachRequestId !== msg.requestId
|
|
16432
17714
|
|| shutdownDetachPhase !== 'prepared') {
|
|
16433
|
-
log(`Ignoring stale
|
|
17715
|
+
log(`Ignoring stale remote shutdown commit ${msg.requestId}`);
|
|
16434
17716
|
break;
|
|
16435
17717
|
}
|
|
16436
|
-
log(`
|
|
17718
|
+
log(`Remote shutdown detach committed (${msg.requestId})`);
|
|
16437
17719
|
shutdownDetachRequestId = null;
|
|
16438
17720
|
shutdownDetachPhase = null;
|
|
16439
17721
|
backend?.commitShutdownDetach?.();
|
|
@@ -16443,11 +17725,11 @@ process.on('message', async (raw) => {
|
|
|
16443
17725
|
cleanup();
|
|
16444
17726
|
process.exit(0);
|
|
16445
17727
|
}
|
|
16446
|
-
case '
|
|
17728
|
+
case 'remote_shutdown_abort': {
|
|
16447
17729
|
if (shutdownDetachRequestId !== msg.requestId) {
|
|
16448
|
-
log(`Ignoring stale
|
|
17730
|
+
log(`Ignoring stale remote shutdown abort ${msg.requestId}`);
|
|
16449
17731
|
send({
|
|
16450
|
-
type: '
|
|
17732
|
+
type: 'remote_shutdown_result',
|
|
16451
17733
|
requestId: msg.requestId,
|
|
16452
17734
|
phase: 'abort',
|
|
16453
17735
|
ok: false,
|
|
@@ -16456,7 +17738,7 @@ process.on('message', async (raw) => {
|
|
|
16456
17738
|
});
|
|
16457
17739
|
break;
|
|
16458
17740
|
}
|
|
16459
|
-
log(`
|
|
17741
|
+
log(`Remote shutdown detach aborted (${msg.requestId})`);
|
|
16460
17742
|
let result;
|
|
16461
17743
|
try {
|
|
16462
17744
|
result = await backend?.abortShutdownDetach?.()
|
|
@@ -16474,7 +17756,7 @@ process.on('message', async (raw) => {
|
|
|
16474
17756
|
shutdownDetachPhase = null;
|
|
16475
17757
|
}
|
|
16476
17758
|
send({
|
|
16477
|
-
type: '
|
|
17759
|
+
type: 'remote_shutdown_result',
|
|
16478
17760
|
requestId: msg.requestId,
|
|
16479
17761
|
phase: 'abort',
|
|
16480
17762
|
ok: result.ok,
|
|
@@ -16504,6 +17786,7 @@ process.on('message', async (raw) => {
|
|
|
16504
17786
|
stopCodexBridge();
|
|
16505
17787
|
killCli();
|
|
16506
17788
|
clearSendMarkers();
|
|
17789
|
+
clearBridgeTurnJournalFile();
|
|
16507
17790
|
cleanup();
|
|
16508
17791
|
process.exit(0);
|
|
16509
17792
|
}
|
|
@@ -16521,17 +17804,33 @@ process.on('message', async (raw) => {
|
|
|
16521
17804
|
});
|
|
16522
17805
|
break;
|
|
16523
17806
|
}
|
|
16524
|
-
log(`Close aborted (${msg.requestId});
|
|
17807
|
+
log(`Close aborted (${msg.requestId}); remote admission restore requested`);
|
|
16525
17808
|
let abortError = null;
|
|
17809
|
+
// A REFUSED rollback is not an error, and it is not a success either. The
|
|
17810
|
+
// backend may be holding a latched fence (an unproven local subtree, an
|
|
17811
|
+
// unnamed remote session), and the daemon used to infer "admission restored"
|
|
17812
|
+
// from a call that merely did not throw — persisting a cleared fence while
|
|
17813
|
+
// write() kept returning false.
|
|
17814
|
+
let admissionRestored = true;
|
|
17815
|
+
let fenceReason;
|
|
16526
17816
|
if (!alreadyRestored) {
|
|
16527
17817
|
try {
|
|
16528
|
-
await backend?.abortDestroySession?.();
|
|
17818
|
+
const outcome = interpretAbortOutcome(await backend?.abortDestroySession?.());
|
|
17819
|
+
admissionRestored = outcome.admissionRestored;
|
|
17820
|
+
fenceReason = outcome.reason;
|
|
16529
17821
|
}
|
|
16530
17822
|
catch (err) {
|
|
16531
17823
|
abortError = err instanceof Error ? err.message : String(err);
|
|
16532
17824
|
}
|
|
16533
17825
|
}
|
|
16534
|
-
if (!abortError) {
|
|
17826
|
+
if (!abortError && !admissionRestored) {
|
|
17827
|
+
log(`Close abort ${msg.requestId} did NOT restore write admission `
|
|
17828
|
+
+ `(${fenceReason ?? 'still fenced'}); this session will not accept writes again \u2014 retry the close`);
|
|
17829
|
+
}
|
|
17830
|
+
// Only clear the close bookkeeping when admission really came back. Leaving
|
|
17831
|
+
// it set on a still-fenced session keeps a later close_abort from being
|
|
17832
|
+
// rejected as stale while the fence is what actually needs resolving.
|
|
17833
|
+
if (!abortError && admissionRestored) {
|
|
16535
17834
|
preparedCloseRequestId = null;
|
|
16536
17835
|
closeRequestInFlightId = null;
|
|
16537
17836
|
lastAbortedCloseRequestId = null;
|
|
@@ -16540,6 +17839,10 @@ process.on('message', async (raw) => {
|
|
|
16540
17839
|
type: 'close_abort_result',
|
|
16541
17840
|
requestId: msg.requestId,
|
|
16542
17841
|
ok: abortError === null,
|
|
17842
|
+
// Distinct from `ok`: the abort was handled, but writes may still be
|
|
17843
|
+
// fenced. The daemon must persist THIS, not `ok`.
|
|
17844
|
+
admissionRestored: abortError === null && admissionRestored,
|
|
17845
|
+
...(fenceReason ? { fenceReason } : {}),
|
|
16543
17846
|
...(abortError ? { error: abortError } : {}),
|
|
16544
17847
|
});
|
|
16545
17848
|
break;
|
|
@@ -16567,7 +17870,14 @@ process.on('message', async (raw) => {
|
|
|
16567
17870
|
// uses to recover sessions after a reboot kills the tmux server).
|
|
16568
17871
|
revokeManagedTurnOriginForRestart();
|
|
16569
17872
|
try {
|
|
16570
|
-
|
|
17873
|
+
// mojo:suspend 语义是「休眠待续」——绝不能 cancel 远端会话(血缘已持久化,
|
|
17874
|
+
// 恢复时 follow-up 续上);只断流 detach。mojo 本来就没有常驻本地进程,
|
|
17875
|
+
// destroySession 想省的那份 CLI 内存并不存在。
|
|
17876
|
+
// riff 已在本 case 开头被直接拒绝(需 prepare/commit),所以这里只剩 mojo。
|
|
17877
|
+
if (isRemoteBackendType(effectiveBackendType))
|
|
17878
|
+
backend?.kill();
|
|
17879
|
+
else
|
|
17880
|
+
(backend?.destroySession ?? backend?.kill)?.call(backend);
|
|
16571
17881
|
}
|
|
16572
17882
|
catch { /* best-effort */ }
|
|
16573
17883
|
backend = null;
|