@seawork/server 1.0.22-rc.3 → 2.0.2-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scripts/supervisor-entrypoint.js +48 -8
- package/dist/scripts/supervisor-entrypoint.js.map +1 -1
- package/dist/scripts/supervisor-native-classifier.js +77 -5
- package/dist/scripts/supervisor-native-classifier.js.map +1 -1
- package/dist/scripts/supervisor-stdio-tail.js +27 -0
- package/dist/scripts/supervisor-stdio-tail.js.map +1 -0
- package/dist/scripts/supervisor.js +12 -0
- package/dist/scripts/supervisor.js.map +1 -1
- package/dist/server/client/daemon-client.d.ts +142 -2
- package/dist/server/client/daemon-client.d.ts.map +1 -1
- package/dist/server/client/daemon-client.js +384 -3
- package/dist/server/client/daemon-client.js.map +1 -1
- package/dist/server/server/agent/agent-manager.d.ts +55 -3
- package/dist/server/server/agent/agent-manager.d.ts.map +1 -1
- package/dist/server/server/agent/agent-manager.js +324 -45
- package/dist/server/server/agent/agent-manager.js.map +1 -1
- package/dist/server/server/agent/agent-metadata-generator.d.ts +1 -0
- package/dist/server/server/agent/agent-metadata-generator.d.ts.map +1 -1
- package/dist/server/server/agent/agent-metadata-generator.js +8 -0
- package/dist/server/server/agent/agent-metadata-generator.js.map +1 -1
- package/dist/server/server/agent/agent-projections.js +7 -2
- package/dist/server/server/agent/agent-projections.js.map +1 -1
- package/dist/server/server/agent/agent-response-loop.d.ts +3 -1
- package/dist/server/server/agent/agent-response-loop.d.ts.map +1 -1
- package/dist/server/server/agent/agent-response-loop.js +33 -6
- package/dist/server/server/agent/agent-response-loop.js.map +1 -1
- package/dist/server/server/agent/agent-sdk-types.d.ts +43 -1
- package/dist/server/server/agent/agent-sdk-types.d.ts.map +1 -1
- package/dist/server/server/agent/claude-memory.d.ts +4 -0
- package/dist/server/server/agent/claude-memory.d.ts.map +1 -0
- package/dist/server/server/agent/claude-memory.js +97 -0
- package/dist/server/server/agent/claude-memory.js.map +1 -0
- package/dist/server/server/agent/mcp-server.d.ts.map +1 -1
- package/dist/server/server/agent/mcp-server.js +247 -0
- package/dist/server/server/agent/mcp-server.js.map +1 -1
- package/dist/server/server/agent/mcp-shared.d.ts +2 -0
- package/dist/server/server/agent/mcp-shared.d.ts.map +1 -1
- package/dist/server/server/agent/provider-launch-config.d.ts +6 -139
- package/dist/server/server/agent/provider-launch-config.d.ts.map +1 -1
- package/dist/server/server/agent/provider-launch-config.js +65 -33
- package/dist/server/server/agent/provider-launch-config.js.map +1 -1
- package/dist/server/server/agent/provider-manifest.d.ts +1 -0
- package/dist/server/server/agent/provider-manifest.d.ts.map +1 -1
- package/dist/server/server/agent/provider-manifest.js +36 -0
- package/dist/server/server/agent/provider-manifest.js.map +1 -1
- package/dist/server/server/agent/provider-registry.d.ts.map +1 -1
- package/dist/server/server/agent/provider-registry.js +4 -0
- package/dist/server/server/agent/provider-registry.js.map +1 -1
- package/dist/server/server/agent/provider-snapshot-manager.d.ts +3 -1
- package/dist/server/server/agent/provider-snapshot-manager.d.ts.map +1 -1
- package/dist/server/server/agent/provider-snapshot-manager.js +13 -0
- package/dist/server/server/agent/provider-snapshot-manager.js.map +1 -1
- package/dist/server/server/agent/providers/claude-agent.d.ts.map +1 -1
- package/dist/server/server/agent/providers/claude-agent.js +141 -27
- package/dist/server/server/agent/providers/claude-agent.js.map +1 -1
- package/dist/server/server/agent/providers/codex/tool-call-mapper.d.ts.map +1 -1
- package/dist/server/server/agent/providers/codex/tool-call-mapper.js +14 -1
- package/dist/server/server/agent/providers/codex/tool-call-mapper.js.map +1 -1
- package/dist/server/server/agent/providers/codex-app-server-agent.d.ts +132 -4
- package/dist/server/server/agent/providers/codex-app-server-agent.d.ts.map +1 -1
- package/dist/server/server/agent/providers/codex-app-server-agent.js +2233 -163
- package/dist/server/server/agent/providers/codex-app-server-agent.js.map +1 -1
- package/dist/server/server/agent/providers/codex-binary-resolver.d.ts +9 -0
- package/dist/server/server/agent/providers/codex-binary-resolver.d.ts.map +1 -1
- package/dist/server/server/agent/providers/codex-binary-resolver.js +35 -14
- package/dist/server/server/agent/providers/codex-binary-resolver.js.map +1 -1
- package/dist/server/server/agent/providers/codex-health-probe.js +1 -1
- package/dist/server/server/agent/providers/codex-health-probe.js.map +1 -1
- package/dist/server/server/agent/providers/deepseek/constants.d.ts +4 -0
- package/dist/server/server/agent/providers/deepseek/constants.d.ts.map +1 -0
- package/dist/server/server/agent/providers/deepseek/constants.js +11 -0
- package/dist/server/server/agent/providers/deepseek/constants.js.map +1 -0
- package/dist/server/server/agent/providers/deepseek/event-mapper.d.ts +21 -0
- package/dist/server/server/agent/providers/deepseek/event-mapper.d.ts.map +1 -0
- package/dist/server/server/agent/providers/deepseek/event-mapper.js +286 -0
- package/dist/server/server/agent/providers/deepseek/event-mapper.js.map +1 -0
- package/dist/server/server/agent/providers/deepseek/serve-client.d.ts +94 -0
- package/dist/server/server/agent/providers/deepseek/serve-client.d.ts.map +1 -0
- package/dist/server/server/agent/providers/deepseek/serve-client.js +142 -0
- package/dist/server/server/agent/providers/deepseek/serve-client.js.map +1 -0
- package/dist/server/server/agent/providers/deepseek/serve-process.d.ts +18 -0
- package/dist/server/server/agent/providers/deepseek/serve-process.d.ts.map +1 -0
- package/dist/server/server/agent/providers/deepseek/serve-process.js +93 -0
- package/dist/server/server/agent/providers/deepseek/serve-process.js.map +1 -0
- package/dist/server/server/agent/providers/deepseek-agent.d.ts +94 -0
- package/dist/server/server/agent/providers/deepseek-agent.d.ts.map +1 -0
- package/dist/server/server/agent/providers/deepseek-agent.js +811 -0
- package/dist/server/server/agent/providers/deepseek-agent.js.map +1 -0
- package/dist/server/server/agent/providers/gateway-telemetry.d.ts +9 -0
- package/dist/server/server/agent/providers/gateway-telemetry.d.ts.map +1 -0
- package/dist/server/server/agent/providers/gateway-telemetry.js +36 -0
- package/dist/server/server/agent/providers/gateway-telemetry.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent/constants.d.ts +3 -0
- package/dist/server/server/agent/providers/seaagent/constants.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent/constants.js +3 -0
- package/dist/server/server/agent/providers/seaagent/constants.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent/event-mapper.d.ts +3 -0
- package/dist/server/server/agent/providers/seaagent/event-mapper.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent/event-mapper.js +69 -0
- package/dist/server/server/agent/providers/seaagent/event-mapper.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent/rpc-client.d.ts +23 -0
- package/dist/server/server/agent/providers/seaagent/rpc-client.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent/rpc-client.js +139 -0
- package/dist/server/server/agent/providers/seaagent/rpc-client.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent/tool-call-mapper.d.ts +3 -0
- package/dist/server/server/agent/providers/seaagent/tool-call-mapper.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent/tool-call-mapper.js +38 -0
- package/dist/server/server/agent/providers/seaagent/tool-call-mapper.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent-agent.d.ts +81 -0
- package/dist/server/server/agent/providers/seaagent-agent.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent-agent.js +502 -0
- package/dist/server/server/agent/providers/seaagent-agent.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent-binary-resolver.d.ts +18 -0
- package/dist/server/server/agent/providers/seaagent-binary-resolver.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent-binary-resolver.js +46 -0
- package/dist/server/server/agent/providers/seaagent-binary-resolver.js.map +1 -0
- package/dist/server/server/agent/providers/seaagent-health-probe.d.ts +11 -0
- package/dist/server/server/agent/providers/seaagent-health-probe.d.ts.map +1 -0
- package/dist/server/server/agent/providers/seaagent-health-probe.js +49 -0
- package/dist/server/server/agent/providers/seaagent-health-probe.js.map +1 -0
- package/dist/server/server/agent/providers/seawork-models.d.ts +8 -0
- package/dist/server/server/agent/providers/seawork-models.d.ts.map +1 -1
- package/dist/server/server/agent/providers/seawork-models.js +118 -74
- package/dist/server/server/agent/providers/seawork-models.js.map +1 -1
- package/dist/server/server/agent/providers/tool-call-detail-primitives.d.ts +2 -2
- package/dist/server/server/agent/timeline-projection.d.ts +5 -1
- package/dist/server/server/agent/timeline-projection.d.ts.map +1 -1
- package/dist/server/server/agent/timeline-projection.js +20 -4
- package/dist/server/server/agent/timeline-projection.js.map +1 -1
- package/dist/server/server/agent-attention-policy.d.ts +1 -0
- package/dist/server/server/agent-attention-policy.d.ts.map +1 -1
- package/dist/server/server/agent-attention-policy.js +6 -0
- package/dist/server/server/agent-attention-policy.js.map +1 -1
- package/dist/server/server/allowed-hosts.d.ts +13 -0
- package/dist/server/server/allowed-hosts.d.ts.map +1 -1
- package/dist/server/server/allowed-hosts.js +33 -0
- package/dist/server/server/allowed-hosts.js.map +1 -1
- package/dist/server/server/bootstrap.d.ts +2 -0
- package/dist/server/server/bootstrap.d.ts.map +1 -1
- package/dist/server/server/bootstrap.js +200 -14
- package/dist/server/server/bootstrap.js.map +1 -1
- package/dist/server/server/browser-extension-token.d.ts +23 -0
- package/dist/server/server/browser-extension-token.d.ts.map +1 -0
- package/dist/server/server/browser-extension-token.js +114 -0
- package/dist/server/server/browser-extension-token.js.map +1 -0
- package/dist/server/server/bug-report-handler.d.ts +7 -1
- package/dist/server/server/bug-report-handler.d.ts.map +1 -1
- package/dist/server/server/bug-report-handler.js +73 -5
- package/dist/server/server/bug-report-handler.js.map +1 -1
- package/dist/server/server/bug-report-redact.d.ts +25 -1
- package/dist/server/server/bug-report-redact.d.ts.map +1 -1
- package/dist/server/server/bug-report-redact.js +42 -5
- package/dist/server/server/bug-report-redact.js.map +1 -1
- package/dist/server/server/config.d.ts +1 -0
- package/dist/server/server/config.d.ts.map +1 -1
- package/dist/server/server/config.js +51 -1
- package/dist/server/server/config.js.map +1 -1
- package/dist/server/server/crash-report.d.ts.map +1 -1
- package/dist/server/server/crash-report.js +18 -0
- package/dist/server/server/crash-report.js.map +1 -1
- package/dist/server/server/daemon-config-store.d.ts.map +1 -1
- package/dist/server/server/daemon-config-store.js +94 -3
- package/dist/server/server/daemon-config-store.js.map +1 -1
- package/dist/server/server/disk-full.d.ts +4 -0
- package/dist/server/server/disk-full.d.ts.map +1 -0
- package/dist/server/server/disk-full.js +46 -0
- package/dist/server/server/disk-full.js.map +1 -0
- package/dist/server/server/exports.d.ts +3 -2
- package/dist/server/server/exports.d.ts.map +1 -1
- package/dist/server/server/exports.js +2 -1
- package/dist/server/server/exports.js.map +1 -1
- package/dist/server/server/git-forge/github-client.d.ts +18 -0
- package/dist/server/server/git-forge/github-client.d.ts.map +1 -1
- package/dist/server/server/git-forge/github-client.js +88 -0
- package/dist/server/server/git-forge/github-client.js.map +1 -1
- package/dist/server/server/git-forge/parse-remote.d.ts +2 -0
- package/dist/server/server/git-forge/parse-remote.d.ts.map +1 -1
- package/dist/server/server/git-forge/parse-remote.js +71 -6
- package/dist/server/server/git-forge/parse-remote.js.map +1 -1
- package/dist/server/server/git-forge/service.d.ts +87 -0
- package/dist/server/server/git-forge/service.d.ts.map +1 -1
- package/dist/server/server/git-forge/service.js +198 -4
- package/dist/server/server/git-forge/service.js.map +1 -1
- package/dist/server/server/index.js +72 -0
- package/dist/server/server/index.js.map +1 -1
- package/dist/server/server/integrations/wecom-openclaw/bridge.d.ts +88 -0
- package/dist/server/server/integrations/wecom-openclaw/bridge.d.ts.map +1 -0
- package/dist/server/server/integrations/wecom-openclaw/bridge.js +1229 -0
- package/dist/server/server/integrations/wecom-openclaw/bridge.js.map +1 -0
- package/dist/server/server/integrations/wecom-openclaw/qr.d.ts +38 -0
- package/dist/server/server/integrations/wecom-openclaw/qr.d.ts.map +1 -0
- package/dist/server/server/integrations/wecom-openclaw/qr.js +101 -0
- package/dist/server/server/integrations/wecom-openclaw/qr.js.map +1 -0
- package/dist/server/server/integrations/wecom-openclaw/workspace.d.ts +5 -0
- package/dist/server/server/integrations/wecom-openclaw/workspace.d.ts.map +1 -0
- package/dist/server/server/integrations/wecom-openclaw/workspace.js +40 -0
- package/dist/server/server/integrations/wecom-openclaw/workspace.js.map +1 -0
- package/dist/server/server/latency-proxy.d.ts.map +1 -1
- package/dist/server/server/latency-proxy.js +45 -5
- package/dist/server/server/latency-proxy.js.map +1 -1
- package/dist/server/server/library/codex-skill-discovery.d.ts +9 -0
- package/dist/server/server/library/codex-skill-discovery.d.ts.map +1 -0
- package/dist/server/server/library/codex-skill-discovery.js +49 -0
- package/dist/server/server/library/codex-skill-discovery.js.map +1 -0
- package/dist/server/server/library/hub-install.d.ts +79 -0
- package/dist/server/server/library/hub-install.d.ts.map +1 -0
- package/dist/server/server/library/hub-install.js +263 -0
- package/dist/server/server/library/hub-install.js.map +1 -0
- package/dist/server/server/library/hub-test-run.d.ts +81 -0
- package/dist/server/server/library/hub-test-run.d.ts.map +1 -0
- package/dist/server/server/library/hub-test-run.js +237 -0
- package/dist/server/server/library/hub-test-run.js.map +1 -0
- package/dist/server/server/library/library-import.d.ts +27 -0
- package/dist/server/server/library/library-import.d.ts.map +1 -0
- package/dist/server/server/library/library-import.js +227 -0
- package/dist/server/server/library/library-import.js.map +1 -0
- package/dist/server/server/library/library-injection.d.ts +16 -0
- package/dist/server/server/library/library-injection.d.ts.map +1 -0
- package/dist/server/server/library/library-injection.js +49 -0
- package/dist/server/server/library/library-injection.js.map +1 -0
- package/dist/server/server/library/library-rpc.d.ts +73 -0
- package/dist/server/server/library/library-rpc.d.ts.map +1 -0
- package/dist/server/server/library/library-rpc.js +239 -0
- package/dist/server/server/library/library-rpc.js.map +1 -0
- package/dist/server/server/library/library-store.d.ts +35 -0
- package/dist/server/server/library/library-store.d.ts.map +1 -0
- package/dist/server/server/library/library-store.js +169 -0
- package/dist/server/server/library/library-store.js.map +1 -0
- package/dist/server/server/library/library-sync.d.ts +46 -0
- package/dist/server/server/library/library-sync.d.ts.map +1 -0
- package/dist/server/server/library/library-sync.js +235 -0
- package/dist/server/server/library/library-sync.js.map +1 -0
- package/dist/server/server/library/library-types.d.ts +756 -0
- package/dist/server/server/library/library-types.d.ts.map +1 -0
- package/dist/server/server/library/library-types.js +99 -0
- package/dist/server/server/library/library-types.js.map +1 -0
- package/dist/server/server/library/worktree-dev.d.ts +14 -0
- package/dist/server/server/library/worktree-dev.d.ts.map +1 -0
- package/dist/server/server/library/worktree-dev.js +24 -0
- package/dist/server/server/library/worktree-dev.js.map +1 -0
- package/dist/server/server/log-stream-error.d.ts +2 -0
- package/dist/server/server/log-stream-error.d.ts.map +1 -0
- package/dist/server/server/log-stream-error.js +33 -0
- package/dist/server/server/log-stream-error.js.map +1 -0
- package/dist/server/server/logger.d.ts +1 -0
- package/dist/server/server/logger.d.ts.map +1 -1
- package/dist/server/server/logger.js +32 -0
- package/dist/server/server/logger.js.map +1 -1
- package/dist/server/server/loop/rpc-schemas.d.ts +96 -96
- package/dist/server/server/loop-service.d.ts +18 -18
- package/dist/server/server/messages.d.ts +4 -1
- package/dist/server/server/messages.d.ts.map +1 -1
- package/dist/server/server/messages.js +40 -2
- package/dist/server/server/messages.js.map +1 -1
- package/dist/server/server/node-pty-error.d.ts +2 -0
- package/dist/server/server/node-pty-error.d.ts.map +1 -0
- package/dist/server/server/node-pty-error.js +19 -0
- package/dist/server/server/node-pty-error.js.map +1 -0
- package/dist/server/server/persisted-config.d.ts +219 -135
- package/dist/server/server/persisted-config.d.ts.map +1 -1
- package/dist/server/server/persisted-config.js +35 -1
- package/dist/server/server/persisted-config.js.map +1 -1
- package/dist/server/server/port-in-use.d.ts +4 -0
- package/dist/server/server/port-in-use.d.ts.map +1 -0
- package/dist/server/server/port-in-use.js +35 -0
- package/dist/server/server/port-in-use.js.map +1 -0
- package/dist/server/server/provider-runtime-settings-mask.d.ts +7 -0
- package/dist/server/server/provider-runtime-settings-mask.d.ts.map +1 -0
- package/dist/server/server/provider-runtime-settings-mask.js +65 -0
- package/dist/server/server/provider-runtime-settings-mask.js.map +1 -0
- package/dist/server/server/sac/auth.d.ts +12 -0
- package/dist/server/server/sac/auth.d.ts.map +1 -1
- package/dist/server/server/sac/auth.js +19 -1
- package/dist/server/server/sac/auth.js.map +1 -1
- package/dist/server/server/sac/index.d.ts +2 -2
- package/dist/server/server/sac/index.d.ts.map +1 -1
- package/dist/server/server/sac/index.js +2 -2
- package/dist/server/server/sac/index.js.map +1 -1
- package/dist/server/server/sac/poll.d.ts +2 -0
- package/dist/server/server/sac/poll.d.ts.map +1 -1
- package/dist/server/server/sac/poll.js +7 -2
- package/dist/server/server/sac/poll.js.map +1 -1
- package/dist/server/server/schedule/cron.d.ts.map +1 -1
- package/dist/server/server/schedule/cron.js +6 -6
- package/dist/server/server/schedule/cron.js.map +1 -1
- package/dist/server/server/schedule/rpc-schemas.d.ts +895 -0
- package/dist/server/server/schedule/rpc-schemas.d.ts.map +1 -1
- package/dist/server/server/schedule/rpc-schemas.js +34 -0
- package/dist/server/server/schedule/rpc-schemas.js.map +1 -1
- package/dist/server/server/schedule/service.d.ts +5 -1
- package/dist/server/server/schedule/service.d.ts.map +1 -1
- package/dist/server/server/schedule/service.js +97 -14
- package/dist/server/server/schedule/service.js.map +1 -1
- package/dist/server/server/schedule/types.d.ts +19 -0
- package/dist/server/server/schedule/types.d.ts.map +1 -1
- package/dist/server/server/schedule/types.js +1 -0
- package/dist/server/server/schedule/types.js.map +1 -1
- package/dist/server/server/session.d.ts +83 -2
- package/dist/server/server/session.d.ts.map +1 -1
- package/dist/server/server/session.js +895 -82
- package/dist/server/server/session.js.map +1 -1
- package/dist/server/server/speech/native-runtime-guard.d.ts +1 -0
- package/dist/server/server/speech/native-runtime-guard.d.ts.map +1 -1
- package/dist/server/server/speech/native-runtime-guard.js +10 -4
- package/dist/server/server/speech/native-runtime-guard.js.map +1 -1
- package/dist/server/server/websocket-server.d.ts +6 -1
- package/dist/server/server/websocket-server.d.ts.map +1 -1
- package/dist/server/server/websocket-server.js +79 -7
- package/dist/server/server/websocket-server.js.map +1 -1
- package/dist/server/server/workspace-git-service.d.ts +2 -1
- package/dist/server/server/workspace-git-service.d.ts.map +1 -1
- package/dist/server/server/workspace-git-service.js +7 -3
- package/dist/server/server/workspace-git-service.js.map +1 -1
- package/dist/server/server/workspace-registry-model.d.ts +1 -0
- package/dist/server/server/workspace-registry-model.d.ts.map +1 -1
- package/dist/server/server/workspace-registry-model.js +18 -0
- package/dist/server/server/workspace-registry-model.js.map +1 -1
- package/dist/server/server/worktree-session.d.ts +3 -3
- package/dist/server/server/worktree-session.d.ts.map +1 -1
- package/dist/server/server/worktree-session.js +1 -3
- package/dist/server/server/worktree-session.js.map +1 -1
- package/dist/server/shared/messages.d.ts +59658 -21927
- package/dist/server/shared/messages.d.ts.map +1 -1
- package/dist/server/shared/messages.js +531 -3
- package/dist/server/shared/messages.js.map +1 -1
- package/dist/server/shared/provider-runtime-settings.d.ts +87 -0
- package/dist/server/shared/provider-runtime-settings.d.ts.map +1 -0
- package/dist/server/shared/provider-runtime-settings.js +33 -0
- package/dist/server/shared/provider-runtime-settings.js.map +1 -0
- package/dist/server/terminal/terminal.d.ts +9 -0
- package/dist/server/terminal/terminal.d.ts.map +1 -1
- package/dist/server/terminal/terminal.js +100 -3
- package/dist/server/terminal/terminal.js.map +1 -1
- package/dist/server/utils/checkout-git.d.ts +23 -1
- package/dist/server/utils/checkout-git.d.ts.map +1 -1
- package/dist/server/utils/checkout-git.js +182 -21
- package/dist/server/utils/checkout-git.js.map +1 -1
- package/dist/server/utils/directory-suggestions.d.ts.map +1 -1
- package/dist/server/utils/directory-suggestions.js +57 -9
- package/dist/server/utils/directory-suggestions.js.map +1 -1
- package/dist/src/server/bug-report-redact.js +42 -5
- package/dist/src/server/bug-report-redact.js.map +1 -1
- package/dist/src/server/crash-report.js +18 -0
- package/dist/src/server/crash-report.js.map +1 -1
- package/dist/src/server/speech/native-runtime-guard.js +177 -0
- package/dist/src/server/speech/native-runtime-guard.js.map +1 -0
- package/dist/src/server/speech/speech-types.js +8 -0
- package/dist/src/server/speech/speech-types.js.map +1 -0
- package/package.json +16 -4
|
@@ -1,24 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
2
|
import { realpathSync } from "node:fs";
|
|
4
3
|
import fs from "node:fs/promises";
|
|
5
4
|
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import readline from "node:readline";
|
|
8
7
|
import { z } from "zod";
|
|
9
|
-
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
|
10
|
-
import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js";
|
|
11
|
-
import { applyProviderEnv, resolveProviderCommandPrefix, } from "../provider-launch-config.js";
|
|
12
8
|
import { resolveElectronNodeRuntime } from "../../../utils/electron-helper.js";
|
|
9
|
+
import { spawnProcess } from "../../../utils/spawn.js";
|
|
13
10
|
import { getLatencyProxyUrlSync } from "../../latency-proxy.js";
|
|
11
|
+
import { resolveCodexSkillDiscoveryDirs } from "../../library/codex-skill-discovery.js";
|
|
12
|
+
import { applyProviderEnv, clearInheritedProxyEnv, mergeLocalhostProxyBypass, resolveProviderCommandPrefix, } from "../provider-launch-config.js";
|
|
13
|
+
import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js";
|
|
14
14
|
import { selectCodexBinary, selectEffectiveCodexBinary, verifyCommandAvailable, } from "./codex-binary-resolver.js";
|
|
15
|
-
import { getSeaworkModels } from "./seawork-models.js";
|
|
16
|
-
import { spawnProcess } from "../../../utils/spawn.js";
|
|
17
|
-
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
|
18
15
|
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
|
16
|
+
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
|
19
17
|
import { collectStdout, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js";
|
|
18
|
+
import { buildSeaworkGatewayHeaders, SEAWORK_AGENT_PROVIDER_HEADER_NAME, SEAWORK_SOURCE_HEADER_NAME, SEAWORK_SOURCE_HEADER_VALUE, } from "./gateway-telemetry.js";
|
|
19
|
+
import { getSeaworkModels } from "./seawork-models.js";
|
|
20
|
+
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
|
20
21
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
|
21
22
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
|
23
|
+
// issue #505: if a turn produces no notifications for this long, treat it as
|
|
24
|
+
// stalled and force-complete it. Codex normally streams item/reasoning/output
|
|
25
|
+
// events continuously, so a healthy long-running turn re-arms the watchdog far
|
|
26
|
+
// more often than this; only a turn whose `turn/completed` is lost goes silent.
|
|
27
|
+
const TURN_WATCHDOG_IDLE_MS = 5 * 60 * 1000;
|
|
28
|
+
// issue #1427: when the watchdog decides a turn is stalled, do NOT force-fail
|
|
29
|
+
// immediately. A successful turn's `turn/completed` can arrive tens of ms after
|
|
30
|
+
// the watchdog fires — codex emits it only after a serial flush_rollout +
|
|
31
|
+
// on_task_finished (token/metric/proxy work) + a second persist, a structural,
|
|
32
|
+
// load/disk-dependent gap measured at ~55-71ms in #1416. Wait this grace window
|
|
33
|
+
// for that completion before committing the failure: if it arrives the turn
|
|
34
|
+
// finalizes normally and we never emit a spurious turn_failed. Far larger than
|
|
35
|
+
// the observed gap, far smaller than the 5min idle window, so it cannot mask a
|
|
36
|
+
// genuine stall.
|
|
37
|
+
const WATCHDOG_LATE_COMPLETION_GRACE_MS = 2500;
|
|
38
|
+
// issue #505: a tool execution still in flight (slow build, network fetch) can
|
|
39
|
+
// be legitimately output-silent, so an in-flight tool grants extra idle cycles
|
|
40
|
+
// before the turn is force-failed. Bounded so a *lost tool completion* still
|
|
41
|
+
// recovers instead of re-arming forever. The watchdog fails on the cycle AFTER
|
|
42
|
+
// the count reaches this max, so total silence tolerated with a tool in flight
|
|
43
|
+
// is the initial idle cycle plus this many deferred cycles:
|
|
44
|
+
// (1 + 3) * 5min = 20min.
|
|
45
|
+
const TURN_WATCHDOG_MAX_INFLIGHT_CYCLES = 3;
|
|
46
|
+
// issue #999: auto-compaction packs the whole (~240K-token) context and makes
|
|
47
|
+
// an extra LLM summarization call, so it can stay turn-event-silent for well
|
|
48
|
+
// over the idle window — a legitimate long operation, not a lost turn. Grant
|
|
49
|
+
// it more deferred cycles than a tool (compaction is slower) but stay bounded
|
|
50
|
+
// so a lost compaction-completion still recovers: (1 + 6) * 5min = 35min.
|
|
51
|
+
const TURN_WATCHDOG_MAX_COMPACTION_CYCLES = 6;
|
|
52
|
+
const COMPACTION_ITEM_WATCHDOG_MS = TURN_WATCHDOG_IDLE_MS * (1 + TURN_WATCHDOG_MAX_COMPACTION_CYCLES);
|
|
22
53
|
// issue #259: window after turn/interrupt during which the next turn/start
|
|
23
54
|
// gets a sentinel reminder prepended to its input.
|
|
24
55
|
const CANCEL_REMINDER_WINDOW_MS = 60 * 1000;
|
|
@@ -26,10 +57,103 @@ const CANCEL_REMINDER_TEXT = "[seawork system note] The user canceled the previo
|
|
|
26
57
|
"Ignore any prior user message that did not receive a complete answer. " +
|
|
27
58
|
"Only respond to the message below.";
|
|
28
59
|
const CODEX_EXTERNAL_MIGRATION_MIN_VERSION = "0.128.0";
|
|
60
|
+
// Cap for the per-agent terminal-session maps. They are keyed by codex
|
|
61
|
+
// terminal processId (and, for the dedup set, by stdin payload), which can
|
|
62
|
+
// legitimately persist across turns — so we cannot sweep them per-turn and
|
|
63
|
+
// only clear them on close(). To stop them from growing unbounded over a
|
|
64
|
+
// long-lived agent (they would otherwise retain raw stdin for the whole
|
|
65
|
+
// session — same OOM family as #1058), bound them: evicting the oldest entry
|
|
66
|
+
// past the cap only risks a stale command label / a re-emitted interaction
|
|
67
|
+
// for a terminal untouched across thousands of newer ones, which is benign.
|
|
68
|
+
const TERMINAL_SESSION_MAP_MAX = 512;
|
|
69
|
+
// Insert into an insertion-ordered Map/Set, evicting oldest entries first
|
|
70
|
+
// once the size cap is exceeded. (Map and Set both iterate in insertion
|
|
71
|
+
// order, so the first key is the oldest.)
|
|
72
|
+
function setBounded(map, key, value, max) {
|
|
73
|
+
map.set(key, value);
|
|
74
|
+
while (map.size > max) {
|
|
75
|
+
const oldest = map.keys().next().value;
|
|
76
|
+
if (oldest === undefined)
|
|
77
|
+
break;
|
|
78
|
+
map.delete(oldest);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function addBounded(set, value, max) {
|
|
82
|
+
set.add(value);
|
|
83
|
+
while (set.size > max) {
|
|
84
|
+
const oldest = set.values().next().value;
|
|
85
|
+
if (oldest === undefined)
|
|
86
|
+
break;
|
|
87
|
+
set.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// issue #1012: manual context compaction. `thread/compact/start` is a dedicated
|
|
91
|
+
// RPC (not a turn), present from this codex version on. Mirrors claude's native
|
|
92
|
+
// `/compact`. Gated so old codex doesn't surface a command its app-server lacks.
|
|
93
|
+
const COMPACT_COMMAND_NAME = "compact";
|
|
94
|
+
const COMPACT_COMMAND = {
|
|
95
|
+
name: COMPACT_COMMAND_NAME,
|
|
96
|
+
description: "压缩当前会话上下文",
|
|
97
|
+
argumentHint: "",
|
|
98
|
+
};
|
|
99
|
+
const CODEX_MANUAL_COMPACT_MIN_VERSION = "0.137.0";
|
|
100
|
+
// issue #973: codex's own auto-compaction threshold is derived from the
|
|
101
|
+
// gateway-reported context_window (limit = context_window * 90%). Some gateway
|
|
102
|
+
// models report an inflated window (e.g. gpt-5.4-ops reports 950K), so the
|
|
103
|
+
// derived threshold (855K) is never reached and a thread keeps growing until it
|
|
104
|
+
// slams into max_output_tokens — the model's real budget is exhausted long
|
|
105
|
+
// before codex's window-relative limit. Pin a conservative absolute limit well
|
|
106
|
+
// below the smallest real context window so compaction fires on token count,
|
|
107
|
+
// not on the (untrustworthy) reported window. Users can override via extra.codex.
|
|
108
|
+
const CODEX_AUTO_COMPACT_TOKEN_LIMIT = 180000;
|
|
29
109
|
const CODEX_PROVIDER = "codex";
|
|
30
110
|
const CODEX_SEAWORK_PROVIDER_ID = "seawork";
|
|
111
|
+
// MCP server key for the daemon-owned builtin (see agent-manager.applySeaworkMcp).
|
|
112
|
+
const SEAWORK_BUILTIN_MCP_NAME = "seawork";
|
|
113
|
+
// codex MCP tools/call timeout for the seawork builtin (default is 120s, too
|
|
114
|
+
// short for SeaArt video/3d generation that generation_task waits on).
|
|
115
|
+
const SEAWORK_MCP_TOOL_TIMEOUT_SEC = 660;
|
|
116
|
+
// Only these seawork builtin tools are safe to auto-approve (no side effects
|
|
117
|
+
// beyond a paid SeaArt generation the user already asked for). The builtin also
|
|
118
|
+
// exposes high-impact agent/terminal/schedule/permission tools that MUST keep
|
|
119
|
+
// their normal approval gate — so we set approval per-tool, never server-wide.
|
|
120
|
+
const SEAWORK_AUTO_APPROVE_TOOLS = [
|
|
121
|
+
"generate_image",
|
|
122
|
+
"generate_video",
|
|
123
|
+
"generate_audio",
|
|
124
|
+
"generate_3d",
|
|
125
|
+
"generation_models",
|
|
126
|
+
"generation_task",
|
|
127
|
+
];
|
|
31
128
|
const CODEX_IMAGE_ATTACHMENT_DIR = "seawork-attachments";
|
|
32
129
|
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = "The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
|
|
130
|
+
// codex's experimental `goals` feature ships in 0.128.0+. Older binaries reject
|
|
131
|
+
// `--enable goals`, so we version-gate both the launch flag and the /goal
|
|
132
|
+
// command. /goal is out-of-band (no turn) so it works mid-turn against the same
|
|
133
|
+
// thread without canceling a running turn.
|
|
134
|
+
const CODEX_GOALS_MIN_VERSION = "0.128.0";
|
|
135
|
+
const GOAL_COMMAND_NAME = "goal";
|
|
136
|
+
const GOAL_COMMAND = {
|
|
137
|
+
name: GOAL_COMMAND_NAME,
|
|
138
|
+
description: "设置、暂停、恢复或清除当前会话目标",
|
|
139
|
+
argumentHint: "[<objective>|pause|resume|clear]",
|
|
140
|
+
};
|
|
141
|
+
function parseGoalSubcommand(args) {
|
|
142
|
+
const trimmed = (args ?? "").trim();
|
|
143
|
+
if (!trimmed)
|
|
144
|
+
return { kind: "usage" };
|
|
145
|
+
const lower = trimmed.toLowerCase();
|
|
146
|
+
if (lower === "pause")
|
|
147
|
+
return { kind: "pause" };
|
|
148
|
+
if (lower === "resume")
|
|
149
|
+
return { kind: "resume" };
|
|
150
|
+
if (lower === "clear")
|
|
151
|
+
return { kind: "clear" };
|
|
152
|
+
return { kind: "set", objective: trimmed };
|
|
153
|
+
}
|
|
154
|
+
function formatOutOfBandStatusMessage(text) {
|
|
155
|
+
return `${text.replace(/\n+$/u, "")}\n\n`;
|
|
156
|
+
}
|
|
33
157
|
const CODEX_APP_SERVER_CAPABILITIES = {
|
|
34
158
|
supportsStreaming: true,
|
|
35
159
|
supportsSessionPersistence: true,
|
|
@@ -66,6 +190,15 @@ const MODE_PRESETS = {
|
|
|
66
190
|
networkAccess: true,
|
|
67
191
|
},
|
|
68
192
|
};
|
|
193
|
+
// plan 模式语义=只产出计划、不写盘:物理收紧 sandbox 到 read-only,approval 不得为 never
|
|
194
|
+
function applyPlanModeConstraints(approvalPolicy, sandboxPolicyType, planModeEnabled) {
|
|
195
|
+
if (!planModeEnabled)
|
|
196
|
+
return { approvalPolicy, sandboxPolicyType };
|
|
197
|
+
return {
|
|
198
|
+
approvalPolicy: approvalPolicy === "never" ? "on-request" : approvalPolicy,
|
|
199
|
+
sandboxPolicyType: "read-only",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
69
202
|
function validateCodexMode(modeId) {
|
|
70
203
|
if (!(modeId in MODE_PRESETS)) {
|
|
71
204
|
const validModes = Object.keys(MODE_PRESETS).join(", ");
|
|
@@ -184,6 +317,13 @@ function isVersionAtLeast(raw, minimum) {
|
|
|
184
317
|
}
|
|
185
318
|
return true;
|
|
186
319
|
}
|
|
320
|
+
// issue #1012: the app-server `initialize` response carries a required
|
|
321
|
+
// `userAgent` like "codex/0.137.0 (Mac OS ...)". parseVersionTriplet picks the
|
|
322
|
+
// first x.y.z, so returning the raw userAgent is enough for version gating.
|
|
323
|
+
function parseCodexUserAgentVersion(initializeResponse) {
|
|
324
|
+
const parsed = z.object({ userAgent: z.string() }).safeParse(initializeResponse);
|
|
325
|
+
return parsed.success ? parsed.data.userAgent : null;
|
|
326
|
+
}
|
|
187
327
|
function readStringMetadata(value) {
|
|
188
328
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
189
329
|
}
|
|
@@ -339,40 +479,14 @@ async function listCodexCustomPrompts() {
|
|
|
339
479
|
}
|
|
340
480
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
341
481
|
}
|
|
342
|
-
async function listCodexSkillEntries(cwd) {
|
|
343
|
-
const candidates =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const repoRoot = (() => {
|
|
349
|
-
try {
|
|
350
|
-
const output = execSync("git rev-parse --show-toplevel", {
|
|
351
|
-
cwd,
|
|
352
|
-
encoding: "utf8",
|
|
353
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
354
|
-
});
|
|
355
|
-
const trimmed = output.trim();
|
|
356
|
-
return trimmed ? trimmed : null;
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
return null;
|
|
360
|
-
}
|
|
361
|
-
})();
|
|
362
|
-
if (repoRoot) {
|
|
363
|
-
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
|
|
364
|
-
candidates.push(path.join(path.dirname(cwd), ".agents", "skills"));
|
|
365
|
-
candidates.push(path.join(path.dirname(cwd), ".claude", "skills"));
|
|
366
|
-
candidates.push(path.join(path.dirname(cwd), "skills"));
|
|
367
|
-
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
|
368
|
-
candidates.push(path.join(repoRoot, ".agents", "skills"));
|
|
369
|
-
candidates.push(path.join(repoRoot, ".claude", "skills"));
|
|
370
|
-
candidates.push(path.join(repoRoot, "skills"));
|
|
371
|
-
}
|
|
372
|
-
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
|
|
373
|
-
candidates.push(path.join(os.homedir(), ".agents", "skills"));
|
|
482
|
+
async function listCodexSkillEntries(cwd, logger) {
|
|
483
|
+
const candidates = resolveCodexSkillDiscoveryDirs({
|
|
484
|
+
cwd,
|
|
485
|
+
codexHomeDir: resolveCodexHomeDir(),
|
|
486
|
+
includeSeaworkCache: true,
|
|
487
|
+
});
|
|
374
488
|
const skillsByName = new Map();
|
|
375
|
-
for (const dir of
|
|
489
|
+
for (const dir of candidates) {
|
|
376
490
|
let entries;
|
|
377
491
|
try {
|
|
378
492
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -397,6 +511,19 @@ async function listCodexSkillEntries(cwd) {
|
|
|
397
511
|
const name = frontMatter["name"];
|
|
398
512
|
const description = frontMatter["description"];
|
|
399
513
|
if (!name || !description) {
|
|
514
|
+
if (logger) {
|
|
515
|
+
const reasons = [];
|
|
516
|
+
if (Object.keys(frontMatter).length === 0) {
|
|
517
|
+
reasons.push("missing YAML frontmatter delimited by ---");
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
if (!name)
|
|
521
|
+
reasons.push("missing 'name' field in frontmatter");
|
|
522
|
+
if (!description)
|
|
523
|
+
reasons.push("missing 'description' field in frontmatter");
|
|
524
|
+
}
|
|
525
|
+
logger.warn({ path: skillPath, reasons }, "Codex skill skipped: invalid SKILL.md");
|
|
526
|
+
}
|
|
400
527
|
continue;
|
|
401
528
|
}
|
|
402
529
|
if (!skillsByName.has(name)) {
|
|
@@ -466,6 +593,33 @@ function toCodexMcpConfig(config) {
|
|
|
466
593
|
};
|
|
467
594
|
}
|
|
468
595
|
}
|
|
596
|
+
function summarizeJsonRpcParamsForLog(params) {
|
|
597
|
+
if (params && typeof params === "object" && !Array.isArray(params)) {
|
|
598
|
+
return { paramKeys: Object.keys(params).sort() };
|
|
599
|
+
}
|
|
600
|
+
if (params === null) {
|
|
601
|
+
return { paramType: "null" };
|
|
602
|
+
}
|
|
603
|
+
if (Array.isArray(params)) {
|
|
604
|
+
return { paramType: "array" };
|
|
605
|
+
}
|
|
606
|
+
return { paramType: typeof params };
|
|
607
|
+
}
|
|
608
|
+
// Pull the gateway `x-request-id` out of codex's `codex_client::default_client:
|
|
609
|
+
// Request completed ... url=.../responses ... headers={... "x-request-id": "..." ...}`
|
|
610
|
+
// debug line. Only `/responses` (model turn) requests are matched so a `/models`
|
|
611
|
+
// request id never masquerades as the turn's. Exported for regression coverage
|
|
612
|
+
// because this scrapes codex's log format, which can drift across versions.
|
|
613
|
+
export function extractResponsesRequestId(logLine) {
|
|
614
|
+
// codex colorizes app-server tracing with ANSI escapes even when stderr is a
|
|
615
|
+
// pipe, which would otherwise break the substring/regex match below.
|
|
616
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI SGR codes
|
|
617
|
+
const line = logLine.replace(/\u001b\[[0-9;]*m/g, "");
|
|
618
|
+
if (!line.includes("Request completed") || !line.includes("/responses")) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
return line.match(/"x-request-id":\s*"([0-9a-fA-F-]+)"/)?.[1] ?? null;
|
|
622
|
+
}
|
|
469
623
|
class CodexAppServerClient {
|
|
470
624
|
constructor(child, logger) {
|
|
471
625
|
this.child = child;
|
|
@@ -473,9 +627,13 @@ class CodexAppServerClient {
|
|
|
473
627
|
this.pending = new Map();
|
|
474
628
|
this.requestHandlers = new Map();
|
|
475
629
|
this.notificationHandler = null;
|
|
630
|
+
this.exitHandler = null;
|
|
631
|
+
this.upstreamLivenessHandler = null;
|
|
476
632
|
this.nextId = 1;
|
|
477
633
|
this.disposed = false;
|
|
478
634
|
this.stderrBuffer = "";
|
|
635
|
+
this.stderrLineBuf = "";
|
|
636
|
+
this.lastResponsesRequestId = null;
|
|
479
637
|
this.resolveExitPromise = null;
|
|
480
638
|
this.rl = readline.createInterface({ input: child.stdout });
|
|
481
639
|
this.exitPromise = new Promise((resolve) => {
|
|
@@ -483,10 +641,12 @@ class CodexAppServerClient {
|
|
|
483
641
|
});
|
|
484
642
|
this.rl.on("line", (line) => this.handleLine(line));
|
|
485
643
|
child.stderr.on("data", (chunk) => {
|
|
486
|
-
|
|
644
|
+
const text = chunk.toString();
|
|
645
|
+
this.stderrBuffer += text;
|
|
487
646
|
if (this.stderrBuffer.length > 8192) {
|
|
488
647
|
this.stderrBuffer = this.stderrBuffer.slice(-8192);
|
|
489
648
|
}
|
|
649
|
+
this.captureResponsesRequestId(text);
|
|
490
650
|
});
|
|
491
651
|
child.on("error", (err) => {
|
|
492
652
|
this.logger.error({ err }, "Codex app-server child process error");
|
|
@@ -498,6 +658,7 @@ class CodexAppServerClient {
|
|
|
498
658
|
this.disposed = true;
|
|
499
659
|
this.resolveExitPromise?.();
|
|
500
660
|
this.resolveExitPromise = null;
|
|
661
|
+
this.exitHandler?.(err instanceof Error ? err.message : String(err));
|
|
501
662
|
});
|
|
502
663
|
child.on("exit", (code, signal) => {
|
|
503
664
|
const message = code === 0 && !signal
|
|
@@ -512,11 +673,56 @@ class CodexAppServerClient {
|
|
|
512
673
|
this.disposed = true;
|
|
513
674
|
this.resolveExitPromise?.();
|
|
514
675
|
this.resolveExitPromise = null;
|
|
676
|
+
this.exitHandler?.(message);
|
|
515
677
|
});
|
|
516
678
|
}
|
|
679
|
+
// Scrape codex's `codex_client::default_client: Request completed ... url=.../responses
|
|
680
|
+
// ... "x-request-id": "..."` debug line so a turn failure can be tagged with the
|
|
681
|
+
// gateway request id (codex omits it from the error message for stream
|
|
682
|
+
// disconnects). Newline-buffered so an id split across stderr chunks still
|
|
683
|
+
// parses; only `/responses` requests are tracked, latest wins.
|
|
684
|
+
captureResponsesRequestId(text) {
|
|
685
|
+
this.stderrLineBuf += text;
|
|
686
|
+
let nl = this.stderrLineBuf.indexOf("\n");
|
|
687
|
+
while (nl >= 0) {
|
|
688
|
+
const line = this.stderrLineBuf.slice(0, nl);
|
|
689
|
+
this.stderrLineBuf = this.stderrLineBuf.slice(nl + 1);
|
|
690
|
+
const requestId = extractResponsesRequestId(line);
|
|
691
|
+
if (requestId) {
|
|
692
|
+
this.lastResponsesRequestId = requestId;
|
|
693
|
+
this.upstreamLivenessHandler?.();
|
|
694
|
+
}
|
|
695
|
+
nl = this.stderrLineBuf.indexOf("\n");
|
|
696
|
+
}
|
|
697
|
+
if (this.stderrLineBuf.length > 16384) {
|
|
698
|
+
this.stderrLineBuf = this.stderrLineBuf.slice(-16384);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
getLastResponsesRequestId() {
|
|
702
|
+
return this.lastResponsesRequestId;
|
|
703
|
+
}
|
|
704
|
+
resetLastResponsesRequestId() {
|
|
705
|
+
this.lastResponsesRequestId = null;
|
|
706
|
+
}
|
|
707
|
+
resetRecentStderrTail() {
|
|
708
|
+
this.stderrBuffer = "";
|
|
709
|
+
this.stderrLineBuf = "";
|
|
710
|
+
}
|
|
517
711
|
setNotificationHandler(handler) {
|
|
518
712
|
this.notificationHandler = handler;
|
|
519
713
|
}
|
|
714
|
+
// Invoked once when the child process exits or errors. The session uses this
|
|
715
|
+
// to fail any foreground turn whose `turn/completed` notification will never
|
|
716
|
+
// arrive (codex process crashed mid-turn).
|
|
717
|
+
setExitHandler(handler) {
|
|
718
|
+
this.exitHandler = handler;
|
|
719
|
+
}
|
|
720
|
+
// Invoked whenever a `/responses` request completes (HTTP round-trip to the
|
|
721
|
+
// gateway), proving codex is alive and talking upstream even when the turn
|
|
722
|
+
// emits no protocol notification (e.g. a multi-minute large-payload upload).
|
|
723
|
+
setUpstreamLivenessHandler(handler) {
|
|
724
|
+
this.upstreamLivenessHandler = handler;
|
|
725
|
+
}
|
|
520
726
|
setRequestHandler(method, handler) {
|
|
521
727
|
this.requestHandlers.set(method, handler);
|
|
522
728
|
}
|
|
@@ -602,6 +808,9 @@ class CodexAppServerClient {
|
|
|
602
808
|
if (typeof msg.method === "string") {
|
|
603
809
|
const request = msg;
|
|
604
810
|
const handler = this.requestHandlers.get(request.method);
|
|
811
|
+
if (!handler) {
|
|
812
|
+
this.logger.warn({ method: request.method, ...summarizeJsonRpcParamsForLog(request.params) }, "Unhandled Codex app-server request method");
|
|
813
|
+
}
|
|
605
814
|
try {
|
|
606
815
|
const result = handler ? await handler(request.params) : {};
|
|
607
816
|
this.writeJsonRpcResponse({ id: request.id, result });
|
|
@@ -685,8 +894,10 @@ function extractUserText(content) {
|
|
|
685
894
|
}
|
|
686
895
|
return parts.length > 0 ? parts.join("\n") : null;
|
|
687
896
|
}
|
|
897
|
+
const MAX_PLAN_TEXT_LENGTH = 50000;
|
|
688
898
|
function normalizePlanMarkdown(text) {
|
|
689
899
|
return text
|
|
900
|
+
.slice(0, MAX_PLAN_TEXT_LENGTH)
|
|
690
901
|
.split("\n")
|
|
691
902
|
.map((line) => line.replace(/\s+$/, ""))
|
|
692
903
|
.join("\n")
|
|
@@ -948,6 +1159,8 @@ function normalizeCodexThreadItemType(rawType) {
|
|
|
948
1159
|
return "webSearch";
|
|
949
1160
|
case "ImageGeneration":
|
|
950
1161
|
return "imageGeneration";
|
|
1162
|
+
case "ContextCompaction":
|
|
1163
|
+
return "contextCompaction";
|
|
951
1164
|
default:
|
|
952
1165
|
return rawType;
|
|
953
1166
|
}
|
|
@@ -958,7 +1171,9 @@ function normalizeCodexCommandValue(value) {
|
|
|
958
1171
|
if (!trimmed.length) {
|
|
959
1172
|
return null;
|
|
960
1173
|
}
|
|
961
|
-
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/)
|
|
1174
|
+
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/) ||
|
|
1175
|
+
trimmed.match(/^(?:[^\s]*[/\\])?(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+-Command\s+([\s\S]+)$/i) ||
|
|
1176
|
+
trimmed.match(/^(?:[^\s]*[/\\])?cmd(?:\.exe)?\s+\/(?:c|k)\s+([\s\S]+)$/i);
|
|
962
1177
|
if (!wrapperMatch) {
|
|
963
1178
|
return trimmed;
|
|
964
1179
|
}
|
|
@@ -985,8 +1200,38 @@ function normalizeCodexCommandValue(value) {
|
|
|
985
1200
|
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
|
|
986
1201
|
return parts[2] ?? parts;
|
|
987
1202
|
}
|
|
1203
|
+
if (parts.length >= 3) {
|
|
1204
|
+
const exe = parts[0]
|
|
1205
|
+
.replace(/^.*[/\\]/, "")
|
|
1206
|
+
.replace(/\.exe$/i, "")
|
|
1207
|
+
.toLowerCase();
|
|
1208
|
+
const flag = parts[1].toLowerCase();
|
|
1209
|
+
if (((exe === "powershell" || exe === "pwsh") && flag === "-command") ||
|
|
1210
|
+
(exe === "cmd" && (flag === "/c" || flag === "/k"))) {
|
|
1211
|
+
return parts.slice(2).join(" ").trim() || parts;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
988
1214
|
return parts;
|
|
989
1215
|
}
|
|
1216
|
+
function buildExecFallbackDetail(command, cwd) {
|
|
1217
|
+
const normalized = normalizeCodexCommandValue(command);
|
|
1218
|
+
const display = Array.isArray(normalized) ? normalized.join(" ") : (normalized ?? "");
|
|
1219
|
+
if (display.trim().length > 0) {
|
|
1220
|
+
return {
|
|
1221
|
+
type: "shell",
|
|
1222
|
+
command: display,
|
|
1223
|
+
...(cwd ? { cwd } : {}),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
type: "unknown",
|
|
1228
|
+
input: {
|
|
1229
|
+
command: Array.isArray(command) ? command : (command ?? null),
|
|
1230
|
+
cwd: cwd ?? null,
|
|
1231
|
+
},
|
|
1232
|
+
output: null,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
990
1235
|
function parseCodexPatchChanges(changes) {
|
|
991
1236
|
const resolvePathFromRecord = (record) => {
|
|
992
1237
|
const directPath = (typeof record.path === "string" && record.path.trim().length > 0
|
|
@@ -1302,6 +1547,10 @@ function threadItemToTimeline(item, options) {
|
|
|
1302
1547
|
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
|
1303
1548
|
case "imageGeneration":
|
|
1304
1549
|
return mapCodexImageGenerationToTimeline(normalizedItem);
|
|
1550
|
+
case "contextCompaction":
|
|
1551
|
+
// issue #973: surface codex's auto-compaction as a timeline marker so the
|
|
1552
|
+
// user sees why the context shrank. The thread item carries no preTokens.
|
|
1553
|
+
return { type: "compaction", status: "completed", trigger: "auto" };
|
|
1305
1554
|
default:
|
|
1306
1555
|
return null;
|
|
1307
1556
|
}
|
|
@@ -1388,17 +1637,20 @@ const TurnDiffUpdatedNotificationSchema = z
|
|
|
1388
1637
|
.passthrough();
|
|
1389
1638
|
const ThreadTokenUsageUpdatedNotificationSchema = z
|
|
1390
1639
|
.object({
|
|
1640
|
+
turnId: z.string().optional(),
|
|
1391
1641
|
tokenUsage: z.unknown(),
|
|
1392
1642
|
})
|
|
1393
1643
|
.passthrough();
|
|
1394
1644
|
const ItemTextDeltaNotificationSchema = z
|
|
1395
1645
|
.object({
|
|
1646
|
+
turnId: z.string().optional(),
|
|
1396
1647
|
itemId: z.string(),
|
|
1397
1648
|
delta: z.string(),
|
|
1398
1649
|
})
|
|
1399
1650
|
.passthrough();
|
|
1400
1651
|
const ItemLifecycleNotificationSchema = z
|
|
1401
1652
|
.object({
|
|
1653
|
+
turnId: z.string().optional(),
|
|
1402
1654
|
item: z
|
|
1403
1655
|
.object({
|
|
1404
1656
|
id: z.string().optional(),
|
|
@@ -1409,6 +1661,7 @@ const ItemLifecycleNotificationSchema = z
|
|
|
1409
1661
|
.passthrough();
|
|
1410
1662
|
const RawResponseItemCompletedNotificationSchema = z
|
|
1411
1663
|
.object({
|
|
1664
|
+
turnId: z.string().optional(),
|
|
1412
1665
|
item: z
|
|
1413
1666
|
.object({
|
|
1414
1667
|
id: z.string().optional(),
|
|
@@ -1510,6 +1763,7 @@ const CodexEventTerminalInteractionNotificationSchema = z
|
|
|
1510
1763
|
.passthrough();
|
|
1511
1764
|
const ItemCommandExecutionTerminalInteractionNotificationSchema = z
|
|
1512
1765
|
.object({
|
|
1766
|
+
turnId: z.string().optional(),
|
|
1513
1767
|
itemId: z.string().optional(),
|
|
1514
1768
|
processId: z.union([z.string(), z.number()]).optional(),
|
|
1515
1769
|
stdin: z.string().optional(),
|
|
@@ -1542,11 +1796,19 @@ const CodexEventPatchApplyEndNotificationSchema = z
|
|
|
1542
1796
|
.passthrough();
|
|
1543
1797
|
const ItemFileChangeOutputDeltaNotificationSchema = z
|
|
1544
1798
|
.object({
|
|
1799
|
+
turnId: z.string().optional(),
|
|
1545
1800
|
itemId: z.string(),
|
|
1546
1801
|
delta: z.string().optional(),
|
|
1547
1802
|
chunk: z.string().optional(),
|
|
1548
1803
|
})
|
|
1549
1804
|
.passthrough();
|
|
1805
|
+
const ItemCommandExecutionOutputDeltaNotificationSchema = z
|
|
1806
|
+
.object({
|
|
1807
|
+
turnId: z.string().optional(),
|
|
1808
|
+
itemId: z.string(),
|
|
1809
|
+
delta: z.string().optional(),
|
|
1810
|
+
})
|
|
1811
|
+
.passthrough();
|
|
1550
1812
|
const CodexEventTurnDiffNotificationSchema = z
|
|
1551
1813
|
.object({
|
|
1552
1814
|
msg: z
|
|
@@ -1619,6 +1881,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1619
1881
|
})
|
|
1620
1882
|
.transform(({ params }) => ({
|
|
1621
1883
|
kind: "token_usage_updated",
|
|
1884
|
+
turnId: params.turnId ?? null,
|
|
1622
1885
|
tokenUsage: params.tokenUsage,
|
|
1623
1886
|
})),
|
|
1624
1887
|
z.object({ method: z.literal("thread/tokenUsage/updated"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1626,6 +1889,50 @@ const CodexNotificationSchema = z.union([
|
|
|
1626
1889
|
method,
|
|
1627
1890
|
params,
|
|
1628
1891
|
})),
|
|
1892
|
+
// codex announces an in-flight response-stream reconnect via `warning`
|
|
1893
|
+
// (message "Reconnecting... n/5") and a paired `error(willRetry:true)`. Both
|
|
1894
|
+
// are non-terminal — surface them as a reconnect notice so a slow turn does
|
|
1895
|
+
// not look frozen. `warning` carries the human-readable attempt counter.
|
|
1896
|
+
z
|
|
1897
|
+
.object({
|
|
1898
|
+
method: z.literal("warning"),
|
|
1899
|
+
params: z.object({ threadId: z.string().optional(), message: z.string() }),
|
|
1900
|
+
})
|
|
1901
|
+
.transform(({ params }) => {
|
|
1902
|
+
const counts = parseReconnectAttemptCounts(params.message);
|
|
1903
|
+
return {
|
|
1904
|
+
kind: "stream_retrying",
|
|
1905
|
+
turnId: null,
|
|
1906
|
+
message: params.message,
|
|
1907
|
+
attempt: counts.attempt,
|
|
1908
|
+
maxAttempts: counts.maxAttempts,
|
|
1909
|
+
};
|
|
1910
|
+
}),
|
|
1911
|
+
z
|
|
1912
|
+
.object({
|
|
1913
|
+
method: z.literal("error"),
|
|
1914
|
+
params: z.object({
|
|
1915
|
+
willRetry: z.boolean().optional(),
|
|
1916
|
+
turnId: z.string().optional(),
|
|
1917
|
+
error: z.object({ message: z.string() }).partial().optional(),
|
|
1918
|
+
}),
|
|
1919
|
+
})
|
|
1920
|
+
.transform(({ method, params }) => {
|
|
1921
|
+
// Only a will_retry error is a non-terminal reconnect. A terminal error
|
|
1922
|
+
// (willRetry false/absent) is delivered to clients via turn/completed
|
|
1923
|
+
// (status Failed); let it fall through as an unknown method (logged, no
|
|
1924
|
+
// UI) rather than misrender it as a reconnect.
|
|
1925
|
+
if (params.willRetry !== true) {
|
|
1926
|
+
return { kind: "unknown_method", method, params };
|
|
1927
|
+
}
|
|
1928
|
+
return {
|
|
1929
|
+
kind: "stream_retrying",
|
|
1930
|
+
turnId: params.turnId ?? null,
|
|
1931
|
+
message: params.error?.message ?? null,
|
|
1932
|
+
attempt: null,
|
|
1933
|
+
maxAttempts: null,
|
|
1934
|
+
};
|
|
1935
|
+
}),
|
|
1629
1936
|
z
|
|
1630
1937
|
.object({
|
|
1631
1938
|
method: z.literal("item/agentMessage/delta"),
|
|
@@ -1633,6 +1940,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1633
1940
|
})
|
|
1634
1941
|
.transform(({ params }) => ({
|
|
1635
1942
|
kind: "agent_message_delta",
|
|
1943
|
+
turnId: params.turnId ?? null,
|
|
1636
1944
|
itemId: params.itemId,
|
|
1637
1945
|
delta: params.delta,
|
|
1638
1946
|
})),
|
|
@@ -1648,6 +1956,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1648
1956
|
})
|
|
1649
1957
|
.transform(({ params }) => ({
|
|
1650
1958
|
kind: "reasoning_delta",
|
|
1959
|
+
turnId: params.turnId ?? null,
|
|
1651
1960
|
itemId: params.itemId,
|
|
1652
1961
|
delta: params.delta,
|
|
1653
1962
|
})),
|
|
@@ -1661,6 +1970,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1661
1970
|
.transform(({ params }) => ({
|
|
1662
1971
|
kind: "item_completed",
|
|
1663
1972
|
source: "item",
|
|
1973
|
+
turnId: params.turnId ?? null,
|
|
1664
1974
|
item: params.item,
|
|
1665
1975
|
})),
|
|
1666
1976
|
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1675,6 +1985,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1675
1985
|
})
|
|
1676
1986
|
.transform(({ params }) => ({
|
|
1677
1987
|
kind: "raw_response_item_completed",
|
|
1988
|
+
turnId: params.turnId ?? null,
|
|
1678
1989
|
item: params.item,
|
|
1679
1990
|
})),
|
|
1680
1991
|
z.object({ method: z.literal("rawResponseItem/completed"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1687,6 +1998,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1687
1998
|
.transform(({ params }) => ({
|
|
1688
1999
|
kind: "item_started",
|
|
1689
2000
|
source: "item",
|
|
2001
|
+
turnId: params.turnId ?? null,
|
|
1690
2002
|
item: params.item,
|
|
1691
2003
|
})),
|
|
1692
2004
|
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1702,6 +2014,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1702
2014
|
.transform(({ params }) => ({
|
|
1703
2015
|
kind: "item_started",
|
|
1704
2016
|
source: "codex_event",
|
|
2017
|
+
turnId: null,
|
|
1705
2018
|
item: params.msg.item,
|
|
1706
2019
|
})),
|
|
1707
2020
|
z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1717,6 +2030,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1717
2030
|
.transform(({ params }) => ({
|
|
1718
2031
|
kind: "item_completed",
|
|
1719
2032
|
source: "codex_event",
|
|
2033
|
+
turnId: null,
|
|
1720
2034
|
item: params.msg.item,
|
|
1721
2035
|
})),
|
|
1722
2036
|
z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(({ method, params }) => ({
|
|
@@ -1771,6 +2085,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1771
2085
|
})
|
|
1772
2086
|
.transform(({ params }) => ({
|
|
1773
2087
|
kind: "exec_command_output_delta",
|
|
2088
|
+
turnId: null,
|
|
1774
2089
|
callId: params.msg.call_id ?? null,
|
|
1775
2090
|
stream: params.msg.stream ?? null,
|
|
1776
2091
|
chunk: params.msg.chunk ?? params.msg.delta ?? null,
|
|
@@ -1785,6 +2100,27 @@ const CodexNotificationSchema = z.union([
|
|
|
1785
2100
|
method,
|
|
1786
2101
|
params,
|
|
1787
2102
|
})),
|
|
2103
|
+
z
|
|
2104
|
+
.object({
|
|
2105
|
+
method: z.literal("item/commandExecution/outputDelta"),
|
|
2106
|
+
params: ItemCommandExecutionOutputDeltaNotificationSchema,
|
|
2107
|
+
})
|
|
2108
|
+
.transform(({ params }) => ({
|
|
2109
|
+
kind: "command_execution_output_delta",
|
|
2110
|
+
turnId: params.turnId ?? null,
|
|
2111
|
+
itemId: params.itemId,
|
|
2112
|
+
delta: params.delta ?? null,
|
|
2113
|
+
})),
|
|
2114
|
+
z
|
|
2115
|
+
.object({
|
|
2116
|
+
method: z.literal("item/commandExecution/outputDelta"),
|
|
2117
|
+
params: z.unknown(),
|
|
2118
|
+
})
|
|
2119
|
+
.transform(({ method, params }) => ({
|
|
2120
|
+
kind: "invalid_payload",
|
|
2121
|
+
method,
|
|
2122
|
+
params,
|
|
2123
|
+
})),
|
|
1788
2124
|
z
|
|
1789
2125
|
.object({
|
|
1790
2126
|
method: z.literal("codex/event/terminal_interaction"),
|
|
@@ -1793,6 +2129,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1793
2129
|
.transform(({ params }) => ({
|
|
1794
2130
|
kind: "terminal_interaction",
|
|
1795
2131
|
source: "codex_event",
|
|
2132
|
+
turnId: null,
|
|
1796
2133
|
callId: params.msg.call_id ?? null,
|
|
1797
2134
|
processId: typeof params.msg.process_id === "number"
|
|
1798
2135
|
? String(params.msg.process_id)
|
|
@@ -1814,6 +2151,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1814
2151
|
.transform(({ params }) => ({
|
|
1815
2152
|
kind: "terminal_interaction",
|
|
1816
2153
|
source: "item",
|
|
2154
|
+
turnId: params.turnId ?? null,
|
|
1817
2155
|
callId: params.itemId ?? null,
|
|
1818
2156
|
processId: typeof params.processId === "number"
|
|
1819
2157
|
? String(params.processId)
|
|
@@ -1870,6 +2208,7 @@ const CodexNotificationSchema = z.union([
|
|
|
1870
2208
|
})
|
|
1871
2209
|
.transform(({ params }) => ({
|
|
1872
2210
|
kind: "file_change_output_delta",
|
|
2211
|
+
turnId: params.turnId ?? null,
|
|
1873
2212
|
itemId: params.itemId,
|
|
1874
2213
|
delta: params.delta ?? params.chunk ?? null,
|
|
1875
2214
|
})),
|
|
@@ -1926,6 +2265,15 @@ const CodexNotificationSchema = z.union([
|
|
|
1926
2265
|
.object({ method: z.string(), params: z.unknown() })
|
|
1927
2266
|
.transform(({ method, params }) => ({ kind: "unknown_method", method, params })),
|
|
1928
2267
|
]);
|
|
2268
|
+
// Pull "n/5" out of codex's "Reconnecting... 2/5" warning message. Best-effort:
|
|
2269
|
+
// returns nulls when the message has no counter (e.g. a transport-fallback warning).
|
|
2270
|
+
function parseReconnectAttemptCounts(message) {
|
|
2271
|
+
const match = message.match(/(\d+)\s*\/\s*(\d+)/);
|
|
2272
|
+
if (!match) {
|
|
2273
|
+
return { attempt: null, maxAttempts: null };
|
|
2274
|
+
}
|
|
2275
|
+
return { attempt: Number(match[1]), maxAttempts: Number(match[2]) };
|
|
2276
|
+
}
|
|
1929
2277
|
async function writeImageAttachment(mimeType, data) {
|
|
1930
2278
|
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
|
|
1931
2279
|
await fs.mkdir(attachmentsDir, { recursive: true });
|
|
@@ -2013,10 +2361,7 @@ function buildCodexAppServerEnv(runtimeSettings, launchEnv) {
|
|
|
2013
2361
|
env.HTTPS_PROXY = latencyProxyUrl;
|
|
2014
2362
|
}
|
|
2015
2363
|
else {
|
|
2016
|
-
env
|
|
2017
|
-
env.https_proxy = "";
|
|
2018
|
-
env.HTTP_PROXY = "";
|
|
2019
|
-
env.HTTPS_PROXY = "";
|
|
2364
|
+
clearInheritedProxyEnv(env, runtimeSettings);
|
|
2020
2365
|
}
|
|
2021
2366
|
const merged = launchEnv
|
|
2022
2367
|
? {
|
|
@@ -2024,8 +2369,25 @@ function buildCodexAppServerEnv(runtimeSettings, launchEnv) {
|
|
|
2024
2369
|
...launchEnv,
|
|
2025
2370
|
}
|
|
2026
2371
|
: env;
|
|
2027
|
-
|
|
2028
|
-
|
|
2372
|
+
// Merge the localhost bypass AFTER launchEnv is spread: a launch context that
|
|
2373
|
+
// carries its own NO_PROXY would otherwise overwrite the bypass and send local
|
|
2374
|
+
// daemon MCP traffic back through the CONNECT-only latency proxy.
|
|
2375
|
+
if (latencyProxyUrl) {
|
|
2376
|
+
mergeLocalhostProxyBypass(merged);
|
|
2377
|
+
}
|
|
2378
|
+
// LLM endpoint comes ONLY from SEAWORK_LLM_BASE_URL (auth.json base_url).
|
|
2379
|
+
// OPENAI_BASE_URL is the desktop's image/SAC gateway, not the LLM gateway.
|
|
2380
|
+
const llmBaseUrl = readStringMetadata(merged.SEAWORK_LLM_BASE_URL);
|
|
2381
|
+
if (llmBaseUrl) {
|
|
2382
|
+
merged.SEAWORK_API_KEY = merged.OPENAI_API_KEY ?? merged.SEAWORK_API_KEY ?? "";
|
|
2383
|
+
}
|
|
2384
|
+
// Enable codex's HTTP request-completion debug line (one per request, which
|
|
2385
|
+
// carries the gateway `x-request-id` response header) so turn failures can be
|
|
2386
|
+
// tagged with a request id — including stream disconnects, where codex omits
|
|
2387
|
+
// the id from the error message. Scoped to a single module plus a global
|
|
2388
|
+
// `error` level so stderr stays lean. A user-set RUST_LOG always wins.
|
|
2389
|
+
if (!readStringMetadata(merged.RUST_LOG)) {
|
|
2390
|
+
merged.RUST_LOG = "error,codex_client::default_client=debug";
|
|
2029
2391
|
}
|
|
2030
2392
|
return merged;
|
|
2031
2393
|
}
|
|
@@ -2034,7 +2396,8 @@ function codexProviderBaseUrl(baseUrl) {
|
|
|
2034
2396
|
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
2035
2397
|
}
|
|
2036
2398
|
function buildCodexSeaworkProviderConfig(env) {
|
|
2037
|
-
|
|
2399
|
+
// LLM endpoint comes ONLY from SEAWORK_LLM_BASE_URL (auth.json base_url).
|
|
2400
|
+
const baseUrl = readStringMetadata(env.SEAWORK_LLM_BASE_URL);
|
|
2038
2401
|
if (!baseUrl) {
|
|
2039
2402
|
return null;
|
|
2040
2403
|
}
|
|
@@ -2045,6 +2408,7 @@ function buildCodexSeaworkProviderConfig(env) {
|
|
|
2045
2408
|
name: "Seawork",
|
|
2046
2409
|
base_url: codexProviderBaseUrl(baseUrl),
|
|
2047
2410
|
env_key: "SEAWORK_API_KEY",
|
|
2411
|
+
http_headers: buildSeaworkGatewayHeaders(CODEX_PROVIDER),
|
|
2048
2412
|
},
|
|
2049
2413
|
},
|
|
2050
2414
|
};
|
|
@@ -2064,6 +2428,322 @@ function mergeCodexConfig(base, override) {
|
|
|
2064
2428
|
},
|
|
2065
2429
|
};
|
|
2066
2430
|
}
|
|
2431
|
+
function formatCodexTomlString(value) {
|
|
2432
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2433
|
+
}
|
|
2434
|
+
function buildManagedCodexConfigToml(config, logger) {
|
|
2435
|
+
if (!config) {
|
|
2436
|
+
return null;
|
|
2437
|
+
}
|
|
2438
|
+
const providerId = readStringMetadata(config.model_provider);
|
|
2439
|
+
const providerConfigs = readStringRecord(config.model_providers);
|
|
2440
|
+
const provider = providerId ? readStringRecord(providerConfigs[providerId]) : {};
|
|
2441
|
+
const providerName = readStringMetadata(provider.name);
|
|
2442
|
+
const providerBaseUrl = readStringMetadata(provider.base_url);
|
|
2443
|
+
const providerEnvKey = readStringMetadata(provider.env_key);
|
|
2444
|
+
const providerHeaders = readStringRecord(provider.http_headers);
|
|
2445
|
+
if (!providerId || !providerName || !providerBaseUrl || !providerEnvKey) {
|
|
2446
|
+
logger?.warn({
|
|
2447
|
+
providerId,
|
|
2448
|
+
hasProviderName: Boolean(providerName),
|
|
2449
|
+
hasProviderBaseUrl: Boolean(providerBaseUrl),
|
|
2450
|
+
hasProviderEnvKey: Boolean(providerEnvKey),
|
|
2451
|
+
}, "Skipping managed Codex config overlay because provider config is incomplete");
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
const lines = [
|
|
2455
|
+
`model_provider = ${formatCodexTomlString(providerId)}`,
|
|
2456
|
+
"",
|
|
2457
|
+
`[model_providers.${providerId}]`,
|
|
2458
|
+
`name = ${formatCodexTomlString(providerName)}`,
|
|
2459
|
+
`base_url = ${formatCodexTomlString(providerBaseUrl)}`,
|
|
2460
|
+
`env_key = ${formatCodexTomlString(providerEnvKey)}`,
|
|
2461
|
+
];
|
|
2462
|
+
const headerEntries = Object.entries(providerHeaders)
|
|
2463
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
2464
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
2465
|
+
if (headerEntries.length > 0) {
|
|
2466
|
+
lines.push("", `[model_providers.${providerId}.http_headers]`);
|
|
2467
|
+
for (const [name, value] of headerEntries) {
|
|
2468
|
+
lines.push(`${formatCodexTomlString(name)} = ${formatCodexTomlString(value)}`);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
return `${lines.join("\n")}\n`;
|
|
2472
|
+
}
|
|
2473
|
+
function managedCodexHomeRoot() {
|
|
2474
|
+
return path.join(os.tmpdir(), "seawork-managed-codex-home");
|
|
2475
|
+
}
|
|
2476
|
+
function managedCodexHomePath(originalHome, configToml) {
|
|
2477
|
+
const digest = createHash("sha1")
|
|
2478
|
+
.update(originalHome)
|
|
2479
|
+
.update("\0")
|
|
2480
|
+
.update(configToml)
|
|
2481
|
+
.digest("hex");
|
|
2482
|
+
return path.join(managedCodexHomeRoot(), digest);
|
|
2483
|
+
}
|
|
2484
|
+
const MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES = ["AGENTS.md", "plugins"];
|
|
2485
|
+
const MANAGED_CODEX_ROOT_ENTRY_LEDGER = ".seawork-managed-root-entries.json";
|
|
2486
|
+
const MANAGED_CODEX_HOME_PRESERVED_ROOT_NAME_SET = new Set(MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES);
|
|
2487
|
+
const MANAGED_CODEX_HOME_BUSY_REMOVE_CODES = new Set(["EBUSY", "EPERM", "EACCES"]);
|
|
2488
|
+
async function ensureManagedCodexLink(source, dest) {
|
|
2489
|
+
let sourceLstat;
|
|
2490
|
+
try {
|
|
2491
|
+
sourceLstat = await fs.lstat(source);
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
try {
|
|
2497
|
+
const existing = await fs.lstat(dest);
|
|
2498
|
+
if (existing.isSymbolicLink()) {
|
|
2499
|
+
const existingTarget = await fs.readlink(dest);
|
|
2500
|
+
const resolvedExisting = path.resolve(path.dirname(dest), existingTarget);
|
|
2501
|
+
if (resolvedExisting === source) {
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
2506
|
+
}
|
|
2507
|
+
catch {
|
|
2508
|
+
// Destination does not exist.
|
|
2509
|
+
}
|
|
2510
|
+
let symlinkType;
|
|
2511
|
+
if (process.platform === "win32") {
|
|
2512
|
+
if (sourceLstat.isSymbolicLink()) {
|
|
2513
|
+
try {
|
|
2514
|
+
const sourceStat = await fs.stat(source);
|
|
2515
|
+
symlinkType = sourceStat.isDirectory() ? "junction" : "file";
|
|
2516
|
+
}
|
|
2517
|
+
catch {
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
else {
|
|
2522
|
+
symlinkType = sourceLstat.isDirectory() ? "junction" : "file";
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
await fs.symlink(source, dest, symlinkType);
|
|
2526
|
+
}
|
|
2527
|
+
function areSameFilesystemEntry(sourceStat, destStat) {
|
|
2528
|
+
return sourceStat.dev === destStat.dev && sourceStat.ino === destStat.ino;
|
|
2529
|
+
}
|
|
2530
|
+
async function ensureManagedCodexFileAlias(source, dest, logger) {
|
|
2531
|
+
const sourceStat = await fs.stat(source);
|
|
2532
|
+
let existing;
|
|
2533
|
+
try {
|
|
2534
|
+
existing = await fs.lstat(dest);
|
|
2535
|
+
}
|
|
2536
|
+
catch {
|
|
2537
|
+
// Destination does not exist.
|
|
2538
|
+
}
|
|
2539
|
+
if (existing) {
|
|
2540
|
+
let shouldPreserveDestFile = false;
|
|
2541
|
+
if (existing.isSymbolicLink()) {
|
|
2542
|
+
const existingTarget = await fs.readlink(dest);
|
|
2543
|
+
const resolvedExisting = path.resolve(path.dirname(dest), existingTarget);
|
|
2544
|
+
if (resolvedExisting === source) {
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
else if (existing.isFile()) {
|
|
2549
|
+
const destStat = await fs.stat(dest);
|
|
2550
|
+
if (areSameFilesystemEntry(sourceStat, destStat)) {
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
shouldPreserveDestFile =
|
|
2554
|
+
process.platform === "win32" && destStat.mtimeMs > sourceStat.mtimeMs;
|
|
2555
|
+
}
|
|
2556
|
+
if (shouldPreserveDestFile) {
|
|
2557
|
+
await fs.copyFile(dest, source);
|
|
2558
|
+
}
|
|
2559
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
2560
|
+
}
|
|
2561
|
+
if (process.platform !== "win32") {
|
|
2562
|
+
await fs.symlink(source, dest);
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
try {
|
|
2566
|
+
await fs.link(source, dest);
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
catch (error) {
|
|
2570
|
+
logger.warn({ error, source, dest }, "Failed to hard-link managed Codex file alias; falling back to copy");
|
|
2571
|
+
}
|
|
2572
|
+
await fs.copyFile(source, dest);
|
|
2573
|
+
}
|
|
2574
|
+
async function ensureManagedCodexPathAlias(params) {
|
|
2575
|
+
if (params.kind === "directory") {
|
|
2576
|
+
await fs.mkdir(params.source, { recursive: true });
|
|
2577
|
+
await ensureManagedCodexLink(params.source, params.dest);
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
await fs.mkdir(path.dirname(params.source), { recursive: true });
|
|
2581
|
+
try {
|
|
2582
|
+
await fs.access(params.source);
|
|
2583
|
+
}
|
|
2584
|
+
catch {
|
|
2585
|
+
try {
|
|
2586
|
+
await fs.copyFile(params.dest, params.source);
|
|
2587
|
+
}
|
|
2588
|
+
catch {
|
|
2589
|
+
await fs.writeFile(params.source, "", "utf8");
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
await ensureManagedCodexFileAlias(params.source, params.dest, params.logger);
|
|
2593
|
+
}
|
|
2594
|
+
async function resolveManagedCodexRootEntryKind(sourcePath, entry) {
|
|
2595
|
+
if (entry.isSymbolicLink()) {
|
|
2596
|
+
try {
|
|
2597
|
+
const stat = await fs.stat(sourcePath);
|
|
2598
|
+
if (stat.isDirectory())
|
|
2599
|
+
return "directory";
|
|
2600
|
+
if (stat.isFile())
|
|
2601
|
+
return "file";
|
|
2602
|
+
return null;
|
|
2603
|
+
}
|
|
2604
|
+
catch {
|
|
2605
|
+
return null;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
if (entry.isDirectory())
|
|
2609
|
+
return "directory";
|
|
2610
|
+
if (entry.isFile())
|
|
2611
|
+
return "file";
|
|
2612
|
+
return null;
|
|
2613
|
+
}
|
|
2614
|
+
async function isManagedCodexRootAlias(params) {
|
|
2615
|
+
const destPath = path.join(params.managedHome, params.entryName);
|
|
2616
|
+
try {
|
|
2617
|
+
const existing = await fs.lstat(destPath);
|
|
2618
|
+
if (existing.isSymbolicLink()) {
|
|
2619
|
+
const existingTarget = await fs.readlink(destPath);
|
|
2620
|
+
const resolvedExisting = path.resolve(path.dirname(destPath), existingTarget);
|
|
2621
|
+
return resolvedExisting === path.join(params.originalHome, params.entryName);
|
|
2622
|
+
}
|
|
2623
|
+
if (process.platform === "win32" && params.entryName === "AGENTS.md" && existing.isFile()) {
|
|
2624
|
+
return true;
|
|
2625
|
+
}
|
|
2626
|
+
return false;
|
|
2627
|
+
}
|
|
2628
|
+
catch {
|
|
2629
|
+
return false;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
async function removeManagedCodexHomeEntry(targetPath, logger) {
|
|
2633
|
+
try {
|
|
2634
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
2635
|
+
}
|
|
2636
|
+
catch (error) {
|
|
2637
|
+
const code = error.code;
|
|
2638
|
+
if (process.platform === "win32" && code && MANAGED_CODEX_HOME_BUSY_REMOVE_CODES.has(code)) {
|
|
2639
|
+
logger.warn({ error, path: targetPath }, "Skipping locked managed Codex home entry during cleanup");
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
throw error;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
function isManagedCodexHomePreservedRootName(value) {
|
|
2646
|
+
return (typeof value === "string" &&
|
|
2647
|
+
MANAGED_CODEX_HOME_PRESERVED_ROOT_NAME_SET.has(value));
|
|
2648
|
+
}
|
|
2649
|
+
async function readManagedCodexRootEntryLedger(managedHome) {
|
|
2650
|
+
try {
|
|
2651
|
+
const raw = await fs.readFile(path.join(managedHome, MANAGED_CODEX_ROOT_ENTRY_LEDGER), "utf8");
|
|
2652
|
+
const parsed = JSON.parse(raw);
|
|
2653
|
+
if (!Array.isArray(parsed)) {
|
|
2654
|
+
return new Set();
|
|
2655
|
+
}
|
|
2656
|
+
return new Set(parsed.filter(isManagedCodexHomePreservedRootName));
|
|
2657
|
+
}
|
|
2658
|
+
catch {
|
|
2659
|
+
return new Set();
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
async function preserveManagedCodexHomeRootEntries(params) {
|
|
2663
|
+
let entries;
|
|
2664
|
+
try {
|
|
2665
|
+
entries = await fs.readdir(params.originalHome, { withFileTypes: true });
|
|
2666
|
+
}
|
|
2667
|
+
catch {
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
2671
|
+
const previouslyManagedNames = await readManagedCodexRootEntryLedger(params.managedHome);
|
|
2672
|
+
const nextManagedNames = new Set();
|
|
2673
|
+
for (const entryName of MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES) {
|
|
2674
|
+
const entry = entriesByName.get(entryName);
|
|
2675
|
+
const sourcePath = path.join(params.originalHome, entryName);
|
|
2676
|
+
const destPath = path.join(params.managedHome, entryName);
|
|
2677
|
+
if (!entry) {
|
|
2678
|
+
if (previouslyManagedNames.has(entryName) ||
|
|
2679
|
+
(previouslyManagedNames.size === 0 &&
|
|
2680
|
+
(await isManagedCodexRootAlias({
|
|
2681
|
+
managedHome: params.managedHome,
|
|
2682
|
+
originalHome: params.originalHome,
|
|
2683
|
+
entryName,
|
|
2684
|
+
})))) {
|
|
2685
|
+
await removeManagedCodexHomeEntry(destPath, params.logger);
|
|
2686
|
+
}
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
const kind = await resolveManagedCodexRootEntryKind(sourcePath, entry);
|
|
2690
|
+
if (!kind) {
|
|
2691
|
+
if (previouslyManagedNames.has(entryName)) {
|
|
2692
|
+
await removeManagedCodexHomeEntry(destPath, params.logger);
|
|
2693
|
+
}
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
await ensureManagedCodexPathAlias({
|
|
2697
|
+
source: sourcePath,
|
|
2698
|
+
dest: destPath,
|
|
2699
|
+
kind,
|
|
2700
|
+
logger: params.logger,
|
|
2701
|
+
});
|
|
2702
|
+
nextManagedNames.add(entryName);
|
|
2703
|
+
}
|
|
2704
|
+
await fs.writeFile(path.join(params.managedHome, MANAGED_CODEX_ROOT_ENTRY_LEDGER), `${JSON.stringify([...nextManagedNames].sort())}\n`, "utf8");
|
|
2705
|
+
}
|
|
2706
|
+
async function prepareManagedCodexHome(params) {
|
|
2707
|
+
const managedConfig = buildCodexSeaworkProviderConfig(params.env);
|
|
2708
|
+
const configToml = buildManagedCodexConfigToml(managedConfig, params.logger);
|
|
2709
|
+
if (!configToml) {
|
|
2710
|
+
return null;
|
|
2711
|
+
}
|
|
2712
|
+
const originalHome = readStringMetadata(params.env.CODEX_HOME) ?? path.join(os.homedir(), ".codex");
|
|
2713
|
+
const managedHome = managedCodexHomePath(originalHome, configToml);
|
|
2714
|
+
await fs.mkdir(managedHome, { recursive: true });
|
|
2715
|
+
await fs.writeFile(path.join(managedHome, "config.toml"), configToml, "utf8");
|
|
2716
|
+
await ensureManagedCodexPathAlias({
|
|
2717
|
+
source: path.join(originalHome, "sessions"),
|
|
2718
|
+
dest: path.join(managedHome, "sessions"),
|
|
2719
|
+
kind: "directory",
|
|
2720
|
+
logger: params.logger,
|
|
2721
|
+
});
|
|
2722
|
+
await ensureManagedCodexPathAlias({
|
|
2723
|
+
source: path.join(originalHome, "prompts"),
|
|
2724
|
+
dest: path.join(managedHome, "prompts"),
|
|
2725
|
+
kind: "directory",
|
|
2726
|
+
logger: params.logger,
|
|
2727
|
+
});
|
|
2728
|
+
await ensureManagedCodexPathAlias({
|
|
2729
|
+
source: path.join(originalHome, "skills"),
|
|
2730
|
+
dest: path.join(managedHome, "skills"),
|
|
2731
|
+
kind: "directory",
|
|
2732
|
+
logger: params.logger,
|
|
2733
|
+
});
|
|
2734
|
+
await ensureManagedCodexPathAlias({
|
|
2735
|
+
source: path.join(originalHome, "external_agent_session_imports.json"),
|
|
2736
|
+
dest: path.join(managedHome, "external_agent_session_imports.json"),
|
|
2737
|
+
kind: "file",
|
|
2738
|
+
logger: params.logger,
|
|
2739
|
+
});
|
|
2740
|
+
await preserveManagedCodexHomeRootEntries({
|
|
2741
|
+
originalHome,
|
|
2742
|
+
managedHome,
|
|
2743
|
+
logger: params.logger,
|
|
2744
|
+
});
|
|
2745
|
+
return managedHome;
|
|
2746
|
+
}
|
|
2067
2747
|
function withManagedCodexConfig(config, runtimeSettings, launchEnv) {
|
|
2068
2748
|
const env = buildCodexAppServerEnv(runtimeSettings, launchEnv);
|
|
2069
2749
|
const managedConfig = buildCodexSeaworkProviderConfig(env);
|
|
@@ -2091,9 +2771,10 @@ function buildCodexAppServerInitializeParams() {
|
|
|
2091
2771
|
};
|
|
2092
2772
|
}
|
|
2093
2773
|
class CodexAppServerAgentSession {
|
|
2094
|
-
constructor(config, resumeHandle, logger, spawnAppServer) {
|
|
2774
|
+
constructor(config, resumeHandle, logger, spawnAppServer, goalsEnabled = false) {
|
|
2095
2775
|
this.resumeHandle = resumeHandle;
|
|
2096
2776
|
this.spawnAppServer = spawnAppServer;
|
|
2777
|
+
this.goalsEnabled = goalsEnabled;
|
|
2097
2778
|
this.provider = CODEX_PROVIDER;
|
|
2098
2779
|
this.capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
|
2099
2780
|
this.currentThreadId = null;
|
|
@@ -2106,6 +2787,62 @@ class CodexAppServerAgentSession {
|
|
|
2106
2787
|
this.subscribers = new Set();
|
|
2107
2788
|
this.nextTurnOrdinal = 0;
|
|
2108
2789
|
this.activeForegroundTurnId = null;
|
|
2790
|
+
// issue #505: fires if the active turn goes silent (lost `turn/completed`).
|
|
2791
|
+
this.turnWatchdogTimer = null;
|
|
2792
|
+
// issue #1427: grace timer armed when the watchdog decides a turn is stalled.
|
|
2793
|
+
// Commits forceFailActiveTurn only if no turn/completed arrives within
|
|
2794
|
+
// WATCHDOG_LATE_COMPLETION_GRACE_MS, so a late-but-successful completion is
|
|
2795
|
+
// not mis-killed. Cleared by any notification (the watchdog re-arm path) and
|
|
2796
|
+
// by turn teardown.
|
|
2797
|
+
this.pendingForceFailTimer = null;
|
|
2798
|
+
// issue #505: consecutive watchdog cycles that have elapsed in silence while
|
|
2799
|
+
// a tool was in flight. Reset by any real notification; bounded so a lost
|
|
2800
|
+
// tool completion still recovers instead of deferring the watchdog forever.
|
|
2801
|
+
this.turnWatchdogInflightCycles = 0;
|
|
2802
|
+
// issue #505: set when a turn is force-failed (watchdog / process exit /
|
|
2803
|
+
// interrupt). Late notifications for that fenced turn are dropped until the
|
|
2804
|
+
// next `turn/started` arrives, so a stale `turn/completed` or terminal item
|
|
2805
|
+
// cannot resurface a turn the UI already saw fail.
|
|
2806
|
+
this.fencedAfterForcedFailure = false;
|
|
2807
|
+
// issue #505: codex's `turn/start` RPC response carries `turn.id`, which the
|
|
2808
|
+
// subsequent `turn/started` notification echoes verbatim. Saving it here lets
|
|
2809
|
+
// the fence reject a late `turn/started` belonging to a force-failed turn
|
|
2810
|
+
// even after a retry has set a fresh `activeForegroundTurnId` — we accept
|
|
2811
|
+
// only the `turn/started` whose id matches the most recent turn/start ack.
|
|
2812
|
+
this.expectedCodexTurnId = null;
|
|
2813
|
+
// issue #505: between the moment startTurn() sets activeForegroundTurnId and
|
|
2814
|
+
// the moment turn/start RPC ack returns and we know codex's turn.id, we
|
|
2815
|
+
// cannot distinguish a dead turn's late turn/started from the new turn's
|
|
2816
|
+
// own. This flag marks that "in-flight ack" window — while true the fence
|
|
2817
|
+
// unconditionally drops every turn/started, so a stale notification cannot
|
|
2818
|
+
// race the ack and pollute the new turn.
|
|
2819
|
+
this.turnStartAckPending = false;
|
|
2820
|
+
// issue #664 review #3 (gpt-5.5): codex can emit a new turn's
|
|
2821
|
+
// `turn/started` BEFORE the `turn/start` RPC ack returns. The ack-pending
|
|
2822
|
+
// fence drops it (since we don't yet know codex's turn.id to verify
|
|
2823
|
+
// ownership). Cache the most recent fence-dropped turn/started here so
|
|
2824
|
+
// the ack handler can replay it after `expectedCodexTurnId` is set —
|
|
2825
|
+
// otherwise the fence stays closed and the new turn looks silent until
|
|
2826
|
+
// the watchdog fires. Cleared on startTurn() entry, startTurn() failure,
|
|
2827
|
+
// and after a successful replay.
|
|
2828
|
+
this.pendingFencedTurnStarted = null;
|
|
2829
|
+
// issue #1235 review #3 (geli-bot): keep the most recently force-failed
|
|
2830
|
+
// codex turn id separate from `currentTurnId`. Older codex can omit
|
|
2831
|
+
// `turn.id` from turn/start ack, leaving item.turnId as the only ownership
|
|
2832
|
+
// signal for the retry turn. This lets the fallback fence reject the dead
|
|
2833
|
+
// turn specifically without hard-locking the retry.
|
|
2834
|
+
this.fencedTurnId = null;
|
|
2835
|
+
// issue #1836: the local foreground turn id of the most recent force-fail.
|
|
2836
|
+
// The fence self-heal reuses it when resuming the old codex turn so the
|
|
2837
|
+
// recovered turn's start/completed accounting (pushTurnEvent durationMs,
|
|
2838
|
+
// bug-report ring, latency) keys off the original id instead of a fresh one.
|
|
2839
|
+
this.lastForcedFailForegroundTurnId = null;
|
|
2840
|
+
// issue #1836: whether the current fence came from a watchdog force-fail (the
|
|
2841
|
+
// only case self-heal should resurrect). `resetForegroundTurnState` is also
|
|
2842
|
+
// called by the user interrupt/cancel paths, which fence the turn too —
|
|
2843
|
+
// self-heal must NOT revive a turn the user deliberately canceled, so it gates
|
|
2844
|
+
// on this flag rather than the fence merely existing.
|
|
2845
|
+
this.fencedByForceFail = false;
|
|
2109
2846
|
this.cachedRuntimeInfo = null;
|
|
2110
2847
|
this.serviceTier = null;
|
|
2111
2848
|
this.planModeEnabled = false;
|
|
@@ -2115,6 +2852,17 @@ class CodexAppServerAgentSession {
|
|
|
2115
2852
|
this.pendingPermissionHandlers = new Map();
|
|
2116
2853
|
this.resolvedPermissionRequests = new Set();
|
|
2117
2854
|
this.pendingAgentMessages = new Map();
|
|
2855
|
+
/**
|
|
2856
|
+
* Per-itemId count of assistant-message characters already emitted as
|
|
2857
|
+
* streaming `assistant_message` timeline deltas. `item_completed` uses this
|
|
2858
|
+
* to emit only the un-streamed tail (and skip entirely if all streamed),
|
|
2859
|
+
* avoiding a duplicate full-text emit at the end of a streamed message.
|
|
2860
|
+
*/
|
|
2861
|
+
this.emittedAgentMessageLength = new Map();
|
|
2862
|
+
/** Pending throttle timer for streaming agent-message deltas. */
|
|
2863
|
+
this.agentMessageFlushTimer = null;
|
|
2864
|
+
/** itemId awaiting a throttled streaming flush. */
|
|
2865
|
+
this.agentMessageFlushPendingItemId = null;
|
|
2118
2866
|
this.pendingReasoning = new Map();
|
|
2119
2867
|
this.pendingCommandOutputDeltas = new Map();
|
|
2120
2868
|
this.pendingFileChangeOutputDeltas = new Map();
|
|
@@ -2125,6 +2873,35 @@ class CodexAppServerAgentSession {
|
|
|
2125
2873
|
this.emittedExecCommandCompletedCallIds = new Set();
|
|
2126
2874
|
this.emittedItemStartedIds = new Set();
|
|
2127
2875
|
this.emittedItemCompletedIds = new Set();
|
|
2876
|
+
// issue #505: callIds of tool executions that have started but not completed.
|
|
2877
|
+
// A long, output-silent command (build, network fetch) keeps this non-empty,
|
|
2878
|
+
// so the stall watchdog can tell "genuinely working" from "lost turn".
|
|
2879
|
+
this.inFlightToolCalls = new Set();
|
|
2880
|
+
// issue #999: itemIds of context-compaction items currently in flight. Like
|
|
2881
|
+
// inFlightToolCalls, it tells the watchdog "genuinely working" (packing the
|
|
2882
|
+
// context + summarizing) from "lost turn", so a long compaction is deferred
|
|
2883
|
+
// instead of force-failed.
|
|
2884
|
+
this.compactionInFlight = new Set();
|
|
2885
|
+
this.compactionItemTimers = new Map();
|
|
2886
|
+
// issue #999: consecutive watchdog cycles elapsed in silence while a
|
|
2887
|
+
// compaction was in flight. Reset by any real notification; bounded so a lost
|
|
2888
|
+
// compaction completion still recovers.
|
|
2889
|
+
this.turnWatchdogCompactionCycles = 0;
|
|
2890
|
+
// issue #1181: wall-clock time of the most recent codex notification accepted
|
|
2891
|
+
// by the active turn. The watchdog compares this to Date.now() so event-loop
|
|
2892
|
+
// starvation from other agents cannot cause a false stall.
|
|
2893
|
+
this.lastTurnNotificationTime = null;
|
|
2894
|
+
// issue #1427: last reconnect-attempt key for which a "reconnecting" loading
|
|
2895
|
+
// marker was emitted, so the paired warning+error(willRetry) for one attempt
|
|
2896
|
+
// collapse into a single marker. Reset to null when a marker is resolved
|
|
2897
|
+
// (real turn progress) or on turn teardown so the next stall re-announces.
|
|
2898
|
+
this.lastReconnectMarkerKey = null;
|
|
2899
|
+
// issue #1012: codex version parsed from the app-server `initialize`
|
|
2900
|
+
// response's `userAgent` (e.g. "codex/0.137.0 (...)"). Gates the manual
|
|
2901
|
+
// `/compact` command — `thread/compact/start` only exists on newer codex.
|
|
2902
|
+
this.codexVersion = null;
|
|
2903
|
+
this.manualCompactTurnId = null;
|
|
2904
|
+
this.manualCompactCanceledTurnIds = new Set();
|
|
2128
2905
|
this.warnedUnknownNotificationMethods = new Set();
|
|
2129
2906
|
this.warnedInvalidNotificationPayloads = new Set();
|
|
2130
2907
|
this.warnedIncompleteEditToolCallIds = new Set();
|
|
@@ -2150,6 +2927,12 @@ class CodexAppServerAgentSession {
|
|
|
2150
2927
|
if (this.resumeHandle?.sessionId) {
|
|
2151
2928
|
this.currentThreadId = this.resumeHandle.sessionId;
|
|
2152
2929
|
this.historyPending = true;
|
|
2930
|
+
// issue #277: restore the cancel sentinel persisted by describePersistence
|
|
2931
|
+
// so a turn/interrupt issued before resume is honored on the next turn.
|
|
2932
|
+
const persistedCanceledAt = this.resumeHandle.metadata?.lastCanceledAt;
|
|
2933
|
+
if (typeof persistedCanceledAt === "number" && Number.isFinite(persistedCanceledAt)) {
|
|
2934
|
+
this.lastCanceledAt = persistedCanceledAt;
|
|
2935
|
+
}
|
|
2153
2936
|
}
|
|
2154
2937
|
}
|
|
2155
2938
|
get id() {
|
|
@@ -2169,17 +2952,41 @@ class CodexAppServerAgentSession {
|
|
|
2169
2952
|
const child = await this.spawnAppServer();
|
|
2170
2953
|
this.client = new CodexAppServerClient(child, this.logger);
|
|
2171
2954
|
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
|
2955
|
+
// issue #505: a mid-turn process crash never produces a `turn/completed`
|
|
2956
|
+
// notification, so fail the foreground turn explicitly instead of leaving
|
|
2957
|
+
// the UI stuck "thinking".
|
|
2958
|
+
this.client.setExitHandler((reason) => {
|
|
2959
|
+
this.handleClientExit(reason);
|
|
2960
|
+
});
|
|
2961
|
+
// issue #1824: a `/responses` round-trip completing proves the turn is alive
|
|
2962
|
+
// even with no protocol notification (multi-minute large-payload uploads),
|
|
2963
|
+
// so feed it into the stall watchdog as a liveness signal.
|
|
2964
|
+
this.client.setUpstreamLivenessHandler(() => this.markUpstreamLiveness());
|
|
2172
2965
|
this.registerRequestHandlers();
|
|
2173
|
-
await this.client.request("initialize", buildCodexAppServerInitializeParams());
|
|
2966
|
+
const initializeResponse = await this.client.request("initialize", buildCodexAppServerInitializeParams());
|
|
2967
|
+
this.codexVersion = parseCodexUserAgentVersion(initializeResponse);
|
|
2174
2968
|
this.client.notify("initialized", {});
|
|
2175
2969
|
await this.loadCollaborationModes();
|
|
2176
2970
|
await this.loadSkills();
|
|
2177
2971
|
if (this.currentThreadId) {
|
|
2178
|
-
await this.
|
|
2179
|
-
await this.ensureThreadLoaded();
|
|
2972
|
+
await this.restorePersistedHistoryForResume();
|
|
2180
2973
|
}
|
|
2181
2974
|
this.connected = true;
|
|
2182
2975
|
}
|
|
2976
|
+
async restorePersistedHistoryForResume() {
|
|
2977
|
+
if (!this.currentThreadId) {
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
const resumedThreadId = this.currentThreadId;
|
|
2981
|
+
const restored = await this.loadPersistedHistory();
|
|
2982
|
+
await this.ensureThreadLoaded();
|
|
2983
|
+
// Some Codex builds only allow thread/read after thread/resume has loaded
|
|
2984
|
+
// the thread into the app-server process. Retry once so reconnect/login
|
|
2985
|
+
// flows don't lose visible history just because the first read raced.
|
|
2986
|
+
if (!restored && this.currentThreadId === resumedThreadId) {
|
|
2987
|
+
await this.loadPersistedHistory();
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2183
2990
|
async loadCollaborationModes() {
|
|
2184
2991
|
if (!this.client)
|
|
2185
2992
|
return;
|
|
@@ -2202,7 +3009,7 @@ class CodexAppServerAgentSession {
|
|
|
2202
3009
|
}
|
|
2203
3010
|
async loadSkills() {
|
|
2204
3011
|
const skillsByName = new Map();
|
|
2205
|
-
const projectSkills = await listCodexSkillEntries(this.config.cwd);
|
|
3012
|
+
const projectSkills = await listCodexSkillEntries(this.config.cwd, this.logger);
|
|
2206
3013
|
for (const skill of projectSkills) {
|
|
2207
3014
|
skillsByName.set(skill.name, skill);
|
|
2208
3015
|
}
|
|
@@ -2231,7 +3038,10 @@ class CodexAppServerAgentSession {
|
|
|
2231
3038
|
catch (error) {
|
|
2232
3039
|
this.logger.trace({ error }, "Failed to load skills list");
|
|
2233
3040
|
}
|
|
2234
|
-
|
|
3041
|
+
const denied = new Set(this.config.librarySkillDenylist ?? []);
|
|
3042
|
+
this.cachedSkills = Array.from(skillsByName.values())
|
|
3043
|
+
.filter((skill) => !denied.has(skill.name))
|
|
3044
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2235
3045
|
}
|
|
2236
3046
|
findCollaborationMode(target) {
|
|
2237
3047
|
if (this.collaborationModes.length === 0)
|
|
@@ -2343,10 +3153,66 @@ class CodexAppServerAgentSession {
|
|
|
2343
3153
|
this.pendingPermissionHandlers.set(requestId, {
|
|
2344
3154
|
resolve: () => undefined,
|
|
2345
3155
|
kind: "plan",
|
|
3156
|
+
foregroundTurnId: this.activeForegroundTurnId,
|
|
2346
3157
|
planText,
|
|
3158
|
+
// Capture the plan timeline item's callId now (while latestPlanResult is
|
|
3159
|
+
// populated) so an edited-plan approval can re-emit/replace that exact card
|
|
3160
|
+
// even after a daemon restart cleared latestPlanResult.
|
|
3161
|
+
planCallId: this.latestPlanResult?.callId,
|
|
2347
3162
|
});
|
|
2348
3163
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
2349
3164
|
}
|
|
3165
|
+
foregroundTurnIdForLegacyPermission() {
|
|
3166
|
+
if (this.turnStartAckPending) {
|
|
3167
|
+
return null;
|
|
3168
|
+
}
|
|
3169
|
+
return this.activeForegroundTurnId;
|
|
3170
|
+
}
|
|
3171
|
+
isActiveForegroundCodexTurnId(codexTurnId) {
|
|
3172
|
+
if (!codexTurnId || this.activeForegroundTurnId === null || this.turnStartAckPending) {
|
|
3173
|
+
return false;
|
|
3174
|
+
}
|
|
3175
|
+
if (this.expectedCodexTurnId) {
|
|
3176
|
+
return codexTurnId === this.expectedCodexTurnId;
|
|
3177
|
+
}
|
|
3178
|
+
if (this.currentTurnId) {
|
|
3179
|
+
return codexTurnId === this.currentTurnId;
|
|
3180
|
+
}
|
|
3181
|
+
if (this.fencedAfterForcedFailure) {
|
|
3182
|
+
return this.fencedTurnId === null || codexTurnId !== this.fencedTurnId;
|
|
3183
|
+
}
|
|
3184
|
+
return false;
|
|
3185
|
+
}
|
|
3186
|
+
foregroundTurnIdForCodexPermission(codexTurnId) {
|
|
3187
|
+
const foregroundTurnId = this.activeForegroundTurnId;
|
|
3188
|
+
if (!foregroundTurnId) {
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
return this.isActiveForegroundCodexTurnId(codexTurnId) ? foregroundTurnId : null;
|
|
3192
|
+
}
|
|
3193
|
+
parsedNotificationTurnId(parsed) {
|
|
3194
|
+
switch (parsed.kind) {
|
|
3195
|
+
case "turn_started":
|
|
3196
|
+
return parsed.turnId;
|
|
3197
|
+
case "token_usage_updated":
|
|
3198
|
+
case "agent_message_delta":
|
|
3199
|
+
case "reasoning_delta":
|
|
3200
|
+
case "raw_response_item_completed":
|
|
3201
|
+
case "command_execution_output_delta":
|
|
3202
|
+
case "file_change_output_delta":
|
|
3203
|
+
return parsed.turnId;
|
|
3204
|
+
case "item_started":
|
|
3205
|
+
case "item_completed":
|
|
3206
|
+
case "terminal_interaction":
|
|
3207
|
+
return parsed.turnId;
|
|
3208
|
+
default:
|
|
3209
|
+
return null;
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
isCurrentRetryTurnNotification(parsed) {
|
|
3213
|
+
const turnId = this.parsedNotificationTurnId(parsed);
|
|
3214
|
+
return this.isActiveForegroundCodexTurnId(turnId);
|
|
3215
|
+
}
|
|
2350
3216
|
/**
|
|
2351
3217
|
* Prepare the session for plan implementation by disabling plan mode (so the
|
|
2352
3218
|
* next turn runs in `code` collaboration mode instead of generating another
|
|
@@ -2365,27 +3231,29 @@ class CodexAppServerAgentSession {
|
|
|
2365
3231
|
return;
|
|
2366
3232
|
this.client.setRequestHandler("item/commandExecution/requestApproval", (params) => this.handleCommandApprovalRequest(params));
|
|
2367
3233
|
this.client.setRequestHandler("item/fileChange/requestApproval", (params) => this.handleFileChangeApprovalRequest(params));
|
|
3234
|
+
this.client.setRequestHandler("execCommandApproval", (params) => this.handleLegacyExecCommandApprovalRequest(params));
|
|
3235
|
+
this.client.setRequestHandler("applyPatchApproval", (params) => this.handleLegacyApplyPatchApprovalRequest(params));
|
|
2368
3236
|
this.client.setRequestHandler("item/tool/requestUserInput", (params) => this.handleToolApprovalRequest(params));
|
|
2369
3237
|
// Keep the legacy method name for older Codex builds.
|
|
2370
3238
|
this.client.setRequestHandler("tool/requestUserInput", (params) => this.handleToolApprovalRequest(params));
|
|
2371
3239
|
}
|
|
2372
3240
|
async loadPersistedHistory() {
|
|
2373
3241
|
if (!this.client || !this.currentThreadId)
|
|
2374
|
-
return;
|
|
3242
|
+
return false;
|
|
3243
|
+
let rolloutTimeline = [];
|
|
3244
|
+
try {
|
|
3245
|
+
rolloutTimeline = await loadCodexPersistedTimeline(this.currentThreadId, undefined, this.logger);
|
|
3246
|
+
}
|
|
3247
|
+
catch {
|
|
3248
|
+
rolloutTimeline = [];
|
|
3249
|
+
}
|
|
3250
|
+
let threadTimeline = [];
|
|
2375
3251
|
try {
|
|
2376
|
-
let rolloutTimeline = [];
|
|
2377
|
-
try {
|
|
2378
|
-
rolloutTimeline = await loadCodexPersistedTimeline(this.currentThreadId, undefined, this.logger);
|
|
2379
|
-
}
|
|
2380
|
-
catch {
|
|
2381
|
-
rolloutTimeline = [];
|
|
2382
|
-
}
|
|
2383
3252
|
const response = (await this.client.request("thread/read", {
|
|
2384
3253
|
threadId: this.currentThreadId,
|
|
2385
3254
|
includeTurns: true,
|
|
2386
3255
|
}));
|
|
2387
3256
|
const thread = response?.thread;
|
|
2388
|
-
const threadTimeline = [];
|
|
2389
3257
|
if (thread && Array.isArray(thread.turns)) {
|
|
2390
3258
|
for (const turn of thread.turns) {
|
|
2391
3259
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
@@ -2402,17 +3270,19 @@ class CodexAppServerAgentSession {
|
|
|
2402
3270
|
}
|
|
2403
3271
|
}
|
|
2404
3272
|
}
|
|
2405
|
-
const timeline = rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
|
|
2406
|
-
if (timeline.length > 0) {
|
|
2407
|
-
this.persistedHistory = timeline;
|
|
2408
|
-
this.historyPending = true;
|
|
2409
|
-
}
|
|
2410
3273
|
}
|
|
2411
3274
|
catch (error) {
|
|
2412
|
-
this.logger.warn({ error }, "Failed to
|
|
3275
|
+
this.logger.warn({ error }, "Failed to read Codex thread history");
|
|
2413
3276
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
3277
|
+
const timeline = rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
|
|
3278
|
+
if (timeline.length === 0) {
|
|
3279
|
+
return false;
|
|
3280
|
+
}
|
|
3281
|
+
this.persistedHistory = timeline;
|
|
3282
|
+
this.historyPending = true;
|
|
3283
|
+
return true;
|
|
3284
|
+
}
|
|
3285
|
+
async ensureThreadLoaded() {
|
|
2416
3286
|
if (!this.client || !this.currentThreadId)
|
|
2417
3287
|
return;
|
|
2418
3288
|
try {
|
|
@@ -2501,6 +3371,9 @@ class CodexAppServerAgentSession {
|
|
|
2501
3371
|
async run(prompt, options) {
|
|
2502
3372
|
const timeline = [];
|
|
2503
3373
|
let finalText = "";
|
|
3374
|
+
// Whether the previous timeline item was an assistant_message, so
|
|
3375
|
+
// consecutive streaming deltas accumulate into finalText.
|
|
3376
|
+
let prevTimelineWasAssistant = false;
|
|
2504
3377
|
let usage;
|
|
2505
3378
|
let turnId = null;
|
|
2506
3379
|
const bufferedEvents = [];
|
|
@@ -2517,11 +3390,26 @@ class CodexAppServerAgentSession {
|
|
|
2517
3390
|
}
|
|
2518
3391
|
if (event.type === "timeline") {
|
|
2519
3392
|
timeline.push(event.item);
|
|
3393
|
+
// assistant_message is now emitted as a stream of deltas (one per
|
|
3394
|
+
// ~60ms throttle window), not a single full-text item. Mirror the
|
|
3395
|
+
// UI's `appendAssistantMessage` coalescing: consecutive
|
|
3396
|
+
// assistant_message items concatenate; a non-assistant item resets
|
|
3397
|
+
// the run, so the last assistant message wins as finalText.
|
|
2520
3398
|
if (event.item.type === "assistant_message") {
|
|
2521
|
-
|
|
3399
|
+
if (prevTimelineWasAssistant) {
|
|
3400
|
+
finalText += event.item.text;
|
|
3401
|
+
}
|
|
3402
|
+
else {
|
|
3403
|
+
finalText = event.item.text;
|
|
3404
|
+
prevTimelineWasAssistant = true;
|
|
3405
|
+
}
|
|
2522
3406
|
}
|
|
2523
3407
|
else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
|
|
2524
3408
|
finalText = event.item.detail.text;
|
|
3409
|
+
prevTimelineWasAssistant = false;
|
|
3410
|
+
}
|
|
3411
|
+
else {
|
|
3412
|
+
prevTimelineWasAssistant = false;
|
|
2525
3413
|
}
|
|
2526
3414
|
return;
|
|
2527
3415
|
}
|
|
@@ -2581,6 +3469,17 @@ class CodexAppServerAgentSession {
|
|
|
2581
3469
|
if (!this.client) {
|
|
2582
3470
|
throw new Error("Codex client not initialized");
|
|
2583
3471
|
}
|
|
3472
|
+
// issue #1012: intercept `/compact` and route to the dedicated
|
|
3473
|
+
// `thread/compact/start` RPC instead of starting a turn. Mirrors claude's
|
|
3474
|
+
// rewind interception — fire-and-forget so we return the turnId immediately
|
|
3475
|
+
// and the synthesized turn events finalize the foreground turn.
|
|
3476
|
+
const compactInvocation = typeof prompt === "string" ? this.parseSlashCommandInput(prompt) : null;
|
|
3477
|
+
if (compactInvocation?.commandName === COMPACT_COMMAND_NAME) {
|
|
3478
|
+
const turnId = this.createTurnId();
|
|
3479
|
+
this.activeForegroundTurnId = turnId;
|
|
3480
|
+
void this.executeManualCompact(turnId);
|
|
3481
|
+
return { turnId };
|
|
3482
|
+
}
|
|
2584
3483
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
|
2585
3484
|
const effectivePrompt = slashCommand
|
|
2586
3485
|
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
|
@@ -2593,8 +3492,9 @@ class CodexAppServerAgentSession {
|
|
|
2593
3492
|
}
|
|
2594
3493
|
const input = this.maybePrependCancelReminder(await this.buildUserInput(effectivePrompt));
|
|
2595
3494
|
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
|
2596
|
-
|
|
2597
|
-
|
|
3495
|
+
let approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
|
3496
|
+
let sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
|
|
3497
|
+
({ approvalPolicy, sandboxPolicyType } = applyPlanModeConstraints(approvalPolicy, sandboxPolicyType, this.planModeEnabled));
|
|
2598
3498
|
const params = {
|
|
2599
3499
|
threadId: this.currentThreadId,
|
|
2600
3500
|
input,
|
|
@@ -2634,22 +3534,72 @@ class CodexAppServerAgentSession {
|
|
|
2634
3534
|
}
|
|
2635
3535
|
const turnId = this.createTurnId();
|
|
2636
3536
|
this.activeForegroundTurnId = turnId;
|
|
3537
|
+
// issue #664 review #3 (gpt-5.5): start with a clean cache. A previous
|
|
3538
|
+
// turn's leftover entry must not be replayed against this new turn.
|
|
3539
|
+
this.pendingFencedTurnStarted = null;
|
|
3540
|
+
// NOTE: the fence is intentionally NOT lifted here. `turn/start` is only a
|
|
3541
|
+
// request; the new turn has not begun until codex starts producing
|
|
3542
|
+
// notifications that we can safely attribute to it. Lifting the fence now
|
|
3543
|
+
// would let a dead turn's late notification, arriving in the gap before
|
|
3544
|
+
// the new turn is identified, be processed against the retry.
|
|
3545
|
+
this.armTurnWatchdog();
|
|
2637
3546
|
const turnStartT0 = Date.now();
|
|
2638
3547
|
this.logger.info({ turnId }, "[timing] codex turn/start request sending");
|
|
3548
|
+
// issue #505: from this point until the ack returns, we don't know codex's
|
|
3549
|
+
// turn.id for the new turn, so the fence must drop every turn/started
|
|
3550
|
+
// unconditionally — otherwise a dead turn's late turn/started could race
|
|
3551
|
+
// the ack and be treated as the new turn.
|
|
3552
|
+
this.turnStartAckPending = true;
|
|
3553
|
+
let turnStartResponse;
|
|
2639
3554
|
try {
|
|
2640
|
-
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
|
3555
|
+
turnStartResponse = await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
|
2641
3556
|
}
|
|
2642
3557
|
catch (error) {
|
|
2643
3558
|
this.logger.info({ turnId, elapsedMs: Date.now() - turnStartT0 }, "[timing] codex turn/start FAILED");
|
|
2644
3559
|
this.activeForegroundTurnId = null;
|
|
3560
|
+
this.clearTurnWatchdog();
|
|
3561
|
+
this.turnStartAckPending = false;
|
|
3562
|
+
// issue #664 review #3 (gpt-5.5): the RPC threw, so no ack will
|
|
3563
|
+
// arrive to replay a cached turn/started. Drop the cache so a later
|
|
3564
|
+
// unrelated startTurn() can't pick it up.
|
|
3565
|
+
this.pendingFencedTurnStarted = null;
|
|
2645
3566
|
throw error;
|
|
2646
3567
|
}
|
|
2647
3568
|
this.logger.info({ turnId, elapsedMs: Date.now() - turnStartT0 }, "[timing] codex turn/start acknowledged");
|
|
3569
|
+
// issue #505: capture codex's turn.id from the start ack so the fence can
|
|
3570
|
+
// reject a late `turn/started` belonging to a force-failed turn. If the
|
|
3571
|
+
// ack omits `turn.id` (older codex or future schema change), leave this
|
|
3572
|
+
// as null and the fence falls back to the weaker "any turn/started after
|
|
3573
|
+
// a retry lifts the fence" rule — no event flow is hard-locked.
|
|
3574
|
+
const parsedStartId = z
|
|
3575
|
+
.object({ turn: z.object({ id: z.string() }).passthrough() })
|
|
3576
|
+
.passthrough()
|
|
3577
|
+
.safeParse(turnStartResponse);
|
|
3578
|
+
this.expectedCodexTurnId = parsedStartId.success ? parsedStartId.data.turn.id : null;
|
|
3579
|
+
this.turnStartAckPending = false;
|
|
2648
3580
|
// issue #259: do NOT consume `lastCanceledAt` here. The sentinel is only
|
|
2649
3581
|
// cleared once we see turn/completed with status "completed" — a
|
|
2650
3582
|
// turn/start ack can still race with turn/completed: failed, in which
|
|
2651
3583
|
// case the orphaned prompt is still in the thread and the next retry
|
|
2652
3584
|
// must carry the reminder (PR #273 review #3).
|
|
3585
|
+
// issue #664 review #3 (gpt-5.5): if codex emitted this turn's
|
|
3586
|
+
// `turn/started` while the ack was still pending, the fence dropped it
|
|
3587
|
+
// and cached it. Now that we know the expected codex turn.id, replay
|
|
3588
|
+
// it through handleNotification — the fence will see
|
|
3589
|
+
// `!turnStartAckPending` and a matching id, lift, and process the
|
|
3590
|
+
// turn_started normally. Without this replay, the new turn would have
|
|
3591
|
+
// no visible turn_started and stall until watchdog. Match by codex
|
|
3592
|
+
// turn.id when we have one; fall back to "any cached turn/started"
|
|
3593
|
+
// when the ack omitted turn.id (matches the looser fence rule above).
|
|
3594
|
+
const cached = this.pendingFencedTurnStarted;
|
|
3595
|
+
this.pendingFencedTurnStarted = null;
|
|
3596
|
+
if (cached) {
|
|
3597
|
+
const idMatches = this.expectedCodexTurnId === null || cached.codexTurnId === this.expectedCodexTurnId;
|
|
3598
|
+
if (idMatches) {
|
|
3599
|
+
this.logger.info({ turnId, codexTurnId: cached.codexTurnId }, "[timing] replaying turn/started that arrived during ack-pending window");
|
|
3600
|
+
this.handleNotification("turn/started", cached.rawParams);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
2653
3603
|
return { turnId };
|
|
2654
3604
|
}
|
|
2655
3605
|
subscribe(callback) {
|
|
@@ -2732,14 +3682,42 @@ class CodexAppServerAgentSession {
|
|
|
2732
3682
|
async respondToPermission(requestId, response) {
|
|
2733
3683
|
const pending = this.pendingPermissionHandlers.get(requestId);
|
|
2734
3684
|
if (!pending) {
|
|
3685
|
+
if (this.resolvedPermissionRequests.has(requestId)) {
|
|
3686
|
+
this.emitEvent({
|
|
3687
|
+
type: "permission_resolved",
|
|
3688
|
+
provider: CODEX_PROVIDER,
|
|
3689
|
+
requestId,
|
|
3690
|
+
resolution: response,
|
|
3691
|
+
});
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
2735
3694
|
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
|
2736
3695
|
}
|
|
2737
3696
|
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
|
2738
3697
|
if (pending.kind === "plan") {
|
|
2739
3698
|
let followUpPrompt;
|
|
2740
3699
|
if (response.behavior === "allow") {
|
|
3700
|
+
const rawEditedPlan = response.updatedInput?.["plan"];
|
|
3701
|
+
const editedPlan = typeof rawEditedPlan === "string" ? rawEditedPlan : undefined;
|
|
3702
|
+
const originalPlan = pending.planText ?? pendingRequest?.metadata?.planText;
|
|
3703
|
+
// When the user edited the plan, re-emit the plan timeline item with the
|
|
3704
|
+
// same callId so the conversation reflects the approved (edited) version
|
|
3705
|
+
// instead of the original proposal. The callId is captured on the pending
|
|
3706
|
+
// handler at request time so this survives a daemon restart (which clears
|
|
3707
|
+
// the in-memory latestPlanResult).
|
|
3708
|
+
const planCallId = pending.planCallId ?? this.latestPlanResult?.callId;
|
|
3709
|
+
if (editedPlan != null && editedPlan !== originalPlan && planCallId) {
|
|
3710
|
+
const updatedItem = mapCodexPlanToToolCall({
|
|
3711
|
+
callId: planCallId,
|
|
3712
|
+
text: editedPlan,
|
|
3713
|
+
});
|
|
3714
|
+
if (updatedItem) {
|
|
3715
|
+
this.rememberPlanResult(updatedItem);
|
|
3716
|
+
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: updatedItem });
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
2741
3719
|
followUpPrompt = this.preparePlanImplementation({
|
|
2742
|
-
planText:
|
|
3720
|
+
planText: editedPlan ?? originalPlan,
|
|
2743
3721
|
});
|
|
2744
3722
|
}
|
|
2745
3723
|
this.pendingPermissionHandlers.delete(requestId);
|
|
@@ -2793,12 +3771,32 @@ class CodexAppServerAgentSession {
|
|
|
2793
3771
|
resolution: response,
|
|
2794
3772
|
});
|
|
2795
3773
|
if (pending.kind === "command") {
|
|
2796
|
-
const decision =
|
|
3774
|
+
const decision = pending.protocol === "legacy-review"
|
|
3775
|
+
? response.behavior === "allow"
|
|
3776
|
+
? "approved"
|
|
3777
|
+
: response.interrupt
|
|
3778
|
+
? "abort"
|
|
3779
|
+
: "denied"
|
|
3780
|
+
: response.behavior === "allow"
|
|
3781
|
+
? "accept"
|
|
3782
|
+
: response.interrupt
|
|
3783
|
+
? "cancel"
|
|
3784
|
+
: "decline";
|
|
2797
3785
|
pending.resolve({ decision });
|
|
2798
3786
|
return;
|
|
2799
3787
|
}
|
|
2800
3788
|
if (pending.kind === "file") {
|
|
2801
|
-
const decision =
|
|
3789
|
+
const decision = pending.protocol === "legacy-review"
|
|
3790
|
+
? response.behavior === "allow"
|
|
3791
|
+
? "approved"
|
|
3792
|
+
: response.interrupt
|
|
3793
|
+
? "abort"
|
|
3794
|
+
: "denied"
|
|
3795
|
+
: response.behavior === "allow"
|
|
3796
|
+
? "accept"
|
|
3797
|
+
: response.interrupt
|
|
3798
|
+
? "cancel"
|
|
3799
|
+
: "decline";
|
|
2802
3800
|
pending.resolve({ decision });
|
|
2803
3801
|
return;
|
|
2804
3802
|
}
|
|
@@ -2860,44 +3858,156 @@ class CodexAppServerAgentSession {
|
|
|
2860
3858
|
model: this.config.model ?? null,
|
|
2861
3859
|
thinkingOptionId,
|
|
2862
3860
|
extra: this.config.extra,
|
|
2863
|
-
|
|
3861
|
+
// Persist the pre-injection base so resume re-injects once, not twice.
|
|
3862
|
+
// `systemPromptBase` may be "" (no user base) — coalesce to undefined so we
|
|
3863
|
+
// never persist the daemon-injected value back as the base.
|
|
3864
|
+
systemPrompt: ("systemPromptBase" in this.config
|
|
3865
|
+
? this.config.systemPromptBase || undefined
|
|
3866
|
+
: this.config.systemPrompt) ?? null,
|
|
2864
3867
|
mcpServers: this.config.mcpServers,
|
|
3868
|
+
// issue #277: persist the cancel sentinel so a turn/interrupt issued
|
|
3869
|
+
// before resume is still honored after — resume re-reads this into
|
|
3870
|
+
// `lastCanceledAt` so the next turn/start prepends the ignore reminder.
|
|
3871
|
+
// Only emitted when set, keeping the metadata backward-compatible.
|
|
3872
|
+
...(this.lastCanceledAt !== null ? { lastCanceledAt: this.lastCanceledAt } : {}),
|
|
2865
3873
|
},
|
|
2866
3874
|
};
|
|
2867
3875
|
}
|
|
2868
3876
|
async interrupt() {
|
|
2869
|
-
if (
|
|
3877
|
+
if (this.client && this.manualCompactTurnId) {
|
|
3878
|
+
const foregroundTurnId = this.activeForegroundTurnId;
|
|
3879
|
+
if (foregroundTurnId === this.manualCompactTurnId) {
|
|
3880
|
+
this.manualCompactCanceledTurnIds.add(foregroundTurnId);
|
|
3881
|
+
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
|
3882
|
+
this.activeForegroundTurnId = null;
|
|
3883
|
+
this.manualCompactTurnId = null;
|
|
3884
|
+
this.clearTurnWatchdog();
|
|
3885
|
+
}
|
|
2870
3886
|
return;
|
|
2871
|
-
try {
|
|
2872
|
-
await this.client.request("turn/interrupt", {
|
|
2873
|
-
threadId: this.currentThreadId,
|
|
2874
|
-
turnId: this.currentTurnId,
|
|
2875
|
-
});
|
|
2876
|
-
// issue #259: codex protocol has no thread-truncate RPC, so a canceled
|
|
2877
|
-
// user prompt stays in the thread. The sentinel timestamp is set on the
|
|
2878
|
-
// turn/completed `interrupted` event below (not here), because a
|
|
2879
|
-
// successful turn/interrupt RPC can race with normal completion — if
|
|
2880
|
-
// the model already finished, the prompt has a paired assistant reply
|
|
2881
|
-
// and the next turn must NOT prepend the sentinel.
|
|
2882
3887
|
}
|
|
2883
|
-
|
|
2884
|
-
|
|
3888
|
+
// issue #1418: turn/start ack succeeded but turn/started not yet received
|
|
3889
|
+
// (currentTurnId === null). No codex turnId → skip turn/interrupt RPC and
|
|
3890
|
+
// tear down locally so activeForegroundTurnId is cleared; otherwise it
|
|
3891
|
+
// never clears and every later startTurn() throws "already active".
|
|
3892
|
+
// `!turnStartAckPending` excludes the in-flight ack window: during that
|
|
3893
|
+
// window currentTurnId is also null, but tearing down here would orphan a
|
|
3894
|
+
// real codex turn (the awaited turn/start RPC still resolves and codex runs
|
|
3895
|
+
// it to completion silently). The guard at line below then makes interrupt()
|
|
3896
|
+
// a no-op while the ack is pending, matching isActiveForegroundCodexTurnId
|
|
3897
|
+
// and the fence-lift semantics.
|
|
3898
|
+
if (this.activeForegroundTurnId && !this.currentTurnId && !this.turnStartAckPending) {
|
|
3899
|
+
this.lastCanceledAt = Date.now();
|
|
3900
|
+
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
|
3901
|
+
this.activeForegroundTurnId = null;
|
|
3902
|
+
this.resetForegroundTurnState();
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
if (!this.client || !this.currentThreadId || !this.currentTurnId)
|
|
3906
|
+
return;
|
|
3907
|
+
const threadId = this.currentThreadId;
|
|
3908
|
+
const codexTurnId = this.currentTurnId;
|
|
3909
|
+
const foregroundTurnId = this.activeForegroundTurnId;
|
|
3910
|
+
// issue #664 review (gpt-5.5): send `turn/interrupt` BEFORE tearing
|
|
3911
|
+
// down local state. If the RPC fails or is rejected (transport drop,
|
|
3912
|
+
// codex rejects the cancel, no client connected), codex's old turn is
|
|
3913
|
+
// still running — we must NOT optimistically clear
|
|
3914
|
+
// `activeForegroundTurnId`, open the fence, or set the sentinel,
|
|
3915
|
+
// because doing so would let a new turn start while the old one keeps
|
|
3916
|
+
// streaming and would also fence-drop the old turn's eventual terminal
|
|
3917
|
+
// notification, leaving UI + provider state desynced from codex's
|
|
3918
|
+
// real state. The throw propagates to agent-manager, whose existing
|
|
3919
|
+
// 2s wait + synthetic `turn_canceled` + watchdog handle the failure
|
|
3920
|
+
// path, matching pre-#664 behavior for the RPC-failed case.
|
|
3921
|
+
await this.client.request("turn/interrupt", {
|
|
3922
|
+
threadId,
|
|
3923
|
+
turnId: codexTurnId,
|
|
3924
|
+
});
|
|
3925
|
+
// issue #664: RPC accepted. Now clear provider-side turn state
|
|
3926
|
+
// synchronously instead of waiting for codex to send
|
|
3927
|
+
// `turn/completed (interrupted)` — codex can fail to send it (upstream
|
|
3928
|
+
// cancel-token guard race in codex-rs `tasks/mod.rs:430`), and that's
|
|
3929
|
+
// the specific case this PR is fixing. agent-manager's 2s
|
|
3930
|
+
// force-dispatch synthetic `turn_canceled` only clears agent-manager's
|
|
3931
|
+
// own state, leaving provider's `activeForegroundTurnId` set, so the
|
|
3932
|
+
// next `startTurn()` would throw "A foreground turn is already
|
|
3933
|
+
// active". Aligns with claude provider's `interrupt()`, which also
|
|
3934
|
+
// drops state synchronously (via `requestCancel`) once the cancel
|
|
3935
|
+
// request has been issued.
|
|
3936
|
+
//
|
|
3937
|
+
// Guard against the RPC await racing a real `turn/completed` (the
|
|
3938
|
+
// happy path where codex DID emit a terminal notification): if
|
|
3939
|
+
// `handleNotification` already cleared `activeForegroundTurnId` or
|
|
3940
|
+
// moved on to a new turn while we awaited, don't double-teardown.
|
|
3941
|
+
if (foregroundTurnId && this.activeForegroundTurnId === foregroundTurnId) {
|
|
3942
|
+
// issue #259 + #664: optimistically set the sentinel. PR #664's
|
|
3943
|
+
// whole point is that codex can fail to send `turn/completed
|
|
3944
|
+
// (interrupted)`. If we waited for that notification, the
|
|
3945
|
+
// lost-notification path would leave the orphaned user prompt in
|
|
3946
|
+
// the thread without the next turn warning the model to ignore it.
|
|
3947
|
+
// The race-aware override stays in `handleNotification`: if codex
|
|
3948
|
+
// actually won the race and reports `turn/completed
|
|
3949
|
+
// status=completed`, the fenced branch clears this back to null so
|
|
3950
|
+
// the next turn does NOT carry a stale sentinel.
|
|
3951
|
+
//
|
|
3952
|
+
// issue #277: set this BEFORE the synchronous `emitEvent` below so the
|
|
3953
|
+
// `turn_canceled`-driven `finalizeForegroundTurn` snapshot persists the
|
|
3954
|
+
// sentinel; otherwise describePersistence would capture the stale null.
|
|
3955
|
+
this.lastCanceledAt = Date.now();
|
|
3956
|
+
// Emit `turn_canceled` BEFORE clearing `activeForegroundTurnId`:
|
|
3957
|
+
// `notifySubscribers` reads `activeForegroundTurnId` to tag the event
|
|
3958
|
+
// with `turnId`, so consumers can correlate the cancel back to the
|
|
3959
|
+
// foreground turn it terminates. Mirrors `forceFailActiveTurn`'s
|
|
3960
|
+
// ordering for the same reason.
|
|
3961
|
+
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
|
3962
|
+
this.activeForegroundTurnId = null;
|
|
3963
|
+
// Fence drops any late `turn/completed` / trailing items for this
|
|
3964
|
+
// turn so we don't emit a second terminal event (which would be
|
|
3965
|
+
// tagged with the *new* foreground turn's id and incorrectly
|
|
3966
|
+
// finalize it). Lifted by the next `turn/started`. The fence still
|
|
3967
|
+
// allows the issue #259 sentinel bookkeeping inside
|
|
3968
|
+
// `handleNotification`, so codex's turn/completed status
|
|
3969
|
+
// (interrupted vs completed — the race winner) can still override
|
|
3970
|
+
// the optimistic sentinel set above if codex won the race.
|
|
3971
|
+
this.resetForegroundTurnState();
|
|
2885
3972
|
}
|
|
2886
3973
|
}
|
|
2887
3974
|
async close() {
|
|
2888
3975
|
for (const pending of this.pendingPermissionHandlers.values()) {
|
|
2889
|
-
pending.resolve({ decision: "cancel" });
|
|
3976
|
+
pending.resolve({ decision: pending.protocol === "legacy-review" ? "abort" : "cancel" });
|
|
2890
3977
|
}
|
|
2891
3978
|
this.pendingPermissionHandlers.clear();
|
|
2892
3979
|
this.pendingPermissions.clear();
|
|
2893
3980
|
this.resolvedPermissionRequests.clear();
|
|
2894
3981
|
this.subscribers.clear();
|
|
2895
3982
|
this.activeForegroundTurnId = null;
|
|
3983
|
+
this.clearTurnWatchdog();
|
|
3984
|
+
this.turnWatchdogInflightCycles = 0;
|
|
3985
|
+
this.turnWatchdogCompactionCycles = 0;
|
|
3986
|
+
this.lastTurnNotificationTime = null;
|
|
3987
|
+
this.lastReconnectMarkerKey = null;
|
|
3988
|
+
this.fencedAfterForcedFailure = false;
|
|
3989
|
+
this.fencedTurnId = null;
|
|
3990
|
+
this.expectedCodexTurnId = null;
|
|
3991
|
+
this.turnStartAckPending = false;
|
|
3992
|
+
this.pendingFencedTurnStarted = null;
|
|
3993
|
+
this.inFlightToolCalls.clear();
|
|
3994
|
+
this.compactionInFlight.clear();
|
|
3995
|
+
this.clearCompactionItemWatchdogs();
|
|
3996
|
+
this.cancelAgentMessageFlush();
|
|
3997
|
+
this.pendingAgentMessages.clear();
|
|
3998
|
+
this.emittedAgentMessageLength.clear();
|
|
3999
|
+
this.pendingReasoning.clear();
|
|
4000
|
+
this.terminalCommandByProcessId.clear();
|
|
4001
|
+
this.emittedTerminalInteractionKeys.clear();
|
|
4002
|
+
this.pendingUnlabeledTerminalInteractions.clear();
|
|
2896
4003
|
if (this.client) {
|
|
2897
4004
|
await this.client.dispose();
|
|
2898
4005
|
}
|
|
2899
4006
|
this.client = null;
|
|
2900
4007
|
this.connected = false;
|
|
4008
|
+
this.codexVersion = null;
|
|
4009
|
+
this.manualCompactTurnId = null;
|
|
4010
|
+
this.manualCompactCanceledTurnIds.clear();
|
|
2901
4011
|
this.currentThreadId = null;
|
|
2902
4012
|
this.currentTurnId = null;
|
|
2903
4013
|
}
|
|
@@ -2914,7 +4024,128 @@ class CodexAppServerAgentSession {
|
|
|
2914
4024
|
description: skill.description,
|
|
2915
4025
|
argumentHint: "",
|
|
2916
4026
|
}));
|
|
2917
|
-
|
|
4027
|
+
const builtins = [
|
|
4028
|
+
...(this.supportsManualCompact() ? [COMPACT_COMMAND] : []),
|
|
4029
|
+
...(this.goalsEnabled ? [GOAL_COMMAND] : []),
|
|
4030
|
+
];
|
|
4031
|
+
return [...builtins, ...appServerSkills, ...prompts].sort((a, b) => a.name.localeCompare(b.name));
|
|
4032
|
+
}
|
|
4033
|
+
// issue #1012: whether codex's `thread/compact/start` RPC is available, based
|
|
4034
|
+
// on the version captured from the `initialize` handshake. Unknown version
|
|
4035
|
+
// (older codex / handshake without userAgent) → don't expose `/compact`.
|
|
4036
|
+
supportsManualCompact() {
|
|
4037
|
+
return (this.codexVersion !== null &&
|
|
4038
|
+
isVersionAtLeast(this.codexVersion, CODEX_MANUAL_COMPACT_MIN_VERSION));
|
|
4039
|
+
}
|
|
4040
|
+
async tryHandleOutOfBand(prompt) {
|
|
4041
|
+
if (!this.goalsEnabled || typeof prompt !== "string") {
|
|
4042
|
+
return null;
|
|
4043
|
+
}
|
|
4044
|
+
const parsed = this.parseSlashCommandInput(prompt);
|
|
4045
|
+
if (!parsed || parsed.commandName !== GOAL_COMMAND_NAME) {
|
|
4046
|
+
return null;
|
|
4047
|
+
}
|
|
4048
|
+
const subcommand = parseGoalSubcommand(parsed.args);
|
|
4049
|
+
const text = formatOutOfBandStatusMessage(await this.executeGoalSubcommand(subcommand));
|
|
4050
|
+
return { handled: true, response: text };
|
|
4051
|
+
}
|
|
4052
|
+
async executeGoalSubcommand(subcommand) {
|
|
4053
|
+
if (subcommand.kind === "usage") {
|
|
4054
|
+
return "用法:/goal <objective>|pause|resume|clear";
|
|
4055
|
+
}
|
|
4056
|
+
try {
|
|
4057
|
+
await this.connect();
|
|
4058
|
+
if (this.currentThreadId) {
|
|
4059
|
+
await this.ensureThreadLoaded();
|
|
4060
|
+
}
|
|
4061
|
+
else if (subcommand.kind === "set") {
|
|
4062
|
+
await this.ensureThread();
|
|
4063
|
+
}
|
|
4064
|
+
else {
|
|
4065
|
+
return "没有活动目标可操作。";
|
|
4066
|
+
}
|
|
4067
|
+
if (!this.client || !this.currentThreadId) {
|
|
4068
|
+
throw new Error("Codex 会话不可用");
|
|
4069
|
+
}
|
|
4070
|
+
switch (subcommand.kind) {
|
|
4071
|
+
case "set":
|
|
4072
|
+
await this.client.request("thread/goal/set", {
|
|
4073
|
+
threadId: this.currentThreadId,
|
|
4074
|
+
objective: subcommand.objective,
|
|
4075
|
+
status: "active",
|
|
4076
|
+
});
|
|
4077
|
+
return `已设置目标:${subcommand.objective}`;
|
|
4078
|
+
case "pause":
|
|
4079
|
+
await this.client.request("thread/goal/set", {
|
|
4080
|
+
threadId: this.currentThreadId,
|
|
4081
|
+
status: "paused",
|
|
4082
|
+
});
|
|
4083
|
+
return "目标已暂停。";
|
|
4084
|
+
case "resume":
|
|
4085
|
+
await this.client.request("thread/goal/set", {
|
|
4086
|
+
threadId: this.currentThreadId,
|
|
4087
|
+
status: "active",
|
|
4088
|
+
});
|
|
4089
|
+
return "目标已恢复。";
|
|
4090
|
+
case "clear":
|
|
4091
|
+
await this.client.request("thread/goal/clear", {
|
|
4092
|
+
threadId: this.currentThreadId,
|
|
4093
|
+
});
|
|
4094
|
+
return "目标已清除。";
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
catch (error) {
|
|
4098
|
+
const message = error instanceof Error ? error.message : "未知错误";
|
|
4099
|
+
return `更新目标失败:${message}`;
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
// issue #1012: run a manual context compaction. `thread/compact/start` is a
|
|
4103
|
+
// standalone RPC, not a turn, so codex emits no `turn/started` / `turn/completed`
|
|
4104
|
+
// for it — we synthesize those here (mirrors claude's `executeRewindTurn`) so the
|
|
4105
|
+
// UI leaves the "thinking" state. The `contextCompaction` thread item streams in
|
|
4106
|
+
// independently and renders the compaction marker (#999 watchdog defer applies).
|
|
4107
|
+
async executeManualCompact(turnId) {
|
|
4108
|
+
this.manualCompactTurnId = turnId;
|
|
4109
|
+
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
|
4110
|
+
try {
|
|
4111
|
+
if (!this.client) {
|
|
4112
|
+
throw new Error("Codex client not initialized");
|
|
4113
|
+
}
|
|
4114
|
+
if (this.currentThreadId) {
|
|
4115
|
+
await this.ensureThreadLoaded();
|
|
4116
|
+
}
|
|
4117
|
+
else {
|
|
4118
|
+
await this.ensureThread();
|
|
4119
|
+
}
|
|
4120
|
+
if (!this.currentThreadId) {
|
|
4121
|
+
throw new Error("没有可压缩的会话上下文");
|
|
4122
|
+
}
|
|
4123
|
+
await this.client.request("thread/compact/start", { threadId: this.currentThreadId });
|
|
4124
|
+
if (this.manualCompactCanceledTurnIds.has(turnId)) {
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER });
|
|
4128
|
+
}
|
|
4129
|
+
catch (error) {
|
|
4130
|
+
if (this.manualCompactCanceledTurnIds.has(turnId)) {
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
this.emitEvent({
|
|
4134
|
+
type: "turn_failed",
|
|
4135
|
+
provider: CODEX_PROVIDER,
|
|
4136
|
+
error: error instanceof Error ? error.message : "手动压缩失败",
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
finally {
|
|
4140
|
+
this.manualCompactCanceledTurnIds.delete(turnId);
|
|
4141
|
+
if (this.manualCompactTurnId === turnId) {
|
|
4142
|
+
this.manualCompactTurnId = null;
|
|
4143
|
+
}
|
|
4144
|
+
if (this.activeForegroundTurnId === turnId) {
|
|
4145
|
+
this.activeForegroundTurnId = null;
|
|
4146
|
+
this.clearTurnWatchdog();
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
2918
4149
|
}
|
|
2919
4150
|
async ensureThread() {
|
|
2920
4151
|
if (!this.client)
|
|
@@ -2952,8 +4183,9 @@ class CodexAppServerAgentSession {
|
|
|
2952
4183
|
this.config.model = model;
|
|
2953
4184
|
this.config.thinkingOptionId = thinkingOptionId;
|
|
2954
4185
|
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
|
2955
|
-
|
|
2956
|
-
|
|
4186
|
+
let approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
|
4187
|
+
let sandbox = this.config.sandboxMode ?? preset.sandbox;
|
|
4188
|
+
({ approvalPolicy, sandboxPolicyType: sandbox } = applyPlanModeConstraints(approvalPolicy, sandbox, this.planModeEnabled));
|
|
2957
4189
|
const innerConfig = this.buildCodexInnerConfig();
|
|
2958
4190
|
const response = (await this.client.request("thread/start", {
|
|
2959
4191
|
model,
|
|
@@ -2973,16 +4205,66 @@ class CodexAppServerAgentSession {
|
|
|
2973
4205
|
}
|
|
2974
4206
|
buildCodexInnerConfig() {
|
|
2975
4207
|
const innerConfig = {};
|
|
4208
|
+
// The daemon-owned seawork builtin (if present) is auto-approved + given a
|
|
4209
|
+
// long tool timeout. We build it separately and re-assert it AFTER the
|
|
4210
|
+
// extra.codex merge below, so a raw `extra.codex.mcp_servers.seawork`
|
|
4211
|
+
// override can never replace the trusted builtin with an auto-approved
|
|
4212
|
+
// attacker-controlled MCP. The `seawork` key itself is already reserved for
|
|
4213
|
+
// the builtin upstream (agent-manager.applySeaworkMcp).
|
|
4214
|
+
let seaworkBuiltin;
|
|
2976
4215
|
if (this.config.mcpServers) {
|
|
2977
4216
|
const mcpServers = {};
|
|
2978
4217
|
for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) {
|
|
2979
|
-
|
|
4218
|
+
const mapped = toCodexMcpConfig(serverConfig);
|
|
4219
|
+
if (name === SEAWORK_BUILTIN_MCP_NAME) {
|
|
4220
|
+
// generation_task blocks while a video/3d task renders; codex aborts
|
|
4221
|
+
// MCP tools/call after DEFAULT_TOOL_TIMEOUT (120s), so raise the limit.
|
|
4222
|
+
mapped.tool_timeout_sec = SEAWORK_MCP_TOOL_TIMEOUT_SEC;
|
|
4223
|
+
// Auto-approve ONLY the safe SeaArt generation tools (per-tool, never
|
|
4224
|
+
// server-wide). Under the default "auto" mode + "on-request" policy
|
|
4225
|
+
// codex routes each call to an approval prompt with no interactive
|
|
4226
|
+
// approver, which resolves to "user rejected MCP tool call". The same
|
|
4227
|
+
// builtin server also exposes high-impact agent/terminal/schedule/
|
|
4228
|
+
// permission tools that MUST keep their approval gate, so a blanket
|
|
4229
|
+
// default_tools_approval_mode would be an over-broad bypass.
|
|
4230
|
+
mapped.tools = Object.fromEntries(SEAWORK_AUTO_APPROVE_TOOLS.map((tool) => [tool, { approval_mode: "approve" }]));
|
|
4231
|
+
seaworkBuiltin = mapped;
|
|
4232
|
+
}
|
|
4233
|
+
mcpServers[name] = mapped;
|
|
2980
4234
|
}
|
|
2981
4235
|
innerConfig.mcp_servers = mcpServers;
|
|
2982
4236
|
}
|
|
4237
|
+
// issue #973: tighten codex's auto-compaction threshold. Scope MUST stay
|
|
4238
|
+
// "body_after_prefix" (codex's default) — that is the only branch that reads
|
|
4239
|
+
// our injected `model_auto_compact_token_limit`. The "total" scope ignores
|
|
4240
|
+
// the config value entirely and only consults the per-model window-derived
|
|
4241
|
+
// limit (codex-rs/core/src/session/turn.rs:664-684), so setting it would
|
|
4242
|
+
// silently disable this fix. Set the defaults before the extra.codex merge so
|
|
4243
|
+
// an explicit override still wins.
|
|
4244
|
+
innerConfig.model_auto_compact_token_limit = CODEX_AUTO_COMPACT_TOKEN_LIMIT;
|
|
4245
|
+
innerConfig.model_auto_compact_token_limit_scope = "body_after_prefix";
|
|
2983
4246
|
if (this.config.extra?.codex) {
|
|
2984
4247
|
Object.assign(innerConfig, this.config.extra.codex);
|
|
2985
4248
|
}
|
|
4249
|
+
// Enforce the reserved-name invariant after the extra.codex merge (a shallow
|
|
4250
|
+
// Object.assign that could replace the whole mcp_servers map). The `seawork`
|
|
4251
|
+
// key must ALWAYS be the daemon-owned builtin or absent — never an
|
|
4252
|
+
// extra.codex-supplied entry, which could carry default_tools_approval_mode
|
|
4253
|
+
// "approve". When a builtin exists, force it back; when none exists
|
|
4254
|
+
// (no mcpBaseUrl / injection disabled), drop any `seawork` extra.codex set.
|
|
4255
|
+
if (seaworkBuiltin) {
|
|
4256
|
+
// Builtin exists → always restore it, even if extra.codex set
|
|
4257
|
+
// mcp_servers to null/garbage (which would otherwise drop it).
|
|
4258
|
+
const mergedMcp = readStringRecord(innerConfig.mcp_servers);
|
|
4259
|
+
mergedMcp[SEAWORK_BUILTIN_MCP_NAME] = seaworkBuiltin;
|
|
4260
|
+
innerConfig.mcp_servers = mergedMcp;
|
|
4261
|
+
}
|
|
4262
|
+
else if (innerConfig.mcp_servers) {
|
|
4263
|
+
// No builtin → strip any `seawork` an extra.codex override smuggled in.
|
|
4264
|
+
const mergedMcp = readStringRecord(innerConfig.mcp_servers);
|
|
4265
|
+
delete mergedMcp[SEAWORK_BUILTIN_MCP_NAME];
|
|
4266
|
+
innerConfig.mcp_servers = mergedMcp;
|
|
4267
|
+
}
|
|
2986
4268
|
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
|
2987
4269
|
}
|
|
2988
4270
|
async buildUserInput(prompt) {
|
|
@@ -3013,13 +4295,358 @@ class CodexAppServerAgentSession {
|
|
|
3013
4295
|
}
|
|
3014
4296
|
return [{ type: "text", text: CANCEL_REMINDER_TEXT }, ...input];
|
|
3015
4297
|
}
|
|
4298
|
+
// Tag a turn failure with the gateway request id captured from codex's
|
|
4299
|
+
// stderr when the message doesn't already carry one (status errors include
|
|
4300
|
+
// `request id:` themselves; stream disconnects do not). The app's
|
|
4301
|
+
// system-error parser reads this `request id: <id>` token.
|
|
4302
|
+
//
|
|
4303
|
+
// `lastResponsesRequestId` is scraped from codex's `Request completed` debug
|
|
4304
|
+
// line, which codex only logs once a response HEADER has arrived. A
|
|
4305
|
+
// mid-stream disconnect therefore has the failing request's own id (correct
|
|
4306
|
+
// to append). But an `error sending request` failure happens in the SEND
|
|
4307
|
+
// phase — no response header, no `Request completed` line — so the scraped id
|
|
4308
|
+
// still points at the PREVIOUS, successful request. Appending it there
|
|
4309
|
+
// mislabels the error with an unrelated id that looks failed but resolves to
|
|
4310
|
+
// a 200 in gateway logs (issues #914/#862: one successful id `de0a34fc…`
|
|
4311
|
+
// surfaced in unrelated disconnect reports). Don't append in that case.
|
|
4312
|
+
appendRequestId(error) {
|
|
4313
|
+
if (/request id:/i.test(error))
|
|
4314
|
+
return error;
|
|
4315
|
+
if (/error sending request/i.test(error))
|
|
4316
|
+
return error;
|
|
4317
|
+
const requestId = typeof this.client?.getLastResponsesRequestId === "function"
|
|
4318
|
+
? this.client.getLastResponsesRequestId()
|
|
4319
|
+
: null;
|
|
4320
|
+
return requestId ? `${error} (request id: ${requestId})` : error;
|
|
4321
|
+
}
|
|
4322
|
+
buildTurnFailedDiagnostic(error) {
|
|
4323
|
+
const stderrTail = typeof this.client?.getRecentStderrTail === "function"
|
|
4324
|
+
? this.client.getRecentStderrTail()?.trim()
|
|
4325
|
+
: undefined;
|
|
4326
|
+
if (!stderrTail) {
|
|
4327
|
+
return undefined;
|
|
4328
|
+
}
|
|
4329
|
+
const normalizedError = error
|
|
4330
|
+
.trim()
|
|
4331
|
+
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+/gm, "")
|
|
4332
|
+
.replace(/^[A-Z]+\s+/gm, "")
|
|
4333
|
+
.trim();
|
|
4334
|
+
const normalizedStderr = stderrTail
|
|
4335
|
+
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+/gm, "")
|
|
4336
|
+
.replace(/^[A-Z]+\s+/gm, "")
|
|
4337
|
+
.trim();
|
|
4338
|
+
if (normalizedStderr === normalizedError || normalizedStderr.endsWith(normalizedError)) {
|
|
4339
|
+
return undefined;
|
|
4340
|
+
}
|
|
4341
|
+
return stderrTail;
|
|
4342
|
+
}
|
|
3016
4343
|
emitEvent(event) {
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
4344
|
+
// Note: assistant-message buffers (`pendingAgentMessages`) are NOT cleared
|
|
4345
|
+
// here. Streaming now emits multiple `assistant_message` timeline items
|
|
4346
|
+
// per message, so an emit-driven clear would wipe the buffer mid-stream.
|
|
4347
|
+
// Buffers are instead cleared per-itemId at `item_completed` and fully on
|
|
4348
|
+
// turn teardown.
|
|
4349
|
+
this.notifySubscribers(event);
|
|
4350
|
+
}
|
|
4351
|
+
clearCompactionItemWatchdogs() {
|
|
4352
|
+
for (const timer of this.compactionItemTimers.values()) {
|
|
4353
|
+
clearTimeout(timer);
|
|
4354
|
+
}
|
|
4355
|
+
this.compactionItemTimers.clear();
|
|
4356
|
+
}
|
|
4357
|
+
clearCompactionItemWatchdog(itemId) {
|
|
4358
|
+
const timer = this.compactionItemTimers.get(itemId);
|
|
4359
|
+
if (timer) {
|
|
4360
|
+
clearTimeout(timer);
|
|
4361
|
+
this.compactionItemTimers.delete(itemId);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
armCompactionItemWatchdog(itemId) {
|
|
4365
|
+
this.clearCompactionItemWatchdog(itemId);
|
|
4366
|
+
const timer = setTimeout(() => {
|
|
4367
|
+
this.compactionItemTimers.delete(itemId);
|
|
4368
|
+
if (!this.compactionInFlight.delete(itemId)) {
|
|
4369
|
+
return;
|
|
4370
|
+
}
|
|
4371
|
+
this.logger.warn({ itemId, timeoutMs: COMPACTION_ITEM_WATCHDOG_MS }, "Codex context compaction completion missing; closing timeline marker");
|
|
4372
|
+
this.emitEvent({
|
|
4373
|
+
type: "timeline",
|
|
4374
|
+
provider: CODEX_PROVIDER,
|
|
4375
|
+
item: { type: "compaction", status: "completed", trigger: "auto" },
|
|
4376
|
+
});
|
|
4377
|
+
}, COMPACTION_ITEM_WATCHDOG_MS);
|
|
4378
|
+
timer.unref?.();
|
|
4379
|
+
this.compactionItemTimers.set(itemId, timer);
|
|
4380
|
+
}
|
|
4381
|
+
// issue #1824: a completed `/responses` HTTP round-trip is upstream activity
|
|
4382
|
+
// that emits no protocol notification, so the watchdog would otherwise force-
|
|
4383
|
+
// fail a turn that is genuinely uploading/streaming. Treat it exactly like a
|
|
4384
|
+
// notification: re-arm and cancel any pending late-completion force-fail.
|
|
4385
|
+
markUpstreamLiveness() {
|
|
4386
|
+
if (!this.activeForegroundTurnId)
|
|
4387
|
+
return;
|
|
4388
|
+
this.clearPendingForceFail();
|
|
4389
|
+
this.lastTurnNotificationTime = Date.now();
|
|
4390
|
+
this.armTurnWatchdog();
|
|
4391
|
+
}
|
|
4392
|
+
// issue #505: the turn lifecycle relies entirely on codex sending a
|
|
4393
|
+
// `turn/completed` notification. If that notification is lost, the turn
|
|
4394
|
+
// never finalizes and the UI stays "thinking" forever. The watchdog is
|
|
4395
|
+
// re-armed on every notification for the active turn, so a healthy turn
|
|
4396
|
+
// never trips it; a turn that goes fully silent is force-completed.
|
|
4397
|
+
//
|
|
4398
|
+
// `deferred` distinguishes a re-arm by the watchdog itself (silence
|
|
4399
|
+
// continuing) from a reset by a genuine notification — only the latter
|
|
4400
|
+
// clears the in-flight grace counter.
|
|
4401
|
+
armTurnWatchdog(deferred = false) {
|
|
4402
|
+
this.clearTurnWatchdog();
|
|
4403
|
+
if (!deferred) {
|
|
4404
|
+
this.turnWatchdogInflightCycles = 0;
|
|
4405
|
+
this.turnWatchdogCompactionCycles = 0;
|
|
4406
|
+
}
|
|
4407
|
+
this.turnWatchdogTimer = setTimeout(() => {
|
|
4408
|
+
this.turnWatchdogTimer = null;
|
|
4409
|
+
const pendingPermissionsForActiveTurn = this.countPendingPermissionsForActiveTurn();
|
|
4410
|
+
if (pendingPermissionsForActiveTurn > 0) {
|
|
4411
|
+
this.logger.debug({
|
|
4412
|
+
pendingPermissions: pendingPermissionsForActiveTurn,
|
|
4413
|
+
totalPendingPermissions: this.pendingPermissionHandlers.size,
|
|
4414
|
+
}, "Codex turn watchdog deferred: waiting for permission response");
|
|
4415
|
+
this.armTurnWatchdog(true);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
// A long, output-silent tool (build, slow network call) is NOT a lost
|
|
4419
|
+
// turn — codex will still send its completion. Grant it a bounded number
|
|
4420
|
+
// of extra idle cycles. The bound matters: if the tool's *completion*
|
|
4421
|
+
// notification is itself lost, an unbounded re-arm would recreate the
|
|
4422
|
+
// exact "stuck thinking forever" bug, just keyed on a lost tool event.
|
|
4423
|
+
if (this.inFlightToolCalls.size > 0 &&
|
|
4424
|
+
this.turnWatchdogInflightCycles < TURN_WATCHDOG_MAX_INFLIGHT_CYCLES) {
|
|
4425
|
+
this.turnWatchdogInflightCycles += 1;
|
|
4426
|
+
this.logger.debug({ inFlight: this.inFlightToolCalls.size, cycle: this.turnWatchdogInflightCycles }, "Codex turn watchdog deferred: tool execution still in flight");
|
|
4427
|
+
this.armTurnWatchdog(true);
|
|
4428
|
+
return;
|
|
4429
|
+
}
|
|
4430
|
+
// issue #999: auto-compaction (pack ~240K-token context + an LLM
|
|
4431
|
+
// summarization call) is a legitimate long, turn-event-silent operation,
|
|
4432
|
+
// not a lost turn. Defer like an in-flight tool, with a wider but still
|
|
4433
|
+
// bounded budget so a lost compaction completion still recovers.
|
|
4434
|
+
if (this.compactionInFlight.size > 0 &&
|
|
4435
|
+
this.turnWatchdogCompactionCycles < TURN_WATCHDOG_MAX_COMPACTION_CYCLES) {
|
|
4436
|
+
this.turnWatchdogCompactionCycles += 1;
|
|
4437
|
+
this.logger.debug({
|
|
4438
|
+
compactionInFlight: this.compactionInFlight.size,
|
|
4439
|
+
cycle: this.turnWatchdogCompactionCycles,
|
|
4440
|
+
}, "Codex turn watchdog deferred: context compaction in flight");
|
|
4441
|
+
this.armTurnWatchdog(true);
|
|
4442
|
+
return;
|
|
3020
4443
|
}
|
|
4444
|
+
// issue #1181: the timer may fire long after the last notification arrived
|
|
4445
|
+
// because other agents starved the event loop. Re-check the real idle gap;
|
|
4446
|
+
// if a notification arrived < IDLE_MS ago, re-arm instead of false-failing.
|
|
4447
|
+
if (this.lastTurnNotificationTime !== null) {
|
|
4448
|
+
const idleSinceMs = Date.now() - this.lastTurnNotificationTime;
|
|
4449
|
+
if (idleSinceMs < TURN_WATCHDOG_IDLE_MS) {
|
|
4450
|
+
this.armTurnWatchdog(true);
|
|
4451
|
+
return;
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
const stallError = "Codex turn stalled (no events received); recovered by watchdog";
|
|
4455
|
+
const context = [
|
|
4456
|
+
`pendingPermissions=${pendingPermissionsForActiveTurn}`,
|
|
4457
|
+
`watchdogCycles inflight=${this.turnWatchdogInflightCycles} compaction=${this.turnWatchdogCompactionCycles}`,
|
|
4458
|
+
`sinceLastNotification=${this.lastTurnNotificationTime !== null ? Date.now() - this.lastTurnNotificationTime : "unknown"}ms`,
|
|
4459
|
+
].join("\n");
|
|
4460
|
+
// issue #1427: do not force-fail yet. Give a late-but-successful
|
|
4461
|
+
// turn/completed a short grace window to land — committing now is what
|
|
4462
|
+
// mis-kills it. armPendingForceFail commits only if the turn is still the
|
|
4463
|
+
// active foreground turn (i.e. no completion arrived) when the window
|
|
4464
|
+
// elapses; any notification in between clears the pending timer.
|
|
4465
|
+
this.armPendingForceFail(stallError, context);
|
|
4466
|
+
}, TURN_WATCHDOG_IDLE_MS);
|
|
4467
|
+
this.turnWatchdogTimer.unref?.();
|
|
4468
|
+
}
|
|
4469
|
+
// issue #1427: arm the grace timer that decides whether a stalled turn is
|
|
4470
|
+
// really dead or just slow to deliver its completion. Snapshots the turn id at
|
|
4471
|
+
// schedule time; on fire, only commits the force-fail if that same turn is
|
|
4472
|
+
// still active (a completion that arrived during the window finalizes the turn
|
|
4473
|
+
// and clears activeForegroundTurnId, so the snapshot no longer matches).
|
|
4474
|
+
armPendingForceFail(stallError, context) {
|
|
4475
|
+
this.clearPendingForceFail();
|
|
4476
|
+
const turnIdAtSchedule = this.activeForegroundTurnId;
|
|
4477
|
+
if (!turnIdAtSchedule) {
|
|
4478
|
+
return;
|
|
3021
4479
|
}
|
|
3022
|
-
this.
|
|
4480
|
+
this.pendingForceFailTimer = setTimeout(() => {
|
|
4481
|
+
this.pendingForceFailTimer = null;
|
|
4482
|
+
if (this.activeForegroundTurnId !== turnIdAtSchedule) {
|
|
4483
|
+
// A turn/completed (or interrupt/new turn) landed during the grace
|
|
4484
|
+
// window and already finalized this turn — the watchdog mis-fired. Do
|
|
4485
|
+
// not emit turn_failed.
|
|
4486
|
+
this.logger.debug({ turnId: turnIdAtSchedule, graceMs: WATCHDOG_LATE_COMPLETION_GRACE_MS }, "Codex watchdog stand-down: turn completed within late-completion grace window");
|
|
4487
|
+
return;
|
|
4488
|
+
}
|
|
4489
|
+
const stderrTail = this.buildTurnFailedDiagnostic(stallError);
|
|
4490
|
+
const graceNote = `lateCompletionGraceMs=${WATCHDOG_LATE_COMPLETION_GRACE_MS}`;
|
|
4491
|
+
const diagnostic = `${context}\n${graceNote}`;
|
|
4492
|
+
this.forceFailActiveTurn(stallError, stderrTail ? `${diagnostic}\n${stderrTail}` : diagnostic);
|
|
4493
|
+
}, WATCHDOG_LATE_COMPLETION_GRACE_MS);
|
|
4494
|
+
this.pendingForceFailTimer.unref?.();
|
|
4495
|
+
}
|
|
4496
|
+
clearPendingForceFail() {
|
|
4497
|
+
if (this.pendingForceFailTimer) {
|
|
4498
|
+
clearTimeout(this.pendingForceFailTimer);
|
|
4499
|
+
this.pendingForceFailTimer = null;
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
// Stops the timers only. The in-flight grace counter is reset by a
|
|
4503
|
+
// non-deferred armTurnWatchdog() (a real notification) and by turn teardown,
|
|
4504
|
+
// so a deferred re-arm — which calls this first — keeps its accumulated count.
|
|
4505
|
+
// issue #1427: also drops a pending late-completion force-fail. Safe across a
|
|
4506
|
+
// deferred re-arm: those paths return before arming a pending force-fail, and
|
|
4507
|
+
// the force-fail-committing path arms its grace timer *after* the last
|
|
4508
|
+
// clearTurnWatchdog() in the same tick, so this never kills the timer it set.
|
|
4509
|
+
clearTurnWatchdog() {
|
|
4510
|
+
if (this.turnWatchdogTimer) {
|
|
4511
|
+
clearTimeout(this.turnWatchdogTimer);
|
|
4512
|
+
this.turnWatchdogTimer = null;
|
|
4513
|
+
}
|
|
4514
|
+
this.clearPendingForceFail();
|
|
4515
|
+
}
|
|
4516
|
+
countPendingPermissionsForActiveTurn() {
|
|
4517
|
+
const foregroundTurnId = this.activeForegroundTurnId;
|
|
4518
|
+
if (!foregroundTurnId) {
|
|
4519
|
+
return 0;
|
|
4520
|
+
}
|
|
4521
|
+
let count = 0;
|
|
4522
|
+
for (const pending of this.pendingPermissionHandlers.values()) {
|
|
4523
|
+
if (pending.foregroundTurnId !== foregroundTurnId) {
|
|
4524
|
+
continue;
|
|
4525
|
+
}
|
|
4526
|
+
if (pending.codexTurnId && !this.isActiveForegroundCodexTurnId(pending.codexTurnId)) {
|
|
4527
|
+
continue;
|
|
4528
|
+
}
|
|
4529
|
+
count += 1;
|
|
4530
|
+
}
|
|
4531
|
+
return count;
|
|
4532
|
+
}
|
|
4533
|
+
// Tear down provider-side foreground turn state after a cancel/fail. Fences
|
|
4534
|
+
// any late notification for the (now cleared) turn. Does not emit, set
|
|
4535
|
+
// `lastCanceledAt`, clear pending permissions, or touch
|
|
4536
|
+
// `activeForegroundTurnId` — callers own those.
|
|
4537
|
+
resetForegroundTurnState() {
|
|
4538
|
+
this.fencedAfterForcedFailure = true;
|
|
4539
|
+
// issue #1836: headline case — the turn/start ack returned codex's turn.id
|
|
4540
|
+
// (expectedCodexTurnId) but turn/started never arrived, so currentTurnId is
|
|
4541
|
+
// still null. Fall back to expectedCodexTurnId so the fenced id is non-null
|
|
4542
|
+
// and the self-heal guard (fencedTurnId !== null) can fire.
|
|
4543
|
+
this.fencedTurnId = this.currentTurnId ?? this.expectedCodexTurnId;
|
|
4544
|
+
this.currentTurnId = null;
|
|
4545
|
+
this.expectedCodexTurnId = null;
|
|
4546
|
+
this.turnStartAckPending = false;
|
|
4547
|
+
this.pendingFencedTurnStarted = null;
|
|
4548
|
+
this.clearTurnWatchdog();
|
|
4549
|
+
this.turnWatchdogInflightCycles = 0;
|
|
4550
|
+
this.turnWatchdogCompactionCycles = 0;
|
|
4551
|
+
this.lastTurnNotificationTime = null;
|
|
4552
|
+
// issue #1836: default the fence to "not from force-fail" so the interrupt
|
|
4553
|
+
// callers leave self-heal disabled; `forceFailActiveTurn` re-sets these two
|
|
4554
|
+
// right after calling this. Clearing the stored id here also stops a prior
|
|
4555
|
+
// force-fail's id leaking into a later interrupt fence.
|
|
4556
|
+
this.fencedByForceFail = false;
|
|
4557
|
+
this.lastForcedFailForegroundTurnId = null;
|
|
4558
|
+
// issue #1427: drop the reconnect-marker dedup key on turn teardown so the
|
|
4559
|
+
// next stall re-announces.
|
|
4560
|
+
this.lastReconnectMarkerKey = null;
|
|
4561
|
+
this.inFlightToolCalls.clear();
|
|
4562
|
+
this.compactionInFlight.clear();
|
|
4563
|
+
this.clearCompactionItemWatchdogs();
|
|
4564
|
+
this.cancelAgentMessageFlush();
|
|
4565
|
+
this.pendingAgentMessages.clear();
|
|
4566
|
+
this.emittedAgentMessageLength.clear();
|
|
4567
|
+
}
|
|
4568
|
+
// issue #505: force-fail the active foreground turn (watchdog stall or
|
|
4569
|
+
// process exit). Emits `turn_failed`, then fences the turn so any late
|
|
4570
|
+
// notification — a delayed `turn/completed`, a trailing terminal item — is
|
|
4571
|
+
// dropped instead of resurfacing a turn the UI already saw fail. The fence
|
|
4572
|
+
// is lifted once we see a safely attributable retry-turn notification.
|
|
4573
|
+
forceFailActiveTurn(error, diagnostic) {
|
|
4574
|
+
if (!this.activeForegroundTurnId)
|
|
4575
|
+
return;
|
|
4576
|
+
const turnId = this.activeForegroundTurnId;
|
|
4577
|
+
this.logger.warn({ turnId, error }, "Codex force-failing turn");
|
|
4578
|
+
this.clearTurnWatchdog();
|
|
4579
|
+
// emitEvent tags the event with activeForegroundTurnId, so emit before clearing.
|
|
4580
|
+
this.emitEvent({ type: "turn_failed", provider: CODEX_PROVIDER, error, diagnostic });
|
|
4581
|
+
this.clearPendingPermissionsForTurn(turnId);
|
|
4582
|
+
this.activeForegroundTurnId = null;
|
|
4583
|
+
this.resetForegroundTurnState();
|
|
4584
|
+
// issue #1836: mark this fence as force-fail (so self-heal may resurrect it)
|
|
4585
|
+
// and remember the local turn id so a self-heal resumes under the same id
|
|
4586
|
+
// (preserving turn-event accounting). Set AFTER resetForegroundTurnState,
|
|
4587
|
+
// which clears both for the interrupt callers that share it.
|
|
4588
|
+
this.fencedByForceFail = true;
|
|
4589
|
+
this.lastForcedFailForegroundTurnId = turnId;
|
|
4590
|
+
}
|
|
4591
|
+
handleClientExit(reason) {
|
|
4592
|
+
this.client = null;
|
|
4593
|
+
this.connected = false;
|
|
4594
|
+
this.clearTurnWatchdog();
|
|
4595
|
+
this.forceFailActiveTurn(`Codex process exited: ${reason}`);
|
|
4596
|
+
this.currentTurnId = null;
|
|
4597
|
+
}
|
|
4598
|
+
clearPendingPermissionsForTurn(turnId) {
|
|
4599
|
+
for (const [requestId, pending] of this.pendingPermissionHandlers.entries()) {
|
|
4600
|
+
if (pending.foregroundTurnId !== turnId) {
|
|
4601
|
+
continue;
|
|
4602
|
+
}
|
|
4603
|
+
this.pendingPermissionHandlers.delete(requestId);
|
|
4604
|
+
this.pendingPermissions.delete(requestId);
|
|
4605
|
+
this.resolvedPermissionRequests.add(requestId);
|
|
4606
|
+
if (pending.kind === "question") {
|
|
4607
|
+
pending.resolve({ answers: {} });
|
|
4608
|
+
continue;
|
|
4609
|
+
}
|
|
4610
|
+
pending.resolve({ decision: pending.protocol === "legacy-review" ? "abort" : "cancel" });
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
/**
|
|
4614
|
+
* Emit any not-yet-streamed tail of an in-flight agent message as an
|
|
4615
|
+
* `assistant_message` timeline delta. Throttled (~60ms) so a fast token
|
|
4616
|
+
* stream does not produce one timeline event — and one client setState —
|
|
4617
|
+
* per token. The UI coalesces consecutive `assistant_message` items into a
|
|
4618
|
+
* single growing bubble (see app `appendAssistantMessage`).
|
|
4619
|
+
*/
|
|
4620
|
+
flushAgentMessageDelta(itemId) {
|
|
4621
|
+
this.agentMessageFlushPendingItemId = itemId;
|
|
4622
|
+
if (this.agentMessageFlushTimer)
|
|
4623
|
+
return;
|
|
4624
|
+
this.agentMessageFlushTimer = setTimeout(() => {
|
|
4625
|
+
this.agentMessageFlushTimer = null;
|
|
4626
|
+
const id = this.agentMessageFlushPendingItemId;
|
|
4627
|
+
this.agentMessageFlushPendingItemId = null;
|
|
4628
|
+
if (!id)
|
|
4629
|
+
return;
|
|
4630
|
+
const full = this.pendingAgentMessages.get(id) ?? "";
|
|
4631
|
+
const emitted = this.emittedAgentMessageLength.get(id) ?? 0;
|
|
4632
|
+
const chunk = full.slice(emitted);
|
|
4633
|
+
if (chunk.length === 0)
|
|
4634
|
+
return;
|
|
4635
|
+
this.emittedAgentMessageLength.set(id, full.length);
|
|
4636
|
+
this.emitEvent({
|
|
4637
|
+
type: "timeline",
|
|
4638
|
+
provider: CODEX_PROVIDER,
|
|
4639
|
+
item: { type: "assistant_message", text: chunk },
|
|
4640
|
+
});
|
|
4641
|
+
}, 60);
|
|
4642
|
+
}
|
|
4643
|
+
/** Cancel a pending streaming flush (turn teardown / cancel / close). */
|
|
4644
|
+
cancelAgentMessageFlush() {
|
|
4645
|
+
if (this.agentMessageFlushTimer) {
|
|
4646
|
+
clearTimeout(this.agentMessageFlushTimer);
|
|
4647
|
+
this.agentMessageFlushTimer = null;
|
|
4648
|
+
}
|
|
4649
|
+
this.agentMessageFlushPendingItemId = null;
|
|
3023
4650
|
}
|
|
3024
4651
|
notifySubscribers(event) {
|
|
3025
4652
|
const turnId = this.activeForegroundTurnId;
|
|
@@ -3038,6 +4665,130 @@ class CodexAppServerAgentSession {
|
|
|
3038
4665
|
}
|
|
3039
4666
|
handleNotification(method, params) {
|
|
3040
4667
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
|
4668
|
+
// issue #505: after a turn was force-failed, drop every late notification
|
|
4669
|
+
// for that turn — a delayed `turn/completed`, a trailing terminal item —
|
|
4670
|
+
// so it cannot resurface a turn the UI already saw fail.
|
|
4671
|
+
//
|
|
4672
|
+
// `turn/started` lifts the fence only when it belongs to the post-retry
|
|
4673
|
+
// turn. Three states for the new turn's `turn/start` ack:
|
|
4674
|
+
// * pending (`turnStartAckPending=true`): activeForegroundTurnId is set
|
|
4675
|
+
// but the ack has not returned, so we do NOT yet know codex's turn.id
|
|
4676
|
+
// for the new turn. Drop unconditionally — a turn/started arriving in
|
|
4677
|
+
// this window belongs to a dead turn racing the ack.
|
|
4678
|
+
// * returned with `turn.id` captured: require the incoming id to match
|
|
4679
|
+
// `expectedCodexTurnId`. Mismatched ids stay fenced.
|
|
4680
|
+
// * returned without `turn.id` (older codex / schema drift): fall back
|
|
4681
|
+
// to the weaker "any turn/started after retry lifts the fence" rule.
|
|
4682
|
+
// Strictly worse than the matched-id path, but does NOT hard-lock the
|
|
4683
|
+
// retry forever.
|
|
4684
|
+
if (this.fencedAfterForcedFailure) {
|
|
4685
|
+
const isFreshTurnStarted = parsed.kind === "turn_started" &&
|
|
4686
|
+
this.activeForegroundTurnId !== null &&
|
|
4687
|
+
!this.turnStartAckPending &&
|
|
4688
|
+
(this.expectedCodexTurnId === null || parsed.turnId === this.expectedCodexTurnId);
|
|
4689
|
+
const isFreshTurnContinuation = this.isCurrentRetryTurnNotification(parsed);
|
|
4690
|
+
if (isFreshTurnStarted || isFreshTurnContinuation) {
|
|
4691
|
+
this.fencedAfterForcedFailure = false;
|
|
4692
|
+
this.fencedTurnId = null;
|
|
4693
|
+
}
|
|
4694
|
+
else {
|
|
4695
|
+
// issue #259 + #664: even though we drop the notification (no emit,
|
|
4696
|
+
// no state mutation), codex's `turn/completed` for the fenced turn
|
|
4697
|
+
// still carries the authoritative interrupted-vs-completed race
|
|
4698
|
+
// outcome. Update only `lastCanceledAt` so the next `turn/start`
|
|
4699
|
+
// either prepends the ignore-sentinel (codex confirmed the cancel)
|
|
4700
|
+
// or starts clean (codex won the race and naturally completed).
|
|
4701
|
+
if (parsed.kind === "turn_completed") {
|
|
4702
|
+
const before = this.lastCanceledAt;
|
|
4703
|
+
if (parsed.status === "interrupted") {
|
|
4704
|
+
this.lastCanceledAt = Date.now();
|
|
4705
|
+
}
|
|
4706
|
+
else if (parsed.status === "completed") {
|
|
4707
|
+
this.lastCanceledAt = null;
|
|
4708
|
+
}
|
|
4709
|
+
// issue #277 (gpt-5.5 review): this fenced path emits no terminal
|
|
4710
|
+
// event, so `finalizeForegroundTurn` never re-snapshots persistence.
|
|
4711
|
+
// Without this, a race the codex side won (sentinel cleared to null)
|
|
4712
|
+
// would leave the optimistic non-null `lastCanceledAt` in the
|
|
4713
|
+
// persisted handle, and a resume within CANCEL_REMINDER_WINDOW_MS
|
|
4714
|
+
// would wrongly prepend the ignore reminder for a turn that actually
|
|
4715
|
+
// completed. Re-emit `thread_started` (same threadId, no UI side
|
|
4716
|
+
// effect — manager only re-reads describePersistence) to persist the
|
|
4717
|
+
// authoritative sentinel state.
|
|
4718
|
+
if (this.lastCanceledAt !== before && this.currentThreadId) {
|
|
4719
|
+
this.emitEvent({
|
|
4720
|
+
type: "thread_started",
|
|
4721
|
+
provider: CODEX_PROVIDER,
|
|
4722
|
+
sessionId: this.currentThreadId,
|
|
4723
|
+
});
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
// issue #664 review #3 (gpt-5.5): if this is a `turn/started`
|
|
4727
|
+
// arriving inside the `turn/start` ack-pending window for a
|
|
4728
|
+
// post-interrupt retry, cache it. We don't yet know the new turn's
|
|
4729
|
+
// codex turn.id (the ack hasn't returned), so we can't safely lift
|
|
4730
|
+
// the fence here. startTurn()'s ack handler will replay this
|
|
4731
|
+
// notification once `expectedCodexTurnId` is set, so the new turn
|
|
4732
|
+
// doesn't lose its only `turn/started` and stall until watchdog.
|
|
4733
|
+
if (parsed.kind === "turn_started" &&
|
|
4734
|
+
this.activeForegroundTurnId !== null &&
|
|
4735
|
+
this.turnStartAckPending) {
|
|
4736
|
+
this.pendingFencedTurnStarted = {
|
|
4737
|
+
codexTurnId: parsed.turnId,
|
|
4738
|
+
rawParams: params,
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
// issue #1836: a force-fail is a purely local action — codex was not
|
|
4742
|
+
// told to abort, so it keeps emitting for the old turn. If that turn is
|
|
4743
|
+
// demonstrably still making progress (an item started or assistant text
|
|
4744
|
+
// streaming) and no retry has begun, the force-fail was a misfire (e.g.
|
|
4745
|
+
// watchdog mis-killed a live turn). Self-heal by un-fencing and resuming
|
|
4746
|
+
// the old turn instead of dropping its events forever, which would leave
|
|
4747
|
+
// the UI permanently blank. `fencedByForceFail` gates this to watchdog
|
|
4748
|
+
// force-fails only — a user interrupt/cancel also fences and must never
|
|
4749
|
+
// be resurrected.
|
|
4750
|
+
const isProgress = parsed.kind === "item_started" || parsed.kind === "agent_message_delta";
|
|
4751
|
+
const notifTurnId = this.parsedNotificationTurnId(parsed);
|
|
4752
|
+
if (this.fencedByForceFail &&
|
|
4753
|
+
isProgress &&
|
|
4754
|
+
this.activeForegroundTurnId === null &&
|
|
4755
|
+
!this.turnStartAckPending &&
|
|
4756
|
+
this.fencedTurnId !== null &&
|
|
4757
|
+
notifTurnId === this.fencedTurnId) {
|
|
4758
|
+
this.logger.warn({ fencedTurnId: this.fencedTurnId }, "Codex fence self-heal: progress after force-fail; resuming turn");
|
|
4759
|
+
this.activeForegroundTurnId = this.lastForcedFailForegroundTurnId ?? this.createTurnId();
|
|
4760
|
+
this.lastForcedFailForegroundTurnId = null;
|
|
4761
|
+
this.fencedByForceFail = false;
|
|
4762
|
+
this.currentTurnId = this.fencedTurnId;
|
|
4763
|
+
this.fencedAfterForcedFailure = false;
|
|
4764
|
+
this.fencedTurnId = null;
|
|
4765
|
+
this.lastTurnNotificationTime = Date.now();
|
|
4766
|
+
this.armTurnWatchdog();
|
|
4767
|
+
this.handleNotification(method, params);
|
|
4768
|
+
return;
|
|
4769
|
+
}
|
|
4770
|
+
this.logger.debug({ method }, "Dropping late Codex notification for force-failed turn");
|
|
4771
|
+
return;
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
// issue #505: any notification proves the turn is still alive; re-arm the
|
|
4775
|
+
// stall watchdog so only a genuinely silent turn ever trips it.
|
|
4776
|
+
// issue #1427: it also cancels a pending late-completion force-fail — the
|
|
4777
|
+
// turn is demonstrably alive, so the grace window's verdict is moot. A
|
|
4778
|
+
// turn/completed during the window finalizes the turn below (clearing
|
|
4779
|
+
// activeForegroundTurnId); any other notification just proves liveness and
|
|
4780
|
+
// re-arms the normal 5min watchdog.
|
|
4781
|
+
if (this.activeForegroundTurnId) {
|
|
4782
|
+
this.clearPendingForceFail();
|
|
4783
|
+
this.lastTurnNotificationTime = Date.now();
|
|
4784
|
+
this.armTurnWatchdog();
|
|
4785
|
+
}
|
|
4786
|
+
// issue #1427: a real (non-reconnect) notification after a reconnect marker
|
|
4787
|
+
// means the stream is flowing again — close the loading marker so the UI
|
|
4788
|
+
// stops showing "Reconnecting…".
|
|
4789
|
+
if (parsed.kind !== "stream_retrying") {
|
|
4790
|
+
this.resolveReconnectMarker();
|
|
4791
|
+
}
|
|
3041
4792
|
if (parsed.kind === "thread_started") {
|
|
3042
4793
|
this.currentThreadId = parsed.threadId;
|
|
3043
4794
|
this.emitEvent({
|
|
@@ -3048,24 +4799,49 @@ class CodexAppServerAgentSession {
|
|
|
3048
4799
|
return;
|
|
3049
4800
|
}
|
|
3050
4801
|
if (parsed.kind === "turn_started") {
|
|
4802
|
+
this.fencedTurnId = null;
|
|
3051
4803
|
this.currentTurnId = parsed.turnId;
|
|
4804
|
+
if (typeof this.client?.resetLastResponsesRequestId === "function") {
|
|
4805
|
+
this.client.resetLastResponsesRequestId();
|
|
4806
|
+
}
|
|
4807
|
+
if (typeof this.client?.resetRecentStderrTail === "function") {
|
|
4808
|
+
this.client.resetRecentStderrTail();
|
|
4809
|
+
}
|
|
3052
4810
|
this.latestPlanResult = null;
|
|
3053
4811
|
this.emittedItemStartedIds.clear();
|
|
3054
4812
|
this.emittedItemCompletedIds.clear();
|
|
3055
4813
|
this.emittedExecCommandStartedCallIds.clear();
|
|
3056
4814
|
this.emittedExecCommandCompletedCallIds.clear();
|
|
4815
|
+
this.inFlightToolCalls.clear();
|
|
4816
|
+
this.compactionInFlight.clear();
|
|
4817
|
+
this.clearCompactionItemWatchdogs();
|
|
3057
4818
|
this.pendingCommandOutputDeltas.clear();
|
|
3058
4819
|
this.pendingFileChangeOutputDeltas.clear();
|
|
3059
4820
|
this.warnedIncompleteEditToolCallIds.clear();
|
|
4821
|
+
this.lastTurnNotificationTime = null;
|
|
4822
|
+
this.cancelAgentMessageFlush();
|
|
4823
|
+
this.pendingAgentMessages.clear();
|
|
4824
|
+
this.emittedAgentMessageLength.clear();
|
|
4825
|
+
// Reasoning buffers are per-itemId and already consumed at
|
|
4826
|
+
// item_completed, so a per-turn sweep is safe and catches items that
|
|
4827
|
+
// never reach item_completed (interrupted/failed turns). We do NOT
|
|
4828
|
+
// clear terminalCommandByProcessId / emittedTerminalInteractionKeys
|
|
4829
|
+
// here: codex terminal sessions (and their stdin-dedup keys) can span
|
|
4830
|
+
// turns, so those are only cleared on close() to avoid losing
|
|
4831
|
+
// cross-turn command labels / re-emitting deduped interactions.
|
|
4832
|
+
this.pendingReasoning.clear();
|
|
3060
4833
|
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
|
3061
4834
|
return;
|
|
3062
4835
|
}
|
|
3063
4836
|
if (parsed.kind === "turn_completed") {
|
|
4837
|
+
this.fencedTurnId = null;
|
|
3064
4838
|
if (parsed.status === "failed") {
|
|
4839
|
+
const error = this.appendRequestId(parsed.errorMessage ?? "Codex turn failed");
|
|
3065
4840
|
this.emitEvent({
|
|
3066
4841
|
type: "turn_failed",
|
|
3067
4842
|
provider: CODEX_PROVIDER,
|
|
3068
|
-
error
|
|
4843
|
+
error,
|
|
4844
|
+
diagnostic: this.buildTurnFailedDiagnostic(error),
|
|
3069
4845
|
});
|
|
3070
4846
|
}
|
|
3071
4847
|
else if (parsed.status === "interrupted") {
|
|
@@ -3095,14 +4871,24 @@ class CodexAppServerAgentSession {
|
|
|
3095
4871
|
});
|
|
3096
4872
|
}
|
|
3097
4873
|
this.activeForegroundTurnId = null;
|
|
4874
|
+
this.clearTurnWatchdog();
|
|
3098
4875
|
this.latestPlanResult = null;
|
|
3099
4876
|
this.emittedItemStartedIds.clear();
|
|
3100
4877
|
this.emittedItemCompletedIds.clear();
|
|
3101
4878
|
this.emittedExecCommandStartedCallIds.clear();
|
|
3102
4879
|
this.emittedExecCommandCompletedCallIds.clear();
|
|
4880
|
+
this.inFlightToolCalls.clear();
|
|
4881
|
+
this.compactionInFlight.clear();
|
|
4882
|
+
this.clearCompactionItemWatchdogs();
|
|
3103
4883
|
this.pendingCommandOutputDeltas.clear();
|
|
3104
4884
|
this.pendingFileChangeOutputDeltas.clear();
|
|
3105
4885
|
this.warnedIncompleteEditToolCallIds.clear();
|
|
4886
|
+
this.cancelAgentMessageFlush();
|
|
4887
|
+
this.pendingAgentMessages.clear();
|
|
4888
|
+
this.emittedAgentMessageLength.clear();
|
|
4889
|
+
// See turn_started: only the per-item reasoning buffers are safe to
|
|
4890
|
+
// sweep per-turn; terminal session maps persist until close().
|
|
4891
|
+
this.pendingReasoning.clear();
|
|
3106
4892
|
return;
|
|
3107
4893
|
}
|
|
3108
4894
|
if (parsed.kind === "plan_updated") {
|
|
@@ -3141,9 +4927,32 @@ class CodexAppServerAgentSession {
|
|
|
3141
4927
|
}
|
|
3142
4928
|
return;
|
|
3143
4929
|
}
|
|
4930
|
+
if (parsed.kind === "stream_retrying") {
|
|
4931
|
+
// Non-terminal: codex is auto-retrying a dropped response stream. The
|
|
4932
|
+
// shared "any notification re-arms the watchdog" path above already kept
|
|
4933
|
+
// the turn alive; here we only surface a UI notice so a slow reconnect
|
|
4934
|
+
// does not look frozen. Dedupe to one marker per (attempt) so the paired
|
|
4935
|
+
// warning + error(willRetry) for the same attempt collapse into one.
|
|
4936
|
+
const attemptKey = `${parsed.attempt ?? ""}/${parsed.maxAttempts ?? ""}`;
|
|
4937
|
+
if (this.lastReconnectMarkerKey !== attemptKey) {
|
|
4938
|
+
this.lastReconnectMarkerKey = attemptKey;
|
|
4939
|
+
this.emitEvent({
|
|
4940
|
+
type: "timeline",
|
|
4941
|
+
provider: CODEX_PROVIDER,
|
|
4942
|
+
item: {
|
|
4943
|
+
type: "reconnecting",
|
|
4944
|
+
status: "loading",
|
|
4945
|
+
attempt: parsed.attempt ?? undefined,
|
|
4946
|
+
maxAttempts: parsed.maxAttempts ?? undefined,
|
|
4947
|
+
},
|
|
4948
|
+
});
|
|
4949
|
+
}
|
|
4950
|
+
return;
|
|
4951
|
+
}
|
|
3144
4952
|
if (parsed.kind === "agent_message_delta") {
|
|
3145
4953
|
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
|
3146
4954
|
this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
|
|
4955
|
+
this.flushAgentMessageDelta(parsed.itemId);
|
|
3147
4956
|
return;
|
|
3148
4957
|
}
|
|
3149
4958
|
if (parsed.kind === "reasoning_delta") {
|
|
@@ -3158,6 +4967,10 @@ class CodexAppServerAgentSession {
|
|
|
3158
4967
|
});
|
|
3159
4968
|
return;
|
|
3160
4969
|
}
|
|
4970
|
+
if (parsed.kind === "command_execution_output_delta") {
|
|
4971
|
+
this.appendOutputDeltaChunk(this.pendingCommandOutputDeltas, parsed.itemId, parsed.delta);
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
3161
4974
|
if (parsed.kind === "file_change_output_delta") {
|
|
3162
4975
|
this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta);
|
|
3163
4976
|
return;
|
|
@@ -3165,6 +4978,7 @@ class CodexAppServerAgentSession {
|
|
|
3165
4978
|
if (parsed.kind === "exec_command_started") {
|
|
3166
4979
|
if (parsed.callId) {
|
|
3167
4980
|
this.emittedExecCommandStartedCallIds.add(parsed.callId);
|
|
4981
|
+
this.inFlightToolCalls.add(parsed.callId);
|
|
3168
4982
|
this.pendingCommandOutputDeltas.delete(parsed.callId);
|
|
3169
4983
|
}
|
|
3170
4984
|
const timelineItem = mapCodexExecNotificationToToolCall({
|
|
@@ -3179,6 +4993,9 @@ class CodexAppServerAgentSession {
|
|
|
3179
4993
|
return;
|
|
3180
4994
|
}
|
|
3181
4995
|
if (parsed.kind === "exec_command_completed") {
|
|
4996
|
+
if (parsed.callId) {
|
|
4997
|
+
this.inFlightToolCalls.delete(parsed.callId);
|
|
4998
|
+
}
|
|
3182
4999
|
const bufferedOutput = this.consumeOutputDelta(this.pendingCommandOutputDeltas, parsed.callId);
|
|
3183
5000
|
const resolvedOutput = parsed.output ?? bufferedOutput;
|
|
3184
5001
|
this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput);
|
|
@@ -3206,7 +5023,7 @@ class CodexAppServerAgentSession {
|
|
|
3206
5023
|
const command = (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
|
|
3207
5024
|
null;
|
|
3208
5025
|
if (!command && parsed.processId) {
|
|
3209
|
-
this.pendingUnlabeledTerminalInteractions
|
|
5026
|
+
addBounded(this.pendingUnlabeledTerminalInteractions, parsed.processId, TERMINAL_SESSION_MAP_MAX);
|
|
3210
5027
|
}
|
|
3211
5028
|
const timelineItem = mapCodexTerminalInteractionToToolCall({
|
|
3212
5029
|
processId: parsed.processId,
|
|
@@ -3218,6 +5035,7 @@ class CodexAppServerAgentSession {
|
|
|
3218
5035
|
}
|
|
3219
5036
|
if (parsed.kind === "patch_apply_started") {
|
|
3220
5037
|
if (parsed.callId) {
|
|
5038
|
+
this.inFlightToolCalls.add(parsed.callId);
|
|
3221
5039
|
this.pendingFileChangeOutputDeltas.delete(parsed.callId);
|
|
3222
5040
|
}
|
|
3223
5041
|
const timelineItem = mapCodexPatchNotificationToToolCall({
|
|
@@ -3236,6 +5054,9 @@ class CodexAppServerAgentSession {
|
|
|
3236
5054
|
return;
|
|
3237
5055
|
}
|
|
3238
5056
|
if (parsed.kind === "patch_apply_completed") {
|
|
5057
|
+
if (parsed.callId) {
|
|
5058
|
+
this.inFlightToolCalls.delete(parsed.callId);
|
|
5059
|
+
}
|
|
3239
5060
|
const bufferedOutput = this.consumeOutputDelta(this.pendingFileChangeOutputDeltas, parsed.callId);
|
|
3240
5061
|
const timelineItem = mapCodexPatchNotificationToToolCall({
|
|
3241
5062
|
callId: parsed.callId,
|
|
@@ -3273,6 +5094,10 @@ class CodexAppServerAgentSession {
|
|
|
3273
5094
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
3274
5095
|
if (itemId) {
|
|
3275
5096
|
this.emittedItemCompletedIds.add(itemId);
|
|
5097
|
+
if (timelineItem.type === "compaction") {
|
|
5098
|
+
this.compactionInFlight.delete(itemId);
|
|
5099
|
+
this.clearCompactionItemWatchdog(itemId);
|
|
5100
|
+
}
|
|
3276
5101
|
}
|
|
3277
5102
|
}
|
|
3278
5103
|
return;
|
|
@@ -3298,14 +5123,38 @@ class CodexAppServerAgentSession {
|
|
|
3298
5123
|
if (callId && this.emittedExecCommandCompletedCallIds.has(callId)) {
|
|
3299
5124
|
return;
|
|
3300
5125
|
}
|
|
5126
|
+
if (callId) {
|
|
5127
|
+
const bufferedOutput = this.consumeOutputDelta(this.pendingCommandOutputDeltas, callId);
|
|
5128
|
+
if (bufferedOutput &&
|
|
5129
|
+
timelineItem.detail.type === "shell" &&
|
|
5130
|
+
(timelineItem.detail.output == null ||
|
|
5131
|
+
bufferedOutput.length > timelineItem.detail.output.length)) {
|
|
5132
|
+
timelineItem.detail.output = bufferedOutput;
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
3301
5135
|
}
|
|
3302
5136
|
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
|
|
3303
5137
|
return;
|
|
3304
5138
|
}
|
|
5139
|
+
// True once a streamed message has been fully emitted via deltas, so
|
|
5140
|
+
// the final `item_completed` emit would only duplicate it.
|
|
5141
|
+
let assistantFullyStreamed = false;
|
|
3305
5142
|
if (timelineItem.type === "assistant_message" && itemId) {
|
|
3306
|
-
const buffered = this.pendingAgentMessages.get(itemId);
|
|
3307
|
-
|
|
3308
|
-
|
|
5143
|
+
const buffered = this.pendingAgentMessages.get(itemId) ?? "";
|
|
5144
|
+
// `item_completed`'s text is codex's authoritative final text.
|
|
5145
|
+
// Take whichever of (final text, accumulated deltas) is longer:
|
|
5146
|
+
// they should match, but a dropped delta leaves `buffered` short,
|
|
5147
|
+
// and the final item then backfills the missing tail.
|
|
5148
|
+
const fullText = timelineItem.text.length >= buffered.length ? timelineItem.text : buffered;
|
|
5149
|
+
const emitted = this.emittedAgentMessageLength.get(itemId) ?? 0;
|
|
5150
|
+
if (emitted > 0) {
|
|
5151
|
+
// Streamed already: emit only the un-streamed tail.
|
|
5152
|
+
timelineItem.text = fullText.slice(emitted);
|
|
5153
|
+
assistantFullyStreamed = timelineItem.text.length === 0;
|
|
5154
|
+
}
|
|
5155
|
+
else {
|
|
5156
|
+
// Never streamed (no deltas seen): emit the full text.
|
|
5157
|
+
timelineItem.text = fullText;
|
|
3309
5158
|
}
|
|
3310
5159
|
}
|
|
3311
5160
|
if (timelineItem.type === "reasoning" && itemId) {
|
|
@@ -3320,12 +5169,33 @@ class CodexAppServerAgentSession {
|
|
|
3320
5169
|
}
|
|
3321
5170
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
|
3322
5171
|
}
|
|
3323
|
-
|
|
5172
|
+
// Skip the emit when the assistant message was fully delivered via
|
|
5173
|
+
// streaming deltas (avoids a duplicate / empty bubble), but still run
|
|
5174
|
+
// the bookkeeping below so completion state stays consistent.
|
|
5175
|
+
if (!assistantFullyStreamed) {
|
|
5176
|
+
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
5177
|
+
}
|
|
3324
5178
|
if (itemId) {
|
|
3325
5179
|
this.emittedItemCompletedIds.add(itemId);
|
|
3326
5180
|
this.emittedItemStartedIds.delete(itemId);
|
|
5181
|
+
this.inFlightToolCalls.delete(itemId);
|
|
5182
|
+
// issue #999: compaction finished — let the watchdog resume normal
|
|
5183
|
+
// silence monitoring.
|
|
5184
|
+
this.compactionInFlight.delete(itemId);
|
|
5185
|
+
this.clearCompactionItemWatchdog(itemId);
|
|
3327
5186
|
this.pendingCommandOutputDeltas.delete(itemId);
|
|
3328
5187
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
|
5188
|
+
// Per-itemId buffer cleanup — replaces the old emit-driven full
|
|
5189
|
+
// `pendingAgentMessages.clear()` that streaming made unsafe.
|
|
5190
|
+
this.pendingAgentMessages.delete(itemId);
|
|
5191
|
+
this.emittedAgentMessageLength.delete(itemId);
|
|
5192
|
+
// The buffered reasoning deltas were already consumed at line ~5289
|
|
5193
|
+
// above. Drop them here too, mirroring the sibling per-item buffers:
|
|
5194
|
+
// gpt-5.x reasoning is voluminous, and without this the array of
|
|
5195
|
+
// every reasoning delta leaks for the whole agent lifetime, slowly
|
|
5196
|
+
// driving the worker to a V8 heap OOM on long multi-agent sessions
|
|
5197
|
+
// (#1058, #1298, #1188, #1124, #1082).
|
|
5198
|
+
this.pendingReasoning.delete(itemId);
|
|
3329
5199
|
}
|
|
3330
5200
|
}
|
|
3331
5201
|
return;
|
|
@@ -3354,10 +5224,31 @@ class CodexAppServerAgentSession {
|
|
|
3354
5224
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
3355
5225
|
if (itemId) {
|
|
3356
5226
|
this.emittedItemStartedIds.add(itemId);
|
|
5227
|
+
this.inFlightToolCalls.add(itemId);
|
|
3357
5228
|
this.pendingCommandOutputDeltas.delete(itemId);
|
|
3358
5229
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
|
3359
5230
|
}
|
|
3360
5231
|
}
|
|
5232
|
+
else if (timelineItem && timelineItem.type === "compaction") {
|
|
5233
|
+
// issue #999: codex's auto-compaction starts a turn-event-silent
|
|
5234
|
+
// operation. Track it so the watchdog defers instead of force-failing,
|
|
5235
|
+
// and surface a "loading" marker (the app reducer pairs it with the
|
|
5236
|
+
// later `item_completed` "completed", so it stays a single row).
|
|
5237
|
+
const itemId = parsed.item.id;
|
|
5238
|
+
if (itemId && this.emittedItemStartedIds.has(itemId)) {
|
|
5239
|
+
return;
|
|
5240
|
+
}
|
|
5241
|
+
this.compactionInFlight.add(itemId ?? "<no-id>");
|
|
5242
|
+
this.armCompactionItemWatchdog(itemId ?? "<no-id>");
|
|
5243
|
+
this.emitEvent({
|
|
5244
|
+
type: "timeline",
|
|
5245
|
+
provider: CODEX_PROVIDER,
|
|
5246
|
+
item: { ...timelineItem, status: "loading" },
|
|
5247
|
+
});
|
|
5248
|
+
if (itemId) {
|
|
5249
|
+
this.emittedItemStartedIds.add(itemId);
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
3361
5252
|
return;
|
|
3362
5253
|
}
|
|
3363
5254
|
if (parsed.kind === "invalid_payload") {
|
|
@@ -3366,6 +5257,20 @@ class CodexAppServerAgentSession {
|
|
|
3366
5257
|
}
|
|
3367
5258
|
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
|
|
3368
5259
|
}
|
|
5260
|
+
// issue #1427: close an open "reconnecting" loading marker by emitting its
|
|
5261
|
+
// completed counterpart, then clear the dedup key. No-op when no marker is
|
|
5262
|
+
// open. Used when real turn progress resumes after a reconnect.
|
|
5263
|
+
resolveReconnectMarker() {
|
|
5264
|
+
if (this.lastReconnectMarkerKey === null) {
|
|
5265
|
+
return;
|
|
5266
|
+
}
|
|
5267
|
+
this.lastReconnectMarkerKey = null;
|
|
5268
|
+
this.emitEvent({
|
|
5269
|
+
type: "timeline",
|
|
5270
|
+
provider: CODEX_PROVIDER,
|
|
5271
|
+
item: { type: "reconnecting", status: "completed" },
|
|
5272
|
+
});
|
|
5273
|
+
}
|
|
3369
5274
|
warnUnknownNotificationMethod(method, params) {
|
|
3370
5275
|
if (this.warnedUnknownNotificationMethods.has(method)) {
|
|
3371
5276
|
return;
|
|
@@ -3419,7 +5324,7 @@ class CodexAppServerAgentSession {
|
|
|
3419
5324
|
if (!processId) {
|
|
3420
5325
|
return;
|
|
3421
5326
|
}
|
|
3422
|
-
this.terminalCommandByProcessId
|
|
5327
|
+
setBounded(this.terminalCommandByProcessId, processId, displayCommand, TERMINAL_SESSION_MAP_MAX);
|
|
3423
5328
|
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
|
|
3424
5329
|
return;
|
|
3425
5330
|
}
|
|
@@ -3437,7 +5342,7 @@ class CodexAppServerAgentSession {
|
|
|
3437
5342
|
if (this.emittedTerminalInteractionKeys.has(key)) {
|
|
3438
5343
|
return false;
|
|
3439
5344
|
}
|
|
3440
|
-
this.emittedTerminalInteractionKeys
|
|
5345
|
+
addBounded(this.emittedTerminalInteractionKeys, key, TERMINAL_SESSION_MAP_MAX);
|
|
3441
5346
|
return true;
|
|
3442
5347
|
}
|
|
3443
5348
|
warnOnIncompleteEditToolCall(item, source, payload) {
|
|
@@ -3460,7 +5365,7 @@ class CodexAppServerAgentSession {
|
|
|
3460
5365
|
}
|
|
3461
5366
|
shouldAutoAcceptToolApprovals() {
|
|
3462
5367
|
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
|
3463
|
-
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
|
5368
|
+
const { approvalPolicy } = applyPlanModeConstraints(this.config.approvalPolicy ?? preset.approvalPolicy, this.config.sandboxMode ?? preset.sandbox, this.planModeEnabled);
|
|
3464
5369
|
return approvalPolicy === "never";
|
|
3465
5370
|
}
|
|
3466
5371
|
handleCommandApprovalRequest(params) {
|
|
@@ -3487,14 +5392,8 @@ class CodexAppServerAgentSession {
|
|
|
3487
5392
|
command: parsed.command ?? undefined,
|
|
3488
5393
|
cwd: parsed.cwd ?? undefined,
|
|
3489
5394
|
},
|
|
3490
|
-
detail: commandPreview?.detail ??
|
|
3491
|
-
|
|
3492
|
-
input: {
|
|
3493
|
-
command: parsed.command ?? null,
|
|
3494
|
-
cwd: parsed.cwd ?? null,
|
|
3495
|
-
},
|
|
3496
|
-
output: null,
|
|
3497
|
-
},
|
|
5395
|
+
detail: commandPreview?.detail ??
|
|
5396
|
+
buildExecFallbackDetail(parsed.command ?? null, parsed.cwd ?? this.config.cwd ?? null),
|
|
3498
5397
|
metadata: {
|
|
3499
5398
|
itemId: parsed.itemId,
|
|
3500
5399
|
threadId: parsed.threadId,
|
|
@@ -3504,7 +5403,65 @@ class CodexAppServerAgentSession {
|
|
|
3504
5403
|
this.pendingPermissions.set(requestId, request);
|
|
3505
5404
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
3506
5405
|
return new Promise((resolve) => {
|
|
3507
|
-
this.pendingPermissionHandlers.set(requestId, {
|
|
5406
|
+
this.pendingPermissionHandlers.set(requestId, {
|
|
5407
|
+
resolve,
|
|
5408
|
+
kind: "command",
|
|
5409
|
+
foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
|
|
5410
|
+
codexTurnId: parsed.turnId,
|
|
5411
|
+
});
|
|
5412
|
+
});
|
|
5413
|
+
}
|
|
5414
|
+
handleLegacyExecCommandApprovalRequest(params) {
|
|
5415
|
+
const parsed = params;
|
|
5416
|
+
if (this.shouldAutoAcceptToolApprovals()) {
|
|
5417
|
+
return Promise.resolve({ decision: "approved" });
|
|
5418
|
+
}
|
|
5419
|
+
const requestKey = parsed.approvalId ?? parsed.callId;
|
|
5420
|
+
const requestId = `permission-${requestKey}`;
|
|
5421
|
+
const commandValue = Array.isArray(parsed.command) && parsed.command.length > 0
|
|
5422
|
+
? parsed.command
|
|
5423
|
+
: parsed.command == null
|
|
5424
|
+
? null
|
|
5425
|
+
: String(parsed.command);
|
|
5426
|
+
const commandPreview = mapCodexExecNotificationToToolCall({
|
|
5427
|
+
callId: parsed.callId,
|
|
5428
|
+
command: commandValue,
|
|
5429
|
+
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
|
5430
|
+
running: true,
|
|
5431
|
+
});
|
|
5432
|
+
const commandText = Array.isArray(commandValue)
|
|
5433
|
+
? commandValue.join(" ")
|
|
5434
|
+
: typeof commandValue === "string"
|
|
5435
|
+
? commandValue
|
|
5436
|
+
: null;
|
|
5437
|
+
const request = {
|
|
5438
|
+
id: requestId,
|
|
5439
|
+
provider: CODEX_PROVIDER,
|
|
5440
|
+
name: "CodexBash",
|
|
5441
|
+
kind: "tool",
|
|
5442
|
+
title: commandText ? `Run command: ${commandText}` : "Run command",
|
|
5443
|
+
description: parsed.reason ?? undefined,
|
|
5444
|
+
input: {
|
|
5445
|
+
command: commandValue ?? undefined,
|
|
5446
|
+
cwd: parsed.cwd ?? undefined,
|
|
5447
|
+
},
|
|
5448
|
+
detail: commandPreview?.detail ??
|
|
5449
|
+
buildExecFallbackDetail(commandValue ?? null, parsed.cwd ?? this.config.cwd ?? null),
|
|
5450
|
+
metadata: {
|
|
5451
|
+
itemId: parsed.callId,
|
|
5452
|
+
approvalId: parsed.approvalId ?? null,
|
|
5453
|
+
threadId: parsed.conversationId ?? null,
|
|
5454
|
+
},
|
|
5455
|
+
};
|
|
5456
|
+
this.pendingPermissions.set(requestId, request);
|
|
5457
|
+
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
5458
|
+
return new Promise((resolve) => {
|
|
5459
|
+
this.pendingPermissionHandlers.set(requestId, {
|
|
5460
|
+
resolve,
|
|
5461
|
+
kind: "command",
|
|
5462
|
+
foregroundTurnId: this.foregroundTurnIdForLegacyPermission(),
|
|
5463
|
+
protocol: "legacy-review",
|
|
5464
|
+
});
|
|
3508
5465
|
});
|
|
3509
5466
|
}
|
|
3510
5467
|
handleFileChangeApprovalRequest(params) {
|
|
@@ -3536,7 +5493,59 @@ class CodexAppServerAgentSession {
|
|
|
3536
5493
|
this.pendingPermissions.set(requestId, request);
|
|
3537
5494
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
3538
5495
|
return new Promise((resolve) => {
|
|
3539
|
-
this.pendingPermissionHandlers.set(requestId, {
|
|
5496
|
+
this.pendingPermissionHandlers.set(requestId, {
|
|
5497
|
+
resolve,
|
|
5498
|
+
kind: "file",
|
|
5499
|
+
foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
|
|
5500
|
+
codexTurnId: parsed.turnId,
|
|
5501
|
+
});
|
|
5502
|
+
});
|
|
5503
|
+
}
|
|
5504
|
+
handleLegacyApplyPatchApprovalRequest(params) {
|
|
5505
|
+
const parsed = params;
|
|
5506
|
+
if (this.shouldAutoAcceptToolApprovals()) {
|
|
5507
|
+
return Promise.resolve({ decision: "approved" });
|
|
5508
|
+
}
|
|
5509
|
+
const requestId = `permission-${parsed.callId}`;
|
|
5510
|
+
const patchPreview = mapCodexPatchNotificationToToolCall({
|
|
5511
|
+
callId: parsed.callId,
|
|
5512
|
+
changes: parsed.fileChanges,
|
|
5513
|
+
cwd: this.config.cwd ?? null,
|
|
5514
|
+
running: true,
|
|
5515
|
+
});
|
|
5516
|
+
const request = {
|
|
5517
|
+
id: requestId,
|
|
5518
|
+
provider: CODEX_PROVIDER,
|
|
5519
|
+
name: "CodexFileChange",
|
|
5520
|
+
kind: "tool",
|
|
5521
|
+
title: "Apply file changes",
|
|
5522
|
+
description: parsed.reason ?? undefined,
|
|
5523
|
+
input: {
|
|
5524
|
+
fileChanges: parsed.fileChanges ?? undefined,
|
|
5525
|
+
grantRoot: parsed.grantRoot ?? undefined,
|
|
5526
|
+
},
|
|
5527
|
+
detail: patchPreview?.detail ?? {
|
|
5528
|
+
type: "unknown",
|
|
5529
|
+
input: {
|
|
5530
|
+
fileChanges: parsed.fileChanges ?? null,
|
|
5531
|
+
grantRoot: parsed.grantRoot ?? null,
|
|
5532
|
+
},
|
|
5533
|
+
output: null,
|
|
5534
|
+
},
|
|
5535
|
+
metadata: {
|
|
5536
|
+
itemId: parsed.callId,
|
|
5537
|
+
threadId: parsed.conversationId ?? null,
|
|
5538
|
+
},
|
|
5539
|
+
};
|
|
5540
|
+
this.pendingPermissions.set(requestId, request);
|
|
5541
|
+
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
5542
|
+
return new Promise((resolve) => {
|
|
5543
|
+
this.pendingPermissionHandlers.set(requestId, {
|
|
5544
|
+
resolve,
|
|
5545
|
+
kind: "file",
|
|
5546
|
+
foregroundTurnId: this.foregroundTurnIdForLegacyPermission(),
|
|
5547
|
+
protocol: "legacy-review",
|
|
5548
|
+
});
|
|
3540
5549
|
});
|
|
3541
5550
|
}
|
|
3542
5551
|
handleToolApprovalRequest(params) {
|
|
@@ -3578,6 +5587,8 @@ class CodexAppServerAgentSession {
|
|
|
3578
5587
|
this.pendingPermissionHandlers.set(requestId, {
|
|
3579
5588
|
resolve,
|
|
3580
5589
|
kind: "question",
|
|
5590
|
+
foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
|
|
5591
|
+
codexTurnId: parsed.turnId,
|
|
3581
5592
|
questions,
|
|
3582
5593
|
});
|
|
3583
5594
|
});
|
|
@@ -3592,6 +5603,24 @@ export class CodexAppServerAgentClient {
|
|
|
3592
5603
|
this.runtimeSettings = runtimeSettings;
|
|
3593
5604
|
this.provider = CODEX_PROVIDER;
|
|
3594
5605
|
this.capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
|
5606
|
+
this.goalsEnabledPromise = null;
|
|
5607
|
+
}
|
|
5608
|
+
// codex `goals` feature gate. Determined once per client (version probe is a
|
|
5609
|
+
// separate `--version` spawn) and cached, so create/resume don't each probe.
|
|
5610
|
+
//
|
|
5611
|
+
// The cache is keyed at the client level (one `CodexAppServerAgentClient`
|
|
5612
|
+
// per provider, constructed once from static `runtimeSettings`). The binary
|
|
5613
|
+
// that gets probed here is resolved from `runtimeSettings.command` /
|
|
5614
|
+
// `runtimeSettings.env.PATH` — the same path that `spawnAppServer` uses at
|
|
5615
|
+
// launch time. `launchContext.env` (injected per-session by agent-manager)
|
|
5616
|
+
// only carries `SEAWORK_AGENT_ID`, which is a runtime identifier and has no
|
|
5617
|
+
// effect on binary resolution, so the cached result stays consistent with
|
|
5618
|
+
// the actual spawn.
|
|
5619
|
+
resolveGoalsEnabled() {
|
|
5620
|
+
if (!this.goalsEnabledPromise) {
|
|
5621
|
+
this.goalsEnabledPromise = this.resolveCodexVersion().then(({ version }) => isVersionAtLeast(version, CODEX_GOALS_MIN_VERSION), () => false);
|
|
5622
|
+
}
|
|
5623
|
+
return this.goalsEnabledPromise;
|
|
3595
5624
|
}
|
|
3596
5625
|
async spawnAppServer(options) {
|
|
3597
5626
|
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings, this.logger);
|
|
@@ -3600,10 +5629,18 @@ export class CodexAppServerAgentClient {
|
|
|
3600
5629
|
}, "Spawning Codex app server");
|
|
3601
5630
|
const launchEnv = options?.launchEnv;
|
|
3602
5631
|
const codexEnv = buildCodexAppServerEnv(this.runtimeSettings, launchEnv);
|
|
5632
|
+
const managedCodexHome = await prepareManagedCodexHome({
|
|
5633
|
+
env: codexEnv,
|
|
5634
|
+
logger: this.logger,
|
|
5635
|
+
});
|
|
5636
|
+
if (managedCodexHome) {
|
|
5637
|
+
codexEnv.CODEX_HOME = managedCodexHome;
|
|
5638
|
+
}
|
|
3603
5639
|
this.logger.info({
|
|
3604
5640
|
hasOpenaiKey: !!codexEnv.OPENAI_API_KEY,
|
|
3605
5641
|
openaiKeyPrefix: codexEnv.OPENAI_API_KEY?.slice(0, 10) ?? null,
|
|
3606
5642
|
openaiBaseUrl: codexEnv.OPENAI_BASE_URL ?? null,
|
|
5643
|
+
managedCodexHome,
|
|
3607
5644
|
}, "Codex app-server env check");
|
|
3608
5645
|
// If a Seawork-managed base URL is available, inject a "seawork" provider
|
|
3609
5646
|
// via -c flags to override any user config.
|
|
@@ -3612,13 +5649,14 @@ export class CodexAppServerAgentClient {
|
|
|
3612
5649
|
const seaworkProvider = readStringRecord(readStringRecord(seaworkConfig?.model_providers)[CODEX_SEAWORK_PROVIDER_ID]);
|
|
3613
5650
|
const seaworkBaseUrl = readStringMetadata(seaworkProvider.base_url);
|
|
3614
5651
|
if (seaworkConfig && seaworkBaseUrl) {
|
|
3615
|
-
extraArgs.push("-c", `model_provider="${CODEX_SEAWORK_PROVIDER_ID}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.name="Seawork"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.base_url="${seaworkBaseUrl}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.env_key="SEAWORK_API_KEY"`, "--disable", "js_repl");
|
|
5652
|
+
extraArgs.push("-c", `model_provider="${CODEX_SEAWORK_PROVIDER_ID}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.name="Seawork"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.base_url="${seaworkBaseUrl}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.env_key="SEAWORK_API_KEY"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.http_headers."${SEAWORK_AGENT_PROVIDER_HEADER_NAME}"="${CODEX_PROVIDER}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.http_headers."${SEAWORK_SOURCE_HEADER_NAME}"="${SEAWORK_SOURCE_HEADER_VALUE}"`, "--disable", "js_repl");
|
|
3616
5653
|
}
|
|
3617
5654
|
let command = launchPrefix.command;
|
|
3618
5655
|
let fullArgs = [
|
|
3619
5656
|
...launchPrefix.args,
|
|
3620
5657
|
"app-server",
|
|
3621
5658
|
...(options?.enableExternalMigration ? ["--enable", "external_migration"] : []),
|
|
5659
|
+
...(options?.enableGoals ? ["--enable", "goals"] : []),
|
|
3622
5660
|
...extraArgs,
|
|
3623
5661
|
];
|
|
3624
5662
|
let runtimeKind = "default";
|
|
@@ -3739,13 +5777,20 @@ export class CodexAppServerAgentClient {
|
|
|
3739
5777
|
// Falling back to process.env.CODEX_HOME would silently break setups that
|
|
3740
5778
|
// override CODEX_HOME via provider runtimeSettings.env.
|
|
3741
5779
|
const spawnEnv = buildCodexAppServerEnv(this.runtimeSettings, input.launchContext?.env);
|
|
3742
|
-
const
|
|
5780
|
+
const managedCodexHome = await prepareManagedCodexHome({
|
|
5781
|
+
env: spawnEnv,
|
|
5782
|
+
logger: this.logger,
|
|
5783
|
+
});
|
|
5784
|
+
const codexHome = managedCodexHome ??
|
|
5785
|
+
readStringMetadata(spawnEnv.CODEX_HOME) ??
|
|
5786
|
+
path.join(os.homedir(), ".codex");
|
|
3743
5787
|
this.logger.info({
|
|
3744
5788
|
sourceProvider: input.source.provider,
|
|
3745
5789
|
sourceSessionId: input.source.sessionId,
|
|
3746
5790
|
sourcePath,
|
|
3747
5791
|
sourceCwd,
|
|
3748
5792
|
codexHome,
|
|
5793
|
+
managedCodexHome,
|
|
3749
5794
|
}, "Codex external migration: starting import");
|
|
3750
5795
|
// Short-circuit: codex's `externalAgentConfig/detect` deliberately omits
|
|
3751
5796
|
// sessions it has already imported (dedup against the ledger). If we
|
|
@@ -3923,7 +5968,8 @@ export class CodexAppServerAgentClient {
|
|
|
3923
5968
|
}
|
|
3924
5969
|
async createSession(config, launchContext) {
|
|
3925
5970
|
const sessionConfig = withManagedCodexConfig({ ...config, provider: CODEX_PROVIDER }, this.runtimeSettings, launchContext?.env);
|
|
3926
|
-
const
|
|
5971
|
+
const goalsEnabled = await this.resolveGoalsEnabled();
|
|
5972
|
+
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env, enableGoals: goalsEnabled }), goalsEnabled);
|
|
3927
5973
|
await session.connect();
|
|
3928
5974
|
return session;
|
|
3929
5975
|
}
|
|
@@ -3937,7 +5983,8 @@ export class CodexAppServerAgentClient {
|
|
|
3937
5983
|
modeId: overrides?.modeId ?? storedConfig.modeId ?? "auto",
|
|
3938
5984
|
};
|
|
3939
5985
|
const sessionConfig = withManagedCodexConfig(merged, this.runtimeSettings, launchContext?.env);
|
|
3940
|
-
const
|
|
5986
|
+
const goalsEnabled = await this.resolveGoalsEnabled();
|
|
5987
|
+
const session = new CodexAppServerAgentSession(sessionConfig, handle, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env, enableGoals: goalsEnabled }), goalsEnabled);
|
|
3941
5988
|
await session.connect();
|
|
3942
5989
|
return session;
|
|
3943
5990
|
}
|
|
@@ -3955,26 +6002,32 @@ export class CodexAppServerAgentClient {
|
|
|
3955
6002
|
const threadId = thread.id;
|
|
3956
6003
|
const cwd = thread.cwd ?? process.cwd();
|
|
3957
6004
|
const title = thread.preview ?? null;
|
|
6005
|
+
// Loading a timeline costs a `thread/read` round-trip plus a rollout
|
|
6006
|
+
// file parse per thread. The daemon discards it unless the caller
|
|
6007
|
+
// passed `includeTimeline`, so skip the work entirely otherwise —
|
|
6008
|
+
// listing 20 threads would otherwise blow past the client timeout.
|
|
3958
6009
|
let timeline = [];
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
for (const
|
|
3969
|
-
const
|
|
3970
|
-
|
|
3971
|
-
|
|
6010
|
+
if (options?.includeTimeline) {
|
|
6011
|
+
try {
|
|
6012
|
+
const rolloutTimeline = await loadCodexPersistedTimeline(threadId, undefined, this.logger);
|
|
6013
|
+
const read = (await client.request("thread/read", {
|
|
6014
|
+
threadId,
|
|
6015
|
+
includeTurns: true,
|
|
6016
|
+
}));
|
|
6017
|
+
const turns = read.thread?.turns ?? [];
|
|
6018
|
+
const itemsFromThreadRead = [];
|
|
6019
|
+
for (const turn of turns) {
|
|
6020
|
+
for (const item of turn.items ?? []) {
|
|
6021
|
+
const timelineItem = threadItemToTimeline(item, { cwd });
|
|
6022
|
+
if (timelineItem)
|
|
6023
|
+
itemsFromThreadRead.push(timelineItem);
|
|
6024
|
+
}
|
|
3972
6025
|
}
|
|
6026
|
+
timeline = rolloutTimeline.length > 0 ? rolloutTimeline : itemsFromThreadRead;
|
|
6027
|
+
}
|
|
6028
|
+
catch {
|
|
6029
|
+
timeline = [];
|
|
3973
6030
|
}
|
|
3974
|
-
timeline = rolloutTimeline.length > 0 ? rolloutTimeline : itemsFromThreadRead;
|
|
3975
|
-
}
|
|
3976
|
-
catch {
|
|
3977
|
-
timeline = [];
|
|
3978
6031
|
}
|
|
3979
6032
|
descriptors.push({
|
|
3980
6033
|
provider: CODEX_PROVIDER,
|
|
@@ -4002,33 +6055,41 @@ export class CodexAppServerAgentClient {
|
|
|
4002
6055
|
await client.dispose();
|
|
4003
6056
|
}
|
|
4004
6057
|
}
|
|
6058
|
+
async loadPersistedTimeline(handle) {
|
|
6059
|
+
const metadata = handle.metadata ?? {};
|
|
6060
|
+
const threadId = readStringMetadata(metadata.threadId) ??
|
|
6061
|
+
readStringMetadata(handle.nativeHandle) ??
|
|
6062
|
+
handle.sessionId;
|
|
6063
|
+
const sessionRoot = readStringMetadata(metadata.sessionRoot);
|
|
6064
|
+
const rolloutPath = readStringMetadata(metadata.rolloutPath);
|
|
6065
|
+
return loadCodexPersistedTimeline(threadId, {
|
|
6066
|
+
...(sessionRoot ? { sessionRoot } : {}),
|
|
6067
|
+
...(rolloutPath ? { rolloutPath } : {}),
|
|
6068
|
+
}, this.logger);
|
|
6069
|
+
}
|
|
4005
6070
|
async listModels(_options) {
|
|
4006
6071
|
return getSeaworkModels("codex");
|
|
4007
6072
|
}
|
|
4008
6073
|
async isAvailable() {
|
|
4009
6074
|
try {
|
|
4010
|
-
//
|
|
4011
|
-
//
|
|
4012
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
//
|
|
4015
|
-
// would fail immediately. The bare-name semantics of
|
|
4016
|
-
// `verifyCommandAvailable` cover `command-override` cases like
|
|
4017
|
-
// `argv=["docker", ...]` while still rejecting paths that don't
|
|
4018
|
-
// exist or aren't executable.
|
|
6075
|
+
// Mirror the daemon spawn path exactly: resolve the binary with the
|
|
6076
|
+
// same selector, then check it is launchable. `verifyCommandAvailable`
|
|
6077
|
+
// covers `command-override` bare names (`argv=["docker", ...]`) and
|
|
6078
|
+
// rejects paths that don't exist or aren't executable — the only
|
|
6079
|
+
// gates daemon spawn itself would care about.
|
|
4019
6080
|
//
|
|
4020
|
-
//
|
|
4021
|
-
//
|
|
4022
|
-
//
|
|
4023
|
-
//
|
|
4024
|
-
//
|
|
4025
|
-
//
|
|
4026
|
-
//
|
|
6081
|
+
// We deliberately do NOT short-circuit on `selection.installStateRejected`.
|
|
6082
|
+
// That field is set when the `codex --help` health probe rejects the
|
|
6083
|
+
// candidate (stale install-state, fake/echo shim, OR transient probe
|
|
6084
|
+
// timeout on a slow Windows host / AV-scanned binary). Daemon spawn
|
|
6085
|
+
// doesn't gate on that probe, so neither should UI availability —
|
|
6086
|
+
// otherwise a transient probe timeout silently hides every codex
|
|
6087
|
+
// model from the selector while daemon could still spawn codex fine
|
|
6088
|
+
// (issue #479). For genuine fake-shim cases, the real spawn surfaces
|
|
6089
|
+
// the error directly; `getDiagnostic` still reports the probe
|
|
6090
|
+
// verdict for troubleshooting.
|
|
4027
6091
|
const spawnEnv = buildCodexAppServerEnv(this.runtimeSettings);
|
|
4028
6092
|
const selection = await selectEffectiveCodexBinary(this.runtimeSettings, { spawnEnv });
|
|
4029
|
-
if (selection.installStateRejected) {
|
|
4030
|
-
return false;
|
|
4031
|
-
}
|
|
4032
6093
|
return await verifyCommandAvailable(selection.binary, { spawnEnv });
|
|
4033
6094
|
}
|
|
4034
6095
|
catch {
|
|
@@ -4121,9 +6182,11 @@ export class CodexAppServerAgentClient {
|
|
|
4121
6182
|
}
|
|
4122
6183
|
}
|
|
4123
6184
|
export const __codexAppServerInternals = {
|
|
6185
|
+
buildManagedCodexConfigToml,
|
|
4124
6186
|
buildCodexAppServerEnv,
|
|
4125
6187
|
buildCodexSeaworkProviderConfig,
|
|
4126
6188
|
codexModelSupportsFastMode,
|
|
6189
|
+
CodexAppServerClient,
|
|
4127
6190
|
CodexAppServerAgentSession,
|
|
4128
6191
|
listCodexSkillEntries,
|
|
4129
6192
|
formatCodexQuestionPrompts,
|
|
@@ -4132,10 +6195,17 @@ export const __codexAppServerInternals = {
|
|
|
4132
6195
|
mapCodexQuestionRequestToToolCall,
|
|
4133
6196
|
mapCodexPatchNotificationToToolCall,
|
|
4134
6197
|
planStepsToMarkdown,
|
|
6198
|
+
prepareManagedCodexHome,
|
|
4135
6199
|
mapCodexPlanToToolCall,
|
|
4136
6200
|
normalizeCodexOutputSchema,
|
|
4137
6201
|
normalizeCodexQuestionPrompts,
|
|
6202
|
+
summarizeJsonRpcParamsForLog,
|
|
4138
6203
|
toAgentUsage,
|
|
4139
6204
|
threadItemToTimeline,
|
|
6205
|
+
TURN_WATCHDOG_IDLE_MS,
|
|
6206
|
+
WATCHDOG_LATE_COMPLETION_GRACE_MS,
|
|
6207
|
+
TURN_WATCHDOG_MAX_INFLIGHT_CYCLES,
|
|
6208
|
+
TURN_WATCHDOG_MAX_COMPACTION_CYCLES,
|
|
6209
|
+
COMPACTION_ITEM_WATCHDOG_MS,
|
|
4140
6210
|
};
|
|
4141
6211
|
//# sourceMappingURL=codex-app-server-agent.js.map
|