@songsid/agend 2.3.4-beta.5 → 2.3.5-alpha.2
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.md +2 -0
- package/README.zh-TW.md +2 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/dist/agent-endpoint.d.ts +5 -1
- package/dist/agent-endpoint.js +12 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +18 -12
- package/dist/backend/claude-code.js +225 -136
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-package.d.ts +5 -2
- package/dist/backend/codex-package.js +1 -5
- package/dist/backend/codex-package.js.map +1 -1
- package/dist/backend/codex.d.ts +136 -113
- package/dist/backend/codex.js +1271 -1219
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok-model-cache.d.ts +7 -0
- package/dist/backend/grok-model-cache.js +65 -0
- package/dist/backend/grok-model-cache.js.map +1 -0
- package/dist/backend/grok.d.ts +2 -0
- package/dist/backend/grok.js +22 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/native-pane.d.ts +9 -0
- package/dist/backend/native-pane.js +59 -0
- package/dist/backend/native-pane.js.map +1 -0
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +2 -0
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +59 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +62 -5
- package/dist/daemon.js +760 -240
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-consumed-watch.d.ts +15 -3
- package/dist/delivery-consumed-watch.js +23 -1
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-outbox.d.ts +5 -0
- package/dist/delivery-outbox.js +32 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +2 -35
- package/dist/delivery-reconciliation.js +2 -156
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -2
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +125 -2
- package/dist/fleet-manager.js +1157 -27
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-windows-runtime.d.ts +92 -0
- package/dist/fleet-windows-runtime.js +373 -0
- package/dist/fleet-windows-runtime.js.map +1 -0
- package/dist/instance-config-impact.js +4 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +20 -3
- package/dist/instance-lifecycle.js +358 -156
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/local-pane-endpoint.d.ts +46 -0
- package/dist/local-pane-endpoint.js +189 -0
- package/dist/local-pane-endpoint.js.map +1 -0
- package/dist/local-pane-launcher.d.ts +2 -0
- package/dist/local-pane-launcher.js +38 -0
- package/dist/local-pane-launcher.js.map +1 -0
- package/dist/local-pane-stage.ps1 +101 -0
- package/dist/locale.js +34 -0
- package/dist/locale.js.map +1 -1
- package/dist/model-catalog-defaults.d.ts +14 -0
- package/dist/model-catalog-defaults.js +20 -0
- package/dist/model-catalog-defaults.js.map +1 -0
- package/dist/native-agent-admission.d.ts +7 -0
- package/dist/native-agent-admission.js +26 -0
- package/dist/native-agent-admission.js.map +1 -0
- package/dist/native-daemon-client.d.ts +13 -0
- package/dist/native-daemon-client.js +28 -0
- package/dist/native-daemon-client.js.map +1 -0
- package/dist/native-instance-api.d.ts +14 -0
- package/dist/native-instance-api.js +205 -0
- package/dist/native-instance-api.js.map +1 -0
- package/dist/native-instance-input.d.ts +84 -0
- package/dist/native-instance-input.js +54 -0
- package/dist/native-instance-input.js.map +1 -0
- package/dist/native-instance-registry.d.ts +32 -0
- package/dist/native-instance-registry.js +105 -0
- package/dist/native-instance-registry.js.map +1 -0
- package/dist/native-mcp.d.ts +40 -0
- package/dist/native-mcp.js +278 -0
- package/dist/native-mcp.js.map +1 -0
- package/dist/native-model-catalog.d.ts +5 -0
- package/dist/native-model-catalog.js +76 -0
- package/dist/native-model-catalog.js.map +1 -0
- package/dist/native-pane-crash-budget.d.ts +20 -0
- package/dist/native-pane-crash-budget.js +60 -0
- package/dist/native-pane-crash-budget.js.map +1 -0
- package/dist/native-pane-create.d.ts +26 -0
- package/dist/native-pane-create.js +53 -0
- package/dist/native-pane-create.js.map +1 -0
- package/dist/native-pane-lifecycle.d.ts +65 -0
- package/dist/native-pane-lifecycle.js +227 -0
- package/dist/native-pane-lifecycle.js.map +1 -0
- package/dist/native-pane-manager.d.ts +58 -0
- package/dist/native-pane-manager.js +563 -0
- package/dist/native-pane-manager.js.map +1 -0
- package/dist/native-pane-output-log.d.ts +15 -0
- package/dist/native-pane-output-log.js +108 -0
- package/dist/native-pane-output-log.js.map +1 -0
- package/dist/native-pane-reconciliation.d.ts +4 -0
- package/dist/native-pane-reconciliation.js +40 -0
- package/dist/native-pane-reconciliation.js.map +1 -0
- package/dist/native-pane-recovery.d.ts +12 -0
- package/dist/native-pane-recovery.js +10 -0
- package/dist/native-pane-recovery.js.map +1 -0
- package/dist/native-pane-tools.d.ts +2 -0
- package/dist/native-pane-tools.js +14 -0
- package/dist/native-pane-tools.js.map +1 -0
- package/dist/needs-you-hub.d.ts +2 -0
- package/dist/needs-you-hub.js +1 -0
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +12 -1
- package/dist/needs-you.js +8 -1
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +16 -2
- package/dist/outbound-handlers.js +67 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +38 -0
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-port.d.ts +107 -0
- package/dist/pane-port.js +2 -0
- package/dist/pane-port.js.map +1 -0
- package/dist/runtime-api.d.ts +8 -0
- package/dist/runtime-api.js +96 -0
- package/dist/runtime-api.js.map +1 -0
- package/dist/runtime-assets/@xterm/addon-serialize/LICENSE +21 -0
- package/dist/runtime-assets/@xterm/addon-serialize/lib/addon-serialize.js +2 -0
- package/dist/runtime-assets/@xterm/addon-serialize/lib/addon-serialize.mjs +19 -0
- package/dist/runtime-assets/@xterm/addon-serialize/package.json +29 -0
- package/dist/runtime-assets/@xterm/headless/LICENSE +21 -0
- package/dist/runtime-assets/@xterm/headless/lib-headless/xterm-headless.js +2 -0
- package/dist/runtime-assets/@xterm/headless/lib-headless/xterm-headless.mjs +46 -0
- package/dist/runtime-assets/@xterm/headless/package.json +29 -0
- package/dist/runtime-assets/node-pty/CONPTY-LICENSE +21 -0
- package/dist/runtime-assets/node-pty/CONPTY-NOTICE.md +503 -0
- package/dist/runtime-assets/node-pty/LICENSE +69 -0
- package/dist/runtime-assets/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/dist/runtime-assets/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/runtime-assets/node-pty/lib/index.js +52 -0
- package/dist/runtime-assets/node-pty/lib/interfaces.js +7 -0
- package/dist/runtime-assets/node-pty/lib/shared/conout.js +11 -0
- package/dist/runtime-assets/node-pty/lib/terminal.js +190 -0
- package/dist/runtime-assets/node-pty/lib/types.js +7 -0
- package/dist/runtime-assets/node-pty/lib/unixTerminal.js +346 -0
- package/dist/runtime-assets/node-pty/lib/utils.js +39 -0
- package/dist/runtime-assets/node-pty/lib/windowsConoutConnection.js +125 -0
- package/dist/runtime-assets/node-pty/lib/windowsPtyAgent.js +320 -0
- package/dist/runtime-assets/node-pty/lib/windowsTerminal.js +199 -0
- package/dist/runtime-assets/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/runtime-assets/node-pty/package.json +64 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/dist/runtime-assets/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/dist/runtime-bundle.d.ts +17 -0
- package/dist/runtime-bundle.js +147 -0
- package/dist/runtime-bundle.js.map +1 -0
- package/dist/runtime-cli.d.ts +2 -0
- package/dist/runtime-cli.js +117 -0
- package/dist/runtime-cli.js.map +1 -0
- package/dist/runtime-client-store.d.ts +49 -0
- package/dist/runtime-client-store.js +292 -0
- package/dist/runtime-client-store.js.map +1 -0
- package/dist/runtime-client.d.ts +100 -0
- package/dist/runtime-client.js +700 -0
- package/dist/runtime-client.js.map +1 -0
- package/dist/runtime-coordinator.d.ts +82 -0
- package/dist/runtime-coordinator.js +864 -0
- package/dist/runtime-coordinator.js.map +1 -0
- package/dist/runtime-pane-assets.d.ts +17 -0
- package/dist/runtime-pane-assets.js +23 -0
- package/dist/runtime-pane-assets.js.map +1 -0
- package/dist/runtime-pane-boot.d.ts +2 -0
- package/dist/runtime-pane-boot.js +24 -0
- package/dist/runtime-pane-boot.js.map +1 -0
- package/dist/runtime-pane-codex-index.d.ts +1 -0
- package/dist/runtime-pane-codex-index.js +47 -0
- package/dist/runtime-pane-codex-index.js.map +1 -0
- package/dist/runtime-pane-config.d.ts +16 -0
- package/dist/runtime-pane-config.js +54 -0
- package/dist/runtime-pane-config.js.map +1 -0
- package/dist/runtime-pane-host.d.ts +43 -0
- package/dist/runtime-pane-host.js +403 -0
- package/dist/runtime-pane-host.js.map +1 -0
- package/dist/runtime-pane-job-owner.d.ts +1 -0
- package/dist/runtime-pane-job-owner.js +29 -0
- package/dist/runtime-pane-job-owner.js.map +1 -0
- package/dist/runtime-pane-job.ps1 +116 -0
- package/dist/runtime-pane-link.d.ts +40 -0
- package/dist/runtime-pane-link.js +246 -0
- package/dist/runtime-pane-link.js.map +1 -0
- package/dist/runtime-pane-mcp-protocol.d.ts +32 -0
- package/dist/runtime-pane-mcp-protocol.js +39 -0
- package/dist/runtime-pane-mcp-protocol.js.map +1 -0
- package/dist/runtime-pane-mcp-relay.d.ts +17 -0
- package/dist/runtime-pane-mcp-relay.js +70 -0
- package/dist/runtime-pane-mcp-relay.js.map +1 -0
- package/dist/runtime-pane-mcp.d.ts +43 -0
- package/dist/runtime-pane-mcp.js +137 -0
- package/dist/runtime-pane-mcp.js.map +1 -0
- package/dist/runtime-pane-native.d.ts +45 -0
- package/dist/runtime-pane-native.js +65 -0
- package/dist/runtime-pane-native.js.map +1 -0
- package/dist/runtime-pane-output.d.ts +19 -0
- package/dist/runtime-pane-output.js +53 -0
- package/dist/runtime-pane-output.js.map +1 -0
- package/dist/runtime-pane-port.d.ts +134 -0
- package/dist/runtime-pane-port.js +537 -0
- package/dist/runtime-pane-port.js.map +1 -0
- package/dist/runtime-pane-private.d.ts +14 -0
- package/dist/runtime-pane-private.js +51 -0
- package/dist/runtime-pane-private.js.map +1 -0
- package/dist/runtime-pane-private.ps1 +14 -0
- package/dist/runtime-pane-process.d.ts +4 -0
- package/dist/runtime-pane-process.js +108 -0
- package/dist/runtime-pane-process.js.map +1 -0
- package/dist/runtime-pane-protocol.d.ts +169 -0
- package/dist/runtime-pane-protocol.js +189 -0
- package/dist/runtime-pane-protocol.js.map +1 -0
- package/dist/runtime-pane-screen.d.ts +19 -0
- package/dist/runtime-pane-screen.js +76 -0
- package/dist/runtime-pane-screen.js.map +1 -0
- package/dist/runtime-pane-service.d.ts +11 -0
- package/dist/runtime-pane-service.js +221 -0
- package/dist/runtime-pane-service.js.map +1 -0
- package/dist/runtime-pane-stdio-protocol.d.ts +54 -0
- package/dist/runtime-pane-stdio-protocol.js +94 -0
- package/dist/runtime-pane-stdio-protocol.js.map +1 -0
- package/dist/runtime-pane-stdio.d.ts +3 -0
- package/dist/runtime-pane-stdio.js +123 -0
- package/dist/runtime-pane-stdio.js.map +1 -0
- package/dist/runtime-pane-store.d.ts +33 -0
- package/dist/runtime-pane-store.js +164 -0
- package/dist/runtime-pane-store.js.map +1 -0
- package/dist/runtime-pane-transcript-protocol.d.ts +43 -0
- package/dist/runtime-pane-transcript-protocol.js +58 -0
- package/dist/runtime-pane-transcript-protocol.js.map +1 -0
- package/dist/runtime-pane-transcript.d.ts +45 -0
- package/dist/runtime-pane-transcript.js +605 -0
- package/dist/runtime-pane-transcript.js.map +1 -0
- package/dist/runtime-pane-worker.d.ts +1 -0
- package/dist/runtime-pane-worker.js +154 -0
- package/dist/runtime-pane-worker.js.map +1 -0
- package/dist/runtime-protocol.d.ts +149 -0
- package/dist/runtime-protocol.js +19 -0
- package/dist/runtime-protocol.js.map +1 -0
- package/dist/runtime-switches.d.ts +79 -0
- package/dist/runtime-switches.js +90 -0
- package/dist/runtime-switches.js.map +1 -0
- package/dist/runtime-transcript-delta-reader.d.ts +23 -0
- package/dist/runtime-transcript-delta-reader.js +74 -0
- package/dist/runtime-transcript-delta-reader.js.map +1 -0
- package/dist/runtime-transcript-source.d.ts +36 -0
- package/dist/runtime-transcript-source.js +189 -0
- package/dist/runtime-transcript-source.js.map +1 -0
- package/dist/runtime-windows-bridge.d.ts +41 -0
- package/dist/runtime-windows-bridge.js +157 -0
- package/dist/runtime-windows-bridge.js.map +1 -0
- package/dist/settings-api.js +58 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +28 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +15 -1
- package/dist/settings-confirmation.js +46 -9
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +23 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +11 -2
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/tmux-control.d.ts +2 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +2 -1
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-pane-host.d.ts +26 -0
- package/dist/tmux-pane-host.js +25 -0
- package/dist/tmux-pane-host.js.map +1 -0
- package/dist/topic-commands.js +3 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +1 -1
- package/dist/transcript-delta-reader.js +1 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-monitor.d.ts +3 -0
- package/dist/transcript-monitor.js +22 -46
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-needles.d.ts +35 -0
- package/dist/transcript-needles.js +158 -0
- package/dist/transcript-needles.js.map +1 -0
- package/dist/transcript-records.d.ts +21 -0
- package/dist/transcript-records.js +140 -0
- package/dist/transcript-records.js.map +1 -0
- package/dist/transcript-sources.d.ts +5 -0
- package/dist/transcript-sources.js +8 -79
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/ui/agent-proposals.js +90 -0
- package/dist/ui/agent-runtime.js +106 -0
- package/dist/ui/chat-store.js +13 -2
- package/dist/ui/chat-strings.js +48 -0
- package/dist/ui/cli-picker.js +42 -0
- package/dist/ui/native-instance-settings.js +47 -0
- package/dist/ui/native-pane-recovery.js +45 -0
- package/dist/ui/panel-chat.js +61 -32
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +99 -18
- package/dist/ui/panel-needs.js +152 -3
- package/dist/ui/panel-runtimes.js +231 -0
- package/dist/ui/panel-settings.js +14 -8
- package/dist/ui/panel-windows-agent.js +86 -0
- package/dist/ui/panel-windows-cli.js +143 -0
- package/dist/ui/provider-marks.js +83 -0
- package/dist/ui/runtime-catalog.js +55 -0
- package/dist/ui/settings-catalog.js +37 -10
- package/dist/ui/settings-confirm.js +3 -1
- package/dist/ui/settings-dialogs.js +14 -5
- package/dist/ui/settings-registry.js +5 -0
- package/dist/ui/settings-strings.js +14 -0
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-route.js +6 -2
- package/dist/ui/shared/app-shell.js +3 -2
- package/dist/ui/shared/app-store.js +1 -0
- package/dist/ui/shared/app-stream.js +3 -1
- package/dist/ui/shared/app.css +301 -0
- package/dist/ui/shared/app.js +4 -0
- package/dist/ui/shared/header-tools.js +6 -4
- package/dist/ui/shared/instance-nav.js +20 -4
- package/dist/ui/shared/instance-switch.js +4 -3
- package/dist/ui/shared/ui-menu.js +28 -8
- package/dist/ui/shared/view-strings.js +6 -0
- package/dist/ui/windows-agents-nav.js +2 -0
- package/dist/ui/windows-cli-catalog.js +79 -0
- package/dist/update-check.d.ts +8 -2
- package/dist/update-check.js +5 -2
- package/dist/update-check.js.map +1 -1
- package/dist/web-api.d.ts +25 -0
- package/dist/web-api.js +171 -6
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +19 -2
- package/dist/web-chat-history.js +72 -3
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -1
- package/dist/web-shell-routes.js +7 -2
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/windows-agents.d.ts +112 -0
- package/dist/windows-agents.js +463 -0
- package/dist/windows-agents.js.map +1 -0
- package/dist/windows-cli-api.d.ts +23 -0
- package/dist/windows-cli-api.js +158 -0
- package/dist/windows-cli-api.js.map +1 -0
- package/dist/windows-cli-providers.d.ts +115 -0
- package/dist/windows-cli-providers.js +429 -0
- package/dist/windows-cli-providers.js.map +1 -0
- package/dist/windows-cli-worker.d.ts +58 -0
- package/dist/windows-cli-worker.js +479 -0
- package/dist/windows-cli-worker.js.map +1 -0
- package/dist/windows-cli.d.ts +115 -0
- package/dist/windows-cli.js +402 -0
- package/dist/windows-cli.js.map +1 -0
- package/dist/windows-native-mcp.d.ts +19 -0
- package/dist/windows-native-mcp.js +143 -0
- package/dist/windows-native-mcp.js.map +1 -0
- package/dist/windows-pane-mcp.d.ts +26 -0
- package/dist/windows-pane-mcp.js +187 -0
- package/dist/windows-pane-mcp.js.map +1 -0
- package/package.json +9 -3
- package/templates/runtime-assets/conpty-LICENSE +21 -0
- package/templates/runtime-assets/conpty-NOTICE.md +503 -0
- package/templates/runtime-assets/local-pane-stage.ps1 +101 -0
- package/templates/runtime-assets/runtime-pane-job.ps1 +116 -0
- package/templates/runtime-assets/runtime-pane-private.ps1 +14 -0
- package/templates/runtime-assets/xterm-LICENSE +21 -0
package/dist/backend/codex.js
CHANGED
|
@@ -604,6 +604,20 @@ function codexWorkspaceLimitPromptVisible(pane) {
|
|
|
604
604
|
}
|
|
605
605
|
return false;
|
|
606
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* The composer's own `›` row above the footer, or -1. Walking up from the footer, only blank rows and indented rows (the
|
|
609
|
+
* composer's wrapped text, however many lines a long paste takes) may come before it. The first column-zero row decides:
|
|
610
|
+
* a `›` there is the composer; a numbered `›` row is a selection card; any other column-zero row (`•`/`■`/`⚠` output, a
|
|
611
|
+
* status line) means a `›` above it is transcript history, never the composer.
|
|
612
|
+
*/
|
|
613
|
+
function ownedComposerRow(rows) {
|
|
614
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
615
|
+
if (!rows[i].trim() || /^[ \t]/.test(rows[i]))
|
|
616
|
+
continue;
|
|
617
|
+
return /^[>›][ \t]+\S/.test(rows[i]) && !/^[>›][ \t]+\d+\./.test(rows[i]) ? i : -1;
|
|
618
|
+
}
|
|
619
|
+
return -1;
|
|
620
|
+
}
|
|
607
621
|
/** Unknown pickers own stdin too; never type or press Enter into one. */
|
|
608
622
|
function codexUnknownSelectionVisible(pane) {
|
|
609
623
|
const rows = pane.replace(/\r/g, "").split("\n");
|
|
@@ -983,8 +997,15 @@ export const CODEX_RESUME_ATTEMPT_FILE = "codex-resume-attempt.json";
|
|
|
983
997
|
export const CODEX_RESUME_FAILED_FILE = "codex-resume-failed.json";
|
|
984
998
|
/** Above this, a resumed rollout carries a note that codex may compact it first. */
|
|
985
999
|
export const CODEX_RESUME_LARGE_BYTES = 20 * 1024 * 1024;
|
|
986
|
-
|
|
987
|
-
|
|
1000
|
+
/** Pane-only detectors: construction performs no provider, config or session discovery. */
|
|
1001
|
+
export class CodexPaneDetectors {
|
|
1002
|
+
paneDetectorContext;
|
|
1003
|
+
constructor(paneDetectorContext) {
|
|
1004
|
+
this.paneDetectorContext = paneDetectorContext;
|
|
1005
|
+
}
|
|
1006
|
+
readConfiguredStatusLineItems() { return this.paneDetectorContext?.statusLineItems()?.slice() ?? null; }
|
|
1007
|
+
trustPaneFields(pane) { const state = codexTrustPromptState(pane); return { promptFolder: state.folder, promptRoot: state.root, rootNote: state.rootNote }; }
|
|
1008
|
+
trustHoldReason(pane) { return { ...this.trustPaneFields(pane), reason: "Folder permission needs confirmation on this execution machine" }; }
|
|
988
1009
|
/**
|
|
989
1010
|
* #1510: the reply-completion guard covers codex, its turn end read from the rollout (task_complete /
|
|
990
1011
|
* turn_aborted, recorded on 0.162.0 in tests/fixtures/reply-guard-1510): the pane alone stays busy through a tool
|
|
@@ -993,121 +1014,10 @@ export class CodexBackend {
|
|
|
993
1014
|
replyCompletionGuard = true;
|
|
994
1015
|
turnEndFromTranscript = true;
|
|
995
1016
|
binaryName = "codex";
|
|
996
|
-
binaryPath;
|
|
997
|
-
sharedCodexHome;
|
|
998
|
-
isolatedCodexHome;
|
|
999
|
-
/** Which subscription this instance runs on, or null for the shared login. */
|
|
1000
|
-
credentialProfile = null;
|
|
1001
|
-
/** Set only after preTrust wrote and read back this instance's private config. */
|
|
1002
|
-
authorizedTrust = null;
|
|
1003
1017
|
configuredStatusLineItems;
|
|
1004
1018
|
configuredStatusLinePattern;
|
|
1005
|
-
/** Set by buildCommand when the resume plan fell back; read once by the daemon. */
|
|
1006
|
-
launchWarning = null;
|
|
1007
1019
|
/** Whether the last buildCommand launched a resume (`resume <id>` or `--last`). */
|
|
1008
1020
|
lastLaunchResumes = true;
|
|
1009
|
-
/** Set by writeConfig when the status_line could not be made verifiable (#931). */
|
|
1010
|
-
statusLineWarning = null;
|
|
1011
|
-
/** #2357: set by buildCommand when the codex being launched lacks a package companion (codex-code-mode-host). */
|
|
1012
|
-
packageWarning = null;
|
|
1013
|
-
constructor(instanceDir) {
|
|
1014
|
-
this.instanceDir = instanceDir;
|
|
1015
|
-
this.binaryPath = resolveBinary("codex");
|
|
1016
|
-
this.sharedCodexHome = resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
1017
|
-
this.isolatedCodexHome = CodexBackend.resolveShortHome(instanceDir);
|
|
1018
|
-
}
|
|
1019
|
-
/**
|
|
1020
|
-
* Compute and (on first call for an existing instance) migrate to a SHORT
|
|
1021
|
-
* persistent CODEX_HOME under `~/.agend/cx/<8-char-hash>/`.
|
|
1022
|
-
*
|
|
1023
|
-
* Background (#953): codex 0.157.0 added `app-server-control.sock` under
|
|
1024
|
-
* CODEX_HOME. For instances with long `-t<topic_id>` suffixes the full path
|
|
1025
|
-
* exceeds the Unix socket SUN_LEN (~107 chars). codex canonicalises the path
|
|
1026
|
-
* before binding the socket, so a symlink workaround does not help — the real
|
|
1027
|
-
* path must be short.
|
|
1028
|
-
*
|
|
1029
|
-
* Migration steps (each is idempotent; half-completed states self-heal):
|
|
1030
|
-
* 1. If shortHome does not exist AND legacyHome is a real directory (not a
|
|
1031
|
-
* symlink) → atomic renameSync. On EXDEV or any other error, fall back
|
|
1032
|
-
* safely: keep using the legacy long path (instance can still start, just
|
|
1033
|
-
* without the short-path fix).
|
|
1034
|
-
* 2. If shortHome exists but legacyHome is missing → recreate the backward-
|
|
1035
|
-
* compat symlink (self-heal for rename-succeeded-but-symlink-failed crash).
|
|
1036
|
-
* 3. If both shortHome and legacyHome (as symlink) exist → nothing to do.
|
|
1037
|
-
* 4. New instance (no legacyHome) → create shortHome directly.
|
|
1038
|
-
*
|
|
1039
|
-
* Fail-safe guarantee: migration failure must never make the instance worse.
|
|
1040
|
-
* The caller always receives a valid path it can use as CODEX_HOME.
|
|
1041
|
-
*/
|
|
1042
|
-
/** Exposed for fleet-manager to delete the short home on instance removal. */
|
|
1043
|
-
static shortHomeFor(instanceDir) {
|
|
1044
|
-
return codexShortHomeFor(instanceDir);
|
|
1045
|
-
}
|
|
1046
|
-
static resolveShortHome(instanceDir) {
|
|
1047
|
-
const shortHome = CodexBackend.shortHomeFor(instanceDir);
|
|
1048
|
-
const shortBase = join(getAgendHome(), "cx");
|
|
1049
|
-
const canonical = resolve(instanceDir);
|
|
1050
|
-
const legacyHome = resolve(instanceDir, "codex-home");
|
|
1051
|
-
// Only migrate/create when the instance directory itself exists.
|
|
1052
|
-
// Constructing CodexBackend for a non-existent dir (cli-env probes, test
|
|
1053
|
-
// backends that never ran) must not litter ~/.agend/cx/ with orphan dirs.
|
|
1054
|
-
if (!existsSync(canonical))
|
|
1055
|
-
return shortHome;
|
|
1056
|
-
const legacyExists = existsSync(legacyHome);
|
|
1057
|
-
const legacyIsRealDir = legacyExists && !lstatSync(legacyHome).isSymbolicLink();
|
|
1058
|
-
const shortExists = existsSync(shortHome);
|
|
1059
|
-
if (!shortExists) {
|
|
1060
|
-
mkdirSync(shortBase, { recursive: true, mode: 0o700 });
|
|
1061
|
-
if (legacyIsRealDir) {
|
|
1062
|
-
// Attempt atomic rename. Requires same device; fails with EXDEV otherwise.
|
|
1063
|
-
try {
|
|
1064
|
-
renameSync(legacyHome, shortHome);
|
|
1065
|
-
// Log the one-time migration for observability.
|
|
1066
|
-
try {
|
|
1067
|
-
const logPath = join(shortBase, `${shortHome.split("/").at(-1)}.migrated`);
|
|
1068
|
-
writeFileSync(logPath, `${new Date().toISOString()} migrated from ${legacyHome}\n`);
|
|
1069
|
-
}
|
|
1070
|
-
catch { /* log failure is non-fatal */ }
|
|
1071
|
-
}
|
|
1072
|
-
catch (err) {
|
|
1073
|
-
// EXDEV (cross-device) or any other rename failure → fail-safe:
|
|
1074
|
-
// fall back to the legacy long path so the instance still works.
|
|
1075
|
-
// The socket-length bug persists for this instance but no data is lost.
|
|
1076
|
-
const code = err.code ?? "unknown";
|
|
1077
|
-
try {
|
|
1078
|
-
const warnPath = join(shortBase, `${shortHome.split("/").at(-1)}.migration-failed`);
|
|
1079
|
-
writeFileSync(warnPath, `${new Date().toISOString()} rename failed (${code}): ${err.message}\n`
|
|
1080
|
-
+ `legacy path remains in use: ${legacyHome}\n`);
|
|
1081
|
-
}
|
|
1082
|
-
catch { /* warn log failure is also non-fatal */ }
|
|
1083
|
-
// Return legacy long path so the instance starts (socket may still fail
|
|
1084
|
-
// on 0.157.0 for long names, but no data is lost or corrupted).
|
|
1085
|
-
return legacyHome;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
else if (!legacyExists) {
|
|
1089
|
-
// New instance — no data to migrate.
|
|
1090
|
-
mkdirSync(shortHome, { recursive: true, mode: 0o700 });
|
|
1091
|
-
}
|
|
1092
|
-
// If legacyExists but is already a symlink: shortHome was deleted externally
|
|
1093
|
-
// while the symlink still points to it. Re-create shortHome as a fresh dir.
|
|
1094
|
-
if (!existsSync(shortHome)) {
|
|
1095
|
-
mkdirSync(shortHome, { recursive: true, mode: 0o700 });
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
// Backward-compat symlink: instanceDir/codex-home → shortHome.
|
|
1099
|
-
// Also serves as self-heal: if a previous run renamed successfully but
|
|
1100
|
-
// crashed before creating the symlink, we recreate it here.
|
|
1101
|
-
if (!legacyExists || legacyIsRealDir) {
|
|
1102
|
-
// legacyIsRealDir means rename just happened (now shortHome exists, legacyHome gone).
|
|
1103
|
-
// !legacyExists means new instance or symlink was deleted — create it.
|
|
1104
|
-
try {
|
|
1105
|
-
symlinkSync(shortHome, legacyHome);
|
|
1106
|
-
}
|
|
1107
|
-
catch { /* race or already exists */ }
|
|
1108
|
-
}
|
|
1109
|
-
return shortHome;
|
|
1110
|
-
}
|
|
1111
1021
|
supportsQueuedInput() {
|
|
1112
1022
|
return true;
|
|
1113
1023
|
}
|
|
@@ -1132,24 +1042,10 @@ export class CodexBackend {
|
|
|
1132
1042
|
}
|
|
1133
1043
|
/** Observed on real Codex 0.156.0: the live input row precedes its footer. */
|
|
1134
1044
|
isDeliveryInputReadyPane(pane) {
|
|
1135
|
-
|
|
1136
|
-
if (
|
|
1137
|
-
return false;
|
|
1138
|
-
const rows = pane.replace(/\r/g, "").split("\n");
|
|
1139
|
-
while (rows.length && !rows[rows.length - 1].trim())
|
|
1140
|
-
rows.pop();
|
|
1141
|
-
const footer = rows.pop() ?? "";
|
|
1142
|
-
// Codex preserves other configured status-line items after the context
|
|
1143
|
-
// meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra"). They
|
|
1144
|
-
// are footer chrome, not evidence that the input row is unavailable.
|
|
1145
|
-
const contextFooter = this.isAnyContextFooter(footer);
|
|
1146
|
-
// A deliberately configured status_line may omit Context entirely. In
|
|
1147
|
-
// that case require a recognized value from the actual configured item
|
|
1148
|
-
// list after Codex's exact empty live-composer row. An arbitrary non-empty
|
|
1149
|
-
// footer or a recent transcript echo is not a readiness signal; that was
|
|
1150
|
-
// the false-ready hole in the old Layer 2 deny-list attempt (#931/#947).
|
|
1151
|
-
if (!contextFooter && !this.isConfiguredStatusLineFooter(footer))
|
|
1045
|
+
const frame = this.composerFrame(pane);
|
|
1046
|
+
if (!frame)
|
|
1152
1047
|
return false;
|
|
1048
|
+
const { rows, contextFooter } = frame;
|
|
1153
1049
|
if (!contextFooter) {
|
|
1154
1050
|
if (this.getBusyPattern().test(pane))
|
|
1155
1051
|
return false;
|
|
@@ -1170,6 +1066,45 @@ export class CodexBackend {
|
|
|
1170
1066
|
}
|
|
1171
1067
|
return false;
|
|
1172
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Complete ownership of a long pasted envelope: the `›` composer row and every wrapped row down to a context footer are
|
|
1071
|
+
* proven by ownedComposerRow, however many rows they take. The Enter gate accepts this as well as isDeliveryInputReadyPane,
|
|
1072
|
+
* never instead of the dialog, busy and expected-seq checks.
|
|
1073
|
+
*/
|
|
1074
|
+
ownsComposerRows(pane) {
|
|
1075
|
+
const frame = this.composerFrame(pane);
|
|
1076
|
+
return !!frame?.contextFooter && ownedComposerRow(frame.rows) >= 0;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* The screen above Codex's footer, shared by both readers: null when the footer is not a Codex footer at all or the
|
|
1080
|
+
* app server behind the composer is gone (#1099). `rows` excludes the footer.
|
|
1081
|
+
*/
|
|
1082
|
+
composerFrame(pane) {
|
|
1083
|
+
// A composer with no app-server behind it accepts text and never submits it (#1099).
|
|
1084
|
+
if (codexAppServerDisconnected(pane))
|
|
1085
|
+
return null;
|
|
1086
|
+
const rows = pane.replace(/\r/g, "").split("\n");
|
|
1087
|
+
while (rows.length && !rows[rows.length - 1].trim())
|
|
1088
|
+
rows.pop();
|
|
1089
|
+
if (this.paneDetectorContext?.windowsConpty && /^[ \t]*\? for shortcuts[ \t]*$/.test(rows.at(-1) ?? "")) {
|
|
1090
|
+
rows.pop();
|
|
1091
|
+
while (rows.length && !rows[rows.length - 1].trim())
|
|
1092
|
+
rows.pop();
|
|
1093
|
+
}
|
|
1094
|
+
const footer = rows.pop() ?? "";
|
|
1095
|
+
// Codex preserves other configured status-line items after the context
|
|
1096
|
+
// meter (observed on 0.156.0: "Context 100% left · GPT-6-Astra"). They
|
|
1097
|
+
// are footer chrome, not evidence that the input row is unavailable.
|
|
1098
|
+
const contextFooter = this.isAnyContextFooter(footer);
|
|
1099
|
+
// A deliberately configured status_line may omit Context entirely. In
|
|
1100
|
+
// that case require a recognized value from the actual configured item
|
|
1101
|
+
// list after Codex's exact empty live-composer row. An arbitrary non-empty
|
|
1102
|
+
// footer or a recent transcript echo is not a readiness signal; that was
|
|
1103
|
+
// the false-ready hole in the old Layer 2 deny-list attempt (#931/#947).
|
|
1104
|
+
if (!contextFooter && !this.isConfiguredStatusLineFooter(footer))
|
|
1105
|
+
return null;
|
|
1106
|
+
return { rows, contextFooter };
|
|
1107
|
+
}
|
|
1173
1108
|
/**
|
|
1174
1109
|
* Live status chrome that must veto the broad prompt/context ready match.
|
|
1175
1110
|
*
|
|
@@ -1236,990 +1171,1256 @@ export class CodexBackend {
|
|
|
1236
1171
|
return false;
|
|
1237
1172
|
return true;
|
|
1238
1173
|
}
|
|
1174
|
+
getReadyPattern() {
|
|
1175
|
+
// Header and context text persist in inline scrollback and even while the
|
|
1176
|
+
// CLI is loading or a modal owns stdin. Require the live prompt followed
|
|
1177
|
+
// by the Context footer at the *end* of the capture. A valid custom
|
|
1178
|
+
// status_line without Context gets a separate configured-item pattern;
|
|
1179
|
+
// unknown footer values still fail closed. getBusyPattern vetoes a working
|
|
1180
|
+
// turn whose empty composer remains visible.
|
|
1181
|
+
// U+22C6 is Codex's observed cosmetic starfield; it can be drawn in the
|
|
1182
|
+
// prompt, between prompt/footer, and below the footer. A drafted composer
|
|
1183
|
+
// is also idle once this same bottom footer proves it owns the screen.
|
|
1184
|
+
const nativeTail = (pattern) => this.paneDetectorContext?.windowsConpty
|
|
1185
|
+
? new RegExp(pattern.source.slice(0, -1) + String.raw `(?:\r?\n[ \t]*\? for shortcuts[ \t]*)?(?:\r?\n[ \t⋆]*)*$`, pattern.flags) : pattern;
|
|
1186
|
+
const contextReady = nativeTail(/(?:^|\n)[>›][ \t⋆]+(?!\d+\.)\S[^\r\n]*\r?\n(?:[ \t⋆]*\r?\n){0,3}[ \t⋆]+(?:[0-9a-f-]{36}[ \t]+·[ \t]+)?Context[ \t]+(?:\d+%[ \t]+(?:left|used)|\d+…|…)[^\r\n]*(?:\r?\n[ \t⋆]*)*$/i);
|
|
1187
|
+
const footerPattern = this.configuredStatusLineFooterPattern();
|
|
1188
|
+
if (!footerPattern)
|
|
1189
|
+
return contextReady;
|
|
1190
|
+
// The no-Context alternative requires the exact empty composer followed by
|
|
1191
|
+
// a recognized configured Codex status_line value. If run-state is
|
|
1192
|
+
// configured, that value must specifically be Ready. This is positive
|
|
1193
|
+
// structural proof, never a deny-list of known dialogs.
|
|
1194
|
+
const footerSource = footerPattern.source.slice(1, -1);
|
|
1195
|
+
const noContextReady = nativeTail(new RegExp(String.raw `(?:^|\n)[>›][ \t⋆]*Ask Codex to do anything[ \t⋆]*\r?\n(?:[ \t⋆]*\r?\n){0,3}${footerSource}(?:\r?\n[ \t⋆]*)*$`, "iu"));
|
|
1196
|
+
// #978: a configured footer that carries a Context item in any position
|
|
1197
|
+
// is the Context footer: same drafted-composer rule as Context-first. The
|
|
1198
|
+
// whole row must match the configured item grammar, so an arbitrary
|
|
1199
|
+
// indented line that merely contains `Context N% left` cannot pass.
|
|
1200
|
+
const configuredContextReady = this.configuredHasContextItem()
|
|
1201
|
+
? nativeTail(new RegExp(String.raw `(?:^|\n)[>›][ \t⋆]+(?!\d+\.)\S[^\r\n]*\r?\n(?:[ \t⋆]*\r?\n){0,3}(?=[^\r\n]*${CODEX_CONTEXT_ITEM})${footerSource}(?:\r?\n[ \t⋆]*)*$`, "iu"))
|
|
1202
|
+
: null;
|
|
1203
|
+
return new RegExp([contextReady.source, noContextReady.source, configuredContextReady?.source]
|
|
1204
|
+
.filter((source) => !!source)
|
|
1205
|
+
.map(source => `(?:${source})`)
|
|
1206
|
+
.join("|"), "iu");
|
|
1207
|
+
}
|
|
1208
|
+
configuredStatusLineFooterPattern() {
|
|
1209
|
+
if (this.configuredStatusLinePattern !== undefined)
|
|
1210
|
+
return this.configuredStatusLinePattern;
|
|
1211
|
+
const items = this.readConfiguredStatusLineItems();
|
|
1212
|
+
if (!items?.length)
|
|
1213
|
+
return this.configuredStatusLinePattern = null;
|
|
1214
|
+
const configuredPatterns = items
|
|
1215
|
+
.map(item => CODEX_STATUS_LINE_VALUE_PATTERNS[item])
|
|
1216
|
+
.filter((pattern) => pattern !== undefined);
|
|
1217
|
+
if (!configuredPatterns.length)
|
|
1218
|
+
return this.configuredStatusLinePattern = null;
|
|
1219
|
+
const segment = `(?:${configuredPatterns.join("|")})`;
|
|
1220
|
+
const separator = String.raw `[ \t]+·[ \t]+`;
|
|
1221
|
+
const hasRunState = items.some(item => CODEX_RUN_STATE_STATUS_ITEMS.has(item));
|
|
1222
|
+
// Ready must occupy its own status-line segment. Other configured items
|
|
1223
|
+
// may precede/follow it, as Codex joins visible values with ` · `.
|
|
1224
|
+
const line = hasRunState
|
|
1225
|
+
? `(?:${segment}${separator})*Ready(?:${separator}${segment})*`
|
|
1226
|
+
: `${segment}(?:${separator}${segment})*`;
|
|
1227
|
+
// The configured items, then (optionally) the CLI's own native fields — the same rule as the Context-first footer, so a
|
|
1228
|
+
// `model · Context 46% left Goal achieved (1h 6m)` row is the footer too, and the readiness regex built from this
|
|
1229
|
+
// source (footerSource) says the same.
|
|
1230
|
+
this.configuredStatusLinePattern = new RegExp(`^[ \t⋆]*${line}(?:${CODEX_NATIVE_FOOTER_FIELDS})?[ \t⋆]*$`, "iu");
|
|
1231
|
+
return this.configuredStatusLinePattern;
|
|
1232
|
+
}
|
|
1233
|
+
isConfiguredStatusLineFooter(row) {
|
|
1234
|
+
const pattern = this.configuredStatusLineFooterPattern();
|
|
1235
|
+
if (!pattern)
|
|
1236
|
+
return false;
|
|
1237
|
+
pattern.lastIndex = 0;
|
|
1238
|
+
return pattern.test(row);
|
|
1239
|
+
}
|
|
1240
|
+
configuredHasContextItem() {
|
|
1241
|
+
return (this.readConfiguredStatusLineItems() ?? []).some(item => CODEX_CONTEXT_STATUS_ITEMS.has(item));
|
|
1242
|
+
}
|
|
1239
1243
|
/**
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1244
|
+
* #978: a Context item in ANY position, accepted only as part of a footer
|
|
1245
|
+
* whose every segment matches the configured status_line items. Codex keeps
|
|
1246
|
+
* the user's order (`gpt-5.6-sol medium · Context 46% left · ~/x`); an
|
|
1247
|
+
* arbitrary indented transcript line that happens to contain the item does
|
|
1248
|
+
* not match that grammar. A stale config fails closed, and the daemon's
|
|
1249
|
+
* stable-composer escape hatch then keeps the instance from latching.
|
|
1243
1250
|
*/
|
|
1244
|
-
|
|
1245
|
-
|
|
1251
|
+
// The configured grammar already makes Context a whole segment, so the item
|
|
1252
|
+
// test only has to establish that one is present.
|
|
1253
|
+
isConfiguredContextFooter(row) {
|
|
1254
|
+
return this.configuredHasContextItem()
|
|
1255
|
+
&& new RegExp(CODEX_CONTEXT_ITEM, "iu").test(row)
|
|
1256
|
+
&& this.isConfiguredStatusLineFooter(row);
|
|
1246
1257
|
}
|
|
1247
|
-
/**
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
buildCommand(config) {
|
|
1251
|
-
this.lastKnownModel = config.model?.trim() || null;
|
|
1252
|
-
this.credentialProfile = this.readProfile(config);
|
|
1253
|
-
// #2886: `--full-auto` is gone from codex (no supported version, 0.155 on, accepts it: the instance never started);
|
|
1254
|
-
// spelled out, it was a workspace-write sandbox that asks for approval on request.
|
|
1255
|
-
const approvalFlag = config.skipPermissions !== false
|
|
1256
|
-
? "--dangerously-bypass-approvals-and-sandbox"
|
|
1257
|
-
: "--sandbox workspace-write --ask-for-approval on-request";
|
|
1258
|
-
// #984: never trust `codex resume --last` to pick this instance's session.
|
|
1259
|
-
// Codex 0.157 scopes it to the git REPOSITORY, so in a worktree it resumes
|
|
1260
|
-
// the newest session of any sibling worktree (a lock screen while that
|
|
1261
|
-
// instance lives, a silent hijack otherwise), and a new instance in a
|
|
1262
|
-
// shared repo does not start fresh. Resume the exact-cwd thread from the
|
|
1263
|
-
// shared state DB instead; `--last` is only the fallback for an unreadable
|
|
1264
|
-
// DB when no other Codex instance shares the repository.
|
|
1265
|
-
let cmd;
|
|
1266
|
-
this.launchWarning = null;
|
|
1267
|
-
this.lastLaunchResumes = false;
|
|
1268
|
-
// #2357: read-only (codex is never run here); a launch is not held for it — the operator is told once per launch.
|
|
1269
|
-
this.packageWarning = codexPackageWarning(this.binaryPath);
|
|
1270
|
-
if (config.skipResume) {
|
|
1271
|
-
cmd = `${this.binaryPath} ${approvalFlag}`;
|
|
1272
|
-
}
|
|
1273
|
-
else {
|
|
1274
|
-
const resumeBy = (id, why, bytes) => {
|
|
1275
|
-
this.lastLaunchResumes = true;
|
|
1276
|
-
this.writeResumeAttempt(id);
|
|
1277
|
-
const large = bytes !== null && bytes > CODEX_RESUME_LARGE_BYTES ? ` ${t("codex.resume_large", String(Math.round(bytes / (1024 * 1024))))}` : "";
|
|
1278
|
-
this.launchWarning = why + large;
|
|
1279
|
-
return `${this.binaryPath} resume ${shellQuote(id)} ${approvalFlag}`;
|
|
1280
|
-
};
|
|
1281
|
-
const sessionsDir = join(this.isolatedCodexHome, "sessions");
|
|
1282
|
-
// M-2266: the previous launch was told to resume a conversation by id. Codex records a thread as it opens it,
|
|
1283
|
-
// so a resume that did not take (codex exited with "No saved session found…") left no row: this launch starts
|
|
1284
|
-
// fresh, once, and says so. A database that cannot be read proves nothing, and planning goes on as usual.
|
|
1285
|
-
// A new one-shot ask comes first: it is the person's explicit choice for this launch.
|
|
1286
|
-
const oneShot = this.takeResumeNext();
|
|
1287
|
-
const attempt = this.takeResumeAttempt();
|
|
1288
|
-
const failed = !oneShot && attempt !== null && codexThreadRowExists(this.stateDbPath(), attempt) === false;
|
|
1289
|
-
if (failed) {
|
|
1290
|
-
// Remembered, so the auto-resume never picks this conversation again on a later launch.
|
|
1291
|
-
this.rememberFailedResume(attempt);
|
|
1292
|
-
cmd = `${this.binaryPath} ${approvalFlag}`;
|
|
1293
|
-
this.launchWarning = t("codex.resume_failed", config.workingDirectory, attempt);
|
|
1294
|
-
}
|
|
1295
|
-
else if (oneShot) {
|
|
1296
|
-
// M-2266: validated when it was asked for (restart_instance); checked again now, as the rollout may have moved.
|
|
1297
|
-
const check = checkResumeId(sessionsDir, config.workingDirectory, oneShot);
|
|
1298
|
-
if (check.ok)
|
|
1299
|
-
cmd = resumeBy(check.rollout.id, t("codex.resume_oneshot", config.workingDirectory, check.rollout.id), check.rollout.bytes);
|
|
1300
|
-
else {
|
|
1301
|
-
cmd = this.plannedCommand(config, approvalFlag, sessionsDir, resumeBy);
|
|
1302
|
-
this.launchWarning = [t("codex.resume_oneshot_invalid", config.workingDirectory, oneShot, check.reason), this.launchWarning].filter(Boolean).join("\n");
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
else {
|
|
1306
|
-
cmd = this.plannedCommand(config, approvalFlag, sessionsDir, resumeBy);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
if (config.model) {
|
|
1310
|
-
const model = validateModel(config.model);
|
|
1311
|
-
warnIfModelMismatch("codex", model);
|
|
1312
|
-
cmd += ` -c ${shellQuote(`model="${model}"`)}`;
|
|
1313
|
-
}
|
|
1314
|
-
if (config.backendOptions?.provider) {
|
|
1315
|
-
const provider = validateProvider(String(config.backendOptions.provider));
|
|
1316
|
-
cmd += ` -c ${shellQuote(`model_provider="${provider}"`)}`;
|
|
1317
|
-
}
|
|
1318
|
-
// #1811: the configured effort, when the catalog entry of the model that will run lists it (launch-effort.ts); else
|
|
1319
|
-
// a quiet note. That model is proven from the command line or the config files codex reads (#1813 review).
|
|
1320
|
-
const runs = resolveCodexLaunchModel({
|
|
1321
|
-
isolatedHome: this.isolatedCodexHome, sharedHome: this.sharedCodexHome,
|
|
1322
|
-
model: config.model, workingDirectory: config.workingDirectory,
|
|
1323
|
-
});
|
|
1324
|
-
const effort = codexEffort(config.effort, runs.proven
|
|
1325
|
-
? readCodexModelEffortLevels({ isolatedHome: this.isolatedCodexHome, sharedHome: this.sharedCodexHome, model: runs.model })
|
|
1326
|
-
: { model: runs.model, levels: null, unproven: true });
|
|
1327
|
-
if (effort.arg)
|
|
1328
|
-
cmd += effort.arg;
|
|
1329
|
-
this.quietLaunchNotes = effort.note ? [effort.note] : [];
|
|
1330
|
-
// AgEnD instances are unattended processes: an interactive self-update
|
|
1331
|
-
// picker blocks delivery and must never be enabled by a copied global or
|
|
1332
|
-
// managed config layer. Keep this last so the CLI override is authoritative.
|
|
1333
|
-
// Both observed Codex 0.155.0 and 0.156.1 support this launch flag. Pin
|
|
1334
|
-
// the initial layout so a user/global fullscreen preference cannot make a
|
|
1335
|
-
// header-only pane look ready. A later unknown layout still fails closed.
|
|
1336
|
-
cmd += " -c check_for_update_on_startup=false --no-alt-screen";
|
|
1337
|
-
// codex 0.159's opt-in `instant_interrupt` makes new input steer the
|
|
1338
|
-
// running response instead of queueing behind it, which AgEnD's delivery
|
|
1339
|
-
// and queued-input (↳) handling assume. Keep it off whatever a user or
|
|
1340
|
-
// global config says. Codex before 0.159 ignores the key with a startup
|
|
1341
|
-
// warning ("`features.instant_interrupt` is ignored"); nothing else.
|
|
1342
|
-
cmd += " -c features.instant_interrupt=false";
|
|
1343
|
-
// CODEX_HOME is the only Codex-supported way to isolate the complete base
|
|
1344
|
-
// config. A profile only layers over the shared config and would therefore
|
|
1345
|
-
// still load every globally registered AgEnD MCP server.
|
|
1346
|
-
return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
|
|
1347
|
-
}
|
|
1348
|
-
writeConfig(config) {
|
|
1349
|
-
this.authorizedTrust = null;
|
|
1350
|
-
// Set before the home is prepared: which login this instance gets is a
|
|
1351
|
-
// property of the home, and the home is built here.
|
|
1352
|
-
this.credentialProfile = this.readProfile(config);
|
|
1353
|
-
this.prepareIsolatedHome();
|
|
1354
|
-
this.cleanSharedConfig();
|
|
1355
|
-
// Copy all user settings but no AgEnD MCP entries into a private base
|
|
1356
|
-
// config, then append only this instance's server(s). Each instance writes
|
|
1357
|
-
// a distinct file, eliminating the old concurrent global-config race.
|
|
1358
|
-
let content = "";
|
|
1359
|
-
try {
|
|
1360
|
-
content = stripAgendMcpTables(readFileSync(join(this.sharedCodexHome, "config.toml"), "utf-8"));
|
|
1361
|
-
}
|
|
1362
|
-
catch { /* a first-time Codex user may have no global config */ }
|
|
1363
|
-
if (content && !content.endsWith("\n"))
|
|
1364
|
-
content += "\n";
|
|
1365
|
-
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
|
1366
|
-
content += `\n${renderMcpServer(name, entry, config.instanceName)}`;
|
|
1367
|
-
}
|
|
1368
|
-
atomicWritePrivate(join(this.isolatedCodexHome, "config.toml"), content);
|
|
1369
|
-
this.enableContextStatusLine();
|
|
1370
|
-
this.disableStartupUpdateCheck();
|
|
1371
|
-
this.hideRateLimitModelNudge();
|
|
1372
|
-
// Write fleet instructions into AGENTS.md (additive via marker block)
|
|
1373
|
-
if (config.instructions) {
|
|
1374
|
-
try {
|
|
1375
|
-
const agentsMd = join(config.workingDirectory, "AGENTS.md");
|
|
1376
|
-
appendWithMarker(agentsMd, config.instanceName, config.instructions);
|
|
1377
|
-
// Warn if file exceeds Codex's project_doc_max_bytes limit
|
|
1378
|
-
try {
|
|
1379
|
-
const size = statSync(agentsMd).size;
|
|
1380
|
-
if (size > CODEX_PROJECT_DOC_MAX_BYTES) {
|
|
1381
|
-
console.warn(`[agend] AGENTS.md is ${size} bytes, exceeds Codex limit of ${CODEX_PROJECT_DOC_MAX_BYTES} — instructions may be truncated`);
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
catch { /* stat failed — skip size check */ }
|
|
1385
|
-
}
|
|
1386
|
-
catch { /* best effort */ }
|
|
1387
|
-
}
|
|
1258
|
+
/** Any footer that proves the Context meter owns the bottom row. */
|
|
1259
|
+
isAnyContextFooter(row) {
|
|
1260
|
+
return isCodexContextFooter(row) || this.isConfiguredContextFooter(row);
|
|
1388
1261
|
}
|
|
1389
1262
|
/**
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
* restart every Codex instance sat waiting on a keypress nobody was going to
|
|
1394
|
-
* send. AgEnD then reported it as a stuck pane, which is true but unhelpful.
|
|
1395
|
-
*
|
|
1396
|
-
* `check_for_update_on_startup = false` is Codex's own config key (verified in
|
|
1397
|
-
* the binary), so this prevents the prompt rather than racing to dismiss it.
|
|
1398
|
-
* There is no CLI flag for it — `codex update` is a subcommand, not a switch.
|
|
1399
|
-
*
|
|
1400
|
-
* Placement matters: this is a TOP-LEVEL key, so it has to go above the first
|
|
1401
|
-
* `[section]` header or TOML would read it as belonging to that section. And a
|
|
1402
|
-
* AgEnD's isolated home is unattended, so an inherited `true` is replaced
|
|
1403
|
-
* rather than preserved. TOML rejects duplicate keys, so replace in place.
|
|
1263
|
+
* #978 escape hatch: the live, EMPTY Codex composer within the last few
|
|
1264
|
+
* non-blank rows, no busy marker, and none of the known selection screens.
|
|
1265
|
+
* Deliberately says nothing about the footer, which is what is unknown.
|
|
1404
1266
|
*/
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
}
|
|
1425
|
-
const LINE = "check_for_update_on_startup = false\n";
|
|
1426
|
-
const firstSection = content.search(/^\s*\[/m);
|
|
1427
|
-
const updated = firstSection === -1
|
|
1428
|
-
? (content.length && !content.endsWith("\n") ? `${content}\n${LINE}` : `${content}${LINE}`)
|
|
1429
|
-
: `${content.slice(0, firstSection)}${LINE}${content.slice(firstSection)}`;
|
|
1430
|
-
try {
|
|
1431
|
-
atomicWritePrivate(configPath, updated);
|
|
1432
|
-
}
|
|
1433
|
-
catch { /* best effort */ }
|
|
1267
|
+
isStableUnknownLayoutIdlePane(pane) {
|
|
1268
|
+
if (this.getBusyPattern().test(pane))
|
|
1269
|
+
return false;
|
|
1270
|
+
// The composer is live-looking and the footer is unknown — exactly this
|
|
1271
|
+
// proof's blind spot — but there is no server behind it (#1099).
|
|
1272
|
+
if (codexAppServerDisconnected(pane))
|
|
1273
|
+
return false;
|
|
1274
|
+
// (No second, wider "esc to interrupt" match here: getBusyPattern() above already reads a live status row of ANY
|
|
1275
|
+
// title — #964 — and anchors it to the whole row at column zero, so a reply that quotes the phrase, indented or
|
|
1276
|
+
// in prose, cannot pin the escape hatch shut (#991).)
|
|
1277
|
+
// A retained queued message (`↳ …` under "Messages to be submitted after
|
|
1278
|
+
// next tool call") means work is pending, whatever the composer shows.
|
|
1279
|
+
if (/↳|Messages to be submitted/i.test(pane))
|
|
1280
|
+
return false;
|
|
1281
|
+
if (codexUsageLimitMenuVisible(pane) || codexRateSwitchVisible(pane)
|
|
1282
|
+
|| codexUnknownSelectionVisible(pane) || codexUpdatePickerVisible(pane))
|
|
1283
|
+
return false;
|
|
1284
|
+
const rows = pane.replace(/\r/g, "").split("\n").filter(row => row.trim() !== "");
|
|
1285
|
+
return rows.slice(-6).some(row => /^[>›][ \t⋆]*Ask Codex to do anything[ \t⋆]*$/.test(row));
|
|
1434
1286
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
* Codex documents this as `notice.hide_rate_limit_model_nudge` (a boolean in
|
|
1438
|
-
* the `[notice]` table). This changes only the per-instance config; the
|
|
1439
|
-
* runtime holdOnly dialog remains as defense-in-depth for older or changed
|
|
1440
|
-
* CLI behavior. No model option is selected automatically.
|
|
1441
|
-
*/
|
|
1442
|
-
hideRateLimitModelNudge() {
|
|
1443
|
-
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
1444
|
-
let content = "";
|
|
1445
|
-
try {
|
|
1446
|
-
content = readFileSync(configPath, "utf-8");
|
|
1447
|
-
}
|
|
1448
|
-
catch {
|
|
1449
|
-
return;
|
|
1450
|
-
}
|
|
1451
|
-
const updated = setTomlTableBoolean(content, "notice", "hide_rate_limit_model_nudge");
|
|
1452
|
-
if (updated !== content) {
|
|
1453
|
-
try {
|
|
1454
|
-
atomicWritePrivate(configPath, updated);
|
|
1455
|
-
}
|
|
1456
|
-
catch { /* best effort */ }
|
|
1457
|
-
}
|
|
1287
|
+
isCodexIdleFooter(row) {
|
|
1288
|
+
return isCodexContextFooter(row) || this.isConfiguredStatusLineFooter(row);
|
|
1458
1289
|
}
|
|
1459
|
-
/**
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
* 1. the effective `tui.status_line` already has a context item
|
|
1464
|
-
* (context-remaining / -usage / -used) → leave the config untouched.
|
|
1465
|
-
* 2. no context item:
|
|
1466
|
-
* - no status_line at all → write status_line = ["context-remaining"]
|
|
1467
|
-
* - status_line exists → prepend "context-remaining" to it
|
|
1468
|
-
* Best-effort string edit (no TOML writer), but every candidate edit is
|
|
1469
|
-
* parsed back and kept only if the effective `tui.status_line` becomes
|
|
1470
|
-
* `["context-remaining", ...the user's items]`. A `status_line` key in some
|
|
1471
|
-
* other table is not the TUI's and must not be the one edited (#931). If no
|
|
1472
|
-
* edit can be verified, the launch warns the operator instead of leaving the
|
|
1473
|
-
* pane silently unready.
|
|
1474
|
-
*/
|
|
1475
|
-
enableContextStatusLine() {
|
|
1476
|
-
this.configuredStatusLineItems = undefined;
|
|
1477
|
-
this.configuredStatusLinePattern = undefined;
|
|
1478
|
-
this.statusLineWarning = null;
|
|
1479
|
-
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
1480
|
-
// The private copy is rebuilt from the shared one at every launch, so the
|
|
1481
|
-
// operator must be pointed at the file their status_line actually lives in.
|
|
1482
|
-
const sharedConfigPath = join(this.sharedCodexHome, "config.toml");
|
|
1483
|
-
let content = "";
|
|
1484
|
-
try {
|
|
1485
|
-
content = readFileSync(configPath, "utf-8");
|
|
1486
|
-
}
|
|
1487
|
-
catch { /* no file yet */ }
|
|
1488
|
-
const ITEM = "context-remaining";
|
|
1489
|
-
const current = effectiveTuiStatusLine(content);
|
|
1490
|
-
if (current === "invalid") {
|
|
1491
|
-
this.statusLineWarning = t("codex.status_line_unverifiable", "tui.status_line is not a list of item names, or config.toml does not parse", sharedConfigPath);
|
|
1492
|
-
return;
|
|
1493
|
-
}
|
|
1494
|
-
// Rule 1: the TUI already shows a context item → don't touch anything.
|
|
1495
|
-
if (current?.some(item => CODEX_CONTEXT_STATUS_ITEM_RE.test(item)))
|
|
1496
|
-
return;
|
|
1497
|
-
// TOML allows quoted keys: `"status_line" = [...]`, `"tui"."status_line"`,
|
|
1498
|
-
// `['tui']`. Every textual candidate is tried; parsing decides.
|
|
1499
|
-
const candidates = [];
|
|
1500
|
-
if (current) {
|
|
1501
|
-
// Rule 2b: prepend our item to the user's existing array (don't overwrite).
|
|
1502
|
-
// First position keeps "Context N% left" at the far left of the footer so a
|
|
1503
|
-
// long cwd/other items can't push it past 80 cols and truncate it.
|
|
1504
|
-
for (const m of content.matchAll(/["']?status_line["']?\s*=\s*\[([^\]]*)\]/g)) {
|
|
1505
|
-
const inner = m[1].trim().replace(/^,\s*/, "").replace(/,\s*$/, "");
|
|
1506
|
-
const newInner = inner.length ? `"${ITEM}", ${inner}` : `"${ITEM}"`;
|
|
1507
|
-
candidates.push(`${content.slice(0, m.index)}status_line = [${newInner}]${content.slice(m.index + m[0].length)}`);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
else {
|
|
1511
|
-
// Rule 2a: no status_line at all → add a minimal one.
|
|
1512
|
-
const base = content.length && !content.endsWith("\n") ? `${content}\n` : content;
|
|
1513
|
-
// Also recognise quoted section headers: `["tui"]` and `['tui']`.
|
|
1514
|
-
if (/^\[["']?tui["']?\][^\n]*\n/m.test(base)) {
|
|
1515
|
-
candidates.push(base.replace(/^\[["']?tui["']?\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`));
|
|
1516
|
-
}
|
|
1517
|
-
candidates.push(`${base}\n[tui]\nstatus_line = ["${ITEM}"]\n`);
|
|
1518
|
-
// A config that defines tui with dotted keys cannot take a [tui] table.
|
|
1519
|
-
candidates.push(`tui.status_line = ["${ITEM}"]\n${base}`);
|
|
1520
|
-
// Nor can an inline `tui = { … }` table: the item goes inside it.
|
|
1521
|
-
candidates.push(base.replace(/^([ \t]*["']?tui["']?[ \t]*=[ \t]*\{)[ \t]*\}/m, `$1 status_line = ["${ITEM}"] }`));
|
|
1522
|
-
candidates.push(base.replace(/^([ \t]*["']?tui["']?[ \t]*=[ \t]*\{)/m, `$1 status_line = ["${ITEM}"],`));
|
|
1523
|
-
}
|
|
1524
|
-
const wanted = [ITEM, ...(current ?? [])];
|
|
1525
|
-
const fixed = candidates.find(candidate => {
|
|
1526
|
-
const items = effectiveTuiStatusLine(candidate);
|
|
1527
|
-
return Array.isArray(items) && items.length === wanted.length && items.every((item, i) => item === wanted[i]);
|
|
1528
|
-
});
|
|
1529
|
-
if (!fixed) {
|
|
1530
|
-
this.statusLineWarning = t("codex.status_line_unverifiable", "no edit of tui.status_line could be verified", sharedConfigPath);
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
try {
|
|
1534
|
-
atomicWritePrivate(configPath, fixed);
|
|
1535
|
-
}
|
|
1536
|
-
catch (err) {
|
|
1537
|
-
// Never block launch on statusline config — but a verified edit that
|
|
1538
|
-
// could not be written leaves the pane as unready as an unfixable one.
|
|
1539
|
-
const reason = `the verified edit could not be written: ${err.code ?? "write failed"}`;
|
|
1540
|
-
this.statusLineWarning = t("codex.status_line_unverifiable", reason, sharedConfigPath);
|
|
1541
|
-
}
|
|
1290
|
+
/** A proxy reply filters chrome per line; whole-pane readiness is separate. */
|
|
1291
|
+
isProxyReplyChromeLine(line) {
|
|
1292
|
+
return /^\s*[›>]\s+Ask Codex to do anything\s*$/.test(line)
|
|
1293
|
+
|| this.isAnyContextFooter(line);
|
|
1542
1294
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1295
|
+
getErrorPatterns() {
|
|
1296
|
+
return [
|
|
1297
|
+
// Specific quota codes must precede the generic HTTP 429 classifier:
|
|
1298
|
+
// OpenAI returns insufficient_quota with status 429, but switching models
|
|
1299
|
+
// cannot repair an exhausted account and would only start a failover loop.
|
|
1300
|
+
{
|
|
1301
|
+
pattern: new RegExp(String.raw `^\s*(?:${CODEX_STATUS_GLYPH}|Error:|API Error:)\s*[\s\S]{0,240}?\b(?:insufficient_quota|billing_hard_limit_reached|exceeded\s+your\s+current\s+quota)\b|^(?:${CODEX_STATUS_GLYPH}) Quota exceeded\. Check your plan and billing details\.\r?$`, "im"),
|
|
1302
|
+
type: "quota",
|
|
1303
|
+
action: "pause",
|
|
1304
|
+
message: "OpenAI quota exceeded",
|
|
1305
|
+
notice: () => quotaPausedLine("OpenAI", "api_quota"),
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
// Codex already exhausted HTTP retries and returned to its composer.
|
|
1309
|
+
// The existing conditional continuation preserves context without
|
|
1310
|
+
// replaying earlier tool effects. Quota remains a preceding pause.
|
|
1311
|
+
pattern: new RegExp(`^${CODEX_EXHAUSTED_429_ROW}$`, "im"),
|
|
1312
|
+
type: "rate_limit",
|
|
1313
|
+
action: "nudge_continue",
|
|
1314
|
+
message: t("inst.codex_rate_retry_stopped"),
|
|
1315
|
+
skipCooldown: true,
|
|
1316
|
+
skipRecoveryWait: true,
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
// Pane history contains the agent's own prose, source code and search
|
|
1320
|
+
// results. Bare `rate limit` used to fail over an otherwise healthy
|
|
1321
|
+
// Codex merely for discussing this regex. Match machine-readable API
|
|
1322
|
+
// forms or an error-decorated terminal line instead.
|
|
1323
|
+
pattern: new RegExp(String.raw `^\s*unexpected\s+status\s+429\b|^(?![\s\S]{0,240}?${CODEX_EXHAUSTED_429_ROW})[ \t]*(?:${CODEX_STATUS_GLYPH}|Error:|API Error:)\s*[\s\S]{0,240}?(?:["'](?:status|code)["']\s*:\s*429\b|\b(?:rate_limit_exceeded|too_many_requests)\b|\brate limit(?:ed| exceeded| reached)?\b|\btoo many requests\b)`, "im"),
|
|
1324
|
+
type: "rate_limit",
|
|
1325
|
+
action: "failover",
|
|
1326
|
+
message: "OpenAI rate limit reached",
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
// Same false-positive boundary as rate limits: `authentication` is a
|
|
1330
|
+
// normal English/code word. Require a structured 401/code or a
|
|
1331
|
+
// decorated CLI error line before pausing every instance on the shared
|
|
1332
|
+
// credential.
|
|
1333
|
+
pattern: new RegExp(String.raw `^\s*unexpected\s+status\s+401\b|^\s*(?:${CODEX_STATUS_GLYPH}|Error:|API Error:)\s*[\s\S]{0,240}?(?:["'](?:status|code)["']\s*:\s*401\b|\b(?:invalid_api_key|authentication_error)\b|\b401\s+Unauthorized\b|\bauthentication (?:failed|error)\b|\binvalid api key\b)`, "im"),
|
|
1334
|
+
type: "auth_error",
|
|
1335
|
+
action: "pause",
|
|
1336
|
+
message: "OpenAI authentication error",
|
|
1337
|
+
},
|
|
1338
|
+
// Codex writes the apostrophe as U+2019 ("You’ve hit your usage limit.") since
|
|
1339
|
+
// 0.156 and as ASCII before (0.153.4): both must match, or no usage limit
|
|
1340
|
+
// ever pauses the instance (#1098).
|
|
1341
|
+
{ pattern: /you['’]ve hit your usage limit/i, type: "quota", action: "pause", message: "Codex usage limit reached — upgrade plan required",
|
|
1342
|
+
notice: () => quotaPausedLine("Codex", "usage") },
|
|
1343
|
+
{
|
|
1344
|
+
// The terminal stage of a lost app-server (#1099): Codex has given up
|
|
1345
|
+
// reconnecting and says so, with a counter that never stops. Nothing
|
|
1346
|
+
// recovers from here without a relaunch, and every message sent in the
|
|
1347
|
+
// meantime waits for an idle that cannot come. Only the LIVE row counts:
|
|
1348
|
+
// directly above the LAST composer with no later transcript item or composer
|
|
1349
|
+
// (the same test as codexAppServerDisconnected, as a lookahead), so text that
|
|
1350
|
+
// merely quotes it does not raise this.
|
|
1351
|
+
pattern: new RegExp(String.raw `^[ \t]*•[ \t]+Reconnect failed[ \t]+—[ \t]+check the endpoint, then relaunch[ \t]+\(\d+[hms](?:[ \t]+\d+[hms])*\)[ \t]*$`
|
|
1352
|
+
+ String.raw `(?=\n(?:[ \t]*\n)*[›>][^\n]*(?:\n(?![•■⚠›>])[^\n]*)*(?![\s\S]))`, "mu"),
|
|
1353
|
+
type: "network",
|
|
1354
|
+
action: "notify",
|
|
1355
|
+
message: "Codex lost its app-server connection and could not reconnect — restart the instance (messages cannot be delivered until then)",
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
// Codex reports an unknown model either as a TUI metadata fallback or
|
|
1359
|
+
// as a ChatGPT-account API rejection. Use whitespace-aware phrases so
|
|
1360
|
+
// capture-pane hard wraps do not hide either form.
|
|
1361
|
+
// Codex quotes the slug with BACKTICKS ("Model metadata for `x` not found.",
|
|
1362
|
+
// verified live on 0.160.0 and in the 0.153.4–0.160.0 binaries). The old
|
|
1363
|
+
// pattern only took ASCII quotes, so it could never match a real line.
|
|
1364
|
+
pattern: /model\s+metadata\s+for\s+['"`‘’“”][^'"`‘’“”]+['"`‘’“”]\s+not\s+found\.\s+defaulting\s+to\s+fallback\s+metadata|model\s+is\s+not\s+supported\s+when\s+using\s+codex\s+with\s+a\s+chatgpt\s+account/i,
|
|
1365
|
+
type: "model_error",
|
|
1366
|
+
action: "notify",
|
|
1367
|
+
message: "Codex model unavailable — use /model to switch",
|
|
1368
|
+
},
|
|
1369
|
+
{
|
|
1370
|
+
// A capacity rejection is a completed failed turn: Codex returns to its
|
|
1371
|
+
// prompt without an answer, with its context intact. Keep this anchored to
|
|
1372
|
+
// the exact decorated TUI line so ordinary prose about model capacity
|
|
1373
|
+
// cannot trigger anything on an otherwise healthy instance. The status
|
|
1374
|
+
// glyph is decoration that changes between Codex versions and error classes
|
|
1375
|
+
// (`■` U+25A0 on 0.159.2 and 0.160.0, `⚠` U+26A0 before), so any one
|
|
1376
|
+
// "Other Symbol" glyph (optionally with its emoji selector) opens the line —
|
|
1377
|
+
// never a bullet, dash, quote marker or number, which prose and the model's
|
|
1378
|
+
// own transcript items use (`•`, `-`, `>`). The sentence must fill the line,
|
|
1379
|
+
// from column 0. Box drawing and block elements (U+2500–U+259F) are
|
|
1380
|
+
// excluded up front: they are `So` too, but a left-border popup or
|
|
1381
|
+
// table row (`│ …`) is chrome, not a status line. The CLI is
|
|
1382
|
+
// already back at the prompt, so skipRecoveryWait avoids an extra wait.
|
|
1383
|
+
// action "nudge_continue": tell the user, then about a minute later tell
|
|
1384
|
+
// the agent to keep going (the lifecycle bounds this to 3 per 30 minutes,
|
|
1385
|
+
// then pauses; see #905). No restart — nothing would re-send the turn — and
|
|
1386
|
+
// no model switch. skipCooldown: each NEW capacity line is a new episode,
|
|
1387
|
+
// including a second one right after the nudge.
|
|
1388
|
+
pattern: /^(?![─-▟])\p{So}\uFE0F? Selected model is at capacity\. Please try a different model\.\r?$/mu,
|
|
1389
|
+
type: "model_error",
|
|
1390
|
+
action: "nudge_continue",
|
|
1391
|
+
message: t("inst.codex_model_capacity"),
|
|
1392
|
+
skipCooldown: true,
|
|
1393
|
+
skipRecoveryWait: true,
|
|
1394
|
+
},
|
|
1395
|
+
// Workspace (business) accounts report exhaustion differently from personal
|
|
1396
|
+
// ones, with codex's own rows (codex-rs/protocol error.rs and tui
|
|
1397
|
+
// turn_runtime.rs, unchanged 0.160.0–0.165.0):
|
|
1398
|
+
// member: "■ Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
|
|
1399
|
+
// "■ You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
|
|
1400
|
+
// owner: "■ You're out of credits. Your workspace is out of credits. Add credits to continue using Codex."
|
|
1401
|
+
// "■ Your workspace is out of credits. Add credits to continue."
|
|
1402
|
+
// "■ You hit your spend cap set in your workspace. Increase your spend cap to continue."
|
|
1403
|
+
// "■ Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex."
|
|
1404
|
+
// Neither `insufficient_quota|billing` nor `you've hit your usage limit`
|
|
1405
|
+
// matches them.
|
|
1406
|
+
//
|
|
1407
|
+
// pause, not notify: nothing works until someone refills or raises the cap.
|
|
1408
|
+
// Anchored to the status glyph in COLUMN 0, because these skip the
|
|
1409
|
+
// usage-window check (a workspace limit is not one of those windows, so the
|
|
1410
|
+
// check would call it "available"): codex draws its own error cells there,
|
|
1411
|
+
// while an agent's message, quote or code block is indented under its `•`
|
|
1412
|
+
// (captured live, tests/fixtures/codex-0165-workspace-quoted.pane.txt).
|
|
1413
|
+
// `\s+` at each word gap: capture-pane runs without -J, so tmux's hard wrap
|
|
1414
|
+
// can land a newline anywhere in the sentence. A resumed transcript reprints
|
|
1415
|
+
// these rows, and the first live composer of a spawn takes them as history.
|
|
1416
|
+
{
|
|
1417
|
+
pattern: new RegExp(String.raw `^(?:${CODEX_STATUS_GLYPH})[ \t]+(?:You['’]re\s+out\s+of\s+credits\.\s+)?Your\s+workspace\s+is\s+out\s+of\s+credits\.`, "m"),
|
|
1418
|
+
type: "quota",
|
|
1419
|
+
action: "pause",
|
|
1420
|
+
message: "Codex workspace credits exhausted — workspace owner must refill",
|
|
1421
|
+
notice: () => quotaPausedLine("Codex", "workspace_credits"),
|
|
1422
|
+
skipUsageVerify: true,
|
|
1423
|
+
historyUntilLiveComposer: true,
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
pattern: new RegExp(String.raw `^(?:${CODEX_STATUS_GLYPH})[ \t]+You\s+hit\s+your\s+spend\s+cap\s+set\s+(?:by\s+the\s+owner\s+of|in)\s+your\s+workspace\.`, "m"),
|
|
1427
|
+
type: "quota",
|
|
1428
|
+
action: "pause",
|
|
1429
|
+
message: "Codex workspace spend cap reached — workspace owner must raise it",
|
|
1430
|
+
notice: () => t("inst.quota_spend_cap"),
|
|
1431
|
+
skipUsageVerify: true,
|
|
1432
|
+
historyUntilLiveComposer: true,
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
pattern: new RegExp(String.raw `^(?:${CODEX_STATUS_GLYPH})[ \t]+Usage\s+limit\s+reached\.\s+You['’]ve\s+reached\s+your\s+usage\s+limit\.\s+Increase\s+your\s+limits`, "m"),
|
|
1436
|
+
type: "quota",
|
|
1437
|
+
action: "pause",
|
|
1438
|
+
message: "Codex workspace usage limit reached — increase the workspace limits to continue",
|
|
1439
|
+
notice: () => quotaPausedLine("Codex", "workspace_usage"),
|
|
1440
|
+
skipUsageVerify: true,
|
|
1441
|
+
historyUntilLiveComposer: true,
|
|
1442
|
+
},
|
|
1443
|
+
// Codex warns at 10% and 5% remaining, and scopes the limit by period —
|
|
1444
|
+
// "weekly" alone missed every `monthly limit` warning.
|
|
1445
|
+
//
|
|
1446
|
+
// `\s+` at EVERY word gap (not literal spaces) because capture-pane runs without -J,
|
|
1447
|
+
// so tmux's hard wrap can land a newline (plus continuation-line padding)
|
|
1448
|
+
// at any of these word gaps. The trailing "Run /status for a breakdown."
|
|
1449
|
+
// is deliberately NOT part of the pattern — that tail is what actually
|
|
1450
|
+
// wrapped in the reported case.
|
|
1451
|
+
//
|
|
1452
|
+
// `of your <period> limit` is load-bearing: it's what keeps the pattern
|
|
1453
|
+
// off the agent's own prose about percentages.
|
|
1454
|
+
{
|
|
1455
|
+
pattern: /less\s+than\s+(\d+)\s*%\s+of\s+your\s+(hourly|daily|weekly|monthly)\s+limit/i,
|
|
1456
|
+
type: "quota",
|
|
1457
|
+
action: "notify",
|
|
1458
|
+
message: "Codex usage limit running low",
|
|
1459
|
+
formatMessage: (m) => `Codex ${m[2].toLowerCase()} limit: less than ${m[1]}% left`,
|
|
1460
|
+
notice: (m) => (m ? quotaLowLine(m[2], m[1]) : quotaLine("Codex", "usage")),
|
|
1461
|
+
},
|
|
1462
|
+
];
|
|
1463
|
+
}
|
|
1464
|
+
getStartupDialogs() {
|
|
1465
|
+
const trustHold = this.trustHoldDialog();
|
|
1466
|
+
return [
|
|
1467
|
+
this.autoTrustDialog(),
|
|
1468
|
+
trustHold,
|
|
1469
|
+
this.updatePickerDialog(),
|
|
1470
|
+
this.rateSwitchPickerDialog(),
|
|
1471
|
+
...this.sessionHoldDialogs(),
|
|
1472
|
+
this.unknownSelectionHoldDialog(),
|
|
1473
|
+
];
|
|
1554
1474
|
}
|
|
1555
1475
|
/**
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
* itself — not a copy, not a link — so codex refreshing the token writes
|
|
1560
|
-
* into that subscription and no other.
|
|
1476
|
+
* #984: session-selection screens only a human may answer. Held (never
|
|
1477
|
+
* keyed) so the delivery gate keeps messages queued and dialog_parked tells
|
|
1478
|
+
* the operator; listed before the generic selection hold for a clear notice.
|
|
1561
1479
|
*/
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
// a stale/untrusted section in the effective isolated config must fail shut.
|
|
1586
|
-
const onDisk = readFileSync(configPath, "utf-8");
|
|
1587
|
-
if (effectiveProjectTrust(onDisk, paths.root) !== "trusted")
|
|
1588
|
-
throw new Error("Codex project trust did not persist");
|
|
1589
|
-
this.authorizedTrust = paths;
|
|
1480
|
+
sessionHoldDialogs() {
|
|
1481
|
+
return [
|
|
1482
|
+
{
|
|
1483
|
+
pattern: /This conversation is open in another app/,
|
|
1484
|
+
keys: [],
|
|
1485
|
+
description: "Codex session is open in another process (thread lock) — close the other one and press r, or fork manually",
|
|
1486
|
+
holdOnly: true,
|
|
1487
|
+
blocksDelivery: true,
|
|
1488
|
+
inputBlocked: true,
|
|
1489
|
+
remoteCard: codexSelectionCard,
|
|
1490
|
+
isActive: codexSessionLockVisible,
|
|
1491
|
+
},
|
|
1492
|
+
{
|
|
1493
|
+
pattern: /Working directory · resume/,
|
|
1494
|
+
keys: [],
|
|
1495
|
+
description: "Codex resume selected a session from another directory (sibling git worktree) — needs a human choice",
|
|
1496
|
+
holdOnly: true,
|
|
1497
|
+
blocksDelivery: true,
|
|
1498
|
+
inputBlocked: true,
|
|
1499
|
+
remoteCard: codexSelectionCard,
|
|
1500
|
+
isActive: codexResumeCwdPickerVisible,
|
|
1501
|
+
},
|
|
1502
|
+
];
|
|
1590
1503
|
}
|
|
1591
1504
|
/**
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1505
|
+
* The user's decision (hmsg-53600a2c): codex's folder-trust prompt in an AgEnD session is always answered Trust — the
|
|
1506
|
+
* workspace is the agent's own, whatever preTrust covered (preTrust stays, so the prompt rarely shows at all). Enter,
|
|
1507
|
+
* only with the cursor on "Trust and continue" among the known options (trustCursor); any other layout or row is
|
|
1508
|
+
* held (trustHoldDialog). It trusts the folder only: per-action approvals are separate dialogs and are not touched.
|
|
1594
1509
|
*/
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
// EEXIST-safe under concurrent instance startup.
|
|
1622
|
-
for (const dir of CodexBackend.SHARED_SESSION_DIRS) {
|
|
1623
|
-
try {
|
|
1624
|
-
mkdirSync(join(this.sharedCodexHome, dir), { recursive: true, mode: 0o700 });
|
|
1625
|
-
}
|
|
1626
|
-
catch { /* best effort */ }
|
|
1627
|
-
}
|
|
1628
|
-
// Heal homes that already forked: merge the private real dir back into the
|
|
1629
|
-
// shared one (never overwriting), then replace it with the symlink.
|
|
1630
|
-
for (const dir of CodexBackend.SHARED_SESSION_DIRS) {
|
|
1631
|
-
this.migrateDivergedSessionDir(dir);
|
|
1632
|
-
}
|
|
1633
|
-
// Older versions linked SQLite's WAL/SHM files independently of their
|
|
1634
|
-
// base database. If the base file had already been created privately,
|
|
1635
|
-
// that split one SQLite database across two homes and caused CANTOPEN (or
|
|
1636
|
-
// worse, cross-database journal recovery). Remove only links AgEnD made to
|
|
1637
|
-
// the matching shared-home path; real private sidecars remain untouched.
|
|
1638
|
-
const healedSidecars = [];
|
|
1639
|
-
for (const name of readdirSync(this.isolatedCodexHome)) {
|
|
1640
|
-
if (!SQLITE_SIDECAR_RE.test(name))
|
|
1641
|
-
continue;
|
|
1642
|
-
const target = join(this.isolatedCodexHome, name);
|
|
1643
|
-
try {
|
|
1644
|
-
if (!lstatSync(target).isSymbolicLink())
|
|
1645
|
-
continue;
|
|
1646
|
-
const linkTarget = resolve(dirname(target), readlinkSync(target));
|
|
1647
|
-
if (linkTarget !== join(this.sharedCodexHome, name))
|
|
1648
|
-
continue;
|
|
1649
|
-
unlinkSync(target);
|
|
1650
|
-
healedSidecars.push(name);
|
|
1651
|
-
}
|
|
1652
|
-
catch {
|
|
1653
|
-
// A concurrent process may remove an ephemeral sidecar/link first.
|
|
1654
|
-
}
|
|
1655
|
-
}
|
|
1656
|
-
if (healedSidecars.length > 0) {
|
|
1657
|
-
console.warn(`[agend] removed unsafe Codex SQLite sidecar links: ${healedSidecars.join(", ")}`);
|
|
1658
|
-
}
|
|
1659
|
-
// One file, possibly from somewhere else. Everything below is unchanged:
|
|
1660
|
-
// sessions, the thread/state/memory databases and every cache still come
|
|
1661
|
-
// from the shared home, which is why switching subscription does not throw
|
|
1662
|
-
// the conversation away.
|
|
1663
|
-
this.linkAuthFile();
|
|
1664
|
-
for (const name of readdirSync(this.sharedCodexHome)) {
|
|
1665
|
-
if (CodexBackend.PRIVATE_RUNTIME_DIRS.has(name))
|
|
1666
|
-
continue;
|
|
1667
|
-
if (name === "config.toml" || name === AGEND_MCP_CLEANUP_LOCK || name.startsWith(".config.toml."))
|
|
1668
|
-
continue;
|
|
1669
|
-
if (name === CODEX_AUTH_FILE)
|
|
1670
|
-
continue; // handled above, possibly from a profile
|
|
1671
|
-
// SQLite resolves a symlinked base DB to the shared path and creates its
|
|
1672
|
-
// own adjacent sidecars there. Linking sidecars separately is redundant
|
|
1673
|
-
// for shared bases and corrupts the file set for private bases.
|
|
1674
|
-
if (SQLITE_SIDECAR_RE.test(name))
|
|
1675
|
-
continue;
|
|
1676
|
-
const source = join(this.sharedCodexHome, name);
|
|
1677
|
-
const target = join(this.isolatedCodexHome, name);
|
|
1678
|
-
if (existsSync(target))
|
|
1679
|
-
continue;
|
|
1680
|
-
try {
|
|
1681
|
-
const type = lstatSync(source).isDirectory() ? "dir" : "file";
|
|
1682
|
-
symlinkSync(source, target, type);
|
|
1683
|
-
}
|
|
1684
|
-
catch {
|
|
1685
|
-
// State/cache links are compatibility aids; config isolation must not
|
|
1686
|
-
// fail merely because a concurrently-created cache entry disappeared.
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1510
|
+
autoTrustDialog() {
|
|
1511
|
+
return {
|
|
1512
|
+
pattern: /^\s*Trust this folder\?/m,
|
|
1513
|
+
keys: ["Enter"],
|
|
1514
|
+
description: "Codex folder trust — trusting the agent's own folder",
|
|
1515
|
+
blocksDelivery: true,
|
|
1516
|
+
inputBlocked: true,
|
|
1517
|
+
autoResolutionKey: "codex-folder-auto-trust",
|
|
1518
|
+
// One Enter per prompt (claimed, then checked): a prompt still up after it is held, never pressed again (#2896 review).
|
|
1519
|
+
verifyAfterKeys: true,
|
|
1520
|
+
isActive: pane => codexTrustPromptState(pane).trustCursor,
|
|
1521
|
+
answerDetail: pane => ({ autoTrusted: true, ...this.trustPaneFields(pane) }),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
trustHoldDialog() {
|
|
1525
|
+
return {
|
|
1526
|
+
pattern: /^\s*(?:Folder access|Trust this folder\?|Do you trust the files in this folder\?)/im,
|
|
1527
|
+
keys: [],
|
|
1528
|
+
description: "Codex folder trust needs human confirmation",
|
|
1529
|
+
holdOnly: true,
|
|
1530
|
+
blocksDelivery: true,
|
|
1531
|
+
inputBlocked: true,
|
|
1532
|
+
isActive: codexTrustVariantActive,
|
|
1533
|
+
remoteCard: codexSelectionCard,
|
|
1534
|
+
heldReason: pane => this.trustHoldReason(pane),
|
|
1535
|
+
};
|
|
1689
1536
|
}
|
|
1690
1537
|
/**
|
|
1691
|
-
*
|
|
1538
|
+
* #1008: the rate-limit model-switch picker parked unattended instances (it
|
|
1539
|
+
* waits forever and its default option changes the model). Prevention is
|
|
1540
|
+
* `notice.hide_rate_limit_model_nudge` (hideRateLimitModelNudge — real and
|
|
1541
|
+
* effective on 0.157.1 and 0.159.2, verified live); this is the answer for a
|
|
1542
|
+
* picker that appears anyway (config not applied, a CLI that moved the key).
|
|
1692
1543
|
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
* file would leave a real file here and quietly fork the login, with refreshes
|
|
1698
|
-
* landing somewhere the profile never sees. So a real file where a link
|
|
1699
|
-
* belongs is treated as exactly that — reported, and put back.
|
|
1544
|
+
* Escape, not a navigated Enter: it selects nothing, so it can never take
|
|
1545
|
+
* option 1 (switch model) or option 3 (writes "never show again" into the
|
|
1546
|
+
* config). Deliberately not one-shot: if the picker swallowed the key it is
|
|
1547
|
+
* simply sent again on the next poll, and a repeated Escape is harmless.
|
|
1700
1548
|
*/
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
catch { /* unreadable */ }
|
|
1715
|
-
if (current === source)
|
|
1716
|
-
return;
|
|
1717
|
-
try {
|
|
1718
|
-
unlinkSync(target);
|
|
1719
|
-
}
|
|
1720
|
-
catch { /* raced */ }
|
|
1721
|
-
}
|
|
1722
|
-
else if (existing) {
|
|
1723
|
-
// Not a link: codex replaced it, or an older AgEnD copied it. Either way
|
|
1724
|
-
// the refreshes it has been collecting are stranded in this instance's
|
|
1725
|
-
// home, so say so rather than deleting them silently.
|
|
1726
|
-
const stranded = `${target}.replaced-${Date.now()}`;
|
|
1727
|
-
try {
|
|
1728
|
-
renameSync(target, stranded);
|
|
1729
|
-
console.warn(`[agend] codex replaced ${CODEX_AUTH_FILE} in ${this.isolatedCodexHome} with a real file — `
|
|
1730
|
-
+ `its login was no longer shared. Kept a copy at ${stranded} and re-linked to ${source}.`);
|
|
1731
|
-
}
|
|
1732
|
-
catch {
|
|
1733
|
-
return; // cannot move it; leave the instance working on what it has
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
if (!existsSync(source))
|
|
1737
|
-
return; // not logged in yet; codex will create it
|
|
1738
|
-
try {
|
|
1739
|
-
symlinkSync(source, target, "file");
|
|
1740
|
-
}
|
|
1741
|
-
catch {
|
|
1742
|
-
// EEXIST from a concurrent start is the ordinary case and means the link
|
|
1743
|
-
// is already there.
|
|
1744
|
-
}
|
|
1549
|
+
rateSwitchPickerDialog() {
|
|
1550
|
+
return {
|
|
1551
|
+
// Documentation only: Daemon.dialogMatches uses isActive INSTEAD of this
|
|
1552
|
+
// pattern, so it filters nothing here — the structural check below is the
|
|
1553
|
+
// whole decision, and ordinary prose about the picker fails it. The words
|
|
1554
|
+
// are the picker's own, kept so a reader can find the screen from the log.
|
|
1555
|
+
pattern: /Keep current model \(never show again\)/,
|
|
1556
|
+
keys: ["Escape"],
|
|
1557
|
+
description: "Codex rate-limit model-switch picker — Escape keeps the current model",
|
|
1558
|
+
blocksDelivery: true,
|
|
1559
|
+
inputBlocked: true,
|
|
1560
|
+
isActive: codexRateSwitchPickerVisible,
|
|
1561
|
+
};
|
|
1745
1562
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
return;
|
|
1767
|
-
}
|
|
1768
|
-
const shared = join(this.sharedCodexHome, name);
|
|
1769
|
-
try {
|
|
1770
|
-
mkdirSync(shared, { recursive: true, mode: 0o700 });
|
|
1771
|
-
// cp -rn semantics: collisions keep the shared copy. Session rollout
|
|
1772
|
-
// filenames are timestamp+uuid, so a genuine collision means the same
|
|
1773
|
-
// file — skipping is lossless either way.
|
|
1774
|
-
cpSync(target, shared, { recursive: true, force: false, errorOnExist: false });
|
|
1775
|
-
rmSync(target, { recursive: true });
|
|
1776
|
-
symlinkSync(shared, target, "dir");
|
|
1777
|
-
console.warn(`[agend] migrated diverged codex ${name} into shared home: ${shared}`);
|
|
1778
|
-
}
|
|
1779
|
-
catch (err) {
|
|
1780
|
-
// Keep whatever is left of the private dir. The copy never overwrites, so
|
|
1781
|
-
// a retry on next start is safe; worst case is duplicated files in the
|
|
1782
|
-
// shared home, never lost ones.
|
|
1783
|
-
console.warn(`[agend] codex ${name} migration failed — keeping the instance-private dir (${err.message})`);
|
|
1784
|
-
}
|
|
1563
|
+
updatePickerDialog() {
|
|
1564
|
+
return {
|
|
1565
|
+
// Defense in depth for config written by older AgEnD versions or a Codex
|
|
1566
|
+
// regression. Match the release URL as well as the banner so ordinary
|
|
1567
|
+
// agent prose cannot trigger a keypress.
|
|
1568
|
+
pattern: /Update available![\s\S]{0,200}Release notes: https:\/\/github\.com\/openai\/codex\/releases/m,
|
|
1569
|
+
keys: ["Escape"],
|
|
1570
|
+
description: "Codex startup update-available picker",
|
|
1571
|
+
// A delivery must never land on this screen. Its selected option is
|
|
1572
|
+
// `› 1. Update now (runs … curl … | sh)` — the same `›` the input row
|
|
1573
|
+
// uses — so an Enter here runs an installer instead of sending a message.
|
|
1574
|
+
// The runtime dismisser presses Escape, but it only polls every few
|
|
1575
|
+
// seconds and a delivery can arrive first (hit live on codex-cli 0.153.4,
|
|
1576
|
+
// which parks on this picker for as long as nobody answers it).
|
|
1577
|
+
blocksDelivery: true,
|
|
1578
|
+
// Daemon.dialogMatches uses isActive INSTEAD OF pattern when present.
|
|
1579
|
+
// Check the complete current picker here; a different Enter-only menu
|
|
1580
|
+
// must never receive this automatic Escape key.
|
|
1581
|
+
isActive: codexUpdatePickerVisible,
|
|
1582
|
+
};
|
|
1785
1583
|
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1584
|
+
usageLimitLunaReserveDialog() {
|
|
1585
|
+
// The real codex usage-limit dialog states codex has ALREADY switched to
|
|
1586
|
+
// Luna Reserve and labels Escape as "continue working". Pressing Escape via
|
|
1587
|
+
// sendSpecialKey is the ONLY safe key:
|
|
1588
|
+
//
|
|
1589
|
+
// • Escape is sent via sendSpecialKey — never via pasteText (which adds
|
|
1590
|
+
// an implicit bracketed-paste Enter that could confirm the highlighted
|
|
1591
|
+
// server action: Upgrade, Reset usage, Add Credits, …).
|
|
1592
|
+
// • A digit selects its row at once, and pasted its implicit Enter would
|
|
1593
|
+
// confirm whatever it landed on. This path is explicitly NOT used.
|
|
1594
|
+
//
|
|
1595
|
+
// verifyAfterKeys: true — confirm the menu is gone after Escape.
|
|
1596
|
+
// No confirmBeforeEnter / keysAfterConfirm: Escape needs no confirm gate.
|
|
1597
|
+
return {
|
|
1598
|
+
pattern: /Press enter to confirm or esc to continue working/i,
|
|
1599
|
+
keys: ["Escape"],
|
|
1600
|
+
description: "Codex usage limit — pressing Escape to continue with Luna Reserve",
|
|
1601
|
+
blocksDelivery: true,
|
|
1602
|
+
inputBlocked: true,
|
|
1603
|
+
isActive: codexUsageLimitMenuVisible,
|
|
1604
|
+
verifyAfterKeys: true,
|
|
1605
|
+
autoResolutionKey: "codex-usage-limit-luna-reserve",
|
|
1606
|
+
postDismissNotice: {
|
|
1607
|
+
text: t("inst.codex_usage_limit_luna_reserve", ""), // instance name injected by daemon
|
|
1608
|
+
label: "codex-usage-limit-selected",
|
|
1609
|
+
},
|
|
1805
1610
|
};
|
|
1806
|
-
if (!acquire()) {
|
|
1807
|
-
try {
|
|
1808
|
-
if (Date.now() - statSync(lockPath).mtimeMs > AGEND_MCP_CLEANUP_LOCK_STALE_MS) {
|
|
1809
|
-
unlinkSync(lockPath);
|
|
1810
|
-
if (!acquire())
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
else {
|
|
1814
|
-
return;
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
catch {
|
|
1818
|
-
return;
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
try {
|
|
1822
|
-
const configPath = join(this.sharedCodexHome, "config.toml");
|
|
1823
|
-
let content;
|
|
1824
|
-
try {
|
|
1825
|
-
content = readFileSync(configPath, "utf-8");
|
|
1826
|
-
}
|
|
1827
|
-
catch {
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
const cleaned = stripAgendMcpTables(content);
|
|
1831
|
-
if (cleaned !== content)
|
|
1832
|
-
atomicWritePrivate(configPath, cleaned);
|
|
1833
|
-
}
|
|
1834
|
-
finally {
|
|
1835
|
-
if (lockFd !== undefined) {
|
|
1836
|
-
try {
|
|
1837
|
-
closeSync(lockFd);
|
|
1838
|
-
}
|
|
1839
|
-
catch { }
|
|
1840
|
-
}
|
|
1841
|
-
try {
|
|
1842
|
-
unlinkSync(lockPath);
|
|
1843
|
-
}
|
|
1844
|
-
catch { }
|
|
1845
|
-
}
|
|
1846
1611
|
}
|
|
1847
|
-
|
|
1848
|
-
//
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
// structural proof, never a deny-list of known dialogs.
|
|
1865
|
-
const footerSource = footerPattern.source.slice(1, -1);
|
|
1866
|
-
const noContextReady = new RegExp(String.raw `(?:^|\n)[>›][ \t⋆]*Ask Codex to do anything[ \t⋆]*\r?\n(?:[ \t⋆]*\r?\n){0,3}${footerSource}(?:\r?\n[ \t⋆]*)*$`, "iu");
|
|
1867
|
-
// #978: a configured footer that carries a Context item in any position
|
|
1868
|
-
// is the Context footer: same drafted-composer rule as Context-first. The
|
|
1869
|
-
// whole row must match the configured item grammar, so an arbitrary
|
|
1870
|
-
// indented line that merely contains `Context N% left` cannot pass.
|
|
1871
|
-
const configuredContextReady = this.configuredHasContextItem()
|
|
1872
|
-
? new RegExp(String.raw `(?:^|\n)[>›][ \t⋆]+(?!\d+\.)\S[^\r\n]*\r?\n(?:[ \t⋆]*\r?\n){0,3}(?=[^\r\n]*${CODEX_CONTEXT_ITEM})${footerSource}(?:\r?\n[ \t⋆]*)*$`, "iu")
|
|
1873
|
-
: null;
|
|
1874
|
-
return new RegExp([contextReady.source, noContextReady.source, configuredContextReady?.source]
|
|
1875
|
-
.filter((source) => !!source)
|
|
1876
|
-
.map(source => `(?:${source})`)
|
|
1877
|
-
.join("|"), "iu");
|
|
1612
|
+
workspaceLimitPromptDialog() {
|
|
1613
|
+
// Escape via sendSpecialKey, the same as the Luna Reserve menu: never a paste
|
|
1614
|
+
// (its implicit Enter would confirm the cursor's row) and never "y". The error
|
|
1615
|
+
// row codex printed above the prompt stays, and the quota pause takes it from
|
|
1616
|
+
// there: one notice, no parked dialog.
|
|
1617
|
+
return {
|
|
1618
|
+
pattern: /^\s*enter select\s*[·•]\s*esc back\s*$/im,
|
|
1619
|
+
keys: ["Escape"],
|
|
1620
|
+
description: "Codex workspace limit prompt — pressing Escape (never asking the owner)",
|
|
1621
|
+
blocksDelivery: true,
|
|
1622
|
+
inputBlocked: true,
|
|
1623
|
+
isActive: codexWorkspaceLimitPromptVisible,
|
|
1624
|
+
verifyAfterKeys: true,
|
|
1625
|
+
autoResolutionKey: "codex-workspace-limit-prompt",
|
|
1626
|
+
answeredByAgend: true,
|
|
1627
|
+
marksLiveIncident: true,
|
|
1628
|
+
};
|
|
1878
1629
|
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
}
|
|
1890
|
-
catch {
|
|
1891
|
-
this.configuredStatusLineItems = null;
|
|
1892
|
-
}
|
|
1893
|
-
return this.configuredStatusLineItems;
|
|
1630
|
+
unknownSelectionHoldDialog() {
|
|
1631
|
+
return {
|
|
1632
|
+
pattern: /^\s*[›❯>]\s+\S/m,
|
|
1633
|
+
keys: [],
|
|
1634
|
+
description: "Codex interactive selection needs human input",
|
|
1635
|
+
holdOnly: true,
|
|
1636
|
+
blocksDelivery: true,
|
|
1637
|
+
inputBlocked: true,
|
|
1638
|
+
remoteCard: codexSelectionCard,
|
|
1639
|
+
isActive: codexUnknownSelectionVisible,
|
|
1640
|
+
};
|
|
1894
1641
|
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1642
|
+
getRuntimeDialogs() {
|
|
1643
|
+
return [
|
|
1644
|
+
this.autoTrustDialog(),
|
|
1645
|
+
this.trustHoldDialog(),
|
|
1646
|
+
this.rateSwitchPickerDialog(),
|
|
1647
|
+
{
|
|
1648
|
+
// Codex 0.156 may change the wording/order of this credit-cost choice.
|
|
1649
|
+
// Never navigate it by position: a moved option could switch to a
|
|
1650
|
+
// more expensive model. A live picker holds delivery and notifies a
|
|
1651
|
+
// human; old transcript mentions are excluded by the tail matcher.
|
|
1652
|
+
pattern: /(?:Approaching rate limits|Switch to [^\r\n]{1,120} for lower credit usage\?)/i,
|
|
1653
|
+
keys: [],
|
|
1654
|
+
description: "Codex rate limit model switch dialog needs human choice",
|
|
1655
|
+
holdOnly: true,
|
|
1656
|
+
blocksDelivery: true,
|
|
1657
|
+
inputBlocked: true,
|
|
1658
|
+
remoteCard: codexSelectionCard,
|
|
1659
|
+
isActive: codexRateSwitchVisible,
|
|
1660
|
+
},
|
|
1661
|
+
this.updatePickerDialog(),
|
|
1662
|
+
this.usageLimitLunaReserveDialog(),
|
|
1663
|
+
this.workspaceLimitPromptDialog(),
|
|
1664
|
+
...this.sessionHoldDialogs(),
|
|
1665
|
+
this.unknownSelectionHoldDialog(),
|
|
1666
|
+
];
|
|
1919
1667
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1668
|
+
getInputUnavailableTransients() {
|
|
1669
|
+
return [{
|
|
1670
|
+
pattern: /(?:^|\n)\s*Resuming session…\s*(?:\n|$)/,
|
|
1671
|
+
description: "Codex session resume in progress",
|
|
1672
|
+
isActive: (pane) => {
|
|
1673
|
+
const rows = pane.split(/\r?\n/);
|
|
1674
|
+
return codexBoxedResumeLoading(rows) || codexUnboxedResumeLoading(rows);
|
|
1675
|
+
},
|
|
1676
|
+
}];
|
|
1926
1677
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1678
|
+
/** Only a resume paints "Resuming session…"; a fresh launch never does. */
|
|
1679
|
+
launchMayShowInputTransient() {
|
|
1680
|
+
return this.paneDetectorContext?.launchMayResume() ?? this.lastLaunchResumes;
|
|
1681
|
+
}
|
|
1682
|
+
getQuitCommand() { return "/quit"; }
|
|
1683
|
+
getCompactCommand() { return "/compact"; }
|
|
1684
|
+
getClearCommand() { return "/clear"; }
|
|
1685
|
+
getCancelKey() { return "Escape"; }
|
|
1686
|
+
}
|
|
1687
|
+
export class CodexBackend extends CodexPaneDetectors {
|
|
1688
|
+
instanceDir;
|
|
1689
|
+
binaryPath;
|
|
1690
|
+
sharedCodexHome;
|
|
1691
|
+
isolatedCodexHome;
|
|
1692
|
+
/** Which subscription this instance runs on, or null for the shared login. */
|
|
1693
|
+
credentialProfile = null;
|
|
1694
|
+
/** Set only after preTrust wrote and read back this instance's private config. */
|
|
1695
|
+
authorizedTrust = null;
|
|
1696
|
+
/** Set by buildCommand when the resume plan fell back; read once by the daemon. */
|
|
1697
|
+
launchWarning = null;
|
|
1698
|
+
/** Set by writeConfig when the status_line could not be made verifiable (#931). */
|
|
1699
|
+
statusLineWarning = null;
|
|
1700
|
+
/** #2357: set by buildCommand when the codex being launched lacks a package companion (codex-code-mode-host). */
|
|
1701
|
+
packageWarning = null;
|
|
1702
|
+
constructor(instanceDir) {
|
|
1703
|
+
super();
|
|
1704
|
+
this.instanceDir = instanceDir;
|
|
1705
|
+
this.binaryPath = resolveBinary("codex");
|
|
1706
|
+
this.sharedCodexHome = resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
1707
|
+
this.isolatedCodexHome = CodexBackend.resolveShortHome(instanceDir);
|
|
1929
1708
|
}
|
|
1930
1709
|
/**
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1710
|
+
* Compute and (on first call for an existing instance) migrate to a SHORT
|
|
1711
|
+
* persistent CODEX_HOME under `~/.agend/cx/<8-char-hash>/`.
|
|
1712
|
+
*
|
|
1713
|
+
* Background (#953): codex 0.157.0 added `app-server-control.sock` under
|
|
1714
|
+
* CODEX_HOME. For instances with long `-t<topic_id>` suffixes the full path
|
|
1715
|
+
* exceeds the Unix socket SUN_LEN (~107 chars). codex canonicalises the path
|
|
1716
|
+
* before binding the socket, so a symlink workaround does not help — the real
|
|
1717
|
+
* path must be short.
|
|
1718
|
+
*
|
|
1719
|
+
* Migration steps (each is idempotent; half-completed states self-heal):
|
|
1720
|
+
* 1. If shortHome does not exist AND legacyHome is a real directory (not a
|
|
1721
|
+
* symlink) → atomic renameSync. On EXDEV or any other error, fall back
|
|
1722
|
+
* safely: keep using the legacy long path (instance can still start, just
|
|
1723
|
+
* without the short-path fix).
|
|
1724
|
+
* 2. If shortHome exists but legacyHome is missing → recreate the backward-
|
|
1725
|
+
* compat symlink (self-heal for rename-succeeded-but-symlink-failed crash).
|
|
1726
|
+
* 3. If both shortHome and legacyHome (as symlink) exist → nothing to do.
|
|
1727
|
+
* 4. New instance (no legacyHome) → create shortHome directly.
|
|
1728
|
+
*
|
|
1729
|
+
* Fail-safe guarantee: migration failure must never make the instance worse.
|
|
1730
|
+
* The caller always receives a valid path it can use as CODEX_HOME.
|
|
1937
1731
|
*/
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
return this.configuredHasContextItem()
|
|
1942
|
-
&& new RegExp(CODEX_CONTEXT_ITEM, "iu").test(row)
|
|
1943
|
-
&& this.isConfiguredStatusLineFooter(row);
|
|
1732
|
+
/** Exposed for fleet-manager to delete the short home on instance removal. */
|
|
1733
|
+
static shortHomeFor(instanceDir) {
|
|
1734
|
+
return codexShortHomeFor(instanceDir);
|
|
1944
1735
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1736
|
+
static resolveShortHome(instanceDir) {
|
|
1737
|
+
const shortHome = CodexBackend.shortHomeFor(instanceDir);
|
|
1738
|
+
const shortBase = join(getAgendHome(), "cx");
|
|
1739
|
+
const canonical = resolve(instanceDir);
|
|
1740
|
+
const legacyHome = resolve(instanceDir, "codex-home");
|
|
1741
|
+
// Only migrate/create when the instance directory itself exists.
|
|
1742
|
+
// Constructing CodexBackend for a non-existent dir (cli-env probes, test
|
|
1743
|
+
// backends that never ran) must not litter ~/.agend/cx/ with orphan dirs.
|
|
1744
|
+
if (!existsSync(canonical))
|
|
1745
|
+
return shortHome;
|
|
1746
|
+
const legacyExists = existsSync(legacyHome);
|
|
1747
|
+
const legacyIsRealDir = legacyExists && !lstatSync(legacyHome).isSymbolicLink();
|
|
1748
|
+
const shortExists = existsSync(shortHome);
|
|
1749
|
+
if (!shortExists) {
|
|
1750
|
+
mkdirSync(shortBase, { recursive: true, mode: 0o700 });
|
|
1751
|
+
if (legacyIsRealDir) {
|
|
1752
|
+
// Attempt atomic rename. Requires same device; fails with EXDEV otherwise.
|
|
1753
|
+
try {
|
|
1754
|
+
renameSync(legacyHome, shortHome);
|
|
1755
|
+
// Log the one-time migration for observability.
|
|
1756
|
+
try {
|
|
1757
|
+
const logPath = join(shortBase, `${shortHome.split("/").at(-1)}.migrated`);
|
|
1758
|
+
writeFileSync(logPath, `${new Date().toISOString()} migrated from ${legacyHome}\n`);
|
|
1759
|
+
}
|
|
1760
|
+
catch { /* log failure is non-fatal */ }
|
|
1761
|
+
}
|
|
1762
|
+
catch (err) {
|
|
1763
|
+
// EXDEV (cross-device) or any other rename failure → fail-safe:
|
|
1764
|
+
// fall back to the legacy long path so the instance still works.
|
|
1765
|
+
// The socket-length bug persists for this instance but no data is lost.
|
|
1766
|
+
const code = err.code ?? "unknown";
|
|
1767
|
+
try {
|
|
1768
|
+
const warnPath = join(shortBase, `${shortHome.split("/").at(-1)}.migration-failed`);
|
|
1769
|
+
writeFileSync(warnPath, `${new Date().toISOString()} rename failed (${code}): ${err.message}\n`
|
|
1770
|
+
+ `legacy path remains in use: ${legacyHome}\n`);
|
|
1771
|
+
}
|
|
1772
|
+
catch { /* warn log failure is also non-fatal */ }
|
|
1773
|
+
// Return legacy long path so the instance starts (socket may still fail
|
|
1774
|
+
// on 0.157.0 for long names, but no data is lost or corrupted).
|
|
1775
|
+
return legacyHome;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
else if (!legacyExists) {
|
|
1779
|
+
// New instance — no data to migrate.
|
|
1780
|
+
mkdirSync(shortHome, { recursive: true, mode: 0o700 });
|
|
1781
|
+
}
|
|
1782
|
+
// If legacyExists but is already a symlink: shortHome was deleted externally
|
|
1783
|
+
// while the symlink still points to it. Re-create shortHome as a fresh dir.
|
|
1784
|
+
if (!existsSync(shortHome)) {
|
|
1785
|
+
mkdirSync(shortHome, { recursive: true, mode: 0o700 });
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
// Backward-compat symlink: instanceDir/codex-home → shortHome.
|
|
1789
|
+
// Also serves as self-heal: if a previous run renamed successfully but
|
|
1790
|
+
// crashed before creating the symlink, we recreate it here.
|
|
1791
|
+
if (!legacyExists || legacyIsRealDir) {
|
|
1792
|
+
// legacyIsRealDir means rename just happened (now shortHome exists, legacyHome gone).
|
|
1793
|
+
// !legacyExists means new instance or symlink was deleted — create it.
|
|
1794
|
+
try {
|
|
1795
|
+
symlinkSync(shortHome, legacyHome);
|
|
1796
|
+
}
|
|
1797
|
+
catch { /* race or already exists */ }
|
|
1798
|
+
}
|
|
1799
|
+
return shortHome;
|
|
1948
1800
|
}
|
|
1949
1801
|
/**
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
1802
|
+
* The row codex paints for input it has taken into its own queue instead of
|
|
1803
|
+
* submitting: `↳ <message>` under "Messages to be submitted after next tool
|
|
1804
|
+
* call (press esc to interrupt and send immediately)".
|
|
1953
1805
|
*/
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
return false;
|
|
1957
|
-
// The composer is live-looking and the footer is unknown — exactly this
|
|
1958
|
-
// proof's blind spot — but there is no server behind it (#1099).
|
|
1959
|
-
if (codexAppServerDisconnected(pane))
|
|
1960
|
-
return false;
|
|
1961
|
-
// (No second, wider "esc to interrupt" match here: getBusyPattern() above already reads a live status row of ANY
|
|
1962
|
-
// title — #964 — and anchors it to the whole row at column zero, so a reply that quotes the phrase, indented or
|
|
1963
|
-
// in prose, cannot pin the escape hatch shut (#991).)
|
|
1964
|
-
// A retained queued message (`↳ …` under "Messages to be submitted after
|
|
1965
|
-
// next tool call") means work is pending, whatever the composer shows.
|
|
1966
|
-
if (/↳|Messages to be submitted/i.test(pane))
|
|
1967
|
-
return false;
|
|
1968
|
-
if (codexUsageLimitMenuVisible(pane) || codexRateSwitchVisible(pane)
|
|
1969
|
-
|| codexUnknownSelectionVisible(pane) || codexUpdatePickerVisible(pane))
|
|
1970
|
-
return false;
|
|
1971
|
-
const rows = pane.replace(/\r/g, "").split("\n").filter(row => row.trim() !== "");
|
|
1972
|
-
return rows.slice(-6).some(row => /^[>›][ \t⋆]*Ask Codex to do anything[ \t⋆]*$/.test(row));
|
|
1973
|
-
}
|
|
1974
|
-
isCodexIdleFooter(row) {
|
|
1975
|
-
return isCodexContextFooter(row) || this.isConfiguredStatusLineFooter(row);
|
|
1976
|
-
}
|
|
1977
|
-
/** A proxy reply filters chrome per line; whole-pane readiness is separate. */
|
|
1978
|
-
isProxyReplyChromeLine(line) {
|
|
1979
|
-
return /^\s*[›>]\s+Ask Codex to do anything\s*$/.test(line)
|
|
1980
|
-
|| this.isAnyContextFooter(line);
|
|
1806
|
+
getQueuedInputMarker() {
|
|
1807
|
+
return /↳/;
|
|
1981
1808
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
{
|
|
2031
|
-
//
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
}
|
|
2121
|
-
{
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
1809
|
+
/** #1811: what the last launch left out and why — the Log tab and daemon.log, never a channel. */
|
|
1810
|
+
quietLaunchNotes = [];
|
|
1811
|
+
consumeQuietLaunchNotes() { const notes = this.quietLaunchNotes; this.quietLaunchNotes = []; return notes; }
|
|
1812
|
+
buildCommand(config) {
|
|
1813
|
+
this.lastKnownModel = config.model?.trim() || null;
|
|
1814
|
+
this.credentialProfile = this.readProfile(config);
|
|
1815
|
+
// #2886: `--full-auto` is gone from codex (no supported version, 0.155 on, accepts it: the instance never started);
|
|
1816
|
+
// spelled out, it was a workspace-write sandbox that asks for approval on request.
|
|
1817
|
+
const approvalFlag = config.skipPermissions !== false
|
|
1818
|
+
? "--dangerously-bypass-approvals-and-sandbox"
|
|
1819
|
+
: "--sandbox workspace-write --ask-for-approval on-request";
|
|
1820
|
+
// #984: never trust `codex resume --last` to pick this instance's session.
|
|
1821
|
+
// Codex 0.157 scopes it to the git REPOSITORY, so in a worktree it resumes
|
|
1822
|
+
// the newest session of any sibling worktree (a lock screen while that
|
|
1823
|
+
// instance lives, a silent hijack otherwise), and a new instance in a
|
|
1824
|
+
// shared repo does not start fresh. Resume the exact-cwd thread from the
|
|
1825
|
+
// shared state DB instead; `--last` is only the fallback for an unreadable
|
|
1826
|
+
// DB when no other Codex instance shares the repository.
|
|
1827
|
+
let cmd;
|
|
1828
|
+
this.launchWarning = null;
|
|
1829
|
+
this.lastLaunchResumes = false;
|
|
1830
|
+
// #2357: read-only (codex is never run here); a launch is not held for it — the operator is told once per launch.
|
|
1831
|
+
this.packageWarning = codexPackageWarning(this.binaryPath);
|
|
1832
|
+
if (config.skipResume) {
|
|
1833
|
+
cmd = `${this.binaryPath} ${approvalFlag}`;
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
const resumeBy = (id, why, bytes) => {
|
|
1837
|
+
this.lastLaunchResumes = true;
|
|
1838
|
+
this.writeResumeAttempt(id);
|
|
1839
|
+
const large = bytes !== null && bytes > CODEX_RESUME_LARGE_BYTES ? ` ${t("codex.resume_large", String(Math.round(bytes / (1024 * 1024))))}` : "";
|
|
1840
|
+
this.launchWarning = why + large;
|
|
1841
|
+
return `${this.binaryPath} resume ${shellQuote(id)} ${approvalFlag}`;
|
|
1842
|
+
};
|
|
1843
|
+
const sessionsDir = join(this.isolatedCodexHome, "sessions");
|
|
1844
|
+
// M-2266: the previous launch was told to resume a conversation by id. Codex records a thread as it opens it,
|
|
1845
|
+
// so a resume that did not take (codex exited with "No saved session found…") left no row: this launch starts
|
|
1846
|
+
// fresh, once, and says so. A database that cannot be read proves nothing, and planning goes on as usual.
|
|
1847
|
+
// A new one-shot ask comes first: it is the person's explicit choice for this launch.
|
|
1848
|
+
const oneShot = this.takeResumeNext();
|
|
1849
|
+
const attempt = this.takeResumeAttempt();
|
|
1850
|
+
const failed = !oneShot && attempt !== null && codexThreadRowExists(this.stateDbPath(), attempt) === false;
|
|
1851
|
+
if (failed) {
|
|
1852
|
+
// Remembered, so the auto-resume never picks this conversation again on a later launch.
|
|
1853
|
+
this.rememberFailedResume(attempt);
|
|
1854
|
+
cmd = `${this.binaryPath} ${approvalFlag}`;
|
|
1855
|
+
this.launchWarning = t("codex.resume_failed", config.workingDirectory, attempt);
|
|
1856
|
+
}
|
|
1857
|
+
else if (oneShot) {
|
|
1858
|
+
// M-2266: validated when it was asked for (restart_instance); checked again now, as the rollout may have moved.
|
|
1859
|
+
const check = checkResumeId(sessionsDir, config.workingDirectory, oneShot);
|
|
1860
|
+
if (check.ok)
|
|
1861
|
+
cmd = resumeBy(check.rollout.id, t("codex.resume_oneshot", config.workingDirectory, check.rollout.id), check.rollout.bytes);
|
|
1862
|
+
else {
|
|
1863
|
+
cmd = this.plannedCommand(config, approvalFlag, sessionsDir, resumeBy);
|
|
1864
|
+
this.launchWarning = [t("codex.resume_oneshot_invalid", config.workingDirectory, oneShot, check.reason), this.launchWarning].filter(Boolean).join("\n");
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
cmd = this.plannedCommand(config, approvalFlag, sessionsDir, resumeBy);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
if (config.model) {
|
|
1872
|
+
const model = validateModel(config.model);
|
|
1873
|
+
warnIfModelMismatch("codex", model);
|
|
1874
|
+
cmd += ` -c ${shellQuote(`model="${model}"`)}`;
|
|
1875
|
+
}
|
|
1876
|
+
if (config.backendOptions?.provider) {
|
|
1877
|
+
const provider = validateProvider(String(config.backendOptions.provider));
|
|
1878
|
+
cmd += ` -c ${shellQuote(`model_provider="${provider}"`)}`;
|
|
1879
|
+
}
|
|
1880
|
+
// #1811: the configured effort, when the catalog entry of the model that will run lists it (launch-effort.ts); else
|
|
1881
|
+
// a quiet note. That model is proven from the command line or the config files codex reads (#1813 review).
|
|
1882
|
+
const runs = resolveCodexLaunchModel({
|
|
1883
|
+
isolatedHome: this.isolatedCodexHome, sharedHome: this.sharedCodexHome,
|
|
1884
|
+
model: config.model, workingDirectory: config.workingDirectory,
|
|
1885
|
+
});
|
|
1886
|
+
const effort = codexEffort(config.effort, runs.proven
|
|
1887
|
+
? readCodexModelEffortLevels({ isolatedHome: this.isolatedCodexHome, sharedHome: this.sharedCodexHome, model: runs.model })
|
|
1888
|
+
: { model: runs.model, levels: null, unproven: true });
|
|
1889
|
+
if (effort.arg)
|
|
1890
|
+
cmd += effort.arg;
|
|
1891
|
+
this.quietLaunchNotes = effort.note ? [effort.note] : [];
|
|
1892
|
+
// AgEnD instances are unattended processes: an interactive self-update
|
|
1893
|
+
// picker blocks delivery and must never be enabled by a copied global or
|
|
1894
|
+
// managed config layer. Keep this last so the CLI override is authoritative.
|
|
1895
|
+
// Both observed Codex 0.155.0 and 0.156.1 support this launch flag. Pin
|
|
1896
|
+
// the initial layout so a user/global fullscreen preference cannot make a
|
|
1897
|
+
// header-only pane look ready. A later unknown layout still fails closed.
|
|
1898
|
+
cmd += " -c check_for_update_on_startup=false --no-alt-screen";
|
|
1899
|
+
// codex 0.159's opt-in `instant_interrupt` makes new input steer the
|
|
1900
|
+
// running response instead of queueing behind it, which AgEnD's delivery
|
|
1901
|
+
// and queued-input (↳) handling assume. Keep it off whatever a user or
|
|
1902
|
+
// global config says. Codex before 0.159 ignores the key with a startup
|
|
1903
|
+
// warning ("`features.instant_interrupt` is ignored"); nothing else.
|
|
1904
|
+
cmd += " -c features.instant_interrupt=false";
|
|
1905
|
+
// CODEX_HOME is the only Codex-supported way to isolate the complete base
|
|
1906
|
+
// config. A profile only layers over the shared config and would therefore
|
|
1907
|
+
// still load every globally registered AgEnD MCP server.
|
|
1908
|
+
return `CODEX_HOME=${shellQuote(this.isolatedCodexHome)} ${cmd}`;
|
|
1909
|
+
}
|
|
1910
|
+
writeConfig(config) {
|
|
1911
|
+
this.authorizedTrust = null;
|
|
1912
|
+
// Set before the home is prepared: which login this instance gets is a
|
|
1913
|
+
// property of the home, and the home is built here.
|
|
1914
|
+
this.credentialProfile = this.readProfile(config);
|
|
1915
|
+
this.prepareIsolatedHome();
|
|
1916
|
+
this.cleanSharedConfig();
|
|
1917
|
+
// Copy all user settings but no AgEnD MCP entries into a private base
|
|
1918
|
+
// config, then append only this instance's server(s). Each instance writes
|
|
1919
|
+
// a distinct file, eliminating the old concurrent global-config race.
|
|
1920
|
+
let content = "";
|
|
1921
|
+
try {
|
|
1922
|
+
content = stripAgendMcpTables(readFileSync(join(this.sharedCodexHome, "config.toml"), "utf-8"));
|
|
1923
|
+
}
|
|
1924
|
+
catch { /* a first-time Codex user may have no global config */ }
|
|
1925
|
+
if (content && !content.endsWith("\n"))
|
|
1926
|
+
content += "\n";
|
|
1927
|
+
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
|
1928
|
+
content += `\n${renderMcpServer(name, entry, config.instanceName)}`;
|
|
1929
|
+
}
|
|
1930
|
+
atomicWritePrivate(join(this.isolatedCodexHome, "config.toml"), content);
|
|
1931
|
+
this.enableContextStatusLine();
|
|
1932
|
+
this.disableStartupUpdateCheck();
|
|
1933
|
+
this.hideRateLimitModelNudge();
|
|
1934
|
+
// Write fleet instructions into AGENTS.md (additive via marker block)
|
|
1935
|
+
if (config.instructions) {
|
|
1936
|
+
try {
|
|
1937
|
+
const agentsMd = join(config.workingDirectory, "AGENTS.md");
|
|
1938
|
+
appendWithMarker(agentsMd, config.instanceName, config.instructions);
|
|
1939
|
+
// Warn if file exceeds Codex's project_doc_max_bytes limit
|
|
1940
|
+
try {
|
|
1941
|
+
const size = statSync(agentsMd).size;
|
|
1942
|
+
if (size > CODEX_PROJECT_DOC_MAX_BYTES) {
|
|
1943
|
+
console.warn(`[agend] AGENTS.md is ${size} bytes, exceeds Codex limit of ${CODEX_PROJECT_DOC_MAX_BYTES} — instructions may be truncated`);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
catch { /* stat failed — skip size check */ }
|
|
1947
|
+
}
|
|
1948
|
+
catch { /* best effort */ }
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Stop Codex opening its "Update available!" picker when an instance starts.
|
|
1953
|
+
*
|
|
1954
|
+
* That dialog owns the input loop until someone answers it, so after a fleet
|
|
1955
|
+
* restart every Codex instance sat waiting on a keypress nobody was going to
|
|
1956
|
+
* send. AgEnD then reported it as a stuck pane, which is true but unhelpful.
|
|
1957
|
+
*
|
|
1958
|
+
* `check_for_update_on_startup = false` is Codex's own config key (verified in
|
|
1959
|
+
* the binary), so this prevents the prompt rather than racing to dismiss it.
|
|
1960
|
+
* There is no CLI flag for it — `codex update` is a subcommand, not a switch.
|
|
1961
|
+
*
|
|
1962
|
+
* Placement matters: this is a TOP-LEVEL key, so it has to go above the first
|
|
1963
|
+
* `[section]` header or TOML would read it as belonging to that section. And a
|
|
1964
|
+
* AgEnD's isolated home is unattended, so an inherited `true` is replaced
|
|
1965
|
+
* rather than preserved. TOML rejects duplicate keys, so replace in place.
|
|
1966
|
+
*/
|
|
1967
|
+
disableStartupUpdateCheck() {
|
|
1968
|
+
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
1969
|
+
let content = "";
|
|
1970
|
+
try {
|
|
1971
|
+
content = readFileSync(configPath, "utf-8");
|
|
1972
|
+
}
|
|
1973
|
+
catch {
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
const existing = /^\s*check_for_update_on_startup\s*=\s*(?:true|false)\s*(?:#.*)?$/m;
|
|
1977
|
+
if (existing.test(content)) {
|
|
1978
|
+
const updated = content.replace(existing, "check_for_update_on_startup = false");
|
|
1979
|
+
if (updated !== content) {
|
|
1980
|
+
try {
|
|
1981
|
+
atomicWritePrivate(configPath, updated);
|
|
1982
|
+
}
|
|
1983
|
+
catch { /* best effort */ }
|
|
1984
|
+
}
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const LINE = "check_for_update_on_startup = false\n";
|
|
1988
|
+
const firstSection = content.search(/^\s*\[/m);
|
|
1989
|
+
const updated = firstSection === -1
|
|
1990
|
+
? (content.length && !content.endsWith("\n") ? `${content}\n${LINE}` : `${content}${LINE}`)
|
|
1991
|
+
: `${content.slice(0, firstSection)}${LINE}${content.slice(firstSection)}`;
|
|
1992
|
+
try {
|
|
1993
|
+
atomicWritePrivate(configPath, updated);
|
|
1994
|
+
}
|
|
1995
|
+
catch { /* best effort */ }
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Suppress Codex's rate-limit model-switch reminder for unattended instances.
|
|
1999
|
+
* Codex documents this as `notice.hide_rate_limit_model_nudge` (a boolean in
|
|
2000
|
+
* the `[notice]` table). This changes only the per-instance config; the
|
|
2001
|
+
* runtime holdOnly dialog remains as defense-in-depth for older or changed
|
|
2002
|
+
* CLI behavior. No model option is selected automatically.
|
|
2003
|
+
*/
|
|
2004
|
+
hideRateLimitModelNudge() {
|
|
2005
|
+
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
2006
|
+
let content = "";
|
|
2007
|
+
try {
|
|
2008
|
+
content = readFileSync(configPath, "utf-8");
|
|
2009
|
+
}
|
|
2010
|
+
catch {
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const updated = setTomlTableBoolean(content, "notice", "hide_rate_limit_model_nudge");
|
|
2014
|
+
if (updated !== content) {
|
|
2015
|
+
try {
|
|
2016
|
+
atomicWritePrivate(configPath, updated);
|
|
2017
|
+
}
|
|
2018
|
+
catch { /* best effort */ }
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Ensure Codex's TUI status line shows context usage: the Context item is
|
|
2023
|
+
* both what /ctx scrapes and the footer AgEnD's readiness proofs rely on.
|
|
2024
|
+
* Rules (never drops or reorders the user's items):
|
|
2025
|
+
* 1. the effective `tui.status_line` already has a context item
|
|
2026
|
+
* (context-remaining / -usage / -used) → leave the config untouched.
|
|
2027
|
+
* 2. no context item:
|
|
2028
|
+
* - no status_line at all → write status_line = ["context-remaining"]
|
|
2029
|
+
* - status_line exists → prepend "context-remaining" to it
|
|
2030
|
+
* Best-effort string edit (no TOML writer), but every candidate edit is
|
|
2031
|
+
* parsed back and kept only if the effective `tui.status_line` becomes
|
|
2032
|
+
* `["context-remaining", ...the user's items]`. A `status_line` key in some
|
|
2033
|
+
* other table is not the TUI's and must not be the one edited (#931). If no
|
|
2034
|
+
* edit can be verified, the launch warns the operator instead of leaving the
|
|
2035
|
+
* pane silently unready.
|
|
2036
|
+
*/
|
|
2037
|
+
enableContextStatusLine() {
|
|
2038
|
+
this.configuredStatusLineItems = undefined;
|
|
2039
|
+
this.configuredStatusLinePattern = undefined;
|
|
2040
|
+
this.statusLineWarning = null;
|
|
2041
|
+
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
2042
|
+
// The private copy is rebuilt from the shared one at every launch, so the
|
|
2043
|
+
// operator must be pointed at the file their status_line actually lives in.
|
|
2044
|
+
const sharedConfigPath = join(this.sharedCodexHome, "config.toml");
|
|
2045
|
+
let content = "";
|
|
2046
|
+
try {
|
|
2047
|
+
content = readFileSync(configPath, "utf-8");
|
|
2048
|
+
}
|
|
2049
|
+
catch { /* no file yet */ }
|
|
2050
|
+
const ITEM = "context-remaining";
|
|
2051
|
+
const current = effectiveTuiStatusLine(content);
|
|
2052
|
+
if (current === "invalid") {
|
|
2053
|
+
this.statusLineWarning = t("codex.status_line_unverifiable", "tui.status_line is not a list of item names, or config.toml does not parse", sharedConfigPath);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
// Rule 1: the TUI already shows a context item → don't touch anything.
|
|
2057
|
+
if (current?.some(item => CODEX_CONTEXT_STATUS_ITEM_RE.test(item)))
|
|
2058
|
+
return;
|
|
2059
|
+
// TOML allows quoted keys: `"status_line" = [...]`, `"tui"."status_line"`,
|
|
2060
|
+
// `['tui']`. Every textual candidate is tried; parsing decides.
|
|
2061
|
+
const candidates = [];
|
|
2062
|
+
if (current) {
|
|
2063
|
+
// Rule 2b: prepend our item to the user's existing array (don't overwrite).
|
|
2064
|
+
// First position keeps "Context N% left" at the far left of the footer so a
|
|
2065
|
+
// long cwd/other items can't push it past 80 cols and truncate it.
|
|
2066
|
+
for (const m of content.matchAll(/["']?status_line["']?\s*=\s*\[([^\]]*)\]/g)) {
|
|
2067
|
+
const inner = m[1].trim().replace(/^,\s*/, "").replace(/,\s*$/, "");
|
|
2068
|
+
const newInner = inner.length ? `"${ITEM}", ${inner}` : `"${ITEM}"`;
|
|
2069
|
+
candidates.push(`${content.slice(0, m.index)}status_line = [${newInner}]${content.slice(m.index + m[0].length)}`);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
else {
|
|
2073
|
+
// Rule 2a: no status_line at all → add a minimal one.
|
|
2074
|
+
const base = content.length && !content.endsWith("\n") ? `${content}\n` : content;
|
|
2075
|
+
// Also recognise quoted section headers: `["tui"]` and `['tui']`.
|
|
2076
|
+
if (/^\[["']?tui["']?\][^\n]*\n/m.test(base)) {
|
|
2077
|
+
candidates.push(base.replace(/^\[["']?tui["']?\][^\n]*\n/m, h => `${h}status_line = ["${ITEM}"]\n`));
|
|
2078
|
+
}
|
|
2079
|
+
candidates.push(`${base}\n[tui]\nstatus_line = ["${ITEM}"]\n`);
|
|
2080
|
+
// A config that defines tui with dotted keys cannot take a [tui] table.
|
|
2081
|
+
candidates.push(`tui.status_line = ["${ITEM}"]\n${base}`);
|
|
2082
|
+
// Nor can an inline `tui = { … }` table: the item goes inside it.
|
|
2083
|
+
candidates.push(base.replace(/^([ \t]*["']?tui["']?[ \t]*=[ \t]*\{)[ \t]*\}/m, `$1 status_line = ["${ITEM}"] }`));
|
|
2084
|
+
candidates.push(base.replace(/^([ \t]*["']?tui["']?[ \t]*=[ \t]*\{)/m, `$1 status_line = ["${ITEM}"],`));
|
|
2085
|
+
}
|
|
2086
|
+
const wanted = [ITEM, ...(current ?? [])];
|
|
2087
|
+
const fixed = candidates.find(candidate => {
|
|
2088
|
+
const items = effectiveTuiStatusLine(candidate);
|
|
2089
|
+
return Array.isArray(items) && items.length === wanted.length && items.every((item, i) => item === wanted[i]);
|
|
2090
|
+
});
|
|
2091
|
+
if (!fixed) {
|
|
2092
|
+
this.statusLineWarning = t("codex.status_line_unverifiable", "no edit of tui.status_line could be verified", sharedConfigPath);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
try {
|
|
2096
|
+
atomicWritePrivate(configPath, fixed);
|
|
2097
|
+
}
|
|
2098
|
+
catch (err) {
|
|
2099
|
+
// Never block launch on statusline config — but a verified edit that
|
|
2100
|
+
// could not be written leaves the pane as unready as an unfixable one.
|
|
2101
|
+
const reason = `the verified edit could not be written: ${err.code ?? "write failed"}`;
|
|
2102
|
+
this.statusLineWarning = t("codex.status_line_unverifiable", reason, sharedConfigPath);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
/** Null when the instance did not ask for a profile — today's behaviour. */
|
|
2106
|
+
readProfile(config) {
|
|
2107
|
+
try {
|
|
2108
|
+
return resolveCredentialProfile(config.backendOptions);
|
|
2109
|
+
}
|
|
2110
|
+
catch {
|
|
2111
|
+
// A malformed name is refused where it is written; launching is not the
|
|
2112
|
+
// place to discover it, and falling back to the shared login is the
|
|
2113
|
+
// behaviour every instance had before profiles existed.
|
|
2114
|
+
return null;
|
|
2115
|
+
}
|
|
2150
2116
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2117
|
+
/**
|
|
2118
|
+
* Where this instance's `auth.json` comes from.
|
|
2119
|
+
*
|
|
2120
|
+
* The shared home unless a profile says otherwise. A profile owns the file
|
|
2121
|
+
* itself — not a copy, not a link — so codex refreshing the token writes
|
|
2122
|
+
* into that subscription and no other.
|
|
2123
|
+
*/
|
|
2124
|
+
authSourceDir() {
|
|
2125
|
+
if (!this.credentialProfile)
|
|
2126
|
+
return this.sharedCodexHome;
|
|
2127
|
+
const spec = credentialHomeSpec(this.binaryName);
|
|
2128
|
+
if (!spec)
|
|
2129
|
+
return this.sharedCodexHome;
|
|
2130
|
+
const home = credentialProfileHome(getAgendHome(), this.binaryName, this.credentialProfile);
|
|
2131
|
+
prepareCredentialProfileHome(spec, home);
|
|
2132
|
+
return home;
|
|
2133
|
+
}
|
|
2134
|
+
preTrust(workDir) {
|
|
2135
|
+
this.authorizedTrust = null;
|
|
2136
|
+
const paths = codexTrustPaths(workDir);
|
|
2137
|
+
const configPath = join(this.isolatedCodexHome, "config.toml");
|
|
2138
|
+
let content = "";
|
|
2139
|
+
try {
|
|
2140
|
+
content = readFileSync(configPath, "utf-8");
|
|
2141
|
+
}
|
|
2142
|
+
catch { }
|
|
2143
|
+
const updated = setProjectTrusted(content, paths.root);
|
|
2144
|
+
if (updated !== content)
|
|
2145
|
+
atomicWritePrivate(configPath, updated);
|
|
2146
|
+
// Do not authorize an automatic Enter merely because the write returned:
|
|
2147
|
+
// a stale/untrusted section in the effective isolated config must fail shut.
|
|
2148
|
+
const onDisk = readFileSync(configPath, "utf-8");
|
|
2149
|
+
if (effectiveProjectTrust(onDisk, paths.root) !== "trusted")
|
|
2150
|
+
throw new Error("Codex project trust did not persist");
|
|
2151
|
+
this.authorizedTrust = paths;
|
|
2161
2152
|
}
|
|
2162
2153
|
/**
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
2165
|
-
* the operator; listed before the generic selection hold for a clear notice.
|
|
2154
|
+
* Preserve Codex login/session/cache sharing while isolating config.toml
|
|
2155
|
+
* and the CODEX_HOME-scoped app-server daemon/control runtime directories.
|
|
2166
2156
|
*/
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2157
|
+
/** Session dirs every instance must share with the terminal CLI (#506). */
|
|
2158
|
+
static SHARED_SESSION_DIRS = ["sessions", "archived_sessions"];
|
|
2159
|
+
static PRIVATE_RUNTIME_DIRS = new Set(["app-server-daemon", "app-server-control"]);
|
|
2160
|
+
prepareIsolatedHome() {
|
|
2161
|
+
mkdirSync(this.isolatedCodexHome, { recursive: true, mode: 0o700 });
|
|
2162
|
+
chmodSync(this.isolatedCodexHome, 0o700);
|
|
2163
|
+
if (this.sharedCodexHome === this.isolatedCodexHome)
|
|
2164
|
+
return;
|
|
2165
|
+
// #1034: Codex's private socket directory check rejects directory symlinks.
|
|
2166
|
+
// Detach only the exact shared-home links the old mirror pass created,
|
|
2167
|
+
// including dangling links. Leave private directories and other links
|
|
2168
|
+
// alone; Codex will create its own real runtime directories on launch.
|
|
2169
|
+
for (const name of CodexBackend.PRIVATE_RUNTIME_DIRS) {
|
|
2170
|
+
const target = join(this.isolatedCodexHome, name);
|
|
2171
|
+
try {
|
|
2172
|
+
if (lstatSync(target).isSymbolicLink()
|
|
2173
|
+
&& readlinkSync(target) === join(this.sharedCodexHome, name)) {
|
|
2174
|
+
unlinkSync(target);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
catch { /* absent, or another startup already detached the link */ }
|
|
2178
|
+
}
|
|
2179
|
+
// The session dirs must exist in the SHARED home before the symlink pass:
|
|
2180
|
+
// on a fresh install they don't yet, so no link was created, and the first
|
|
2181
|
+
// archive made Codex create a REAL dir in the instance-private home — a
|
|
2182
|
+
// permanent fork the terminal CLI could never see (#506). mkdir -p is
|
|
2183
|
+
// EEXIST-safe under concurrent instance startup.
|
|
2184
|
+
for (const dir of CodexBackend.SHARED_SESSION_DIRS) {
|
|
2185
|
+
try {
|
|
2186
|
+
mkdirSync(join(this.sharedCodexHome, dir), { recursive: true, mode: 0o700 });
|
|
2187
|
+
}
|
|
2188
|
+
catch { /* best effort */ }
|
|
2189
|
+
}
|
|
2190
|
+
// Heal homes that already forked: merge the private real dir back into the
|
|
2191
|
+
// shared one (never overwriting), then replace it with the symlink.
|
|
2192
|
+
for (const dir of CodexBackend.SHARED_SESSION_DIRS) {
|
|
2193
|
+
this.migrateDivergedSessionDir(dir);
|
|
2194
|
+
}
|
|
2195
|
+
// Older versions linked SQLite's WAL/SHM files independently of their
|
|
2196
|
+
// base database. If the base file had already been created privately,
|
|
2197
|
+
// that split one SQLite database across two homes and caused CANTOPEN (or
|
|
2198
|
+
// worse, cross-database journal recovery). Remove only links AgEnD made to
|
|
2199
|
+
// the matching shared-home path; real private sidecars remain untouched.
|
|
2200
|
+
const healedSidecars = [];
|
|
2201
|
+
for (const name of readdirSync(this.isolatedCodexHome)) {
|
|
2202
|
+
if (!SQLITE_SIDECAR_RE.test(name))
|
|
2203
|
+
continue;
|
|
2204
|
+
const target = join(this.isolatedCodexHome, name);
|
|
2205
|
+
try {
|
|
2206
|
+
if (!lstatSync(target).isSymbolicLink())
|
|
2207
|
+
continue;
|
|
2208
|
+
const linkTarget = resolve(dirname(target), readlinkSync(target));
|
|
2209
|
+
if (linkTarget !== join(this.sharedCodexHome, name))
|
|
2210
|
+
continue;
|
|
2211
|
+
unlinkSync(target);
|
|
2212
|
+
healedSidecars.push(name);
|
|
2213
|
+
}
|
|
2214
|
+
catch {
|
|
2215
|
+
// A concurrent process may remove an ephemeral sidecar/link first.
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
if (healedSidecars.length > 0) {
|
|
2219
|
+
console.warn(`[agend] removed unsafe Codex SQLite sidecar links: ${healedSidecars.join(", ")}`);
|
|
2220
|
+
}
|
|
2221
|
+
// One file, possibly from somewhere else. Everything below is unchanged:
|
|
2222
|
+
// sessions, the thread/state/memory databases and every cache still come
|
|
2223
|
+
// from the shared home, which is why switching subscription does not throw
|
|
2224
|
+
// the conversation away.
|
|
2225
|
+
this.linkAuthFile();
|
|
2226
|
+
for (const name of readdirSync(this.sharedCodexHome)) {
|
|
2227
|
+
if (CodexBackend.PRIVATE_RUNTIME_DIRS.has(name))
|
|
2228
|
+
continue;
|
|
2229
|
+
if (name === "config.toml" || name === AGEND_MCP_CLEANUP_LOCK || name.startsWith(".config.toml."))
|
|
2230
|
+
continue;
|
|
2231
|
+
if (name === CODEX_AUTH_FILE)
|
|
2232
|
+
continue; // handled above, possibly from a profile
|
|
2233
|
+
// SQLite resolves a symlinked base DB to the shared path and creates its
|
|
2234
|
+
// own adjacent sidecars there. Linking sidecars separately is redundant
|
|
2235
|
+
// for shared bases and corrupts the file set for private bases.
|
|
2236
|
+
if (SQLITE_SIDECAR_RE.test(name))
|
|
2237
|
+
continue;
|
|
2238
|
+
const source = join(this.sharedCodexHome, name);
|
|
2239
|
+
const target = join(this.isolatedCodexHome, name);
|
|
2240
|
+
if (existsSync(target))
|
|
2241
|
+
continue;
|
|
2242
|
+
try {
|
|
2243
|
+
const type = lstatSync(source).isDirectory() ? "dir" : "file";
|
|
2244
|
+
symlinkSync(source, target, type);
|
|
2245
|
+
}
|
|
2246
|
+
catch {
|
|
2247
|
+
// State/cache links are compatibility aids; config isolation must not
|
|
2248
|
+
// fail merely because a concurrently-created cache entry disappeared.
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Point this home's `auth.json` at whichever login it should use.
|
|
2254
|
+
*
|
|
2255
|
+
* Codex refreshes the token by writing through the link rather than replacing
|
|
2256
|
+
* it — observed across every instance home on a machine that has been running
|
|
2257
|
+
* this arrangement for months, with the shared file's mtime moving. That is
|
|
2258
|
+
* behaviour, though, not a contract: a release that starts renaming over the
|
|
2259
|
+
* file would leave a real file here and quietly fork the login, with refreshes
|
|
2260
|
+
* landing somewhere the profile never sees. So a real file where a link
|
|
2261
|
+
* belongs is treated as exactly that — reported, and put back.
|
|
2262
|
+
*/
|
|
2263
|
+
linkAuthFile() {
|
|
2264
|
+
const source = join(this.authSourceDir(), CODEX_AUTH_FILE);
|
|
2265
|
+
const target = join(this.isolatedCodexHome, CODEX_AUTH_FILE);
|
|
2266
|
+
let existing = null;
|
|
2267
|
+
try {
|
|
2268
|
+
existing = lstatSync(target);
|
|
2269
|
+
}
|
|
2270
|
+
catch { /* absent */ }
|
|
2271
|
+
if (existing?.isSymbolicLink()) {
|
|
2272
|
+
let current = "";
|
|
2273
|
+
try {
|
|
2274
|
+
current = resolve(dirname(target), readlinkSync(target));
|
|
2275
|
+
}
|
|
2276
|
+
catch { /* unreadable */ }
|
|
2277
|
+
if (current === source)
|
|
2278
|
+
return;
|
|
2279
|
+
try {
|
|
2280
|
+
unlinkSync(target);
|
|
2281
|
+
}
|
|
2282
|
+
catch { /* raced */ }
|
|
2283
|
+
}
|
|
2284
|
+
else if (existing) {
|
|
2285
|
+
// Not a link: codex replaced it, or an older AgEnD copied it. Either way
|
|
2286
|
+
// the refreshes it has been collecting are stranded in this instance's
|
|
2287
|
+
// home, so say so rather than deleting them silently.
|
|
2288
|
+
const stranded = `${target}.replaced-${Date.now()}`;
|
|
2289
|
+
try {
|
|
2290
|
+
renameSync(target, stranded);
|
|
2291
|
+
console.warn(`[agend] codex replaced ${CODEX_AUTH_FILE} in ${this.isolatedCodexHome} with a real file — `
|
|
2292
|
+
+ `its login was no longer shared. Kept a copy at ${stranded} and re-linked to ${source}.`);
|
|
2293
|
+
}
|
|
2294
|
+
catch {
|
|
2295
|
+
return; // cannot move it; leave the instance working on what it has
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
if (!existsSync(source))
|
|
2299
|
+
return; // not logged in yet; codex will create it
|
|
2300
|
+
try {
|
|
2301
|
+
symlinkSync(source, target, "file");
|
|
2302
|
+
}
|
|
2303
|
+
catch {
|
|
2304
|
+
// EEXIST from a concurrent start is the ordinary case and means the link
|
|
2305
|
+
// is already there.
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* One-time heal for an instance whose isolated home grew a REAL session dir
|
|
2310
|
+
* (#506): merge it into the shared home without overwriting anything, and
|
|
2311
|
+
* only after both the copy and the removal succeed put the symlink in its
|
|
2312
|
+
* place. Any failure keeps the private dir — sessions are never the thing
|
|
2313
|
+
* sacrificed for tidiness — and logs what happened.
|
|
2314
|
+
*/
|
|
2315
|
+
migrateDivergedSessionDir(name) {
|
|
2316
|
+
const target = join(this.isolatedCodexHome, name);
|
|
2317
|
+
let st;
|
|
2318
|
+
try {
|
|
2319
|
+
st = lstatSync(target);
|
|
2320
|
+
}
|
|
2321
|
+
catch {
|
|
2322
|
+
return; // absent — the symlink pass below will create the link
|
|
2323
|
+
}
|
|
2324
|
+
if (st.isSymbolicLink())
|
|
2325
|
+
return; // already correct
|
|
2326
|
+
if (!st.isDirectory()) {
|
|
2327
|
+
console.warn(`[agend] codex-home/${name} is neither a symlink nor a directory — leaving it untouched`);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const shared = join(this.sharedCodexHome, name);
|
|
2331
|
+
try {
|
|
2332
|
+
mkdirSync(shared, { recursive: true, mode: 0o700 });
|
|
2333
|
+
// cp -rn semantics: collisions keep the shared copy. Session rollout
|
|
2334
|
+
// filenames are timestamp+uuid, so a genuine collision means the same
|
|
2335
|
+
// file — skipping is lossless either way.
|
|
2336
|
+
cpSync(target, shared, { recursive: true, force: false, errorOnExist: false });
|
|
2337
|
+
rmSync(target, { recursive: true });
|
|
2338
|
+
symlinkSync(shared, target, "dir");
|
|
2339
|
+
console.warn(`[agend] migrated diverged codex ${name} into shared home: ${shared}`);
|
|
2340
|
+
}
|
|
2341
|
+
catch (err) {
|
|
2342
|
+
// Keep whatever is left of the private dir. The copy never overwrites, so
|
|
2343
|
+
// a retry on next start is safe; worst case is duplicated files in the
|
|
2344
|
+
// shared home, never lost ones.
|
|
2345
|
+
console.warn(`[agend] codex ${name} migration failed — keeping the instance-private dir (${err.message})`);
|
|
2346
|
+
}
|
|
2190
2347
|
}
|
|
2191
2348
|
/**
|
|
2192
|
-
*
|
|
2193
|
-
*
|
|
2194
|
-
*
|
|
2195
|
-
* held (trustHoldDialog). It trusts the folder only: per-action approvals are separate dialogs and are not touched.
|
|
2349
|
+
* One-time migration for installations polluted by the old global `codex
|
|
2350
|
+
* mcp add` path. The lock protects separate fleet processes/upgrades, and
|
|
2351
|
+
* atomic rename prevents readers from observing a truncated config.
|
|
2196
2352
|
*/
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2353
|
+
cleanSharedConfig() {
|
|
2354
|
+
if (this.sharedCodexHome === this.isolatedCodexHome)
|
|
2355
|
+
return;
|
|
2356
|
+
mkdirSync(this.sharedCodexHome, { recursive: true, mode: 0o700 });
|
|
2357
|
+
const lockPath = join(this.sharedCodexHome, AGEND_MCP_CLEANUP_LOCK);
|
|
2358
|
+
let lockFd;
|
|
2359
|
+
const acquire = () => {
|
|
2360
|
+
try {
|
|
2361
|
+
lockFd = openSync(lockPath, "wx", 0o600);
|
|
2362
|
+
return true;
|
|
2363
|
+
}
|
|
2364
|
+
catch {
|
|
2365
|
+
return false;
|
|
2366
|
+
}
|
|
2209
2367
|
};
|
|
2368
|
+
if (!acquire()) {
|
|
2369
|
+
try {
|
|
2370
|
+
if (Date.now() - statSync(lockPath).mtimeMs > AGEND_MCP_CLEANUP_LOCK_STALE_MS) {
|
|
2371
|
+
unlinkSync(lockPath);
|
|
2372
|
+
if (!acquire())
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
else {
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
catch {
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
try {
|
|
2384
|
+
const configPath = join(this.sharedCodexHome, "config.toml");
|
|
2385
|
+
let content;
|
|
2386
|
+
try {
|
|
2387
|
+
content = readFileSync(configPath, "utf-8");
|
|
2388
|
+
}
|
|
2389
|
+
catch {
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const cleaned = stripAgendMcpTables(content);
|
|
2393
|
+
if (cleaned !== content)
|
|
2394
|
+
atomicWritePrivate(configPath, cleaned);
|
|
2395
|
+
}
|
|
2396
|
+
finally {
|
|
2397
|
+
if (lockFd !== undefined) {
|
|
2398
|
+
try {
|
|
2399
|
+
closeSync(lockFd);
|
|
2400
|
+
}
|
|
2401
|
+
catch { }
|
|
2402
|
+
}
|
|
2403
|
+
try {
|
|
2404
|
+
unlinkSync(lockPath);
|
|
2405
|
+
}
|
|
2406
|
+
catch { }
|
|
2407
|
+
}
|
|
2210
2408
|
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2409
|
+
readConfiguredStatusLineItems() {
|
|
2410
|
+
if (this.configuredStatusLineItems !== undefined)
|
|
2411
|
+
return this.configuredStatusLineItems;
|
|
2412
|
+
try {
|
|
2413
|
+
const config = tomlTable(parseToml(readFileSync(join(this.isolatedCodexHome, "config.toml"), "utf-8")));
|
|
2414
|
+
const tui = tomlTable(config?.tui);
|
|
2415
|
+
const items = tui?.status_line;
|
|
2416
|
+
this.configuredStatusLineItems = Array.isArray(items) && items.every(item => typeof item === "string")
|
|
2417
|
+
? items
|
|
2418
|
+
: null;
|
|
2419
|
+
}
|
|
2420
|
+
catch {
|
|
2421
|
+
this.configuredStatusLineItems = null;
|
|
2422
|
+
}
|
|
2423
|
+
return this.configuredStatusLineItems;
|
|
2223
2424
|
}
|
|
2224
2425
|
/**
|
|
2225
2426
|
* #2893: why the authorized folder-trust answer did not take this prompt (log only): what the pane shows against what
|
|
@@ -2254,147 +2455,6 @@ export class CodexBackend {
|
|
|
2254
2455
|
authorizedCwd: authorized?.cwd ?? null, authorizedRoot: authorized?.root ?? null,
|
|
2255
2456
|
configPath: join(this.isolatedCodexHome, "config.toml") };
|
|
2256
2457
|
}
|
|
2257
|
-
/**
|
|
2258
|
-
* #1008: the rate-limit model-switch picker parked unattended instances (it
|
|
2259
|
-
* waits forever and its default option changes the model). Prevention is
|
|
2260
|
-
* `notice.hide_rate_limit_model_nudge` (hideRateLimitModelNudge — real and
|
|
2261
|
-
* effective on 0.157.1 and 0.159.2, verified live); this is the answer for a
|
|
2262
|
-
* picker that appears anyway (config not applied, a CLI that moved the key).
|
|
2263
|
-
*
|
|
2264
|
-
* Escape, not a navigated Enter: it selects nothing, so it can never take
|
|
2265
|
-
* option 1 (switch model) or option 3 (writes "never show again" into the
|
|
2266
|
-
* config). Deliberately not one-shot: if the picker swallowed the key it is
|
|
2267
|
-
* simply sent again on the next poll, and a repeated Escape is harmless.
|
|
2268
|
-
*/
|
|
2269
|
-
rateSwitchPickerDialog() {
|
|
2270
|
-
return {
|
|
2271
|
-
// Documentation only: Daemon.dialogMatches uses isActive INSTEAD of this
|
|
2272
|
-
// pattern, so it filters nothing here — the structural check below is the
|
|
2273
|
-
// whole decision, and ordinary prose about the picker fails it. The words
|
|
2274
|
-
// are the picker's own, kept so a reader can find the screen from the log.
|
|
2275
|
-
pattern: /Keep current model \(never show again\)/,
|
|
2276
|
-
keys: ["Escape"],
|
|
2277
|
-
description: "Codex rate-limit model-switch picker — Escape keeps the current model",
|
|
2278
|
-
blocksDelivery: true,
|
|
2279
|
-
inputBlocked: true,
|
|
2280
|
-
isActive: codexRateSwitchPickerVisible,
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
updatePickerDialog() {
|
|
2284
|
-
return {
|
|
2285
|
-
// Defense in depth for config written by older AgEnD versions or a Codex
|
|
2286
|
-
// regression. Match the release URL as well as the banner so ordinary
|
|
2287
|
-
// agent prose cannot trigger a keypress.
|
|
2288
|
-
pattern: /Update available![\s\S]{0,200}Release notes: https:\/\/github\.com\/openai\/codex\/releases/m,
|
|
2289
|
-
keys: ["Escape"],
|
|
2290
|
-
description: "Codex startup update-available picker",
|
|
2291
|
-
// A delivery must never land on this screen. Its selected option is
|
|
2292
|
-
// `› 1. Update now (runs … curl … | sh)` — the same `›` the input row
|
|
2293
|
-
// uses — so an Enter here runs an installer instead of sending a message.
|
|
2294
|
-
// The runtime dismisser presses Escape, but it only polls every few
|
|
2295
|
-
// seconds and a delivery can arrive first (hit live on codex-cli 0.153.4,
|
|
2296
|
-
// which parks on this picker for as long as nobody answers it).
|
|
2297
|
-
blocksDelivery: true,
|
|
2298
|
-
// Daemon.dialogMatches uses isActive INSTEAD OF pattern when present.
|
|
2299
|
-
// Check the complete current picker here; a different Enter-only menu
|
|
2300
|
-
// must never receive this automatic Escape key.
|
|
2301
|
-
isActive: codexUpdatePickerVisible,
|
|
2302
|
-
};
|
|
2303
|
-
}
|
|
2304
|
-
usageLimitLunaReserveDialog() {
|
|
2305
|
-
// The real codex usage-limit dialog states codex has ALREADY switched to
|
|
2306
|
-
// Luna Reserve and labels Escape as "continue working". Pressing Escape via
|
|
2307
|
-
// sendSpecialKey is the ONLY safe key:
|
|
2308
|
-
//
|
|
2309
|
-
// • Escape is sent via sendSpecialKey — never via pasteText (which adds
|
|
2310
|
-
// an implicit bracketed-paste Enter that could confirm the highlighted
|
|
2311
|
-
// server action: Upgrade, Reset usage, Add Credits, …).
|
|
2312
|
-
// • A digit selects its row at once, and pasted its implicit Enter would
|
|
2313
|
-
// confirm whatever it landed on. This path is explicitly NOT used.
|
|
2314
|
-
//
|
|
2315
|
-
// verifyAfterKeys: true — confirm the menu is gone after Escape.
|
|
2316
|
-
// No confirmBeforeEnter / keysAfterConfirm: Escape needs no confirm gate.
|
|
2317
|
-
return {
|
|
2318
|
-
pattern: /Press enter to confirm or esc to continue working/i,
|
|
2319
|
-
keys: ["Escape"],
|
|
2320
|
-
description: "Codex usage limit — pressing Escape to continue with Luna Reserve",
|
|
2321
|
-
blocksDelivery: true,
|
|
2322
|
-
inputBlocked: true,
|
|
2323
|
-
isActive: codexUsageLimitMenuVisible,
|
|
2324
|
-
verifyAfterKeys: true,
|
|
2325
|
-
autoResolutionKey: "codex-usage-limit-luna-reserve",
|
|
2326
|
-
postDismissNotice: {
|
|
2327
|
-
text: t("inst.codex_usage_limit_luna_reserve", ""), // instance name injected by daemon
|
|
2328
|
-
label: "codex-usage-limit-selected",
|
|
2329
|
-
},
|
|
2330
|
-
};
|
|
2331
|
-
}
|
|
2332
|
-
workspaceLimitPromptDialog() {
|
|
2333
|
-
// Escape via sendSpecialKey, the same as the Luna Reserve menu: never a paste
|
|
2334
|
-
// (its implicit Enter would confirm the cursor's row) and never "y". The error
|
|
2335
|
-
// row codex printed above the prompt stays, and the quota pause takes it from
|
|
2336
|
-
// there: one notice, no parked dialog.
|
|
2337
|
-
return {
|
|
2338
|
-
pattern: /^\s*enter select\s*[·•]\s*esc back\s*$/im,
|
|
2339
|
-
keys: ["Escape"],
|
|
2340
|
-
description: "Codex workspace limit prompt — pressing Escape (never asking the owner)",
|
|
2341
|
-
blocksDelivery: true,
|
|
2342
|
-
inputBlocked: true,
|
|
2343
|
-
isActive: codexWorkspaceLimitPromptVisible,
|
|
2344
|
-
verifyAfterKeys: true,
|
|
2345
|
-
autoResolutionKey: "codex-workspace-limit-prompt",
|
|
2346
|
-
answeredByAgend: true,
|
|
2347
|
-
marksLiveIncident: true,
|
|
2348
|
-
};
|
|
2349
|
-
}
|
|
2350
|
-
unknownSelectionHoldDialog() {
|
|
2351
|
-
return {
|
|
2352
|
-
pattern: /^\s*[›❯>]\s+\S/m,
|
|
2353
|
-
keys: [],
|
|
2354
|
-
description: "Codex interactive selection needs human input",
|
|
2355
|
-
holdOnly: true,
|
|
2356
|
-
blocksDelivery: true,
|
|
2357
|
-
inputBlocked: true,
|
|
2358
|
-
remoteCard: codexSelectionCard,
|
|
2359
|
-
isActive: codexUnknownSelectionVisible,
|
|
2360
|
-
};
|
|
2361
|
-
}
|
|
2362
|
-
getRuntimeDialogs() {
|
|
2363
|
-
return [
|
|
2364
|
-
this.autoTrustDialog(),
|
|
2365
|
-
this.trustHoldDialog(),
|
|
2366
|
-
this.rateSwitchPickerDialog(),
|
|
2367
|
-
{
|
|
2368
|
-
// Codex 0.156 may change the wording/order of this credit-cost choice.
|
|
2369
|
-
// Never navigate it by position: a moved option could switch to a
|
|
2370
|
-
// more expensive model. A live picker holds delivery and notifies a
|
|
2371
|
-
// human; old transcript mentions are excluded by the tail matcher.
|
|
2372
|
-
pattern: /(?:Approaching rate limits|Switch to [^\r\n]{1,120} for lower credit usage\?)/i,
|
|
2373
|
-
keys: [],
|
|
2374
|
-
description: "Codex rate limit model switch dialog needs human choice",
|
|
2375
|
-
holdOnly: true,
|
|
2376
|
-
blocksDelivery: true,
|
|
2377
|
-
inputBlocked: true,
|
|
2378
|
-
remoteCard: codexSelectionCard,
|
|
2379
|
-
isActive: codexRateSwitchVisible,
|
|
2380
|
-
},
|
|
2381
|
-
this.updatePickerDialog(),
|
|
2382
|
-
this.usageLimitLunaReserveDialog(),
|
|
2383
|
-
this.workspaceLimitPromptDialog(),
|
|
2384
|
-
...this.sessionHoldDialogs(),
|
|
2385
|
-
this.unknownSelectionHoldDialog(),
|
|
2386
|
-
];
|
|
2387
|
-
}
|
|
2388
|
-
getInputUnavailableTransients() {
|
|
2389
|
-
return [{
|
|
2390
|
-
pattern: /(?:^|\n)\s*Resuming session…\s*(?:\n|$)/,
|
|
2391
|
-
description: "Codex session resume in progress",
|
|
2392
|
-
isActive: (pane) => {
|
|
2393
|
-
const rows = pane.split(/\r?\n/);
|
|
2394
|
-
return codexBoxedResumeLoading(rows) || codexUnboxedResumeLoading(rows);
|
|
2395
|
-
},
|
|
2396
|
-
}];
|
|
2397
|
-
}
|
|
2398
2458
|
getContextUsage() {
|
|
2399
2459
|
return null;
|
|
2400
2460
|
}
|
|
@@ -2507,10 +2567,6 @@ export class CodexBackend {
|
|
|
2507
2567
|
return peers ? codexSiblingState(config.workingDirectory, peers) : "siblings";
|
|
2508
2568
|
});
|
|
2509
2569
|
}
|
|
2510
|
-
/** Only a resume paints "Resuming session…"; a fresh launch never does. */
|
|
2511
|
-
launchMayShowInputTransient() {
|
|
2512
|
-
return this.lastLaunchResumes;
|
|
2513
|
-
}
|
|
2514
2570
|
consumeLaunchWarning() {
|
|
2515
2571
|
const warnings = [this.packageWarning, this.statusLineWarning, this.launchWarning].filter((w) => !!w);
|
|
2516
2572
|
this.packageWarning = null;
|
|
@@ -2518,10 +2574,6 @@ export class CodexBackend {
|
|
|
2518
2574
|
this.launchWarning = null;
|
|
2519
2575
|
return warnings.length ? warnings.join("\n") : null;
|
|
2520
2576
|
}
|
|
2521
|
-
getQuitCommand() { return "/quit"; }
|
|
2522
|
-
getCompactCommand() { return "/compact"; }
|
|
2523
|
-
getClearCommand() { return "/clear"; }
|
|
2524
|
-
getCancelKey() { return "Escape"; }
|
|
2525
2577
|
// Codex has no `/effort`; reasoning effort is the `model_reasoning_effort`
|
|
2526
2578
|
// config key (settable per launch with `-c`). The TUI reads it at startup, so
|
|
2527
2579
|
// a change needs a respawn — restart, not runtime.
|