@remodex/rmx 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +91 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +584 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-CZqebSPQ.css +1 -0
- package/gui/dist/assets/index-CkETtt7P.js +71 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
- package/gui/dist/fonts/google-sans-latin.woff2 +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +118 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic.ts +1205 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +83 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/command-code.ts +453 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/cursor-errors.ts +165 -0
- package/src/adapters/cursor/discovery.ts +276 -0
- package/src/adapters/cursor/effort-map.ts +139 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +153 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1235 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +59 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +548 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +604 -0
- package/src/adapters/cursor/protobuf-events.ts +735 -0
- package/src/adapters/cursor/protobuf-request.ts +719 -0
- package/src/adapters/cursor/request-builder.ts +280 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +621 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +59 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +520 -0
- package/src/adapters/google-antigravity-wire.ts +140 -0
- package/src/adapters/google-errors.ts +85 -0
- package/src/adapters/google-http.ts +100 -0
- package/src/adapters/google-tool-schema.ts +173 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +859 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +104 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1924 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1265 -0
- package/src/adapters/openai-responses.ts +1309 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/android-remote/assets.ts +218 -0
- package/src/android-remote/attachments.ts +168 -0
- package/src/android-remote/auth.ts +237 -0
- package/src/android-remote/cloudflare-provisioning.ts +409 -0
- package/src/android-remote/cloudflare-secret.ts +106 -0
- package/src/android-remote/cloudflare-tunnel.ts +488 -0
- package/src/android-remote/cloudflared.ts +286 -0
- package/src/android-remote/codex-app-server.ts +565 -0
- package/src/android-remote/desktop-history-page.ts +936 -0
- package/src/android-remote/desktop-ipc.ts +3643 -0
- package/src/android-remote/desktop-ownership-store.ts +98 -0
- package/src/android-remote/desktop-project-registration.ts +129 -0
- package/src/android-remote/desktop-session-stream.ts +1110 -0
- package/src/android-remote/desktop-workspace-state.ts +443 -0
- package/src/android-remote/file-change-parser.ts +112 -0
- package/src/android-remote/gateway.ts +9108 -0
- package/src/android-remote/mutation-store.ts +299 -0
- package/src/android-remote/projection.ts +1780 -0
- package/src/android-remote/queued-turn-store.ts +248 -0
- package/src/android-remote/session-command-recovery.ts +1384 -0
- package/src/android-remote/store.ts +466 -0
- package/src/android-remote/thread-reconciliation.ts +133 -0
- package/src/android-remote/thread-source-paths.ts +307 -0
- package/src/android-remote/thread-stream.ts +546 -0
- package/src/android-remote/turn-activity.ts +235 -0
- package/src/android-remote/user-message-identity.ts +94 -0
- package/src/bridge.ts +1793 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +267 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +601 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +519 -0
- package/src/claude/model-info.ts +154 -0
- package/src/claude/outbound.ts +898 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +296 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +476 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +297 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +184 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-desktop.ts +211 -0
- package/src/cli/claude.ts +302 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/codex-shim-readiness.ts +69 -0
- package/src/cli/combo.ts +124 -0
- package/src/cli/config-command.ts +183 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/desktop-first-run.ts +25 -0
- package/src/cli/doctor.ts +1022 -0
- package/src/cli/export-command.ts +201 -0
- package/src/cli/help.ts +370 -0
- package/src/cli/index.ts +1565 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +225 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +35 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/models-runtime.ts +224 -0
- package/src/cli/models.ts +340 -0
- package/src/cli/observe.ts +170 -0
- package/src/cli/opencode.ts +587 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +476 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/system-command.ts +196 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +205 -0
- package/src/cli/v2.ts +200 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1109 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +40 -0
- package/src/codex/admission.ts +263 -0
- package/src/codex/app-server-processes.ts +799 -0
- package/src/codex/auth-api.ts +2098 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +480 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +471 -0
- package/src/codex/catalog/bundled.ts +533 -0
- package/src/codex/catalog/effort.ts +432 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +287 -0
- package/src/codex/catalog/native-models.ts +7 -0
- package/src/codex/catalog/parsing.ts +503 -0
- package/src/codex/catalog/provider-fetch.ts +2267 -0
- package/src/codex/catalog/sync.ts +1606 -0
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-refresh-status.ts +87 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +15 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +580 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-client-processes.ts +521 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1091 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +347 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +115 -0
- package/src/codex/history-provider.ts +1075 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +204 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +257 -0
- package/src/codex/inject.ts +1857 -0
- package/src/codex/injected-marker.ts +79 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +105 -0
- package/src/codex/journal.ts +172 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/model-cache.ts +267 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +348 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +691 -0
- package/src/codex/paths.ts +78 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +426 -0
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/provider-adoption.ts +242 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +494 -0
- package/src/codex/refresh.ts +60 -0
- package/src/codex/routing.ts +1855 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +1215 -0
- package/src/codex/subagent-defaults.ts +557 -0
- package/src/codex/subagent-model-fallback.ts +560 -0
- package/src/codex/sync.ts +238 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +374 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +44 -0
- package/src/combos/request.ts +64 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +392 -0
- package/src/config.ts +3270 -0
- package/src/generated/model-metadata.ts +144 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +540 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +127 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +922 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +108 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +242 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +127 -0
- package/src/lib/config-ownership.ts +438 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +316 -0
- package/src/lib/errors.ts +364 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +182 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-environment.ts +470 -0
- package/src/lib/provider-outbound.ts +203 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +510 -0
- package/src/lib/remodex-home.ts +616 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +178 -0
- package/src/lib/shadow-call.ts +54 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +98 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +366 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +508 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-elevation.ts +705 -0
- package/src/lib/windows-secret-acl.ts +817 -0
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +402 -0
- package/src/model-sources.ts +73 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +177 -0
- package/src/oauth/callback-server.ts +294 -0
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +231 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +443 -0
- package/src/oauth/index.ts +1280 -0
- package/src/oauth/key-providers.ts +128 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +121 -0
- package/src/oauth/log.ts +48 -0
- package/src/oauth/login-cli.ts +163 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +655 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +317 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/command-code-efforts.ts +85 -0
- package/src/providers/context-cap.ts +73 -0
- package/src/providers/derive.ts +451 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +361 -0
- package/src/providers/openai-sidecar.ts +235 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +301 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +179 -0
- package/src/providers/quota.ts +1942 -0
- package/src/providers/reasoning-capabilities.ts +336 -0
- package/src/providers/registry.ts +2375 -0
- package/src/providers/slug-codec.ts +74 -0
- package/src/providers/xai-transport.ts +149 -0
- package/src/reasoning-effort.ts +243 -0
- package/src/responses/compaction.ts +124 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +714 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +106 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +431 -0
- package/src/responses/state.ts +1039 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +802 -0
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +205 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +424 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/adapter-resolve.ts +83 -0
- package/src/server/auth-cors.ts +606 -0
- package/src/server/chat-completions.ts +379 -0
- package/src/server/claude-messages.ts +980 -0
- package/src/server/effort-policy.ts +251 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +485 -0
- package/src/server/index.ts +1633 -0
- package/src/server/lifecycle.ts +482 -0
- package/src/server/live.ts +609 -0
- package/src/server/management/agent-settings-routes.ts +1180 -0
- package/src/server/management/android-remote-routes.ts +390 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +244 -0
- package/src/server/management/config-routes.ts +602 -0
- package/src/server/management/context.ts +88 -0
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +516 -0
- package/src/server/management/model-routes.ts +519 -0
- package/src/server/management/model-rows.ts +143 -0
- package/src/server/management/native-integration-routes.ts +781 -0
- package/src/server/management/oauth-account-routes.ts +573 -0
- package/src/server/management/provider-routes.ts +781 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +384 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +433 -0
- package/src/server/management/system-routes.ts +141 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +269 -0
- package/src/server/management-auth.ts +353 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +326 -0
- package/src/server/proxy-stop.ts +92 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1179 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1072 -0
- package/src/server/responses/collaboration.ts +409 -0
- package/src/server/responses/compact.ts +710 -0
- package/src/server/responses/core.ts +3561 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +171 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +152 -0
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses.ts +10 -0
- package/src/server/search.ts +181 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +119 -0
- package/src/server/system-env.ts +418 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +470 -0
- package/src/service-manager-probe.ts +824 -0
- package/src/service.ts +3011 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +738 -0
- package/src/types.ts +1531 -0
- package/src/update/badge.ts +72 -0
- package/src/update/desktop-release.ts +1620 -0
- package/src/update/index.ts +402 -0
- package/src/update/job.ts +1906 -0
- package/src/update/notify.ts +261 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +283 -0
- package/src/usage/log.ts +695 -0
- package/src/usage/summary.ts +585 -0
- package/src/usage/totals.ts +14 -0
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/describe.ts +127 -0
- package/src/vision/index.ts +558 -0
- package/src/vision/reasoning.ts +55 -0
- package/src/web-search/anthropic-executor.ts +189 -0
- package/src/web-search/executor.ts +105 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/index.ts +196 -0
- package/src/web-search/loop.ts +791 -0
- package/src/web-search/parse.ts +235 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,3643 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createConnection, type Socket } from "node:net";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import type { CodexJsonRpcMessage } from "./codex-app-server";
|
|
7
|
+
import {
|
|
8
|
+
createAndroidDesktopOwnershipStore,
|
|
9
|
+
type AndroidDesktopOwnershipStore,
|
|
10
|
+
} from "./desktop-ownership-store";
|
|
11
|
+
import { canonicalUserMessageText } from "./user-message-identity";
|
|
12
|
+
import {
|
|
13
|
+
DESKTOP_HISTORY_PAGE_MAX_ITEMS,
|
|
14
|
+
DESKTOP_HISTORY_PAGE_MAX_TURNS,
|
|
15
|
+
DesktopHistoryPageStore,
|
|
16
|
+
type DesktopBoundedHistoryPage,
|
|
17
|
+
type DesktopContentChunk,
|
|
18
|
+
type DesktopHistoryPageDirection,
|
|
19
|
+
type DesktopJsonRecord,
|
|
20
|
+
} from "./desktop-history-page";
|
|
21
|
+
|
|
22
|
+
type JsonRecord = Record<string, unknown>;
|
|
23
|
+
type RequestId = string | number;
|
|
24
|
+
type Timer = ReturnType<typeof setTimeout>;
|
|
25
|
+
|
|
26
|
+
const FRAME_HEADER_BYTES = 4;
|
|
27
|
+
const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const MAX_FRAME_BUFFER_BYTES = FRAME_HEADER_BYTES + MAX_FRAME_BYTES;
|
|
29
|
+
const SNAPSHOT_DEBOUNCE_MS = 75;
|
|
30
|
+
const RECONNECT_MS = 1_500;
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
32
|
+
const FOLLOW_CONFIRM_MS = 1_500;
|
|
33
|
+
const FOLLOW_MAX_ATTEMPTS = 3;
|
|
34
|
+
const FOLLOW_STATE_TIMEOUT_MS = 3_000;
|
|
35
|
+
const OWNER_REACQUIRE_TIMEOUT_MS = 1_500;
|
|
36
|
+
const FOLLOWER_MOUNT_BASELINE_DELAY_MS = 250;
|
|
37
|
+
const SIDEBAR_REFRESH_DELAY_MS = 1_200;
|
|
38
|
+
const INITIAL_HISTORY_RETRY_MS = 1_000;
|
|
39
|
+
const INITIAL_HISTORY_MAX_ATTEMPTS = 5;
|
|
40
|
+
const FOLLOWER_BASELINE_RETRY_MS = 1_000;
|
|
41
|
+
const FOLLOWER_BASELINE_MAX_RETRY_MS = 15_000;
|
|
42
|
+
const FOLLOWER_BASELINE_MAX_ATTEMPTS = 5;
|
|
43
|
+
const MAX_QUEUED_FOLLOWER_CHANGES = 300;
|
|
44
|
+
const MAX_PATCH_COUNT = 2_000;
|
|
45
|
+
const MAX_PATCH_BYTES = 512 * 1024;
|
|
46
|
+
const MAX_STREAM_TEXT_BYTES = 1024 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* A Desktop renderer can publish its stream snapshot before it has registered
|
|
49
|
+
* the follower request handler. A router-level no-client-found is the one
|
|
50
|
+
* pre-delivery failure that is safe to retry. Keep this schedule centralized
|
|
51
|
+
* so every follower mutation has identical delivery semantics.
|
|
52
|
+
*/
|
|
53
|
+
const FOLLOWER_OWNER_RETRY_DELAYS_MS = [0, 125, 350] as const;
|
|
54
|
+
const THREAD_STREAM_STATE_CHANGED = "thread-stream-state-changed";
|
|
55
|
+
const THREAD_STREAM_FOLLOWING_CHANGED = "thread-stream-following-changed";
|
|
56
|
+
const CLIENT_STATUS_CHANGED = "client-status-changed";
|
|
57
|
+
const THREAD_ARCHIVED = "thread-archived";
|
|
58
|
+
/**
|
|
59
|
+
* Metadata-only route probe. This method must never cause a renderer to
|
|
60
|
+
* materialize or broadcast conversation history; the router uses the
|
|
61
|
+
* discovery response (and its handledByClientId) solely to identify the
|
|
62
|
+
* current writer.
|
|
63
|
+
*/
|
|
64
|
+
export const DESKTOP_IPC_OWNER_DISCOVERY_METHOD = "thread-owner-discovery";
|
|
65
|
+
const THREAD_OWNER_DISCOVERY = DESKTOP_IPC_OWNER_DISCOVERY_METHOD;
|
|
66
|
+
const THREAD_FOLLOWER_LOAD_HISTORY_PAGE = "thread-follower-load-history-page";
|
|
67
|
+
const THREAD_FOLLOWER_READ_CONTENT_CHUNK = "thread-follower-read-content-chunk";
|
|
68
|
+
const HOST_ID = "local";
|
|
69
|
+
const OWNER_SOURCE = "opencodex-android-live-owner";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Single source of truth for the private Codex Desktop IPC protocol. The
|
|
73
|
+
* Desktop renderer validates these versions before dispatching a request; a
|
|
74
|
+
* stale version is a protocol error, never evidence that the thread writer was
|
|
75
|
+
* released.
|
|
76
|
+
*/
|
|
77
|
+
export const DESKTOP_IPC_METHOD_VERSIONS = new Map<string, number>([
|
|
78
|
+
["initialize", 1],
|
|
79
|
+
[CLIENT_STATUS_CHANGED, 1],
|
|
80
|
+
[THREAD_STREAM_STATE_CHANGED, 11],
|
|
81
|
+
[THREAD_STREAM_FOLLOWING_CHANGED, 1],
|
|
82
|
+
["thread-stream-following-status-requested", 1],
|
|
83
|
+
[THREAD_OWNER_DISCOVERY, 1],
|
|
84
|
+
[THREAD_FOLLOWER_LOAD_HISTORY_PAGE, 1],
|
|
85
|
+
[THREAD_FOLLOWER_READ_CONTENT_CHUNK, 1],
|
|
86
|
+
[THREAD_ARCHIVED, 2],
|
|
87
|
+
["thread-unarchived", 1],
|
|
88
|
+
["thread-read-state-changed", 2],
|
|
89
|
+
["thread-queued-followups-changed", 1],
|
|
90
|
+
["thread-follower-start-turn", 2],
|
|
91
|
+
["thread-follower-load-complete-history", 1],
|
|
92
|
+
["thread-follower-update-thread-settings", 1],
|
|
93
|
+
["thread-follower-compact-thread", 1],
|
|
94
|
+
["thread-follower-steer-turn", 1],
|
|
95
|
+
["thread-follower-interrupt-turn", 4],
|
|
96
|
+
["thread-follower-rollback-thread", 1],
|
|
97
|
+
["thread-follower-edit-last-user-turn", 2],
|
|
98
|
+
["thread-follower-set-model-and-reasoning", 1],
|
|
99
|
+
["thread-follower-set-collaboration-mode", 1],
|
|
100
|
+
["thread-follower-command-approval-decision", 1],
|
|
101
|
+
["thread-follower-file-approval-decision", 1],
|
|
102
|
+
["thread-follower-permissions-request-approval-response", 1],
|
|
103
|
+
["thread-follower-submit-user-input", 1],
|
|
104
|
+
["thread-follower-submit-mcp-server-elicitation-response", 1],
|
|
105
|
+
["thread-follower-set-queued-follow-ups-state", 1],
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** Compatibility rule retained deliberately for old stop buttons. */
|
|
109
|
+
export function desktopIpcRequestVersion(method: string, params: unknown = {}): number {
|
|
110
|
+
if (
|
|
111
|
+
method === "thread-follower-interrupt-turn"
|
|
112
|
+
&& (!record(params)?.expectedTurnId && !record(params)?.expected_turn_id)
|
|
113
|
+
) {
|
|
114
|
+
return 3;
|
|
115
|
+
}
|
|
116
|
+
return DESKTOP_IPC_METHOD_VERSIONS.get(method) ?? 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const FOLLOWER_METHODS = new Set([
|
|
120
|
+
"thread-follower-start-turn",
|
|
121
|
+
"thread-follower-load-complete-history",
|
|
122
|
+
THREAD_FOLLOWER_LOAD_HISTORY_PAGE,
|
|
123
|
+
THREAD_FOLLOWER_READ_CONTENT_CHUNK,
|
|
124
|
+
"thread-follower-update-thread-settings",
|
|
125
|
+
"thread-follower-compact-thread",
|
|
126
|
+
"thread-follower-steer-turn",
|
|
127
|
+
"thread-follower-interrupt-turn",
|
|
128
|
+
"thread-follower-rollback-thread",
|
|
129
|
+
"thread-follower-set-model-and-reasoning",
|
|
130
|
+
"thread-follower-set-collaboration-mode",
|
|
131
|
+
"thread-follower-command-approval-decision",
|
|
132
|
+
"thread-follower-file-approval-decision",
|
|
133
|
+
"thread-follower-permissions-request-approval-response",
|
|
134
|
+
"thread-follower-submit-user-input",
|
|
135
|
+
"thread-follower-submit-mcp-server-elicitation-response",
|
|
136
|
+
"thread-follower-set-queued-follow-ups-state",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const SERVER_REQUEST_METHODS = new Set([
|
|
140
|
+
"item/commandExecution/requestApproval",
|
|
141
|
+
"item/fileChange/requestApproval",
|
|
142
|
+
"item/fileRead/requestApproval",
|
|
143
|
+
"item/permissions/requestApproval",
|
|
144
|
+
"item/tool/requestUserInput",
|
|
145
|
+
"item/tool/requestMcpServerElicitation",
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
const ALLOWED_TURN_START_KEYS = new Set([
|
|
149
|
+
"threadId",
|
|
150
|
+
"clientUserMessageId",
|
|
151
|
+
"input",
|
|
152
|
+
"cwd",
|
|
153
|
+
"approvalPolicy",
|
|
154
|
+
"approvalsReviewer",
|
|
155
|
+
"sandboxPolicy",
|
|
156
|
+
"model",
|
|
157
|
+
"serviceTier",
|
|
158
|
+
"effort",
|
|
159
|
+
"summary",
|
|
160
|
+
"personality",
|
|
161
|
+
"outputSchema",
|
|
162
|
+
"collaborationMode",
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
export type DesktopIpcEnvelope = JsonRecord & {
|
|
166
|
+
type?: string;
|
|
167
|
+
method?: string;
|
|
168
|
+
requestId?: RequestId;
|
|
169
|
+
sourceClientId?: string;
|
|
170
|
+
targetClientId?: string;
|
|
171
|
+
targetClientIds?: string[];
|
|
172
|
+
timeoutMs?: number;
|
|
173
|
+
params?: JsonRecord;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export type DesktopIpcResponseSettled = {
|
|
177
|
+
requestId: RequestId;
|
|
178
|
+
method: string;
|
|
179
|
+
threadId: string;
|
|
180
|
+
commandId: string;
|
|
181
|
+
localClientId: string;
|
|
182
|
+
handledByClientId: string;
|
|
183
|
+
targetClientId?: string;
|
|
184
|
+
resultType: "success" | "error";
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type DesktopThreadOwnershipState = "unknown" | "desktop-owned" | "local-owned";
|
|
188
|
+
|
|
189
|
+
export type DesktopThreadOwnership = {
|
|
190
|
+
state: DesktopThreadOwnershipState;
|
|
191
|
+
/** Current Desktop renderer owner, when one has been observed. */
|
|
192
|
+
ownerClientId: string | null;
|
|
193
|
+
/** True once any authoritative Desktop state/response has named an owner. */
|
|
194
|
+
everDesktopOwned: boolean;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type DesktopIpcConnectionSnapshot = {
|
|
198
|
+
connected: boolean;
|
|
199
|
+
generation: number;
|
|
200
|
+
localClientId: string;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export type DesktopIpcDiscoveryResult = {
|
|
204
|
+
canHandle: boolean;
|
|
205
|
+
handledByClientId?: string;
|
|
206
|
+
timedOut?: boolean;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface AndroidDesktopIpcSync {
|
|
210
|
+
start(): void;
|
|
211
|
+
stop(): void;
|
|
212
|
+
claimThread(input: {
|
|
213
|
+
threadId: string;
|
|
214
|
+
turnStartParams: JsonRecord;
|
|
215
|
+
cwd?: string;
|
|
216
|
+
title?: string;
|
|
217
|
+
}): void;
|
|
218
|
+
releaseThread(threadId: string): void;
|
|
219
|
+
observeCodexMessage(message: CodexJsonRpcMessage): void;
|
|
220
|
+
isThreadOwned(threadId: string): boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Distinguishes the real app-server writer from this bridge's presentation
|
|
223
|
+
* mirror. Older test doubles may omit this optional method; callers then
|
|
224
|
+
* conservatively derive local ownership from `isThreadOwned`.
|
|
225
|
+
*/
|
|
226
|
+
threadOwnership?(threadId: string): DesktopThreadOwnership;
|
|
227
|
+
desktopOwnerClientId?(threadId: string): string | null;
|
|
228
|
+
hasObservedDesktopOwner?(threadId: string): boolean;
|
|
229
|
+
/** Explicit, authoritative owner-release/local-adoption transition. */
|
|
230
|
+
releaseDesktopOwnership?(threadId: string): void;
|
|
231
|
+
/** Record that the private app-server has already mounted this thread. */
|
|
232
|
+
adoptLocalThread?(threadId: string): void;
|
|
233
|
+
/**
|
|
234
|
+
* Ask Codex Desktop to perform an action on a task it already owns.
|
|
235
|
+
* A missing Desktop handler is reported as an error so the caller can use
|
|
236
|
+
* the normal local Codex connection instead.
|
|
237
|
+
*/
|
|
238
|
+
requestFollowerAction?(method: string, params: JsonRecord): Promise<unknown>;
|
|
239
|
+
readFollowerThreadState?(
|
|
240
|
+
threadId: string,
|
|
241
|
+
options?: { fresh?: boolean },
|
|
242
|
+
): Promise<JsonRecord | null>;
|
|
243
|
+
/** Read one bounded recent/older/newer page without registering a follower. */
|
|
244
|
+
readFollowerHistoryPage?(
|
|
245
|
+
threadId: string,
|
|
246
|
+
options?: {
|
|
247
|
+
direction?: DesktopHistoryPageDirection;
|
|
248
|
+
pageToken?: string | null;
|
|
249
|
+
},
|
|
250
|
+
): Promise<JsonRecord | null>;
|
|
251
|
+
/** Read one bounded opaque content chunk associated with a page handle. */
|
|
252
|
+
readFollowerContentChunk?(
|
|
253
|
+
threadId: string,
|
|
254
|
+
input: { handle: string; sourceRevision: string; offset: number },
|
|
255
|
+
): Promise<DesktopContentChunk>;
|
|
256
|
+
/**
|
|
257
|
+
* Read-only route preflight used before a prompt is written to Desktop IPC.
|
|
258
|
+
* `absent` means no Desktop owner handled the request, while `unhealthy`
|
|
259
|
+
* means the route existed but could not publish a trustworthy state.
|
|
260
|
+
*/
|
|
261
|
+
probeFollowerRoute?(
|
|
262
|
+
threadId: string,
|
|
263
|
+
): Promise<"ready" | "absent" | "unhealthy">;
|
|
264
|
+
/**
|
|
265
|
+
* Reacquire the current renderer id for a remembered Desktop-owned task.
|
|
266
|
+
* This is a read-only owner probe. A caller must not send a follower
|
|
267
|
+
* mutation until this resolves to a concrete client id.
|
|
268
|
+
*/
|
|
269
|
+
reacquireDesktopOwner?(threadId: string): Promise<string | null>;
|
|
270
|
+
/**
|
|
271
|
+
* Non-sensitive connection metadata for route diagnostics. This never
|
|
272
|
+
* includes prompt text, attachments, credentials, or raw IPC payloads.
|
|
273
|
+
*/
|
|
274
|
+
connectionSnapshot?(): DesktopIpcConnectionSnapshot;
|
|
275
|
+
/**
|
|
276
|
+
* Bring a Desktop-owned task back into the live IPC owner set. Desktop may
|
|
277
|
+
* release an idle task while its append-only transcript still contains a
|
|
278
|
+
* pending question, so Android response delivery must be able to reattach it.
|
|
279
|
+
*/
|
|
280
|
+
activateFollowerThread?(threadId: string): Promise<void>;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type DesktopIpcSyncOptions = {
|
|
284
|
+
platform?: NodeJS.Platform;
|
|
285
|
+
socketPaths?: () => string[];
|
|
286
|
+
now?: () => number;
|
|
287
|
+
connect?: (path: string) => Socket;
|
|
288
|
+
openUrl?: (url: string) => Promise<void>;
|
|
289
|
+
readThread: (threadId: string) => Promise<JsonRecord | null>;
|
|
290
|
+
/** Optional byte-bounded recent-page reader used during local hydration. */
|
|
291
|
+
readThreadPage?: (threadId: string) => Promise<JsonRecord | null>;
|
|
292
|
+
sendCodexRequest: (method: string, params: JsonRecord) => Promise<unknown>;
|
|
293
|
+
respondToCodexRequest: (id: RequestId, result: JsonRecord) => void;
|
|
294
|
+
log?: (message: string) => void;
|
|
295
|
+
warn?: (message: string) => void;
|
|
296
|
+
reconnectMs?: number;
|
|
297
|
+
snapshotDebounceMs?: number;
|
|
298
|
+
followConfirmMs?: number;
|
|
299
|
+
followMaxAttempts?: number;
|
|
300
|
+
initialHistoryRetryMs?: number;
|
|
301
|
+
initialHistoryMaxAttempts?: number;
|
|
302
|
+
followerBaselineRetryMs?: number;
|
|
303
|
+
followerBaselineMaxRetryMs?: number;
|
|
304
|
+
followerBaselineMaxAttempts?: number;
|
|
305
|
+
followerStateTimeoutMs?: number;
|
|
306
|
+
ownerReacquireTimeoutMs?: number;
|
|
307
|
+
ownershipStore?: AndroidDesktopOwnershipStore;
|
|
308
|
+
historyPageStore?: DesktopHistoryPageStore;
|
|
309
|
+
transport?: DesktopIpcTransportLike;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export type CodexDesktopOpenCommand = {
|
|
313
|
+
command: string;
|
|
314
|
+
args: string[];
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export interface DesktopIpcTransportLike {
|
|
318
|
+
start(): void;
|
|
319
|
+
stop(): void;
|
|
320
|
+
sendBroadcast(method: string, params: JsonRecord): boolean;
|
|
321
|
+
request(
|
|
322
|
+
method: string,
|
|
323
|
+
params: JsonRecord,
|
|
324
|
+
options?: { targetClientId?: string },
|
|
325
|
+
): Promise<unknown>;
|
|
326
|
+
/**
|
|
327
|
+
* Read-only owner discovery through the router's public request path. The
|
|
328
|
+
* discovery method itself returns no history or transcript state.
|
|
329
|
+
*/
|
|
330
|
+
discover?(
|
|
331
|
+
method: string,
|
|
332
|
+
params: JsonRecord,
|
|
333
|
+
options?: { targetClientId?: string; timeoutMs?: number },
|
|
334
|
+
): Promise<DesktopIpcDiscoveryResult>;
|
|
335
|
+
reset?(reason?: string): void;
|
|
336
|
+
setHandlers(handlers: DesktopIpcTransportHandlers): void;
|
|
337
|
+
readonly connected: boolean;
|
|
338
|
+
readonly localClientId?: string;
|
|
339
|
+
readonly generation?: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
type DesktopIpcTransportHandlers = {
|
|
343
|
+
onConnected: () => void;
|
|
344
|
+
onDisconnected?: () => void;
|
|
345
|
+
onBroadcast: (envelope: DesktopIpcEnvelope) => void;
|
|
346
|
+
canHandleRequest: (envelope: DesktopIpcEnvelope) => boolean;
|
|
347
|
+
handleRequest: (envelope: DesktopIpcEnvelope) => Promise<unknown>;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
type ConversationTurn = JsonRecord & {
|
|
351
|
+
id: string;
|
|
352
|
+
turnId: string;
|
|
353
|
+
params: JsonRecord;
|
|
354
|
+
items: JsonRecord[];
|
|
355
|
+
status: unknown;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
type ConversationState = JsonRecord & {
|
|
359
|
+
id: string;
|
|
360
|
+
hostId: string;
|
|
361
|
+
turns: ConversationTurn[];
|
|
362
|
+
requests: JsonRecord[];
|
|
363
|
+
updatedAt: number;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
type JsonPatch = {
|
|
367
|
+
op: "add" | "remove" | "replace";
|
|
368
|
+
path: Array<string | number>;
|
|
369
|
+
value?: unknown;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function record(value: unknown): JsonRecord | null {
|
|
373
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
374
|
+
? value as JsonRecord
|
|
375
|
+
: null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stringValue(value: unknown): string {
|
|
379
|
+
return typeof value === "string" ? value.trim() : "";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function threadSettingsUpdateUnavailable(error: unknown): boolean {
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
return /method not found|unknown method|unsupported method|thread\/settings\/update.*(?:unsupported|unavailable)|experimental.*disabled|-32601/iu.test(message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function numberValue(value: unknown): number | null {
|
|
388
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function requestIdKey(value: unknown): string {
|
|
392
|
+
if (typeof value === "string" && value) return value;
|
|
393
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function clone<T>(value: T): T {
|
|
397
|
+
return structuredClone(value);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function normalizeToken(value: unknown): string {
|
|
401
|
+
return stringValue(value).replace(/[^a-z0-9]/giu, "").toLowerCase();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function threadIdFromParams(params: unknown): string {
|
|
405
|
+
const row = record(params);
|
|
406
|
+
const turn = record(row?.turn);
|
|
407
|
+
const thread = record(row?.thread);
|
|
408
|
+
return stringValue(row?.threadId)
|
|
409
|
+
|| stringValue(row?.thread_id)
|
|
410
|
+
|| stringValue(row?.conversationId)
|
|
411
|
+
|| stringValue(row?.conversation_id)
|
|
412
|
+
|| stringValue(turn?.threadId)
|
|
413
|
+
|| stringValue(thread?.id);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function commandIdFromParams(params: unknown): string {
|
|
417
|
+
const row = record(params);
|
|
418
|
+
const turnStart = record(row?.turnStart);
|
|
419
|
+
const turnStartRequest = record(turnStart?.request);
|
|
420
|
+
const turnStartParams = record(row?.turnStartParams) ?? record(row?.turn_start_params);
|
|
421
|
+
const turnSteerParams = record(row?.turnSteerParams) ?? record(row?.turn_steer_params);
|
|
422
|
+
return stringValue(row?.senderRequestId)
|
|
423
|
+
|| stringValue(row?.sender_request_id)
|
|
424
|
+
|| stringValue(row?.commandId)
|
|
425
|
+
|| stringValue(row?.command_id)
|
|
426
|
+
|| stringValue(row?.clientUserMessageId)
|
|
427
|
+
|| stringValue(row?.client_user_message_id)
|
|
428
|
+
|| stringValue(turnStartRequest?.clientUserMessageId)
|
|
429
|
+
|| stringValue(turnStartRequest?.client_user_message_id)
|
|
430
|
+
|| stringValue(turnStartParams?.clientUserMessageId)
|
|
431
|
+
|| stringValue(turnStartParams?.client_user_message_id)
|
|
432
|
+
|| stringValue(turnSteerParams?.clientUserMessageId)
|
|
433
|
+
|| stringValue(turnSteerParams?.client_user_message_id);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function diagnosticToken(value: unknown, maximum = 160): string {
|
|
437
|
+
const normalized = stringValue(value)
|
|
438
|
+
.replace(/[^a-z0-9._:/-]/giu, "_")
|
|
439
|
+
.slice(0, maximum);
|
|
440
|
+
return normalized || "-";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function desktopFollowerRouteIsAbsent(error: unknown): boolean {
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
return /no codex ipc client can handle|no-client-found|conversation-not-owned|thread not found|conversation not found|not connected/iu.test(
|
|
446
|
+
message,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function desktopFollowerNoClientFound(error: unknown): boolean {
|
|
451
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
452
|
+
// Only router-level absence is retryable. A timeout, socket close, protocol
|
|
453
|
+
// mismatch, or Desktop application error may have happened after the write.
|
|
454
|
+
return /no codex ipc client can handle|no-client-found/iu.test(message);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function desktopFollowerMethodUnsupported(error: unknown): boolean {
|
|
458
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
459
|
+
return /(?:method|request).*(?:not found|unknown|unsupported|unavailable)|unknown method|unsupported method|-32601/iu.test(
|
|
460
|
+
message,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export class DesktopIpcOwnershipError extends Error {
|
|
465
|
+
constructor(
|
|
466
|
+
message: string,
|
|
467
|
+
readonly threadId: string,
|
|
468
|
+
readonly method: string,
|
|
469
|
+
readonly attempts: number,
|
|
470
|
+
readonly reason: "no-client-found" | "owner-unavailable" | "ownership-conflict" = "ownership-conflict",
|
|
471
|
+
) {
|
|
472
|
+
super(message);
|
|
473
|
+
this.name = "DesktopIpcOwnershipError";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function turnIdFromParams(params: unknown): string {
|
|
478
|
+
const row = record(params);
|
|
479
|
+
const turn = record(row?.turn);
|
|
480
|
+
return stringValue(row?.turnId)
|
|
481
|
+
|| stringValue(row?.turn_id)
|
|
482
|
+
|| stringValue(turn?.id)
|
|
483
|
+
|| stringValue(turn?.turnId);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function timestampMs(value: unknown, fallback: number): number {
|
|
487
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
488
|
+
? Math.round(value < 10_000_000_000 ? value * 1_000 : value)
|
|
489
|
+
: fallback;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function waitMs(delayMs: number): Promise<void> {
|
|
493
|
+
return new Promise(resolve => {
|
|
494
|
+
const timer = setTimeout(resolve, Math.max(0, delayMs));
|
|
495
|
+
timer.unref?.();
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function desktopIpcSocketPaths(
|
|
500
|
+
platform: NodeJS.Platform = process.platform,
|
|
501
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
502
|
+
): string[] {
|
|
503
|
+
if (platform === "win32") return ["\\\\.\\pipe\\codex-ipc"];
|
|
504
|
+
if (platform !== "darwin" && platform !== "linux") return [];
|
|
505
|
+
const configuredHome = stringValue(environment.CODEX_HOME);
|
|
506
|
+
const codexHome = configuredHome
|
|
507
|
+
? posix.normalize(configuredHome.replace(/\\/gu, "/"))
|
|
508
|
+
: posix.join(homedir().replace(/\\/gu, "/"), ".codex");
|
|
509
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
510
|
+
return [
|
|
511
|
+
posix.join(codexHome, "ipc", "ipc.sock"),
|
|
512
|
+
posix.join(tmpdir().replace(/\\/gu, "/"), "codex-ipc", `ipc-${uid}.sock`),
|
|
513
|
+
];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function encodeDesktopIpcFrame(envelope: DesktopIpcEnvelope): Buffer {
|
|
517
|
+
const body = Buffer.from(JSON.stringify(envelope), "utf8");
|
|
518
|
+
if (body.length > MAX_FRAME_BYTES) {
|
|
519
|
+
throw new RangeError("Codex Desktop IPC frame exceeds the 64 MB limit");
|
|
520
|
+
}
|
|
521
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
522
|
+
header.writeUInt32LE(body.length, 0);
|
|
523
|
+
return Buffer.concat([header, body]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export class DesktopIpcFrameReader {
|
|
527
|
+
private buffer = Buffer.alloc(0);
|
|
528
|
+
|
|
529
|
+
constructor(
|
|
530
|
+
private readonly onFrame: (envelope: DesktopIpcEnvelope) => void,
|
|
531
|
+
private readonly onCorruption: (error: Error) => void = () => undefined,
|
|
532
|
+
) {}
|
|
533
|
+
|
|
534
|
+
push(chunk: Buffer): void {
|
|
535
|
+
if (chunk.length === 0) return;
|
|
536
|
+
if (
|
|
537
|
+
chunk.length > MAX_FRAME_BUFFER_BYTES
|
|
538
|
+
|| this.buffer.length > MAX_FRAME_BUFFER_BYTES - chunk.length
|
|
539
|
+
) {
|
|
540
|
+
this.fail("Codex Desktop IPC receive buffer exceeded the 64 MB frame limit");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
this.buffer = this.buffer.length === 0
|
|
545
|
+
? Buffer.from(chunk)
|
|
546
|
+
: Buffer.concat([this.buffer, chunk], this.buffer.length + chunk.length);
|
|
547
|
+
} catch {
|
|
548
|
+
this.fail("Codex Desktop IPC could not allocate its bounded receive buffer");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
while (this.buffer.length >= FRAME_HEADER_BYTES) {
|
|
552
|
+
const frameLength = this.buffer.readUInt32LE(0);
|
|
553
|
+
if (frameLength > MAX_FRAME_BYTES) {
|
|
554
|
+
this.fail("Codex Desktop IPC frame length exceeded the 64 MB limit");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (this.buffer.length < FRAME_HEADER_BYTES + frameLength) return;
|
|
558
|
+
const text = this.buffer.subarray(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
|
|
559
|
+
this.buffer = this.buffer.subarray(FRAME_HEADER_BYTES + frameLength);
|
|
560
|
+
try {
|
|
561
|
+
const envelope = record(JSON.parse(text));
|
|
562
|
+
if (!envelope) {
|
|
563
|
+
this.fail("Codex Desktop IPC frame did not contain an object envelope");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this.onFrame(envelope as DesktopIpcEnvelope);
|
|
567
|
+
} catch {
|
|
568
|
+
// A malformed body or throwing frame handler means this connection can
|
|
569
|
+
// no longer be trusted. Reconnect instead of attempting to continue on
|
|
570
|
+
// a potentially misaligned byte stream.
|
|
571
|
+
this.fail("Codex Desktop IPC received a malformed frame");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
reset(): void {
|
|
578
|
+
this.buffer = Buffer.alloc(0);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private fail(message: string): void {
|
|
582
|
+
this.buffer = Buffer.alloc(0);
|
|
583
|
+
this.onCorruption(new Error(message));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export class DesktopIpcTransport implements DesktopIpcTransportLike {
|
|
588
|
+
private socket: Socket | null = null;
|
|
589
|
+
private reader: DesktopIpcFrameReader | null = null;
|
|
590
|
+
private connecting = false;
|
|
591
|
+
private initialized = false;
|
|
592
|
+
private clientId = "";
|
|
593
|
+
private connectionGeneration = 0;
|
|
594
|
+
private shouldReconnect = false;
|
|
595
|
+
private reconnectTimer: Timer | null = null;
|
|
596
|
+
private remainingPaths: string[] = [];
|
|
597
|
+
private handlers: DesktopIpcTransportHandlers = {
|
|
598
|
+
onConnected: () => undefined,
|
|
599
|
+
onBroadcast: () => undefined,
|
|
600
|
+
canHandleRequest: () => false,
|
|
601
|
+
handleRequest: async () => null,
|
|
602
|
+
};
|
|
603
|
+
private readonly pending = new Map<string, {
|
|
604
|
+
method: string;
|
|
605
|
+
threadId: string;
|
|
606
|
+
commandId: string;
|
|
607
|
+
resolve: (value: unknown) => void;
|
|
608
|
+
reject: (error: Error) => void;
|
|
609
|
+
timer: Timer;
|
|
610
|
+
generation: number;
|
|
611
|
+
targetClientId?: string;
|
|
612
|
+
}>();
|
|
613
|
+
private readonly pendingDiscoveries = new Map<string, {
|
|
614
|
+
resolve: (result: DesktopIpcDiscoveryResult) => void;
|
|
615
|
+
timer: Timer;
|
|
616
|
+
}>();
|
|
617
|
+
|
|
618
|
+
constructor(private readonly options: {
|
|
619
|
+
paths: () => string[];
|
|
620
|
+
connect: (path: string) => Socket;
|
|
621
|
+
now: () => number;
|
|
622
|
+
reconnectMs: number;
|
|
623
|
+
requestTimeoutMs?: number;
|
|
624
|
+
warn: (message: string) => void;
|
|
625
|
+
onResponseSettled?: (response: DesktopIpcResponseSettled) => void;
|
|
626
|
+
}) {}
|
|
627
|
+
|
|
628
|
+
get connected(): boolean {
|
|
629
|
+
return Boolean(this.socket && !this.socket.destroyed && this.initialized);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
get localClientId(): string {
|
|
633
|
+
return this.clientId;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
get generation(): number {
|
|
637
|
+
return this.connectionGeneration;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
setHandlers(handlers: DesktopIpcTransportHandlers): void {
|
|
641
|
+
this.handlers = handlers;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
start(): void {
|
|
645
|
+
this.shouldReconnect = true;
|
|
646
|
+
this.ensureConnected();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
stop(): void {
|
|
650
|
+
this.shouldReconnect = false;
|
|
651
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
652
|
+
this.reconnectTimer = null;
|
|
653
|
+
this.closeSocket();
|
|
654
|
+
for (const waiter of this.pending.values()) {
|
|
655
|
+
clearTimeout(waiter.timer);
|
|
656
|
+
waiter.reject(new Error("Codex Desktop IPC stopped"));
|
|
657
|
+
}
|
|
658
|
+
this.pending.clear();
|
|
659
|
+
for (const pending of this.pendingDiscoveries.values()) {
|
|
660
|
+
clearTimeout(pending.timer);
|
|
661
|
+
pending.resolve({ canHandle: false, timedOut: true });
|
|
662
|
+
}
|
|
663
|
+
this.pendingDiscoveries.clear();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
reset(reason = "Codex Desktop IPC connection was reset"): void {
|
|
667
|
+
this.closeSocket(new Error(reason));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
sendBroadcast(method: string, params: JsonRecord): boolean {
|
|
671
|
+
this.ensureConnected();
|
|
672
|
+
if (!this.connected) return false;
|
|
673
|
+
return this.write({
|
|
674
|
+
type: "broadcast",
|
|
675
|
+
method,
|
|
676
|
+
sourceClientId: this.clientId,
|
|
677
|
+
version: desktopIpcRequestVersion(method, params),
|
|
678
|
+
params,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
request(
|
|
683
|
+
method: string,
|
|
684
|
+
params: JsonRecord,
|
|
685
|
+
options: { targetClientId?: string } = {},
|
|
686
|
+
): Promise<unknown> {
|
|
687
|
+
this.ensureConnected();
|
|
688
|
+
return this.sendRequest(method, params, false, undefined, options);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
discover(
|
|
692
|
+
method: string,
|
|
693
|
+
params: JsonRecord,
|
|
694
|
+
options: { targetClientId?: string; timeoutMs?: number } = {},
|
|
695
|
+
): Promise<DesktopIpcDiscoveryResult> {
|
|
696
|
+
this.ensureConnected();
|
|
697
|
+
const socket = this.socket;
|
|
698
|
+
const generation = this.connectionGeneration;
|
|
699
|
+
if (
|
|
700
|
+
!socket
|
|
701
|
+
|| socket.destroyed
|
|
702
|
+
|| !this.isCurrentConnection(socket, generation)
|
|
703
|
+
) return Promise.resolve({ canHandle: false, timedOut: true });
|
|
704
|
+
const requestId = `opencodex-discovery-${this.options.now().toString(36)}-${randomUUID()}`;
|
|
705
|
+
const timeoutMs = Math.max(1, options.timeoutMs ?? OWNER_REACQUIRE_TIMEOUT_MS);
|
|
706
|
+
return new Promise(resolvePromise => {
|
|
707
|
+
const timer = setTimeout(() => {
|
|
708
|
+
this.pendingDiscoveries.delete(requestId);
|
|
709
|
+
resolvePromise({ canHandle: false, timedOut: true });
|
|
710
|
+
}, timeoutMs);
|
|
711
|
+
timer.unref?.();
|
|
712
|
+
this.pendingDiscoveries.set(requestId, { resolve: resolvePromise, timer });
|
|
713
|
+
// Clients send an ordinary request to the Desktop router. The router
|
|
714
|
+
// creates its private client-discovery-request envelope when asking
|
|
715
|
+
// renderer candidates whether they can handle this metadata-only method.
|
|
716
|
+
// Sending that private envelope from here makes the router reject it as
|
|
717
|
+
// an unexpected server-side message and every Stop/Steer probe times out.
|
|
718
|
+
const sent = this.write({
|
|
719
|
+
type: "request",
|
|
720
|
+
requestId,
|
|
721
|
+
sourceClientId: this.clientId || "opencodex-android-bridge",
|
|
722
|
+
version: desktopIpcRequestVersion(method, params),
|
|
723
|
+
method,
|
|
724
|
+
params,
|
|
725
|
+
timeoutMs,
|
|
726
|
+
...(stringValue(options.targetClientId)
|
|
727
|
+
? { targetClientId: stringValue(options.targetClientId) }
|
|
728
|
+
: {}),
|
|
729
|
+
}, socket, generation);
|
|
730
|
+
if (!sent) {
|
|
731
|
+
clearTimeout(timer);
|
|
732
|
+
this.pendingDiscoveries.delete(requestId);
|
|
733
|
+
resolvePromise({ canHandle: false, timedOut: true });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private ensureConnected(): void {
|
|
739
|
+
if (!this.shouldReconnect || this.socket || this.connecting) return;
|
|
740
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
741
|
+
this.reconnectTimer = null;
|
|
742
|
+
this.remainingPaths = this.options.paths();
|
|
743
|
+
this.connectNext();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private connectNext(): void {
|
|
747
|
+
const path = this.remainingPaths.shift();
|
|
748
|
+
if (!path) {
|
|
749
|
+
this.connecting = false;
|
|
750
|
+
this.scheduleReconnect();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
this.connecting = true;
|
|
754
|
+
let socket: Socket;
|
|
755
|
+
try {
|
|
756
|
+
socket = this.options.connect(path);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
this.connecting = false;
|
|
759
|
+
if (this.remainingPaths.length > 0) {
|
|
760
|
+
this.connectNext();
|
|
761
|
+
} else {
|
|
762
|
+
this.scheduleReconnect();
|
|
763
|
+
}
|
|
764
|
+
this.options.warn(
|
|
765
|
+
`Codex Desktop IPC connection failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
766
|
+
);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const generation = this.connectionGeneration + 1;
|
|
770
|
+
this.connectionGeneration = generation;
|
|
771
|
+
this.socket = socket;
|
|
772
|
+
const reader = new DesktopIpcFrameReader(
|
|
773
|
+
envelope => {
|
|
774
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
775
|
+
this.dispatch(envelope);
|
|
776
|
+
},
|
|
777
|
+
error => {
|
|
778
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
779
|
+
this.options.warn(error.message);
|
|
780
|
+
this.closeSocket(error, socket, generation);
|
|
781
|
+
},
|
|
782
|
+
);
|
|
783
|
+
this.reader = reader;
|
|
784
|
+
socket.once("connect", () => {
|
|
785
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
786
|
+
this.connecting = false;
|
|
787
|
+
void this.sendRequest(
|
|
788
|
+
"initialize",
|
|
789
|
+
{ clientType: "opencodex-android-bridge" },
|
|
790
|
+
true,
|
|
791
|
+
{ socket, generation },
|
|
792
|
+
)
|
|
793
|
+
.then(result => {
|
|
794
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
795
|
+
this.clientId = stringValue(record(result)?.clientId) || this.clientId;
|
|
796
|
+
this.initialized = true;
|
|
797
|
+
this.handlers.onConnected();
|
|
798
|
+
})
|
|
799
|
+
.catch(error => {
|
|
800
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
801
|
+
this.options.warn(`Codex Desktop IPC initialization failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
802
|
+
this.closeSocket(
|
|
803
|
+
new Error("Codex Desktop IPC initialization failed"),
|
|
804
|
+
socket,
|
|
805
|
+
generation,
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
socket.on("data", chunk => {
|
|
810
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
811
|
+
try {
|
|
812
|
+
reader.push(Buffer.from(chunk));
|
|
813
|
+
} catch {
|
|
814
|
+
const error = new Error("Codex Desktop IPC failed while reading a frame");
|
|
815
|
+
this.options.warn(error.message);
|
|
816
|
+
this.closeSocket(error, socket, generation);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
socket.once("close", () => this.handleClose(socket, generation));
|
|
820
|
+
socket.once("error", error => {
|
|
821
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
822
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
823
|
+
if (code === "ENOENT" || code === "ECONNREFUSED") {
|
|
824
|
+
if (this.remainingPaths.length > 0) {
|
|
825
|
+
this.socket = null;
|
|
826
|
+
this.reader = null;
|
|
827
|
+
this.connecting = false;
|
|
828
|
+
this.initialized = false;
|
|
829
|
+
this.clientId = "";
|
|
830
|
+
reader.reset();
|
|
831
|
+
socket.destroy();
|
|
832
|
+
this.connectNext();
|
|
833
|
+
} else {
|
|
834
|
+
this.closeSocket(
|
|
835
|
+
new Error("Codex Desktop IPC endpoint is unavailable"),
|
|
836
|
+
socket,
|
|
837
|
+
generation,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (code !== "ENOENT" && code !== "ECONNREFUSED") {
|
|
843
|
+
this.options.warn(`Codex Desktop IPC connection failed: ${error.message}`);
|
|
844
|
+
this.closeSocket(
|
|
845
|
+
new Error("Codex Desktop IPC connection failed"),
|
|
846
|
+
socket,
|
|
847
|
+
generation,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private sendRequest(
|
|
854
|
+
method: string,
|
|
855
|
+
params: JsonRecord,
|
|
856
|
+
initializing = false,
|
|
857
|
+
expected?: { socket: Socket; generation: number },
|
|
858
|
+
options: { targetClientId?: string } = {},
|
|
859
|
+
): Promise<unknown> {
|
|
860
|
+
const socket = expected?.socket ?? this.socket;
|
|
861
|
+
const generation = expected?.generation ?? this.connectionGeneration;
|
|
862
|
+
if (
|
|
863
|
+
!socket
|
|
864
|
+
|| socket.destroyed
|
|
865
|
+
|| !this.isCurrentConnection(socket, generation)
|
|
866
|
+
) {
|
|
867
|
+
return Promise.reject(new Error("Codex Desktop IPC is not connected"));
|
|
868
|
+
}
|
|
869
|
+
const requestId = `opencodex-${this.options.now().toString(36)}-${randomUUID()}`;
|
|
870
|
+
return new Promise((resolvePromise, reject) => {
|
|
871
|
+
const timer = setTimeout(() => {
|
|
872
|
+
this.pending.delete(requestId);
|
|
873
|
+
const error = new Error(`Codex Desktop IPC request timed out: ${method}`);
|
|
874
|
+
reject(error);
|
|
875
|
+
if (initializing) {
|
|
876
|
+
this.closeSocket(error, socket, generation);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
// A thread-specific timeout is not proof that the whole Desktop IPC
|
|
880
|
+
// connection is broken. Keep the connection and fail this request
|
|
881
|
+
// without retrying it through another owner.
|
|
882
|
+
}, this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
883
|
+
timer.unref?.();
|
|
884
|
+
this.pending.set(requestId, {
|
|
885
|
+
method,
|
|
886
|
+
threadId: threadIdFromParams(params),
|
|
887
|
+
commandId: commandIdFromParams(params),
|
|
888
|
+
resolve: resolvePromise,
|
|
889
|
+
reject,
|
|
890
|
+
timer,
|
|
891
|
+
generation,
|
|
892
|
+
...(stringValue(options.targetClientId)
|
|
893
|
+
? { targetClientId: stringValue(options.targetClientId) }
|
|
894
|
+
: {}),
|
|
895
|
+
});
|
|
896
|
+
const sent = this.write({
|
|
897
|
+
type: "request",
|
|
898
|
+
requestId,
|
|
899
|
+
sourceClientId: initializing ? "initializing-client" : this.clientId || "opencodex-android-bridge",
|
|
900
|
+
version: desktopIpcRequestVersion(method, params),
|
|
901
|
+
method,
|
|
902
|
+
params,
|
|
903
|
+
...(stringValue(options.targetClientId)
|
|
904
|
+
? { targetClientId: stringValue(options.targetClientId) }
|
|
905
|
+
: {}),
|
|
906
|
+
}, socket, generation);
|
|
907
|
+
if (!sent) {
|
|
908
|
+
clearTimeout(timer);
|
|
909
|
+
this.pending.delete(requestId);
|
|
910
|
+
reject(new Error("Codex Desktop IPC write failed"));
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private dispatch(envelope: DesktopIpcEnvelope): void {
|
|
916
|
+
if (envelope.type === "client-discovery-response") {
|
|
917
|
+
const key = requestIdKey(envelope.requestId);
|
|
918
|
+
const pending = key ? this.pendingDiscoveries.get(key) : null;
|
|
919
|
+
if (!pending) return;
|
|
920
|
+
this.pendingDiscoveries.delete(key);
|
|
921
|
+
clearTimeout(pending.timer);
|
|
922
|
+
const response = record(envelope.response) ?? {};
|
|
923
|
+
const handledByClientId = stringValue(
|
|
924
|
+
response.handledByClientId
|
|
925
|
+
?? response.handled_by_client_id
|
|
926
|
+
?? envelope.handledByClientId,
|
|
927
|
+
);
|
|
928
|
+
pending.resolve({
|
|
929
|
+
canHandle: response.canHandle === true,
|
|
930
|
+
...(handledByClientId ? { handledByClientId } : {}),
|
|
931
|
+
});
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (envelope.type === "response") {
|
|
935
|
+
const key = requestIdKey(envelope.requestId);
|
|
936
|
+
const discovery = key ? this.pendingDiscoveries.get(key) : null;
|
|
937
|
+
if (discovery) {
|
|
938
|
+
this.pendingDiscoveries.delete(key);
|
|
939
|
+
clearTimeout(discovery.timer);
|
|
940
|
+
const result = record(envelope.result);
|
|
941
|
+
const handledByClientId = stringValue(
|
|
942
|
+
envelope.handledByClientId
|
|
943
|
+
?? result?.handledByClientId
|
|
944
|
+
?? result?.handled_by_client_id,
|
|
945
|
+
);
|
|
946
|
+
if (envelope.resultType === "error") {
|
|
947
|
+
const error = stringValue(envelope.error);
|
|
948
|
+
discovery.resolve({
|
|
949
|
+
canHandle: false,
|
|
950
|
+
...(!desktopFollowerNoClientFound(error) ? { timedOut: true } : {}),
|
|
951
|
+
});
|
|
952
|
+
} else {
|
|
953
|
+
discovery.resolve({
|
|
954
|
+
canHandle: Boolean(handledByClientId),
|
|
955
|
+
...(handledByClientId ? { handledByClientId } : { timedOut: true }),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const waiter = key ? this.pending.get(key) : null;
|
|
961
|
+
if (!waiter) return;
|
|
962
|
+
this.pending.delete(key);
|
|
963
|
+
clearTimeout(waiter.timer);
|
|
964
|
+
const responseMethod = stringValue(envelope.method);
|
|
965
|
+
const methodMismatch = Boolean(responseMethod && responseMethod !== waiter.method);
|
|
966
|
+
try {
|
|
967
|
+
this.options.onResponseSettled?.({
|
|
968
|
+
requestId: envelope.requestId ?? key,
|
|
969
|
+
method: waiter.method,
|
|
970
|
+
threadId: waiter.threadId,
|
|
971
|
+
commandId: waiter.commandId,
|
|
972
|
+
localClientId: this.clientId,
|
|
973
|
+
handledByClientId: stringValue(envelope.handledByClientId),
|
|
974
|
+
...(stringValue(waiter.targetClientId)
|
|
975
|
+
? { targetClientId: stringValue(waiter.targetClientId) }
|
|
976
|
+
: {}),
|
|
977
|
+
resultType: envelope.resultType === "error" || methodMismatch ? "error" : "success",
|
|
978
|
+
});
|
|
979
|
+
} catch {
|
|
980
|
+
// Diagnostics must never affect IPC delivery.
|
|
981
|
+
}
|
|
982
|
+
if (methodMismatch) {
|
|
983
|
+
waiter.reject(new Error(
|
|
984
|
+
`Codex Desktop IPC response method mismatch: expected ${waiter.method}, received ${responseMethod}`,
|
|
985
|
+
));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (envelope.resultType === "error") {
|
|
989
|
+
waiter.reject(new Error(stringValue(envelope.error) || `Codex Desktop IPC request failed: ${waiter.method}`));
|
|
990
|
+
} else {
|
|
991
|
+
waiter.resolve(envelope.result ?? null);
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (envelope.type === "broadcast") {
|
|
996
|
+
this.handlers.onBroadcast(envelope);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (envelope.type === "client-discovery-request") {
|
|
1000
|
+
const request = record(envelope.request) ?? envelope;
|
|
1001
|
+
const canHandle = this.handlers.canHandleRequest(request as DesktopIpcEnvelope);
|
|
1002
|
+
this.write({
|
|
1003
|
+
type: "client-discovery-response",
|
|
1004
|
+
requestId: envelope.requestId,
|
|
1005
|
+
response: {
|
|
1006
|
+
canHandle,
|
|
1007
|
+
...(canHandle
|
|
1008
|
+
? { handledByClientId: this.clientId }
|
|
1009
|
+
: {}),
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (envelope.type === "request") {
|
|
1015
|
+
void this.handlers.handleRequest(envelope)
|
|
1016
|
+
.then(result => this.write({
|
|
1017
|
+
type: "response",
|
|
1018
|
+
requestId: envelope.requestId,
|
|
1019
|
+
resultType: "success",
|
|
1020
|
+
method: envelope.method,
|
|
1021
|
+
handledByClientId: this.clientId,
|
|
1022
|
+
result: result ?? null,
|
|
1023
|
+
}))
|
|
1024
|
+
.catch(error => this.write({
|
|
1025
|
+
type: "response",
|
|
1026
|
+
requestId: envelope.requestId,
|
|
1027
|
+
resultType: "error",
|
|
1028
|
+
method: envelope.method,
|
|
1029
|
+
handledByClientId: this.clientId,
|
|
1030
|
+
error: error instanceof Error ? error.message : "Remodex Desktop IPC request failed",
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private handleClose(
|
|
1036
|
+
socket: Socket,
|
|
1037
|
+
generation: number,
|
|
1038
|
+
error = new Error("Codex Desktop IPC connection closed"),
|
|
1039
|
+
): void {
|
|
1040
|
+
if (!this.isCurrentConnection(socket, generation)) return;
|
|
1041
|
+
this.socket = null;
|
|
1042
|
+
this.reader = null;
|
|
1043
|
+
this.connecting = false;
|
|
1044
|
+
this.initialized = false;
|
|
1045
|
+
this.clientId = "";
|
|
1046
|
+
for (const [requestId, waiter] of this.pending) {
|
|
1047
|
+
if (waiter.generation !== generation) continue;
|
|
1048
|
+
clearTimeout(waiter.timer);
|
|
1049
|
+
waiter.reject(error);
|
|
1050
|
+
this.pending.delete(requestId);
|
|
1051
|
+
}
|
|
1052
|
+
for (const [requestId, pending] of this.pendingDiscoveries) {
|
|
1053
|
+
clearTimeout(pending.timer);
|
|
1054
|
+
pending.resolve({ canHandle: false, timedOut: true });
|
|
1055
|
+
this.pendingDiscoveries.delete(requestId);
|
|
1056
|
+
}
|
|
1057
|
+
this.handlers.onDisconnected?.();
|
|
1058
|
+
this.scheduleReconnect();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private scheduleReconnect(): void {
|
|
1062
|
+
if (!this.shouldReconnect || this.reconnectTimer) return;
|
|
1063
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1064
|
+
this.reconnectTimer = null;
|
|
1065
|
+
this.ensureConnected();
|
|
1066
|
+
}, this.options.reconnectMs);
|
|
1067
|
+
this.reconnectTimer.unref?.();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private closeSocket(
|
|
1071
|
+
error = new Error("Codex Desktop IPC connection closed"),
|
|
1072
|
+
expectedSocket = this.socket,
|
|
1073
|
+
expectedGeneration = this.connectionGeneration,
|
|
1074
|
+
): void {
|
|
1075
|
+
const socket = expectedSocket;
|
|
1076
|
+
if (!socket) {
|
|
1077
|
+
this.reader?.reset();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (!this.isCurrentConnection(socket, expectedGeneration)) return;
|
|
1081
|
+
this.reader?.reset();
|
|
1082
|
+
this.handleClose(socket, expectedGeneration, error);
|
|
1083
|
+
if (!socket.destroyed) socket.destroy();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private write(
|
|
1087
|
+
envelope: DesktopIpcEnvelope,
|
|
1088
|
+
expectedSocket = this.socket,
|
|
1089
|
+
expectedGeneration = this.connectionGeneration,
|
|
1090
|
+
): boolean {
|
|
1091
|
+
const socket = expectedSocket;
|
|
1092
|
+
if (
|
|
1093
|
+
!socket
|
|
1094
|
+
|| socket.destroyed
|
|
1095
|
+
|| !this.isCurrentConnection(socket, expectedGeneration)
|
|
1096
|
+
) return false;
|
|
1097
|
+
try {
|
|
1098
|
+
socket.write(encodeDesktopIpcFrame(envelope));
|
|
1099
|
+
return true;
|
|
1100
|
+
} catch {
|
|
1101
|
+
this.closeSocket(
|
|
1102
|
+
new Error("Codex Desktop IPC write failed"),
|
|
1103
|
+
socket,
|
|
1104
|
+
expectedGeneration,
|
|
1105
|
+
);
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private isCurrentConnection(socket: Socket, generation: number): boolean {
|
|
1111
|
+
return this.socket === socket && this.connectionGeneration === generation;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function isPlainRecord(value: unknown): value is JsonRecord {
|
|
1116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1117
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1118
|
+
return prototype === Object.prototype || prototype === null;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export function buildDesktopStatePatches(previous: unknown, current: unknown): JsonPatch[] | null {
|
|
1122
|
+
if (!isPlainRecord(previous) || !isPlainRecord(current)) return null;
|
|
1123
|
+
const patches: JsonPatch[] = [];
|
|
1124
|
+
if (!collectPatches(previous, current, [], patches)) return null;
|
|
1125
|
+
if (patches.length === 0) return patches;
|
|
1126
|
+
return Buffer.byteLength(JSON.stringify(patches), "utf8") <= MAX_PATCH_BYTES ? patches : null;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function collectPatches(
|
|
1130
|
+
previous: unknown,
|
|
1131
|
+
current: unknown,
|
|
1132
|
+
path: Array<string | number>,
|
|
1133
|
+
patches: JsonPatch[],
|
|
1134
|
+
): boolean {
|
|
1135
|
+
if (previous === current) return true;
|
|
1136
|
+
if (patches.length >= MAX_PATCH_COUNT) return false;
|
|
1137
|
+
if (Array.isArray(previous) || Array.isArray(current)) {
|
|
1138
|
+
if (!Array.isArray(previous) || !Array.isArray(current)) {
|
|
1139
|
+
return pushPatch(patches, { op: "replace", path, value: clone(current) });
|
|
1140
|
+
}
|
|
1141
|
+
const shared = Math.min(previous.length, current.length);
|
|
1142
|
+
for (let index = 0; index < shared; index += 1) {
|
|
1143
|
+
if (!collectPatches(previous[index], current[index], [...path, index], patches)) return false;
|
|
1144
|
+
}
|
|
1145
|
+
for (let index = previous.length - 1; index >= current.length; index -= 1) {
|
|
1146
|
+
if (!pushPatch(patches, { op: "remove", path: [...path, index] })) return false;
|
|
1147
|
+
}
|
|
1148
|
+
for (let index = shared; index < current.length; index += 1) {
|
|
1149
|
+
if (!pushPatch(patches, { op: "add", path: [...path, index], value: clone(current[index]) })) return false;
|
|
1150
|
+
}
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
if (isPlainRecord(previous) || isPlainRecord(current)) {
|
|
1154
|
+
if (!isPlainRecord(previous) || !isPlainRecord(current)) {
|
|
1155
|
+
return pushPatch(patches, { op: "replace", path, value: clone(current) });
|
|
1156
|
+
}
|
|
1157
|
+
for (const key of Object.keys(previous)) {
|
|
1158
|
+
if (!(key in current) && !pushPatch(patches, { op: "remove", path: [...path, key] })) return false;
|
|
1159
|
+
}
|
|
1160
|
+
for (const [key, value] of Object.entries(current)) {
|
|
1161
|
+
if (!(key in previous)) {
|
|
1162
|
+
if (!pushPatch(patches, { op: "add", path: [...path, key], value: clone(value) })) return false;
|
|
1163
|
+
} else if (!collectPatches(previous[key], value, [...path, key], patches)) {
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
return pushPatch(patches, { op: "replace", path, value: clone(current) });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function pushPatch(patches: JsonPatch[], patch: JsonPatch): boolean {
|
|
1173
|
+
if (patch.path.length === 0) return false;
|
|
1174
|
+
patches.push(patch);
|
|
1175
|
+
return patches.length <= MAX_PATCH_COUNT;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function followerStateChange(value: unknown): FollowerStateChange | null {
|
|
1179
|
+
const change = record(value);
|
|
1180
|
+
const type = normalizeToken(change?.type);
|
|
1181
|
+
const revision = finiteRevision(change?.revision);
|
|
1182
|
+
if (type === "snapshot") {
|
|
1183
|
+
const conversationState = record(change?.conversationState) ?? record(change?.conversation_state);
|
|
1184
|
+
return conversationState ? { type: "snapshot", revision, conversationState } : null;
|
|
1185
|
+
}
|
|
1186
|
+
if (type !== "patches") return null;
|
|
1187
|
+
const patches: JsonPatch[] = [];
|
|
1188
|
+
let patchBytes = 2; // JSON array delimiters
|
|
1189
|
+
if (Array.isArray(change?.patches)) {
|
|
1190
|
+
for (const candidate of change.patches) {
|
|
1191
|
+
const patch = record(candidate);
|
|
1192
|
+
const op = stringValue(patch?.op) as JsonPatch["op"];
|
|
1193
|
+
if ((op !== "add" && op !== "remove" && op !== "replace") || !Array.isArray(patch?.path)) continue;
|
|
1194
|
+
const path = patch.path.length <= 64 && patch.path.every(segment =>
|
|
1195
|
+
(typeof segment === "string" && Buffer.byteLength(segment, "utf8") <= 512)
|
|
1196
|
+
|| (typeof segment === "number" && Number.isSafeInteger(segment)))
|
|
1197
|
+
? patch.path as Array<string | number>
|
|
1198
|
+
: null;
|
|
1199
|
+
if (!path) continue;
|
|
1200
|
+
if (op === "remove") {
|
|
1201
|
+
const patchSize = Buffer.byteLength(JSON.stringify(path), "utf8") + 16;
|
|
1202
|
+
if (patchBytes + patchSize > MAX_PATCH_BYTES) break;
|
|
1203
|
+
patches.push({ op, path });
|
|
1204
|
+
patchBytes += patchSize;
|
|
1205
|
+
} else {
|
|
1206
|
+
const bounded = boundInboundPatchValue(patch?.value);
|
|
1207
|
+
const encoded = JSON.stringify(bounded);
|
|
1208
|
+
const pathBytes = Buffer.byteLength(JSON.stringify(path), "utf8");
|
|
1209
|
+
const patchSize = pathBytes + Buffer.byteLength(encoded, "utf8") + 32;
|
|
1210
|
+
if (patchBytes + patchSize > MAX_PATCH_BYTES) break;
|
|
1211
|
+
patches.push({ op, path, value: bounded });
|
|
1212
|
+
patchBytes += patchSize;
|
|
1213
|
+
}
|
|
1214
|
+
if (patches.length > MAX_PATCH_COUNT) break;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (patches.length > MAX_PATCH_COUNT || patchBytes > MAX_PATCH_BYTES) return null;
|
|
1218
|
+
return {
|
|
1219
|
+
type: "patches",
|
|
1220
|
+
revision,
|
|
1221
|
+
baseRevision: finiteRevision(change?.baseRevision ?? change?.base_revision),
|
|
1222
|
+
patches,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const INBOUND_PATCH_MAX_INLINE_BYTES = 256 * 1024;
|
|
1227
|
+
const INBOUND_PATCH_MAX_ENTRIES = 64;
|
|
1228
|
+
const INBOUND_PATCH_MAX_DEPTH = 64;
|
|
1229
|
+
// Retain enough turn shells for bounded older-page navigation, while the
|
|
1230
|
+
// global item trim below keeps payload memory bounded.
|
|
1231
|
+
const INBOUND_HISTORY_MAX_TURNS = 64;
|
|
1232
|
+
const INBOUND_HISTORY_MAX_ITEMS_PER_TURN = 500;
|
|
1233
|
+
const INBOUND_ITEM_MAX_LARGE_TEXT_BYTES = 64 * 1024 * 1024;
|
|
1234
|
+
|
|
1235
|
+
function boundInboundPatchValue(
|
|
1236
|
+
value: unknown,
|
|
1237
|
+
seen = new WeakSet<object>(),
|
|
1238
|
+
depth = 0,
|
|
1239
|
+
allowLargeText = false,
|
|
1240
|
+
): unknown {
|
|
1241
|
+
if (typeof value === "string") {
|
|
1242
|
+
const rawBytes = Buffer.byteLength(value, "utf8");
|
|
1243
|
+
return rawBytes <= (allowLargeText ? INBOUND_ITEM_MAX_LARGE_TEXT_BYTES : INBOUND_PATCH_MAX_INLINE_BYTES)
|
|
1244
|
+
&& (allowLargeText || Buffer.byteLength(JSON.stringify(value), "utf8") <= INBOUND_PATCH_MAX_INLINE_BYTES)
|
|
1245
|
+
? value
|
|
1246
|
+
: { kind: "missing", reason: "not-retained" };
|
|
1247
|
+
}
|
|
1248
|
+
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
|
1249
|
+
if (typeof value !== "object") return String(value);
|
|
1250
|
+
if (depth >= INBOUND_PATCH_MAX_DEPTH) return { kind: "missing", reason: "not-retained" };
|
|
1251
|
+
if (seen.has(value)) return { kind: "missing", reason: "not-retained" };
|
|
1252
|
+
seen.add(value);
|
|
1253
|
+
try {
|
|
1254
|
+
if (Array.isArray(value)) {
|
|
1255
|
+
if (value.length > INBOUND_PATCH_MAX_ENTRIES) return { kind: "missing", reason: "not-retained" };
|
|
1256
|
+
return value.map(entry => boundInboundPatchValue(entry, seen, depth + 1, allowLargeText));
|
|
1257
|
+
}
|
|
1258
|
+
const output: JsonRecord = {};
|
|
1259
|
+
let entries = 0;
|
|
1260
|
+
for (const key in value) {
|
|
1261
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
1262
|
+
entries += 1;
|
|
1263
|
+
if (entries > INBOUND_PATCH_MAX_ENTRIES || Buffer.byteLength(key, "utf8") > 512) {
|
|
1264
|
+
return { kind: "missing", reason: "not-retained" };
|
|
1265
|
+
}
|
|
1266
|
+
output[key] = boundInboundPatchValue((value as JsonRecord)[key], seen, depth + 1, allowLargeText);
|
|
1267
|
+
}
|
|
1268
|
+
return output;
|
|
1269
|
+
} finally {
|
|
1270
|
+
seen.delete(value);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function finiteRevision(value: unknown): number | undefined {
|
|
1275
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function applyDesktopStatePatches(previous: JsonRecord, patches: JsonPatch[]): JsonRecord | null {
|
|
1279
|
+
const next = clone(previous);
|
|
1280
|
+
for (const patch of patches) {
|
|
1281
|
+
if (patch.path.length === 0) return null;
|
|
1282
|
+
let parent: unknown = next;
|
|
1283
|
+
for (let index = 0; index < patch.path.length - 1; index += 1) {
|
|
1284
|
+
const segment = patch.path[index]!;
|
|
1285
|
+
if (typeof segment === "string" && isUnsafePatchSegment(segment)) return null;
|
|
1286
|
+
if (!parent || typeof parent !== "object") return null;
|
|
1287
|
+
parent = (parent as Record<string | number, unknown>)[segment];
|
|
1288
|
+
}
|
|
1289
|
+
const final = patch.path.at(-1)!;
|
|
1290
|
+
if (typeof final === "string" && isUnsafePatchSegment(final)) return null;
|
|
1291
|
+
if (Array.isArray(parent)) {
|
|
1292
|
+
if (typeof final !== "number" || final < 0 || final > parent.length) return null;
|
|
1293
|
+
if (patch.op === "remove") {
|
|
1294
|
+
if (final >= parent.length) return null;
|
|
1295
|
+
parent.splice(final, 1);
|
|
1296
|
+
} else if (patch.op === "add") {
|
|
1297
|
+
parent.splice(final, 0, clone(patch.value));
|
|
1298
|
+
} else {
|
|
1299
|
+
if (final >= parent.length) return null;
|
|
1300
|
+
parent[final] = clone(patch.value);
|
|
1301
|
+
}
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (!isPlainRecord(parent) || typeof final !== "string") return null;
|
|
1305
|
+
if (patch.op === "remove") {
|
|
1306
|
+
if (!(final in parent)) return null;
|
|
1307
|
+
delete parent[final];
|
|
1308
|
+
} else {
|
|
1309
|
+
parent[final] = clone(patch.value);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return next;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function isUnsafePatchSegment(segment: string): boolean {
|
|
1316
|
+
return segment === "__proto__" || segment === "prototype" || segment === "constructor";
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function snapshotShowsActiveTurn(change: FollowerStateChange): boolean {
|
|
1320
|
+
const state = record(change.conversationState);
|
|
1321
|
+
const turns = Array.isArray(state?.turns) ? state.turns : [];
|
|
1322
|
+
return turns.some(candidate => {
|
|
1323
|
+
const status = normalizeToken(record(candidate)?.status);
|
|
1324
|
+
return status === "inprogress" || status === "running" || status === "active";
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const DESKTOP_THREAD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
|
|
1329
|
+
|
|
1330
|
+
export function codexDesktopOpenCommands(
|
|
1331
|
+
url: string,
|
|
1332
|
+
platform: NodeJS.Platform = process.platform,
|
|
1333
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
1334
|
+
): CodexDesktopOpenCommand[] {
|
|
1335
|
+
if (platform === "win32") {
|
|
1336
|
+
return [{
|
|
1337
|
+
command: environment.SystemRoot
|
|
1338
|
+
? win32.join(environment.SystemRoot, "System32", "rundll32.exe")
|
|
1339
|
+
: "rundll32.exe",
|
|
1340
|
+
args: ["url.dll,FileProtocolHandler", url],
|
|
1341
|
+
}];
|
|
1342
|
+
}
|
|
1343
|
+
if (platform === "darwin") {
|
|
1344
|
+
return [
|
|
1345
|
+
{ command: "/usr/bin/open", args: ["-b", "com.openai.codex", url] },
|
|
1346
|
+
{ command: "/usr/bin/open", args: ["-a", "/Applications/Codex.app", url] },
|
|
1347
|
+
];
|
|
1348
|
+
}
|
|
1349
|
+
if (platform === "linux") {
|
|
1350
|
+
return [
|
|
1351
|
+
{ command: "xdg-open", args: [url] },
|
|
1352
|
+
{ command: "gio", args: ["open", url] },
|
|
1353
|
+
];
|
|
1354
|
+
}
|
|
1355
|
+
return [];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export async function openCodexDesktopThread(input: {
|
|
1359
|
+
threadId: string;
|
|
1360
|
+
platform?: NodeJS.Platform;
|
|
1361
|
+
activationToken?: string;
|
|
1362
|
+
run?: (command: string, args: string[]) => Promise<void>;
|
|
1363
|
+
}): Promise<void> {
|
|
1364
|
+
const threadId = stringValue(input.threadId);
|
|
1365
|
+
if (!DESKTOP_THREAD_ID.test(threadId)) throw new Error("Codex task id is not valid");
|
|
1366
|
+
const token = encodeURIComponent(input.activationToken || randomUUID());
|
|
1367
|
+
const url = `codex://threads/${encodeURIComponent(threadId)}?opencodex-follow=${token}`;
|
|
1368
|
+
await openCodexDesktopUrl({ url, platform: input.platform, run: input.run });
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Open a supported Codex Desktop deep link on the host operating system. */
|
|
1372
|
+
export async function openCodexDesktopUrl(input: {
|
|
1373
|
+
url: string;
|
|
1374
|
+
platform?: NodeJS.Platform;
|
|
1375
|
+
run?: (command: string, args: string[]) => Promise<void>;
|
|
1376
|
+
}): Promise<void> {
|
|
1377
|
+
const commands = codexDesktopOpenCommands(input.url, input.platform);
|
|
1378
|
+
if (commands.length === 0) throw new Error("Codex Desktop live follow is not supported on this operating system");
|
|
1379
|
+
const run = input.run ?? runDesktopOpenCommand;
|
|
1380
|
+
let lastError: unknown = null;
|
|
1381
|
+
for (const command of commands) {
|
|
1382
|
+
try {
|
|
1383
|
+
await run(command.command, command.args);
|
|
1384
|
+
return;
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
lastError = error;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
throw lastError instanceof Error ? lastError : new Error("Could not open Codex Desktop");
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function runDesktopOpenCommand(command: string, args: string[]): Promise<void> {
|
|
1393
|
+
return new Promise((resolvePromise, reject) => {
|
|
1394
|
+
execFile(command, args, { timeout: 10_000, windowsHide: true }, error => {
|
|
1395
|
+
if (error) reject(error);
|
|
1396
|
+
else resolvePromise();
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
type PendingTurnStart = {
|
|
1402
|
+
params: JsonRecord;
|
|
1403
|
+
fallbackTurnId: string;
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
type FollowerStateChange = {
|
|
1407
|
+
type: "snapshot" | "patches";
|
|
1408
|
+
revision?: number;
|
|
1409
|
+
baseRevision?: number;
|
|
1410
|
+
conversationState?: JsonRecord;
|
|
1411
|
+
patches?: JsonPatch[];
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
type FollowerRecoveryState = {
|
|
1415
|
+
attempts: number;
|
|
1416
|
+
timer: Timer | null;
|
|
1417
|
+
inFlight: boolean;
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
export class AndroidDesktopIpcLiveSync implements AndroidDesktopIpcSync {
|
|
1421
|
+
private readonly platform: NodeJS.Platform;
|
|
1422
|
+
private readonly now: () => number;
|
|
1423
|
+
private readonly readThread: DesktopIpcSyncOptions["readThread"];
|
|
1424
|
+
private readonly readThreadPage: DesktopIpcSyncOptions["readThreadPage"];
|
|
1425
|
+
private readonly sendCodexRequest: DesktopIpcSyncOptions["sendCodexRequest"];
|
|
1426
|
+
private readonly respondToCodexRequest: DesktopIpcSyncOptions["respondToCodexRequest"];
|
|
1427
|
+
private readonly openUrl: (url: string) => Promise<void>;
|
|
1428
|
+
private readonly log: (message: string) => void;
|
|
1429
|
+
private readonly warn: (message: string) => void;
|
|
1430
|
+
private readonly snapshotDebounceMs: number;
|
|
1431
|
+
private readonly followConfirmMs: number;
|
|
1432
|
+
private readonly followMaxAttempts: number;
|
|
1433
|
+
private readonly initialHistoryRetryMs: number;
|
|
1434
|
+
private readonly initialHistoryMaxAttempts: number;
|
|
1435
|
+
private readonly followerBaselineRetryMs: number;
|
|
1436
|
+
private readonly followerBaselineMaxRetryMs: number;
|
|
1437
|
+
private readonly followerBaselineMaxAttempts: number;
|
|
1438
|
+
private readonly followerStateTimeoutMs: number;
|
|
1439
|
+
private readonly ownerReacquireTimeoutMs: number;
|
|
1440
|
+
private readonly ownershipStore: AndroidDesktopOwnershipStore;
|
|
1441
|
+
private readonly transport: DesktopIpcTransportLike;
|
|
1442
|
+
/**
|
|
1443
|
+
* The current renderer that emitted authoritative state for each thread.
|
|
1444
|
+
* This map is intentionally separate from the local mirror ownership map.
|
|
1445
|
+
*/
|
|
1446
|
+
private readonly desktopOwnerClientIds = new Map<string, string>();
|
|
1447
|
+
/** Waiters used only while reacquiring a renderer id after reconnect. */
|
|
1448
|
+
private readonly desktopOwnerWaiters = new Map<string, Set<{
|
|
1449
|
+
resolve: (clientId: string | null) => void;
|
|
1450
|
+
timer: Timer;
|
|
1451
|
+
}>>();
|
|
1452
|
+
/**
|
|
1453
|
+
* Monotonic safety fact: once Desktop has been observed as the owner, a
|
|
1454
|
+
* missing handler or a disconnected socket can never authorize local resume.
|
|
1455
|
+
* It is cleared only by an explicit release/local-owner transition.
|
|
1456
|
+
*/
|
|
1457
|
+
private readonly desktopOwnedThreadIds = new Set<string>();
|
|
1458
|
+
private readonly ownedThreadIds = new Set<string>();
|
|
1459
|
+
private readonly conversations = new Map<string, ConversationState>();
|
|
1460
|
+
private readonly revisions = new Map<string, number>();
|
|
1461
|
+
/** Last bounded wire state, never the unbounded in-memory conversation. */
|
|
1462
|
+
private readonly lastBroadcastStates = new Map<string, JsonRecord>();
|
|
1463
|
+
private readonly pendingTurnStarts = new Map<string, PendingTurnStart[]>();
|
|
1464
|
+
private readonly dirtyThreadIds = new Set<string>();
|
|
1465
|
+
private readonly followerClientIds = new Map<string, Set<string>>();
|
|
1466
|
+
/** Desktop-owned tasks that this Android bridge follows for live requests. */
|
|
1467
|
+
private readonly followedDesktopThreadIds = new Set<string>();
|
|
1468
|
+
private readonly followAttempts = new Map<string, number>();
|
|
1469
|
+
private readonly followTimers = new Map<string, Timer>();
|
|
1470
|
+
private readonly followerBaselineTimers = new Map<string, Timer>();
|
|
1471
|
+
private readonly sidebarTimers = new Map<string, Timer>();
|
|
1472
|
+
private readonly announcedSidebarThreadIds = new Set<string>();
|
|
1473
|
+
private readonly runtimeOverrides = new Map<string, JsonRecord>();
|
|
1474
|
+
private readonly hydrationPromises = new Map<string, Promise<boolean>>();
|
|
1475
|
+
private readonly threadsAwaitingInitialHistory = new Set<string>();
|
|
1476
|
+
private readonly initialHistoryAttempts = new Map<string, number>();
|
|
1477
|
+
private readonly initialHistoryTimers = new Map<string, Timer>();
|
|
1478
|
+
/** Connection-scoped baselines for tasks owned by another Desktop client. */
|
|
1479
|
+
private readonly followerStates = new Map<string, JsonRecord>();
|
|
1480
|
+
private readonly followerRevisions = new Map<string, number>();
|
|
1481
|
+
private readonly queuedFollowerChanges = new Map<string, FollowerStateChange[]>();
|
|
1482
|
+
private readonly followerRecovery = new Map<string, FollowerRecoveryState>();
|
|
1483
|
+
private readonly followerStateWaiters = new Map<string, Set<{
|
|
1484
|
+
resolve: (state: JsonRecord | null) => void;
|
|
1485
|
+
reject: (error: Error) => void;
|
|
1486
|
+
timer: Timer;
|
|
1487
|
+
}>>();
|
|
1488
|
+
/** Connection-local opaque page/content references for Desktop followers. */
|
|
1489
|
+
private readonly historyPages: DesktopHistoryPageStore;
|
|
1490
|
+
/** Capability is scoped to the current IPC connection generation. */
|
|
1491
|
+
private boundedHistoryUnsupportedGeneration: number | null = null;
|
|
1492
|
+
private snapshotTimer: Timer | null = null;
|
|
1493
|
+
|
|
1494
|
+
constructor(options: DesktopIpcSyncOptions) {
|
|
1495
|
+
this.platform = options.platform ?? process.platform;
|
|
1496
|
+
this.now = options.now ?? Date.now;
|
|
1497
|
+
this.readThread = options.readThread;
|
|
1498
|
+
this.readThreadPage = options.readThreadPage;
|
|
1499
|
+
this.sendCodexRequest = options.sendCodexRequest;
|
|
1500
|
+
this.respondToCodexRequest = options.respondToCodexRequest;
|
|
1501
|
+
this.log = options.log ?? (() => undefined);
|
|
1502
|
+
this.warn = options.warn ?? (() => undefined);
|
|
1503
|
+
this.snapshotDebounceMs = options.snapshotDebounceMs ?? SNAPSHOT_DEBOUNCE_MS;
|
|
1504
|
+
this.followConfirmMs = options.followConfirmMs ?? FOLLOW_CONFIRM_MS;
|
|
1505
|
+
this.followMaxAttempts = options.followMaxAttempts ?? FOLLOW_MAX_ATTEMPTS;
|
|
1506
|
+
this.initialHistoryRetryMs = options.initialHistoryRetryMs ?? INITIAL_HISTORY_RETRY_MS;
|
|
1507
|
+
this.initialHistoryMaxAttempts = options.initialHistoryMaxAttempts ?? INITIAL_HISTORY_MAX_ATTEMPTS;
|
|
1508
|
+
this.followerBaselineRetryMs = options.followerBaselineRetryMs ?? FOLLOWER_BASELINE_RETRY_MS;
|
|
1509
|
+
this.followerBaselineMaxRetryMs = options.followerBaselineMaxRetryMs ?? FOLLOWER_BASELINE_MAX_RETRY_MS;
|
|
1510
|
+
this.followerBaselineMaxAttempts = options.followerBaselineMaxAttempts ?? FOLLOWER_BASELINE_MAX_ATTEMPTS;
|
|
1511
|
+
this.followerStateTimeoutMs = options.followerStateTimeoutMs ?? FOLLOW_STATE_TIMEOUT_MS;
|
|
1512
|
+
this.ownerReacquireTimeoutMs = Math.max(1, options.ownerReacquireTimeoutMs ?? OWNER_REACQUIRE_TIMEOUT_MS);
|
|
1513
|
+
this.ownershipStore = options.ownershipStore ?? createAndroidDesktopOwnershipStore();
|
|
1514
|
+
this.historyPages = options.historyPageStore ?? new DesktopHistoryPageStore({ now: this.now });
|
|
1515
|
+
for (const threadId of this.ownershipStore.list()) this.desktopOwnedThreadIds.add(threadId);
|
|
1516
|
+
this.openUrl = options.openUrl ?? (url => openUrlWithPlatform(url, this.platform));
|
|
1517
|
+
this.transport = options.transport ?? new DesktopIpcTransport({
|
|
1518
|
+
paths: options.socketPaths ?? (() => desktopIpcSocketPaths(this.platform)),
|
|
1519
|
+
connect: options.connect ?? (path => createConnection(path)),
|
|
1520
|
+
now: this.now,
|
|
1521
|
+
reconnectMs: options.reconnectMs ?? RECONNECT_MS,
|
|
1522
|
+
warn: this.warn,
|
|
1523
|
+
onResponseSettled: response => {
|
|
1524
|
+
if (
|
|
1525
|
+
!response.method.startsWith("thread-follower-")
|
|
1526
|
+
&& response.method !== THREAD_OWNER_DISCOVERY
|
|
1527
|
+
) return;
|
|
1528
|
+
if (response.resultType === "success") {
|
|
1529
|
+
this.rememberDesktopOwner(
|
|
1530
|
+
response.threadId,
|
|
1531
|
+
response.handledByClientId,
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
this.log(
|
|
1535
|
+
`Desktop IPC response method=${diagnosticToken(response.method)}`
|
|
1536
|
+
+ ` thread=${diagnosticToken(response.threadId)}`
|
|
1537
|
+
+ ` command=${diagnosticToken(response.commandId)}`
|
|
1538
|
+
+ ` generation=${this.transport.generation ?? 0}`
|
|
1539
|
+
+ ` localClient=${diagnosticToken(response.localClientId)}`
|
|
1540
|
+
+ ` handledBy=${diagnosticToken(response.handledByClientId)}`
|
|
1541
|
+
+ ` target=${diagnosticToken(response.targetClientId)}`
|
|
1542
|
+
+ ` result=${response.resultType}`,
|
|
1543
|
+
);
|
|
1544
|
+
},
|
|
1545
|
+
});
|
|
1546
|
+
this.transport.setHandlers({
|
|
1547
|
+
onConnected: () => this.onConnected(),
|
|
1548
|
+
onDisconnected: () => this.onDisconnected(),
|
|
1549
|
+
onBroadcast: envelope => this.onBroadcast(envelope),
|
|
1550
|
+
canHandleRequest: envelope => this.canHandleRequest(envelope),
|
|
1551
|
+
handleRequest: envelope => this.handleRequest(envelope),
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
start(): void {
|
|
1556
|
+
this.transport.start();
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
stop(): void {
|
|
1560
|
+
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
|
|
1561
|
+
this.snapshotTimer = null;
|
|
1562
|
+
for (const timer of this.followTimers.values()) clearTimeout(timer);
|
|
1563
|
+
this.followTimers.clear();
|
|
1564
|
+
for (const timer of this.followerBaselineTimers.values()) clearTimeout(timer);
|
|
1565
|
+
this.followerBaselineTimers.clear();
|
|
1566
|
+
for (const timer of this.sidebarTimers.values()) clearTimeout(timer);
|
|
1567
|
+
this.sidebarTimers.clear();
|
|
1568
|
+
for (const timer of this.initialHistoryTimers.values()) clearTimeout(timer);
|
|
1569
|
+
this.initialHistoryTimers.clear();
|
|
1570
|
+
for (const recovery of this.followerRecovery.values()) {
|
|
1571
|
+
if (recovery.timer) clearTimeout(recovery.timer);
|
|
1572
|
+
}
|
|
1573
|
+
this.followerRecovery.clear();
|
|
1574
|
+
for (const threadId of [...this.followedDesktopThreadIds]) {
|
|
1575
|
+
// Desktop-owned tasks are observed through metadata discovery and their
|
|
1576
|
+
// bounded read path. They are not follower registrations; in particular
|
|
1577
|
+
// do not emit a later `following:false` when this bridge stops.
|
|
1578
|
+
if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
|
|
1579
|
+
this.followedDesktopThreadIds.delete(threadId);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
this.transport.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
|
|
1583
|
+
conversationId: threadId,
|
|
1584
|
+
hostId: HOST_ID,
|
|
1585
|
+
following: false,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
this.followedDesktopThreadIds.clear();
|
|
1589
|
+
this.transport.stop();
|
|
1590
|
+
this.boundedHistoryUnsupportedGeneration = null;
|
|
1591
|
+
const ownedBeforeStop = [...this.ownedThreadIds];
|
|
1592
|
+
this.ownedThreadIds.clear();
|
|
1593
|
+
this.desktopOwnerClientIds.clear();
|
|
1594
|
+
for (const waiters of this.desktopOwnerWaiters.values()) {
|
|
1595
|
+
for (const waiter of waiters) {
|
|
1596
|
+
clearTimeout(waiter.timer);
|
|
1597
|
+
waiter.resolve(null);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
this.desktopOwnerWaiters.clear();
|
|
1601
|
+
this.conversations.clear();
|
|
1602
|
+
this.revisions.clear();
|
|
1603
|
+
this.lastBroadcastStates.clear();
|
|
1604
|
+
// Page tokens/handles are connection-local. Dropping them on stop keeps
|
|
1605
|
+
// an old Desktop renderer from replaying content after a new connection.
|
|
1606
|
+
for (const threadId of ownedBeforeStop) this.historyPages.clearThread(threadId);
|
|
1607
|
+
this.pendingTurnStarts.clear();
|
|
1608
|
+
this.dirtyThreadIds.clear();
|
|
1609
|
+
this.followerClientIds.clear();
|
|
1610
|
+
this.followAttempts.clear();
|
|
1611
|
+
this.announcedSidebarThreadIds.clear();
|
|
1612
|
+
this.runtimeOverrides.clear();
|
|
1613
|
+
this.hydrationPromises.clear();
|
|
1614
|
+
this.threadsAwaitingInitialHistory.clear();
|
|
1615
|
+
this.initialHistoryAttempts.clear();
|
|
1616
|
+
this.followerStates.clear();
|
|
1617
|
+
this.followerRevisions.clear();
|
|
1618
|
+
this.queuedFollowerChanges.clear();
|
|
1619
|
+
for (const waiters of this.followerStateWaiters.values()) {
|
|
1620
|
+
for (const waiter of waiters) {
|
|
1621
|
+
clearTimeout(waiter.timer);
|
|
1622
|
+
waiter.reject(new Error("Codex Desktop IPC stopped"));
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
this.followerStateWaiters.clear();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
isThreadOwned(threadId: string): boolean {
|
|
1629
|
+
return this.ownedThreadIds.has(stringValue(threadId));
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
threadOwnership(threadIdValue: string): DesktopThreadOwnership {
|
|
1633
|
+
const threadId = stringValue(threadIdValue);
|
|
1634
|
+
if (this.ownedThreadIds.has(threadId)) {
|
|
1635
|
+
return {
|
|
1636
|
+
state: "local-owned",
|
|
1637
|
+
ownerClientId: null,
|
|
1638
|
+
everDesktopOwned: this.desktopOwnedThreadIds.has(threadId),
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
if (this.desktopOwnedThreadIds.has(threadId)) {
|
|
1642
|
+
return {
|
|
1643
|
+
state: "desktop-owned",
|
|
1644
|
+
ownerClientId: this.desktopOwnerClientIds.get(threadId) ?? null,
|
|
1645
|
+
everDesktopOwned: true,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
return { state: "unknown", ownerClientId: null, everDesktopOwned: false };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
desktopOwnerClientId(threadIdValue: string): string | null {
|
|
1652
|
+
return this.desktopOwnerClientIds.get(stringValue(threadIdValue)) ?? null;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
hasObservedDesktopOwner(threadIdValue: string): boolean {
|
|
1656
|
+
return this.desktopOwnedThreadIds.has(stringValue(threadIdValue));
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async reacquireDesktopOwner(threadIdValue: string): Promise<string | null> {
|
|
1660
|
+
const threadId = stringValue(threadIdValue);
|
|
1661
|
+
if (!threadId || !this.desktopOwnedThreadIds.has(threadId)) return null;
|
|
1662
|
+
const current = this.desktopOwnerClientIds.get(threadId);
|
|
1663
|
+
if (current) return current;
|
|
1664
|
+
|
|
1665
|
+
let waiter!: { resolve: (clientId: string | null) => void; timer: Timer };
|
|
1666
|
+
const owner = new Promise<string | null>(resolve => {
|
|
1667
|
+
const timer = setTimeout(() => {
|
|
1668
|
+
const waiters = this.desktopOwnerWaiters.get(threadId);
|
|
1669
|
+
if (waiters) {
|
|
1670
|
+
waiters.delete(waiter);
|
|
1671
|
+
if (waiters.size === 0) this.desktopOwnerWaiters.delete(threadId);
|
|
1672
|
+
}
|
|
1673
|
+
resolve(this.desktopOwnerClientIds.get(threadId) ?? null);
|
|
1674
|
+
}, this.ownerReacquireTimeoutMs);
|
|
1675
|
+
timer.unref?.();
|
|
1676
|
+
waiter = { resolve, timer };
|
|
1677
|
+
const waiters = this.desktopOwnerWaiters.get(threadId) ?? new Set();
|
|
1678
|
+
waiters.add(waiter);
|
|
1679
|
+
this.desktopOwnerWaiters.set(threadId, waiters);
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
// `start()` is intentionally non-blocking. If a reconnect is already in
|
|
1683
|
+
// progress, onConnected() below will issue the same metadata-only probe
|
|
1684
|
+
// and resolve this waiter without registering a transcript follower.
|
|
1685
|
+
this.transport.start();
|
|
1686
|
+
if (this.transport.connected) {
|
|
1687
|
+
const discovery = await this.discoverDesktopOwner(threadId);
|
|
1688
|
+
if (discovery.handledByClientId) {
|
|
1689
|
+
clearTimeout(waiter.timer);
|
|
1690
|
+
const waiters = this.desktopOwnerWaiters.get(threadId);
|
|
1691
|
+
if (waiters) {
|
|
1692
|
+
waiters.delete(waiter);
|
|
1693
|
+
if (waiters.size === 0) this.desktopOwnerWaiters.delete(threadId);
|
|
1694
|
+
}
|
|
1695
|
+
return this.desktopOwnerClientIds.get(threadId) ?? discovery.handledByClientId;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return owner;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Ask the IPC router which renderer owns a thread without invoking any
|
|
1703
|
+
* follower action. `discover` is preferred because it stops at the
|
|
1704
|
+
* metadata-only canHandle path; the direct request fallback is retained for
|
|
1705
|
+
* older test/transport implementations and still returns an empty result,
|
|
1706
|
+
* never transcript state.
|
|
1707
|
+
*/
|
|
1708
|
+
private async discoverDesktopOwner(threadId: string): Promise<DesktopIpcDiscoveryResult> {
|
|
1709
|
+
const params = { hostId: HOST_ID, conversationId: threadId };
|
|
1710
|
+
const discover = this.transport.discover;
|
|
1711
|
+
if (discover) {
|
|
1712
|
+
const result = await discover.call(
|
|
1713
|
+
this.transport,
|
|
1714
|
+
THREAD_OWNER_DISCOVERY,
|
|
1715
|
+
params,
|
|
1716
|
+
{ timeoutMs: this.ownerReacquireTimeoutMs },
|
|
1717
|
+
);
|
|
1718
|
+
if (result.handledByClientId) {
|
|
1719
|
+
this.rememberDesktopOwner(threadId, result.handledByClientId);
|
|
1720
|
+
}
|
|
1721
|
+
return result;
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const result = await this.transport.request(THREAD_OWNER_DISCOVERY, params);
|
|
1725
|
+
this.rememberDesktopOwnerFromResult(threadId, result);
|
|
1726
|
+
const handledByClientId = this.desktopOwnerClientIds.get(threadId) ?? undefined;
|
|
1727
|
+
return {
|
|
1728
|
+
canHandle: Boolean(handledByClientId),
|
|
1729
|
+
...(handledByClientId ? { handledByClientId } : {}),
|
|
1730
|
+
};
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
return {
|
|
1733
|
+
canHandle: false,
|
|
1734
|
+
...(desktopFollowerNoClientFound(error) ? {} : { timedOut: true }),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
releaseDesktopOwnership(threadIdValue: string): void {
|
|
1740
|
+
const threadId = stringValue(threadIdValue);
|
|
1741
|
+
if (!threadId) return;
|
|
1742
|
+
this.desktopOwnerClientIds.delete(threadId);
|
|
1743
|
+
this.desktopOwnedThreadIds.delete(threadId);
|
|
1744
|
+
this.ownershipStore.release(threadId);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
adoptLocalThread(threadIdValue: string): void {
|
|
1748
|
+
const threadId = stringValue(threadIdValue);
|
|
1749
|
+
if (!threadId) return;
|
|
1750
|
+
if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
|
|
1751
|
+
throw new DesktopIpcOwnershipError(
|
|
1752
|
+
`Codex Desktop still owns task ${threadId}; local adoption requires an explicit owner release`,
|
|
1753
|
+
threadId,
|
|
1754
|
+
"local-adopt",
|
|
1755
|
+
0,
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
this.ownedThreadIds.add(threadId);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
connectionSnapshot(): DesktopIpcConnectionSnapshot {
|
|
1762
|
+
return {
|
|
1763
|
+
connected: this.transport.connected,
|
|
1764
|
+
generation: this.transport.generation ?? 0,
|
|
1765
|
+
localClientId: stringValue(this.transport.localClientId),
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async requestFollowerAction(method: string, params: JsonRecord): Promise<unknown> {
|
|
1770
|
+
const threadId = threadIdFromParams(params);
|
|
1771
|
+
const knownDesktopOwner = Boolean(
|
|
1772
|
+
threadId && this.desktopOwnedThreadIds.has(threadId),
|
|
1773
|
+
);
|
|
1774
|
+
let initialOwner = threadId ? this.desktopOwnerClientIds.get(threadId) ?? "" : "";
|
|
1775
|
+
// A remembered Desktop-owned task may have lost its renderer id when the
|
|
1776
|
+
// socket disconnected. Reacquire that id through the read-only stream
|
|
1777
|
+
// probe before sending *any* follower mutation. An untargeted mutation
|
|
1778
|
+
// during this window could be picked up by a different renderer (or by a
|
|
1779
|
+
// stale local mirror), so fail closed when reacquisition is inconclusive.
|
|
1780
|
+
if (knownDesktopOwner && threadId && !initialOwner) {
|
|
1781
|
+
const reacquired = await this.reacquireDesktopOwner(threadId);
|
|
1782
|
+
if (!reacquired) {
|
|
1783
|
+
throw new DesktopIpcOwnershipError(
|
|
1784
|
+
`Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
|
|
1785
|
+
threadId,
|
|
1786
|
+
method,
|
|
1787
|
+
0,
|
|
1788
|
+
"owner-unavailable",
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
initialOwner = reacquired;
|
|
1792
|
+
}
|
|
1793
|
+
let lastError: unknown = null;
|
|
1794
|
+
for (let attempt = 0; attempt < FOLLOWER_OWNER_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
1795
|
+
const delayMs = FOLLOWER_OWNER_RETRY_DELAYS_MS[attempt] ?? 0;
|
|
1796
|
+
if (delayMs > 0) await waitMs(delayMs);
|
|
1797
|
+
// Keep a known owner target stable across the handler-registration race.
|
|
1798
|
+
// A newer authoritative snapshot may replace it between attempts; use
|
|
1799
|
+
// that new id, but never broaden a known-owner request to local Codex.
|
|
1800
|
+
let targetClientId = threadId
|
|
1801
|
+
? this.desktopOwnerClientIds.get(threadId) || initialOwner
|
|
1802
|
+
: "";
|
|
1803
|
+
if (knownDesktopOwner && threadId && !targetClientId) {
|
|
1804
|
+
const reacquired = await this.reacquireDesktopOwner(threadId);
|
|
1805
|
+
if (!reacquired) {
|
|
1806
|
+
throw new DesktopIpcOwnershipError(
|
|
1807
|
+
`Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
|
|
1808
|
+
threadId,
|
|
1809
|
+
method,
|
|
1810
|
+
attempt,
|
|
1811
|
+
"owner-unavailable",
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
targetClientId = reacquired;
|
|
1815
|
+
}
|
|
1816
|
+
try {
|
|
1817
|
+
const result = await this.transport.request(method, params, {
|
|
1818
|
+
...(targetClientId ? { targetClientId } : {}),
|
|
1819
|
+
});
|
|
1820
|
+
this.rememberDesktopOwnerFromResult(threadId, result);
|
|
1821
|
+
return result;
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
lastError = error;
|
|
1824
|
+
if (!desktopFollowerNoClientFound(error)) throw error;
|
|
1825
|
+
// A targeted no-client-found is safe to retry, but never broaden it
|
|
1826
|
+
// into an untargeted request. If the renderer was replaced, the next
|
|
1827
|
+
// attempt must first obtain the replacement sourceClientId.
|
|
1828
|
+
if (targetClientId) {
|
|
1829
|
+
// The router has conclusively rejected this connection-scoped
|
|
1830
|
+
// renderer id. Drop only that stale mapping while preserving the
|
|
1831
|
+
// monotonic Desktop-owned fact, then let the read-only discovery
|
|
1832
|
+
// path identify the replacement renderer before another mutation.
|
|
1833
|
+
this.removeDesktopOwnerClient(targetClientId);
|
|
1834
|
+
if (initialOwner === targetClientId) initialOwner = "";
|
|
1835
|
+
}
|
|
1836
|
+
if (knownDesktopOwner && threadId && !this.desktopOwnerClientIds.get(threadId)) {
|
|
1837
|
+
const reacquired = await this.reacquireDesktopOwner(threadId);
|
|
1838
|
+
if (!reacquired && attempt < FOLLOWER_OWNER_RETRY_DELAYS_MS.length - 1) {
|
|
1839
|
+
throw new DesktopIpcOwnershipError(
|
|
1840
|
+
`Codex Desktop still owns task ${threadId}; its current renderer owner could not be reacquired`,
|
|
1841
|
+
threadId,
|
|
1842
|
+
method,
|
|
1843
|
+
attempt + 1,
|
|
1844
|
+
"owner-unavailable",
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
if (reacquired) initialOwner = reacquired;
|
|
1848
|
+
}
|
|
1849
|
+
if (attempt === FOLLOWER_OWNER_RETRY_DELAYS_MS.length - 1) break;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (knownDesktopOwner && threadId) {
|
|
1853
|
+
throw new DesktopIpcOwnershipError(
|
|
1854
|
+
`Codex Desktop still owns task ${threadId}; its follower IPC handler could not be reached`,
|
|
1855
|
+
threadId,
|
|
1856
|
+
method,
|
|
1857
|
+
FOLLOWER_OWNER_RETRY_DELAYS_MS.length,
|
|
1858
|
+
"no-client-found",
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
throw lastError instanceof Error
|
|
1862
|
+
? lastError
|
|
1863
|
+
: new Error(`Codex Desktop IPC request failed: ${method}`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
async activateFollowerThread(threadIdValue: string): Promise<void> {
|
|
1867
|
+
const threadId = stringValue(threadIdValue);
|
|
1868
|
+
if (!DESKTOP_THREAD_ID.test(threadId)) throw new Error("Codex task id is not valid");
|
|
1869
|
+
this.transport.start();
|
|
1870
|
+
// This task is owned by Desktop and Android is the follower. Reopening an
|
|
1871
|
+
// already-active route without changing its URL is a navigation no-op, so
|
|
1872
|
+
// the owner never re-registers after an IPC reconnect. A unique neutral
|
|
1873
|
+
// query value forces route activation without assigning the opposite
|
|
1874
|
+
// Remodex-owned "follow" role.
|
|
1875
|
+
const token = encodeURIComponent(randomUUID());
|
|
1876
|
+
await this.openUrl(
|
|
1877
|
+
`codex://threads/${encodeURIComponent(threadId)}?opencodex-reactivate=${token}`,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async readFollowerHistoryPage(
|
|
1882
|
+
threadIdValue: string,
|
|
1883
|
+
options: {
|
|
1884
|
+
direction?: DesktopHistoryPageDirection;
|
|
1885
|
+
pageToken?: string | null;
|
|
1886
|
+
} = {},
|
|
1887
|
+
): Promise<JsonRecord | null> {
|
|
1888
|
+
const threadId = stringValue(threadIdValue);
|
|
1889
|
+
if (!threadId) return null;
|
|
1890
|
+
const direction = options.direction ?? "recent";
|
|
1891
|
+
if (this.boundedHistoryUnsupportedForCurrentConnection()) {
|
|
1892
|
+
return this.readFollowerHistoryPageFallback(threadId, direction, options.pageToken ?? null);
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
const result = await this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
|
|
1896
|
+
hostId: HOST_ID,
|
|
1897
|
+
conversationId: threadId,
|
|
1898
|
+
direction,
|
|
1899
|
+
pageToken: options.pageToken ?? null,
|
|
1900
|
+
});
|
|
1901
|
+
const response = record(result);
|
|
1902
|
+
const state = record(response?.conversationState ?? response?.conversation_state ?? response?.state);
|
|
1903
|
+
if (state) {
|
|
1904
|
+
return direction === "recent"
|
|
1905
|
+
? clone(this.historyPages.recentPage(threadId, state).state)
|
|
1906
|
+
: clone(state);
|
|
1907
|
+
}
|
|
1908
|
+
return response ? clone(response) : null;
|
|
1909
|
+
} catch (error) {
|
|
1910
|
+
if (!desktopFollowerMethodUnsupported(error)) throw error;
|
|
1911
|
+
this.markBoundedHistoryUnsupported();
|
|
1912
|
+
return this.readFollowerHistoryPageFallback(threadId, direction, options.pageToken ?? null);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
async readFollowerContentChunk(
|
|
1917
|
+
threadIdValue: string,
|
|
1918
|
+
input: { handle: string; sourceRevision: string; offset: number },
|
|
1919
|
+
): Promise<DesktopContentChunk> {
|
|
1920
|
+
const threadId = stringValue(threadIdValue);
|
|
1921
|
+
if (!threadId) return { kind: "missing", reason: "source-missing" };
|
|
1922
|
+
// Handles created by the local compatibility page store never need to
|
|
1923
|
+
// cross the IPC boundary. This also makes a fallback page immediately
|
|
1924
|
+
// usable by clients that request its content chunks.
|
|
1925
|
+
const localChunk = this.historyPages.readContentChunk({
|
|
1926
|
+
threadId,
|
|
1927
|
+
sourceRevision: input.sourceRevision,
|
|
1928
|
+
handle: input.handle,
|
|
1929
|
+
offset: input.offset,
|
|
1930
|
+
});
|
|
1931
|
+
if (localChunk.kind === "chunk") return localChunk;
|
|
1932
|
+
if (this.boundedHistoryUnsupportedForCurrentConnection()) return localChunk;
|
|
1933
|
+
try {
|
|
1934
|
+
const result = await this.requestFollowerAction(THREAD_FOLLOWER_READ_CONTENT_CHUNK, {
|
|
1935
|
+
hostId: HOST_ID,
|
|
1936
|
+
conversationId: threadId,
|
|
1937
|
+
handle: input.handle,
|
|
1938
|
+
sourceRevision: input.sourceRevision,
|
|
1939
|
+
offset: input.offset,
|
|
1940
|
+
});
|
|
1941
|
+
const chunk = record(result);
|
|
1942
|
+
if (chunk?.kind === "chunk" || chunk?.kind === "missing") return chunk as unknown as DesktopContentChunk;
|
|
1943
|
+
return { kind: "missing", reason: "source-missing" };
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
if (!desktopFollowerMethodUnsupported(error)) throw error;
|
|
1946
|
+
this.markBoundedHistoryUnsupported();
|
|
1947
|
+
return { kind: "missing", reason: "not-retained" };
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
async readFollowerThreadState(
|
|
1952
|
+
threadIdValue: string,
|
|
1953
|
+
options: { fresh?: boolean } = {},
|
|
1954
|
+
): Promise<JsonRecord | null> {
|
|
1955
|
+
const threadId = stringValue(threadIdValue);
|
|
1956
|
+
if (!threadId) return null;
|
|
1957
|
+
const current = this.followerStates.get(threadId);
|
|
1958
|
+
if (current && !options.fresh) return clone(current);
|
|
1959
|
+
if (this.boundedHistoryUnsupportedForCurrentConnection()) {
|
|
1960
|
+
return this.readFollowerThreadStateFallback(threadId);
|
|
1961
|
+
}
|
|
1962
|
+
let stateWaiter!: {
|
|
1963
|
+
resolve: (state: JsonRecord | null) => void;
|
|
1964
|
+
reject: (error: Error) => void;
|
|
1965
|
+
timer: Timer;
|
|
1966
|
+
};
|
|
1967
|
+
const state = new Promise<JsonRecord | null>((resolvePromise, reject) => {
|
|
1968
|
+
const timer = setTimeout(() => {
|
|
1969
|
+
const waiters = this.followerStateWaiters.get(threadId);
|
|
1970
|
+
if (waiters) {
|
|
1971
|
+
for (const waiter of waiters) {
|
|
1972
|
+
if (waiter.resolve !== resolvePromise) continue;
|
|
1973
|
+
waiters.delete(waiter);
|
|
1974
|
+
break;
|
|
1975
|
+
}
|
|
1976
|
+
if (waiters.size === 0) this.followerStateWaiters.delete(threadId);
|
|
1977
|
+
}
|
|
1978
|
+
reject(new Error("Codex Desktop did not publish the requested task state"));
|
|
1979
|
+
}, this.followerStateTimeoutMs);
|
|
1980
|
+
timer.unref?.();
|
|
1981
|
+
const waiter = { resolve: resolvePromise, reject, timer };
|
|
1982
|
+
stateWaiter = waiter;
|
|
1983
|
+
const waiters = this.followerStateWaiters.get(threadId) ?? new Set();
|
|
1984
|
+
waiters.add(waiter);
|
|
1985
|
+
this.followerStateWaiters.set(threadId, waiters);
|
|
1986
|
+
});
|
|
1987
|
+
try {
|
|
1988
|
+
// A state read is a bounded recent-page request. It intentionally does
|
|
1989
|
+
// not register a stream follower: older Desktop builds respond to that
|
|
1990
|
+
// registration by broadcasting their complete materialized transcript.
|
|
1991
|
+
const action = this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
|
|
1992
|
+
hostId: HOST_ID,
|
|
1993
|
+
conversationId: threadId,
|
|
1994
|
+
direction: "recent",
|
|
1995
|
+
pageToken: null,
|
|
1996
|
+
});
|
|
1997
|
+
const directState = action.then(result => {
|
|
1998
|
+
const response = record(result);
|
|
1999
|
+
const value = record(
|
|
2000
|
+
response?.conversationState
|
|
2001
|
+
?? response?.conversation_state
|
|
2002
|
+
?? response?.state,
|
|
2003
|
+
);
|
|
2004
|
+
if (!value) return null;
|
|
2005
|
+
const revision = finiteRevision(response?.revision) ?? 0;
|
|
2006
|
+
const bounded = this.historyPages.recentPage(threadId, value).state;
|
|
2007
|
+
this.followerStates.set(threadId, clone(bounded));
|
|
2008
|
+
this.followerRevisions.set(threadId, revision);
|
|
2009
|
+
this.clearFollowerRecovery(threadId);
|
|
2010
|
+
this.resolveFollowerStateWaiters(threadId);
|
|
2011
|
+
return clone(bounded);
|
|
2012
|
+
});
|
|
2013
|
+
// The publication waiter can win the race while the direct request is
|
|
2014
|
+
// still in flight. Attach a sink so a later unsupported-method rejection
|
|
2015
|
+
// cannot become an unhandled promise, while still allowing the race to
|
|
2016
|
+
// observe it when it is the first result.
|
|
2017
|
+
void directState.catch(error => {
|
|
2018
|
+
if (desktopFollowerMethodUnsupported(error)) this.markBoundedHistoryUnsupported();
|
|
2019
|
+
});
|
|
2020
|
+
// Race the bounded page request against the state publication timeout.
|
|
2021
|
+
// A slow or lost Desktop response must not keep the gateway request
|
|
2022
|
+
// open for the full IPC timeout.
|
|
2023
|
+
const resolved = await Promise.race([
|
|
2024
|
+
directState,
|
|
2025
|
+
state,
|
|
2026
|
+
]);
|
|
2027
|
+
return resolved ?? await state;
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
const waiters = this.followerStateWaiters.get(threadId);
|
|
2030
|
+
if (waiters && waiters.delete(stateWaiter)) {
|
|
2031
|
+
clearTimeout(stateWaiter.timer);
|
|
2032
|
+
if (waiters.size === 0) this.followerStateWaiters.delete(threadId);
|
|
2033
|
+
}
|
|
2034
|
+
if (desktopFollowerMethodUnsupported(error)) {
|
|
2035
|
+
this.markBoundedHistoryUnsupported();
|
|
2036
|
+
return this.readFollowerThreadStateFallback(threadId);
|
|
2037
|
+
}
|
|
2038
|
+
throw error;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
private boundedHistoryGeneration(): number {
|
|
2043
|
+
return this.transport.generation ?? 0;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
private boundedHistoryUnsupportedForCurrentConnection(): boolean {
|
|
2047
|
+
return this.boundedHistoryUnsupportedGeneration === this.boundedHistoryGeneration();
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
private markBoundedHistoryUnsupported(): void {
|
|
2051
|
+
this.boundedHistoryUnsupportedGeneration = this.boundedHistoryGeneration();
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Compatibility reader for Desktop builds that predate the bounded-page
|
|
2056
|
+
* follower methods. `readThreadPage` is backed by the bounded
|
|
2057
|
+
* thread/turns/list request in the default gateway integration; it never
|
|
2058
|
+
* asks the Desktop renderer to publish its complete transcript.
|
|
2059
|
+
*/
|
|
2060
|
+
private async readFollowerThreadStateFallback(threadId: string): Promise<JsonRecord | null> {
|
|
2061
|
+
if (!this.readThreadPage) return null;
|
|
2062
|
+
const result = await this.readThreadPage(threadId);
|
|
2063
|
+
const state = record(result?.thread) ?? record(result);
|
|
2064
|
+
if (!state) return null;
|
|
2065
|
+
const bounded = this.historyPages.recentPage(threadId, state).state;
|
|
2066
|
+
this.followerStates.set(threadId, clone(bounded));
|
|
2067
|
+
this.followerRevisions.set(threadId, this.followerRevisions.get(threadId) ?? 0);
|
|
2068
|
+
this.clearFollowerRecovery(threadId);
|
|
2069
|
+
this.resolveFollowerStateWaiters(threadId);
|
|
2070
|
+
return clone(bounded);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
private async readFollowerHistoryPageFallback(
|
|
2074
|
+
threadId: string,
|
|
2075
|
+
direction: Exclude<DesktopHistoryPageDirection, "recent"> | "recent",
|
|
2076
|
+
pageToken: string | null,
|
|
2077
|
+
): Promise<JsonRecord | null> {
|
|
2078
|
+
let state = this.followerStates.get(threadId);
|
|
2079
|
+
if (!state || direction === "recent") {
|
|
2080
|
+
state = await this.readFollowerThreadStateFallback(threadId) ?? undefined;
|
|
2081
|
+
}
|
|
2082
|
+
if (!state) return null;
|
|
2083
|
+
if (direction === "recent") return clone(state);
|
|
2084
|
+
if (!pageToken) throw new Error("A page token is required for older or newer history");
|
|
2085
|
+
const page = this.historyPages.page(threadId, state, direction, pageToken);
|
|
2086
|
+
return clone(page.state);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
private rememberDesktopOwner(threadIdValue: string, clientIdValue: string): void {
|
|
2090
|
+
const threadId = stringValue(threadIdValue);
|
|
2091
|
+
const clientId = stringValue(clientIdValue);
|
|
2092
|
+
const localClientId = stringValue(this.transport.localClientId);
|
|
2093
|
+
if (!threadId || !clientId || (localClientId && clientId === localClientId)) return;
|
|
2094
|
+
// A locally mounted writer is authoritative for this bridge. Ignore its
|
|
2095
|
+
// own echoed snapshots/responses rather than converting the mirror into a
|
|
2096
|
+
// false Desktop owner.
|
|
2097
|
+
if (this.ownedThreadIds.has(threadId)) return;
|
|
2098
|
+
this.desktopOwnerClientIds.set(threadId, clientId);
|
|
2099
|
+
this.desktopOwnedThreadIds.add(threadId);
|
|
2100
|
+
this.ownershipStore.remember(threadId);
|
|
2101
|
+
const waiters = this.desktopOwnerWaiters.get(threadId);
|
|
2102
|
+
if (waiters) {
|
|
2103
|
+
this.desktopOwnerWaiters.delete(threadId);
|
|
2104
|
+
for (const waiter of waiters) {
|
|
2105
|
+
clearTimeout(waiter.timer);
|
|
2106
|
+
waiter.resolve(clientId);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
private rememberDesktopOwnerFromResult(threadIdValue: string, result: unknown): void {
|
|
2112
|
+
const response = record(result);
|
|
2113
|
+
const handledByClientId = stringValue(
|
|
2114
|
+
response?.handledByClientId
|
|
2115
|
+
?? response?.handled_by_client_id
|
|
2116
|
+
?? record(response?.result)?.handledByClientId,
|
|
2117
|
+
);
|
|
2118
|
+
if (handledByClientId) this.rememberDesktopOwner(threadIdValue, handledByClientId);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
private removeDesktopOwnerClient(clientIdValue: string): void {
|
|
2122
|
+
const clientId = stringValue(clientIdValue);
|
|
2123
|
+
if (!clientId) return;
|
|
2124
|
+
for (const [threadId, ownerClientId] of this.desktopOwnerClientIds) {
|
|
2125
|
+
if (ownerClientId === clientId) this.desktopOwnerClientIds.delete(threadId);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
async probeFollowerRoute(
|
|
2130
|
+
threadIdValue: string,
|
|
2131
|
+
): Promise<"ready" | "absent" | "unhealthy"> {
|
|
2132
|
+
const threadId = stringValue(threadIdValue);
|
|
2133
|
+
if (!threadId) return "absent";
|
|
2134
|
+
const ownership = this.threadOwnership(threadId);
|
|
2135
|
+
if (ownership.state === "local-owned") return "absent";
|
|
2136
|
+
// A disconnected bus says nothing about the Desktop app-server writer.
|
|
2137
|
+
// Treat it as unhealthy so callers cannot adopt a competing local writer.
|
|
2138
|
+
if (!this.transport.connected) return "unhealthy";
|
|
2139
|
+
const discovery = await this.discoverDesktopOwner(threadId);
|
|
2140
|
+
if (discovery.canHandle && discovery.handledByClientId) return "ready";
|
|
2141
|
+
if (discovery.timedOut) return "unhealthy";
|
|
2142
|
+
return this.desktopOwnedThreadIds.has(threadId) ? "unhealthy" : "absent";
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
releaseThread(threadId: string): void {
|
|
2146
|
+
const id = stringValue(threadId);
|
|
2147
|
+
if (!id) return;
|
|
2148
|
+
this.ownedThreadIds.delete(id);
|
|
2149
|
+
this.conversations.delete(id);
|
|
2150
|
+
this.revisions.delete(id);
|
|
2151
|
+
this.lastBroadcastStates.delete(id);
|
|
2152
|
+
this.historyPages.clearThread(id);
|
|
2153
|
+
this.pendingTurnStarts.delete(id);
|
|
2154
|
+
this.dirtyThreadIds.delete(id);
|
|
2155
|
+
this.followerClientIds.delete(id);
|
|
2156
|
+
this.followedDesktopThreadIds.delete(id);
|
|
2157
|
+
this.followerStates.delete(id);
|
|
2158
|
+
this.followerRevisions.delete(id);
|
|
2159
|
+
this.queuedFollowerChanges.delete(id);
|
|
2160
|
+
this.clearFollowerRecovery(id);
|
|
2161
|
+
this.followAttempts.delete(id);
|
|
2162
|
+
this.runtimeOverrides.delete(id);
|
|
2163
|
+
this.stopAwaitingInitialHistory(id);
|
|
2164
|
+
const timer = this.followTimers.get(id);
|
|
2165
|
+
if (timer) clearTimeout(timer);
|
|
2166
|
+
this.followTimers.delete(id);
|
|
2167
|
+
const baselineTimer = this.followerBaselineTimers.get(id);
|
|
2168
|
+
if (baselineTimer) clearTimeout(baselineTimer);
|
|
2169
|
+
this.followerBaselineTimers.delete(id);
|
|
2170
|
+
const sidebarTimer = this.sidebarTimers.get(id);
|
|
2171
|
+
if (sidebarTimer) clearTimeout(sidebarTimer);
|
|
2172
|
+
this.sidebarTimers.delete(id);
|
|
2173
|
+
this.announcedSidebarThreadIds.delete(id);
|
|
2174
|
+
// `releaseThread` releases only this bridge's local mirror. Do not erase
|
|
2175
|
+
// the monotonic Desktop-ownership fact: a transient handoff/reconnect must
|
|
2176
|
+
// not make a later Android prompt call private thread/resume.
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
claimThread(input: {
|
|
2180
|
+
threadId: string;
|
|
2181
|
+
turnStartParams: JsonRecord;
|
|
2182
|
+
cwd?: string;
|
|
2183
|
+
title?: string;
|
|
2184
|
+
}): void {
|
|
2185
|
+
const threadId = stringValue(input.threadId);
|
|
2186
|
+
if (!threadId || !DESKTOP_THREAD_ID.test(threadId)) return;
|
|
2187
|
+
if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
|
|
2188
|
+
throw new DesktopIpcOwnershipError(
|
|
2189
|
+
`Codex Desktop still owns task ${threadId}; local adoption requires an explicit owner release`,
|
|
2190
|
+
threadId,
|
|
2191
|
+
"local-claim",
|
|
2192
|
+
0,
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
const needsInitialHistory = !this.ownedThreadIds.has(threadId)
|
|
2196
|
+
&& !this.conversations.has(threadId)
|
|
2197
|
+
&& !this.lastBroadcastStates.has(threadId);
|
|
2198
|
+
// Claiming is the local-owner transition. The gateway performs the
|
|
2199
|
+
// explicit `releaseDesktopOwnership` step only after a definitive owner
|
|
2200
|
+
// release/no-owner result; this method itself never silently overrides a
|
|
2201
|
+
// remembered Desktop writer.
|
|
2202
|
+
this.ownedThreadIds.add(threadId);
|
|
2203
|
+
if (needsInitialHistory) this.threadsAwaitingInitialHistory.add(threadId);
|
|
2204
|
+
const conversation = this.ensureConversation(threadId, input.cwd);
|
|
2205
|
+
if (input.cwd) conversation.cwd = input.cwd;
|
|
2206
|
+
if (input.title) conversation.title = input.title;
|
|
2207
|
+
const params = sanitizeTurnStartParams({ ...input.turnStartParams, threadId });
|
|
2208
|
+
this.applyRuntimeMetadata(conversation, params);
|
|
2209
|
+
const fallbackTurnId = `opencodex-pending-${this.now()}-${randomUUID()}`;
|
|
2210
|
+
const queue = this.pendingTurnStarts.get(threadId) ?? [];
|
|
2211
|
+
queue.push({ params: clone(params), fallbackTurnId });
|
|
2212
|
+
this.pendingTurnStarts.set(threadId, queue);
|
|
2213
|
+
conversation.turns.push(createConversationTurn({
|
|
2214
|
+
id: fallbackTurnId,
|
|
2215
|
+
status: "inProgress",
|
|
2216
|
+
items: [],
|
|
2217
|
+
}, conversation, this.now, params));
|
|
2218
|
+
this.trimConversationHistory(conversation);
|
|
2219
|
+
conversation.updatedAt = this.now();
|
|
2220
|
+
this.markDirty(threadId);
|
|
2221
|
+
this.scheduleSidebarAnnouncement(threadId);
|
|
2222
|
+
this.transport.start();
|
|
2223
|
+
this.requestInitialHistoryBaseline(threadId);
|
|
2224
|
+
if (this.transport.connected) this.beginFollow(threadId);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
observeCodexMessage(message: CodexJsonRpcMessage): void {
|
|
2228
|
+
const method = stringValue(message.method);
|
|
2229
|
+
if (!method) return;
|
|
2230
|
+
const params = record(message.params) ?? {};
|
|
2231
|
+
const threadId = threadIdFromMessage(method, params);
|
|
2232
|
+
if (!threadId || !this.ownedThreadIds.has(threadId)) return;
|
|
2233
|
+
const conversation = this.ensureConversation(threadId);
|
|
2234
|
+
let changed = false;
|
|
2235
|
+
let requiresAuthoritativeSnapshot = false;
|
|
2236
|
+
|
|
2237
|
+
if (SERVER_REQUEST_METHODS.has(method) && message.id !== undefined) {
|
|
2238
|
+
upsertById(conversation.requests, {
|
|
2239
|
+
id: message.id,
|
|
2240
|
+
method,
|
|
2241
|
+
params: clone(params),
|
|
2242
|
+
});
|
|
2243
|
+
changed = true;
|
|
2244
|
+
requiresAuthoritativeSnapshot = true;
|
|
2245
|
+
} else if (method === "thread/started") {
|
|
2246
|
+
const thread = record(params.thread);
|
|
2247
|
+
if (thread) this.mergeThread(conversation, thread);
|
|
2248
|
+
changed = true;
|
|
2249
|
+
} else if (method === "thread/name/updated") {
|
|
2250
|
+
conversation.title = stringValue(params.threadName)
|
|
2251
|
+
|| stringValue(params.name)
|
|
2252
|
+
|| stringValue(params.title)
|
|
2253
|
+
|| conversation.title;
|
|
2254
|
+
changed = true;
|
|
2255
|
+
} else if (method === "thread/status/changed") {
|
|
2256
|
+
conversation.threadRuntimeStatus = clone(params.status ?? null);
|
|
2257
|
+
changed = true;
|
|
2258
|
+
} else if (method === "thread/tokenUsage/updated") {
|
|
2259
|
+
conversation.latestTokenUsageInfo = clone(params.tokenUsage ?? params.usage ?? null);
|
|
2260
|
+
changed = true;
|
|
2261
|
+
} else if (method === "turn/started" || method === "turn/completed") {
|
|
2262
|
+
const rawTurn = record(params.turn);
|
|
2263
|
+
const explicitTurnId = turnIdFromParams(params) || stringValue(rawTurn?.id);
|
|
2264
|
+
const pending = method === "turn/started"
|
|
2265
|
+
? this.shiftPendingTurn(threadId)
|
|
2266
|
+
: explicitTurnId && !findTurn(conversation, explicitTurnId)
|
|
2267
|
+
? this.shiftPendingTurn(threadId)
|
|
2268
|
+
: null;
|
|
2269
|
+
const turnId = explicitTurnId || pending?.fallbackTurnId || this.activeTurnId(conversation);
|
|
2270
|
+
if (turnId) {
|
|
2271
|
+
const previousId = pending?.fallbackTurnId;
|
|
2272
|
+
const previous = previousId ? findTurn(conversation, previousId) : findTurn(conversation, turnId);
|
|
2273
|
+
const next = createConversationTurn({
|
|
2274
|
+
...(rawTurn ?? {}),
|
|
2275
|
+
id: turnId,
|
|
2276
|
+
...(method === "turn/completed" && rawTurn?.status == null ? { status: "completed" } : {}),
|
|
2277
|
+
}, conversation, this.now, pending?.params ?? previous?.params);
|
|
2278
|
+
if (previous) Object.assign(next, mergeTurnContinuity(previous, next));
|
|
2279
|
+
replaceTurn(conversation, previousId || turnId, next);
|
|
2280
|
+
if (pending) this.applyRuntimeMetadata(conversation, pending.params);
|
|
2281
|
+
}
|
|
2282
|
+
changed = true;
|
|
2283
|
+
if (method === "turn/completed") {
|
|
2284
|
+
requiresAuthoritativeSnapshot = true;
|
|
2285
|
+
this.announceSidebarThread(threadId, true);
|
|
2286
|
+
}
|
|
2287
|
+
} else if (method === "turn/diff/updated") {
|
|
2288
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
2289
|
+
if (turn) turn.diff = typeof params.diff === "string" ? params.diff : "";
|
|
2290
|
+
changed = Boolean(turn);
|
|
2291
|
+
requiresAuthoritativeSnapshot = changed;
|
|
2292
|
+
} else if (method === "turn/plan/updated") {
|
|
2293
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
2294
|
+
if (turn) upsertTurnItem(turn, {
|
|
2295
|
+
id: `todo-list-${turn.turnId}`,
|
|
2296
|
+
type: "todo-list",
|
|
2297
|
+
explanation: stringValue(params.explanation) || null,
|
|
2298
|
+
plan: Array.isArray(params.plan) ? clone(params.plan) : [],
|
|
2299
|
+
});
|
|
2300
|
+
changed = Boolean(turn);
|
|
2301
|
+
} else if (method === "item/started" || method === "item/completed") {
|
|
2302
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
2303
|
+
const item = sanitizeDesktopItem(record(params.item));
|
|
2304
|
+
if (turn && item) {
|
|
2305
|
+
const duplicatesInitialInput = turn.items.every(isInitialTurnPrefixItem)
|
|
2306
|
+
&& userMessageDuplicatesTurnInput(turn, item);
|
|
2307
|
+
if (!duplicatesInitialInput) upsertTurnItem(turn, item);
|
|
2308
|
+
if (item.type !== "userMessage") turn.firstTurnWorkItemStartedAtMs ||= this.now();
|
|
2309
|
+
if (item.type === "agentMessage") turn.finalAssistantStartedAtMs ||= this.now();
|
|
2310
|
+
if (item.type === "commandExecution") {
|
|
2311
|
+
(turn.commandExecutionStartedAtMsById as JsonRecord)[stringValue(item.id)] ||= this.now();
|
|
2312
|
+
}
|
|
2313
|
+
changed = true;
|
|
2314
|
+
if (method === "item/completed") requiresAuthoritativeSnapshot = true;
|
|
2315
|
+
if (item.type === "userMessage" && method === "item/completed") {
|
|
2316
|
+
this.announceSidebarThread(threadId, true);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
} else if (method === "item/agentMessage/delta") {
|
|
2320
|
+
changed = this.appendItemDelta(conversation, params, "agentMessage", "text");
|
|
2321
|
+
} else if (method === "item/plan/delta") {
|
|
2322
|
+
changed = this.appendItemDelta(conversation, params, "plan", "text");
|
|
2323
|
+
} else if (method === "item/reasoning/summaryTextDelta") {
|
|
2324
|
+
changed = this.appendReasoningSummary(conversation, params);
|
|
2325
|
+
} else if (method === "item/fileChange/patchUpdated") {
|
|
2326
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params), false);
|
|
2327
|
+
const itemId = stringValue(params.itemId);
|
|
2328
|
+
if (turn && itemId) {
|
|
2329
|
+
const item = ensureItem(turn, itemId, () => ({ type: "fileChange", id: itemId, changes: [], status: "inProgress" }));
|
|
2330
|
+
item.changes = Array.isArray(params.changes) ? clone(params.changes) : [];
|
|
2331
|
+
changed = true;
|
|
2332
|
+
}
|
|
2333
|
+
} else if (method === "item/commandExecution/outputDelta") {
|
|
2334
|
+
changed = this.appendItemDelta(conversation, params, "commandExecution", "aggregatedOutput");
|
|
2335
|
+
} else if (method === "item/mcpToolCall/progress") {
|
|
2336
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
2337
|
+
const itemId = stringValue(params.itemId);
|
|
2338
|
+
if (turn && itemId) {
|
|
2339
|
+
const item = ensureItem(turn, itemId, () => ({ type: "mcpToolCall", id: itemId, status: "inProgress" }));
|
|
2340
|
+
item.progress = stringValue(params.message).slice(0, 8192);
|
|
2341
|
+
changed = true;
|
|
2342
|
+
}
|
|
2343
|
+
} else if (method === "serverRequest/resolved") {
|
|
2344
|
+
const key = requestIdKey(params.requestId ?? params.request_id);
|
|
2345
|
+
conversation.requests = conversation.requests.filter(request => requestIdKey(request.id) !== key);
|
|
2346
|
+
changed = true;
|
|
2347
|
+
requiresAuthoritativeSnapshot = true;
|
|
2348
|
+
} else if (method === "error") {
|
|
2349
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
2350
|
+
if (turn) {
|
|
2351
|
+
turn.error = clone(params.error ?? null);
|
|
2352
|
+
upsertTurnItem(turn, {
|
|
2353
|
+
id: `error-${this.now()}`,
|
|
2354
|
+
type: "error",
|
|
2355
|
+
message: stringValue(record(params.error)?.message) || "Codex error",
|
|
2356
|
+
willRetry: Boolean(params.willRetry),
|
|
2357
|
+
});
|
|
2358
|
+
changed = true;
|
|
2359
|
+
requiresAuthoritativeSnapshot = true;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
if (changed) {
|
|
2364
|
+
this.trimConversationHistory(conversation);
|
|
2365
|
+
conversation.updatedAt = this.now();
|
|
2366
|
+
this.markDirty(threadId);
|
|
2367
|
+
if (requiresAuthoritativeSnapshot) this.forceSnapshot(threadId);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
private onConnected(): void {
|
|
2372
|
+
// A remembered Desktop-owned task remains protected across a socket or
|
|
2373
|
+
// renderer restart. Reconnect is deliberately metadata-only: registering
|
|
2374
|
+
// a follower here can make older Desktop builds materialize and broadcast
|
|
2375
|
+
// their complete transcript.
|
|
2376
|
+
for (const threadId of [...this.followedDesktopThreadIds]) {
|
|
2377
|
+
// Re-registering a follower can make Desktop eagerly broadcast its
|
|
2378
|
+
// complete materialized snapshot. That is precisely the unsafe path
|
|
2379
|
+
// this recovery code is designed to avoid for a remembered
|
|
2380
|
+
// Desktop-owned task. State consumers explicitly request bounded
|
|
2381
|
+
// pages instead (see readFollowerThreadState), so reconnect itself is
|
|
2382
|
+
// metadata-only.
|
|
2383
|
+
if (this.desktopOwnedThreadIds.has(threadId) && !this.ownedThreadIds.has(threadId)) {
|
|
2384
|
+
this.followedDesktopThreadIds.delete(threadId);
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
this.transport.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
|
|
2388
|
+
conversationId: threadId,
|
|
2389
|
+
hostId: HOST_ID,
|
|
2390
|
+
following: true,
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
// A waiter may have started while the bus was disconnected. Resolve it
|
|
2394
|
+
// with the metadata-only owner probe; never ask Desktop to load history.
|
|
2395
|
+
for (const threadId of this.desktopOwnerWaiters.keys()) {
|
|
2396
|
+
void this.discoverDesktopOwner(threadId).catch(() => undefined);
|
|
2397
|
+
}
|
|
2398
|
+
for (const threadId of this.ownedThreadIds) {
|
|
2399
|
+
this.transport.sendBroadcast("thread-stream-following-status-requested", {
|
|
2400
|
+
hostId: HOST_ID,
|
|
2401
|
+
conversationId: threadId,
|
|
2402
|
+
});
|
|
2403
|
+
if (!this.shouldDelayInitialSnapshot(threadId)) this.forceSnapshot(threadId);
|
|
2404
|
+
this.beginFollow(threadId);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
private onDisconnected(): void {
|
|
2409
|
+
// Current renderer ids are connection-scoped. Keep the monotonic
|
|
2410
|
+
// `desktopOwnedThreadIds` fact, but force the next mutation through the
|
|
2411
|
+
// router/discovery path (or a newly observed snapshot).
|
|
2412
|
+
this.desktopOwnerClientIds.clear();
|
|
2413
|
+
this.boundedHistoryUnsupportedGeneration = null;
|
|
2414
|
+
for (const waiters of this.desktopOwnerWaiters.values()) {
|
|
2415
|
+
for (const waiter of waiters) {
|
|
2416
|
+
clearTimeout(waiter.timer);
|
|
2417
|
+
waiter.resolve(null);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
this.desktopOwnerWaiters.clear();
|
|
2421
|
+
this.followerClientIds.clear();
|
|
2422
|
+
this.followAttempts.clear();
|
|
2423
|
+
for (const timer of this.followTimers.values()) clearTimeout(timer);
|
|
2424
|
+
this.followTimers.clear();
|
|
2425
|
+
for (const timer of this.followerBaselineTimers.values()) clearTimeout(timer);
|
|
2426
|
+
this.followerBaselineTimers.clear();
|
|
2427
|
+
const followerThreadsBeforeDisconnect = [...this.followerStates.keys()];
|
|
2428
|
+
this.followerStates.clear();
|
|
2429
|
+
this.followerRevisions.clear();
|
|
2430
|
+
for (const threadId of followerThreadsBeforeDisconnect) this.historyPages.clearThread(threadId);
|
|
2431
|
+
this.queuedFollowerChanges.clear();
|
|
2432
|
+
for (const recovery of this.followerRecovery.values()) {
|
|
2433
|
+
if (recovery.timer) clearTimeout(recovery.timer);
|
|
2434
|
+
}
|
|
2435
|
+
this.followerRecovery.clear();
|
|
2436
|
+
for (const waiters of this.followerStateWaiters.values()) {
|
|
2437
|
+
for (const waiter of waiters) {
|
|
2438
|
+
clearTimeout(waiter.timer);
|
|
2439
|
+
waiter.reject(new Error("Codex Desktop IPC connection closed"));
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
this.followerStateWaiters.clear();
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
private onBroadcast(envelope: DesktopIpcEnvelope): void {
|
|
2446
|
+
if (envelope.method === THREAD_ARCHIVED) {
|
|
2447
|
+
const params = record(envelope.params) ?? {};
|
|
2448
|
+
const threadId = threadIdFromParams(params);
|
|
2449
|
+
const ownerClientId = this.desktopOwnerClientIds.get(threadId);
|
|
2450
|
+
const sourceClientId = stringValue(envelope.sourceClientId);
|
|
2451
|
+
if (
|
|
2452
|
+
threadId
|
|
2453
|
+
&& (params.ownerReleased === true || (ownerClientId && ownerClientId === sourceClientId))
|
|
2454
|
+
) {
|
|
2455
|
+
this.releaseDesktopOwnership(threadId);
|
|
2456
|
+
}
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (envelope.method === THREAD_STREAM_STATE_CHANGED) {
|
|
2460
|
+
const params = record(envelope.params) ?? {};
|
|
2461
|
+
if (stringValue(params.opencodexOwnerSource) === OWNER_SOURCE) return;
|
|
2462
|
+
const threadId = threadIdFromParams(params);
|
|
2463
|
+
if (
|
|
2464
|
+
threadId
|
|
2465
|
+
&& (params.desktopOwnerReleased === true || params.ownerReleased === true)
|
|
2466
|
+
) {
|
|
2467
|
+
this.releaseDesktopOwnership(threadId);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const change = followerStateChange(params.change);
|
|
2471
|
+
if (!threadId || !change) return;
|
|
2472
|
+
const wasOwned = this.ownedThreadIds.has(threadId);
|
|
2473
|
+
if (wasOwned && change.type === "snapshot") {
|
|
2474
|
+
if (this.hasActiveLocalTurn(threadId) && !snapshotShowsActiveTurn(change)) {
|
|
2475
|
+
this.log(`Ignoring idle peer snapshot while local turn is active for ${threadId}`);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
this.log(`Yielding IPC ownership of ${threadId} to peer snapshot revision ${change.revision ?? "unknown"}`);
|
|
2479
|
+
this.releaseThread(threadId);
|
|
2480
|
+
}
|
|
2481
|
+
// This broadcast is authoritative even when the snapshot is idle and
|
|
2482
|
+
// Android is not currently following the task. Record the source before
|
|
2483
|
+
// any reconnect/handler race can make a later mutation look ownerless.
|
|
2484
|
+
this.rememberDesktopOwner(threadId, stringValue(envelope.sourceClientId));
|
|
2485
|
+
const interested = wasOwned
|
|
2486
|
+
|| this.desktopOwnedThreadIds.has(threadId)
|
|
2487
|
+
|| this.followedDesktopThreadIds.has(threadId)
|
|
2488
|
+
|| this.followerStateWaiters.has(threadId);
|
|
2489
|
+
if (interested) this.applyFollowerStateChange(threadId, change);
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
if (envelope.method === THREAD_STREAM_FOLLOWING_CHANGED) {
|
|
2493
|
+
const params = record(envelope.params) ?? {};
|
|
2494
|
+
const threadId = threadIdFromParams(params);
|
|
2495
|
+
if (!this.ownedThreadIds.has(threadId)) return;
|
|
2496
|
+
const clientId = stringValue(envelope.sourceClientId) || stringValue(params.clientId);
|
|
2497
|
+
const followers = this.followerClientIds.get(threadId) ?? new Set<string>();
|
|
2498
|
+
if (params.following === false) followers.delete(clientId);
|
|
2499
|
+
else if (params.following === true && clientId) followers.add(clientId);
|
|
2500
|
+
this.followerClientIds.set(threadId, followers);
|
|
2501
|
+
if (params.following === true) {
|
|
2502
|
+
const timer = this.followTimers.get(threadId);
|
|
2503
|
+
if (timer) clearTimeout(timer);
|
|
2504
|
+
this.followTimers.delete(threadId);
|
|
2505
|
+
this.log(`Codex Desktop is following Android task ${threadId}`);
|
|
2506
|
+
this.forceSnapshot(threadId);
|
|
2507
|
+
this.scheduleFollowerMountBaseline(threadId);
|
|
2508
|
+
}
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (envelope.method === CLIENT_STATUS_CHANGED) {
|
|
2512
|
+
const params = record(envelope.params) ?? {};
|
|
2513
|
+
if (normalizeToken(params.status) === "disconnected") {
|
|
2514
|
+
const clientId = stringValue(params.clientId) || stringValue(envelope.sourceClientId);
|
|
2515
|
+
for (const followers of this.followerClientIds.values()) followers.delete(clientId);
|
|
2516
|
+
this.removeDesktopOwnerClient(clientId);
|
|
2517
|
+
} else {
|
|
2518
|
+
for (const threadId of this.ownedThreadIds) {
|
|
2519
|
+
this.transport.sendBroadcast("thread-stream-following-status-requested", {
|
|
2520
|
+
hostId: HOST_ID,
|
|
2521
|
+
conversationId: threadId,
|
|
2522
|
+
});
|
|
2523
|
+
if (!this.shouldDelayInitialSnapshot(threadId)) this.forceSnapshot(threadId);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
private applyFollowerStateChange(threadId: string, change: FollowerStateChange): void {
|
|
2530
|
+
if (change.type === "snapshot") {
|
|
2531
|
+
const state = record(change.conversationState);
|
|
2532
|
+
if (!state) return;
|
|
2533
|
+
// Desktop is allowed to have a much larger native transcript than the
|
|
2534
|
+
// bridge can safely retain. Bound an inbound snapshot before storing it
|
|
2535
|
+
// or resolving a pending-request read; otherwise one legacy renderer
|
|
2536
|
+
// broadcast could recreate the original 171 MB failure on this side.
|
|
2537
|
+
const bounded = this.historyPages.recentPage(threadId, state).state;
|
|
2538
|
+
this.followerStates.set(threadId, clone(bounded));
|
|
2539
|
+
this.followerRevisions.set(threadId, change.revision ?? 0);
|
|
2540
|
+
this.clearFollowerRecovery(threadId);
|
|
2541
|
+
this.applyQueuedFollowerChanges(threadId);
|
|
2542
|
+
this.resolveFollowerStateWaiters(threadId);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
const state = this.followerStates.get(threadId);
|
|
2547
|
+
const revision = this.followerRevisions.get(threadId);
|
|
2548
|
+
if (
|
|
2549
|
+
state
|
|
2550
|
+
&& revision !== undefined
|
|
2551
|
+
&& change.baseRevision === revision
|
|
2552
|
+
&& change.revision !== undefined
|
|
2553
|
+
&& Array.isArray(change.patches)
|
|
2554
|
+
) {
|
|
2555
|
+
const next = applyDesktopStatePatches(state, change.patches);
|
|
2556
|
+
if (next) {
|
|
2557
|
+
this.followerStates.set(threadId, next);
|
|
2558
|
+
this.followerRevisions.set(threadId, change.revision);
|
|
2559
|
+
this.resolveFollowerStateWaiters(threadId);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
this.queueFollowerChange(threadId, change);
|
|
2565
|
+
this.followerStates.delete(threadId);
|
|
2566
|
+
this.followerRevisions.delete(threadId);
|
|
2567
|
+
this.recoverFollowerBaseline(threadId);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
private applyQueuedFollowerChanges(threadId: string): void {
|
|
2571
|
+
const queued = this.queuedFollowerChanges.get(threadId);
|
|
2572
|
+
if (!queued?.length) return;
|
|
2573
|
+
let state = this.followerStates.get(threadId);
|
|
2574
|
+
let revision = this.followerRevisions.get(threadId);
|
|
2575
|
+
if (!state || revision === undefined) return;
|
|
2576
|
+
const remaining: FollowerStateChange[] = [];
|
|
2577
|
+
for (const change of queued) {
|
|
2578
|
+
if (change.type === "snapshot") {
|
|
2579
|
+
const snapshot = record(change.conversationState);
|
|
2580
|
+
if (snapshot) {
|
|
2581
|
+
state = clone(snapshot);
|
|
2582
|
+
revision = change.revision ?? revision;
|
|
2583
|
+
}
|
|
2584
|
+
continue;
|
|
2585
|
+
}
|
|
2586
|
+
if (change.baseRevision !== undefined && change.baseRevision < revision) continue;
|
|
2587
|
+
if (
|
|
2588
|
+
change.baseRevision !== revision
|
|
2589
|
+
|| change.revision === undefined
|
|
2590
|
+
|| !Array.isArray(change.patches)
|
|
2591
|
+
) {
|
|
2592
|
+
remaining.push(change);
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
const next = applyDesktopStatePatches(state, change.patches);
|
|
2596
|
+
if (!next) {
|
|
2597
|
+
remaining.push(change);
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
state = next;
|
|
2601
|
+
revision = change.revision;
|
|
2602
|
+
}
|
|
2603
|
+
this.followerStates.set(threadId, state);
|
|
2604
|
+
this.followerRevisions.set(threadId, revision);
|
|
2605
|
+
if (remaining.length > 0) {
|
|
2606
|
+
this.queuedFollowerChanges.set(threadId, remaining);
|
|
2607
|
+
this.recoverFollowerBaseline(threadId);
|
|
2608
|
+
} else {
|
|
2609
|
+
this.queuedFollowerChanges.delete(threadId);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
private queueFollowerChange(threadId: string, change: FollowerStateChange): void {
|
|
2614
|
+
const queued = this.queuedFollowerChanges.get(threadId) ?? [];
|
|
2615
|
+
queued.push(clone(change));
|
|
2616
|
+
if (queued.length > MAX_QUEUED_FOLLOWER_CHANGES) {
|
|
2617
|
+
queued.splice(0, queued.length - MAX_QUEUED_FOLLOWER_CHANGES);
|
|
2618
|
+
}
|
|
2619
|
+
this.queuedFollowerChanges.set(threadId, queued);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
private recoverFollowerBaseline(threadId: string): void {
|
|
2623
|
+
if (!this.transport.connected) return;
|
|
2624
|
+
const recovery = this.followerRecovery.get(threadId) ?? { attempts: 0, timer: null, inFlight: false };
|
|
2625
|
+
if (recovery.inFlight || recovery.timer || recovery.attempts >= Math.max(1, this.followerBaselineMaxAttempts)) return;
|
|
2626
|
+
recovery.attempts += 1;
|
|
2627
|
+
recovery.inFlight = true;
|
|
2628
|
+
this.followerRecovery.set(threadId, recovery);
|
|
2629
|
+
void this.requestFollowerAction(THREAD_FOLLOWER_LOAD_HISTORY_PAGE, {
|
|
2630
|
+
hostId: HOST_ID,
|
|
2631
|
+
conversationId: threadId,
|
|
2632
|
+
direction: "recent",
|
|
2633
|
+
pageToken: null,
|
|
2634
|
+
}).then(result => {
|
|
2635
|
+
const response = record(result);
|
|
2636
|
+
const state = record(response?.conversationState ?? response?.conversation_state ?? response?.state);
|
|
2637
|
+
if (!state) return;
|
|
2638
|
+
const bounded = this.historyPages.recentPage(threadId, state).state;
|
|
2639
|
+
this.followerStates.set(threadId, clone(bounded));
|
|
2640
|
+
this.followerRevisions.set(threadId, finiteRevision(response?.revision) ?? 0);
|
|
2641
|
+
this.clearFollowerRecovery(threadId);
|
|
2642
|
+
this.applyQueuedFollowerChanges(threadId);
|
|
2643
|
+
this.resolveFollowerStateWaiters(threadId);
|
|
2644
|
+
}).catch(error => {
|
|
2645
|
+
if (recovery.attempts === 1 || recovery.attempts === this.followerBaselineMaxAttempts) {
|
|
2646
|
+
this.warn(`Could not recover the Desktop stream baseline for ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2647
|
+
}
|
|
2648
|
+
}).finally(() => {
|
|
2649
|
+
const current = this.followerRecovery.get(threadId);
|
|
2650
|
+
if (!current || current !== recovery) return;
|
|
2651
|
+
current.inFlight = false;
|
|
2652
|
+
if (this.followerStates.has(threadId) || current.attempts >= Math.max(1, this.followerBaselineMaxAttempts)) return;
|
|
2653
|
+
const delay = Math.min(
|
|
2654
|
+
Math.max(0, this.followerBaselineMaxRetryMs),
|
|
2655
|
+
Math.max(0, this.followerBaselineRetryMs) * (2 ** Math.min(current.attempts - 1, 5)),
|
|
2656
|
+
);
|
|
2657
|
+
current.timer = setTimeout(() => {
|
|
2658
|
+
const latest = this.followerRecovery.get(threadId);
|
|
2659
|
+
if (latest) latest.timer = null;
|
|
2660
|
+
this.recoverFollowerBaseline(threadId);
|
|
2661
|
+
}, delay);
|
|
2662
|
+
current.timer.unref?.();
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
private clearFollowerRecovery(threadId: string): void {
|
|
2667
|
+
const recovery = this.followerRecovery.get(threadId);
|
|
2668
|
+
if (recovery?.timer) clearTimeout(recovery.timer);
|
|
2669
|
+
this.followerRecovery.delete(threadId);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
private resolveFollowerStateWaiters(threadId: string): void {
|
|
2673
|
+
const state = this.followerStates.get(threadId);
|
|
2674
|
+
const waiters = this.followerStateWaiters.get(threadId);
|
|
2675
|
+
if (!state || !waiters) return;
|
|
2676
|
+
this.followerStateWaiters.delete(threadId);
|
|
2677
|
+
for (const waiter of waiters) {
|
|
2678
|
+
clearTimeout(waiter.timer);
|
|
2679
|
+
waiter.resolve(clone(state));
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
private hasActiveLocalTurn(threadId: string): boolean {
|
|
2684
|
+
if ((this.pendingTurnStarts.get(threadId)?.length ?? 0) > 0) return true;
|
|
2685
|
+
return (this.conversations.get(threadId)?.turns ?? []).some(turn => {
|
|
2686
|
+
const status = normalizeToken(turn.status);
|
|
2687
|
+
return !status || status === "inprogress" || status === "running" || status === "active";
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
private canHandleRequest(envelope: DesktopIpcEnvelope): boolean {
|
|
2692
|
+
const method = stringValue(envelope.method);
|
|
2693
|
+
const params = record(envelope.params) ?? {};
|
|
2694
|
+
const threadId = threadIdFromParams(params);
|
|
2695
|
+
const sourceClientId = stringValue(envelope.sourceClientId);
|
|
2696
|
+
const localClientId = stringValue(this.transport.localClientId);
|
|
2697
|
+
if (sourceClientId && localClientId && sourceClientId === localClientId) {
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
// Owner discovery is deliberately metadata-only. It is part of the
|
|
2701
|
+
// router's route-probe protocol, not a request to hydrate or broadcast a
|
|
2702
|
+
// conversation. Restrict it to the bridge's local writer so a stale
|
|
2703
|
+
// renderer cannot claim an arbitrary thread.
|
|
2704
|
+
if (method === THREAD_OWNER_DISCOVERY) {
|
|
2705
|
+
return stringValue(params.hostId) === HOST_ID
|
|
2706
|
+
&& Boolean(threadId)
|
|
2707
|
+
&& this.ownedThreadIds.has(threadId);
|
|
2708
|
+
}
|
|
2709
|
+
return FOLLOWER_METHODS.has(method) && this.ownedThreadIds.has(threadId);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
private async handleRequest(envelope: DesktopIpcEnvelope): Promise<unknown> {
|
|
2713
|
+
const method = stringValue(envelope.method);
|
|
2714
|
+
const params = record(envelope.params) ?? {};
|
|
2715
|
+
const threadId = threadIdFromParams(params);
|
|
2716
|
+
if (!threadId || !this.ownedThreadIds.has(threadId)) throw new Error("conversation-not-owned");
|
|
2717
|
+
if (method === THREAD_OWNER_DISCOVERY) {
|
|
2718
|
+
// The response envelope carries handledByClientId. Returning an empty
|
|
2719
|
+
// object is intentional: no turns, items, requests, or content cross
|
|
2720
|
+
// the IPC boundary during route discovery.
|
|
2721
|
+
return {};
|
|
2722
|
+
}
|
|
2723
|
+
if (method === THREAD_FOLLOWER_LOAD_HISTORY_PAGE) {
|
|
2724
|
+
const directionToken = normalizeToken(params.direction);
|
|
2725
|
+
const direction: DesktopHistoryPageDirection = directionToken === "older"
|
|
2726
|
+
? "older"
|
|
2727
|
+
: directionToken === "newer"
|
|
2728
|
+
? "newer"
|
|
2729
|
+
: "recent";
|
|
2730
|
+
const pageToken = stringValue(params.pageToken ?? params.page_token);
|
|
2731
|
+
let page: DesktopBoundedHistoryPage;
|
|
2732
|
+
const conversation = this.ensureConversation(threadId);
|
|
2733
|
+
if (direction === "recent") {
|
|
2734
|
+
page = this.historyPages.recentPage(threadId, conversation);
|
|
2735
|
+
} else {
|
|
2736
|
+
if (!pageToken) throw new Error("A page token is required for older or newer history");
|
|
2737
|
+
page = this.historyPages.page(threadId, conversation, direction, pageToken);
|
|
2738
|
+
}
|
|
2739
|
+
// Return the bounded page directly as well as publishing it. A direct
|
|
2740
|
+
// response lets a caller recover when it is not currently subscribed to
|
|
2741
|
+
// stream broadcasts, while the broadcast keeps existing followers in
|
|
2742
|
+
// sync. Both paths carry the same byte-bounded state.
|
|
2743
|
+
this.stopAwaitingInitialHistory(threadId);
|
|
2744
|
+
this.broadcastState(threadId, true);
|
|
2745
|
+
const revision = this.revisions.get(threadId) ?? 0;
|
|
2746
|
+
return {
|
|
2747
|
+
revision,
|
|
2748
|
+
conversationState: page.state,
|
|
2749
|
+
historyPage: page.pageInfo,
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
if (method === THREAD_FOLLOWER_READ_CONTENT_CHUNK) {
|
|
2753
|
+
const handle = stringValue(params.handle);
|
|
2754
|
+
const sourceRevision = stringValue(params.sourceRevision ?? params.source_revision);
|
|
2755
|
+
const offset = numberValue(params.offset) ?? 0;
|
|
2756
|
+
if (!handle || !sourceRevision || !Number.isSafeInteger(offset) || offset < 0) {
|
|
2757
|
+
throw new Error("A valid content handle, source revision, and offset are required");
|
|
2758
|
+
}
|
|
2759
|
+
return this.historyPages.readContentChunk({
|
|
2760
|
+
threadId,
|
|
2761
|
+
sourceRevision,
|
|
2762
|
+
handle,
|
|
2763
|
+
offset,
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
if (method === "thread-follower-load-complete-history") {
|
|
2767
|
+
// Legacy Desktop clients may still send this method. Keep the method
|
|
2768
|
+
// available for compatibility, but make its response bounded; it must
|
|
2769
|
+
// never materialize a 64 MiB+ IPC frame.
|
|
2770
|
+
await this.hydrateThread(threadId);
|
|
2771
|
+
this.stopAwaitingInitialHistory(threadId);
|
|
2772
|
+
if (!this.broadcastState(threadId, true)) throw new Error("Codex Desktop is not connected");
|
|
2773
|
+
const bounded = this.historyPages.recentPage(threadId, this.ensureConversation(threadId));
|
|
2774
|
+
return {
|
|
2775
|
+
revision: this.revisions.get(threadId) ?? 0,
|
|
2776
|
+
conversationState: bounded.state,
|
|
2777
|
+
historyPage: bounded.pageInfo,
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
if (method === "thread-follower-start-turn") {
|
|
2781
|
+
const turnStart = record(params.turnStart);
|
|
2782
|
+
const raw = record(turnStart?.request)
|
|
2783
|
+
?? record(params.turnStartParams)
|
|
2784
|
+
?? record(params.turn_start_params)
|
|
2785
|
+
?? params;
|
|
2786
|
+
const startParams = this.withRuntimeOverrides(threadId, sanitizeTurnStartParams({ ...raw, threadId }));
|
|
2787
|
+
this.rememberFollowerTurnStart(threadId, startParams);
|
|
2788
|
+
try {
|
|
2789
|
+
const result = await this.sendCodexRequest("turn/start", startParams);
|
|
2790
|
+
return { result: result ?? null };
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
this.removeLatestPendingTurn(threadId);
|
|
2793
|
+
throw error;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (method === "thread-follower-steer-turn") {
|
|
2797
|
+
const raw = record(params.turnSteerParams) ?? record(params.turn_steer_params) ?? params;
|
|
2798
|
+
const expectedTurnId = stringValue(raw.expectedTurnId)
|
|
2799
|
+
|| stringValue(raw.expected_turn_id)
|
|
2800
|
+
|| this.activeTurnId(this.ensureConversation(threadId));
|
|
2801
|
+
if (!expectedTurnId) throw new Error("The active Codex turn could not be found");
|
|
2802
|
+
return await this.sendCodexRequest("turn/steer", {
|
|
2803
|
+
threadId,
|
|
2804
|
+
...(stringValue(raw.clientUserMessageId ?? raw.client_user_message_id)
|
|
2805
|
+
? { clientUserMessageId: stringValue(raw.clientUserMessageId ?? raw.client_user_message_id) }
|
|
2806
|
+
: {}),
|
|
2807
|
+
input: Array.isArray(raw.input) ? raw.input : [],
|
|
2808
|
+
expectedTurnId,
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
if (method === "thread-follower-interrupt-turn") {
|
|
2812
|
+
const turnId = stringValue(params.expectedTurnId)
|
|
2813
|
+
|| stringValue(params.expected_turn_id)
|
|
2814
|
+
|| stringValue(params.turnId)
|
|
2815
|
+
|| stringValue(params.turn_id)
|
|
2816
|
+
|| this.activeTurnId(this.ensureConversation(threadId));
|
|
2817
|
+
if (!turnId) throw new Error("The active Codex turn could not be found");
|
|
2818
|
+
return await this.sendCodexRequest("turn/interrupt", {
|
|
2819
|
+
threadId,
|
|
2820
|
+
turnId,
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
if (method === "thread-follower-rollback-thread") {
|
|
2824
|
+
const numTurns = numberValue(params.numTurns ?? params.num_turns);
|
|
2825
|
+
if (numTurns === null || !Number.isInteger(numTurns) || numTurns < 1) {
|
|
2826
|
+
throw new Error("numTurns must be an integer greater than zero");
|
|
2827
|
+
}
|
|
2828
|
+
const response = record(await this.sendCodexRequest("thread/rollback", {
|
|
2829
|
+
threadId,
|
|
2830
|
+
numTurns,
|
|
2831
|
+
}));
|
|
2832
|
+
const rolledBackThread = record(response?.thread);
|
|
2833
|
+
if (rolledBackThread) {
|
|
2834
|
+
this.mergeThread(this.ensureConversation(threadId), rolledBackThread, true);
|
|
2835
|
+
} else {
|
|
2836
|
+
await this.hydrateThread(threadId);
|
|
2837
|
+
}
|
|
2838
|
+
this.pendingTurnStarts.delete(threadId);
|
|
2839
|
+
// A rollback removes an arbitrary suffix of turns. Publish the complete
|
|
2840
|
+
// replacement immediately so an already-open Codex Desktop follower
|
|
2841
|
+
// cannot keep rendering the removed prompt while Android starts the
|
|
2842
|
+
// edited turn. Ordinary patches are intentionally not used for this
|
|
2843
|
+
// non-append history rewrite.
|
|
2844
|
+
this.stopAwaitingInitialHistory(threadId);
|
|
2845
|
+
this.forceSnapshot(threadId);
|
|
2846
|
+
return { result: response ?? null };
|
|
2847
|
+
}
|
|
2848
|
+
if (method === "thread-follower-compact-thread") {
|
|
2849
|
+
return await this.sendCodexRequest("thread/compact/start", { threadId });
|
|
2850
|
+
}
|
|
2851
|
+
if (method === "thread-follower-set-model-and-reasoning") {
|
|
2852
|
+
await this.applyFollowerRuntimeSettings(threadId, params);
|
|
2853
|
+
return { ok: true };
|
|
2854
|
+
}
|
|
2855
|
+
if (method === "thread-follower-set-collaboration-mode") {
|
|
2856
|
+
await this.applyFollowerRuntimeSettings(threadId, { collaborationMode: params.collaborationMode });
|
|
2857
|
+
return { ok: true };
|
|
2858
|
+
}
|
|
2859
|
+
if (method === "thread-follower-update-thread-settings") {
|
|
2860
|
+
await this.applyFollowerRuntimeSettings(threadId, record(params.threadSettings) ?? {});
|
|
2861
|
+
return { ok: true };
|
|
2862
|
+
}
|
|
2863
|
+
const requestId = params.requestId ?? params.request_id;
|
|
2864
|
+
if (method === "thread-follower-command-approval-decision") {
|
|
2865
|
+
return this.respondToPendingRequest(threadId, requestId, { decision: params.decision });
|
|
2866
|
+
}
|
|
2867
|
+
if (method === "thread-follower-file-approval-decision") {
|
|
2868
|
+
return this.respondToPendingRequest(threadId, requestId, this.fileApprovalResult(threadId, requestId, params));
|
|
2869
|
+
}
|
|
2870
|
+
if (
|
|
2871
|
+
method === "thread-follower-permissions-request-approval-response"
|
|
2872
|
+
|| method === "thread-follower-submit-user-input"
|
|
2873
|
+
|| method === "thread-follower-submit-mcp-server-elicitation-response"
|
|
2874
|
+
) {
|
|
2875
|
+
return this.respondToPendingRequest(threadId, requestId, record(params.response) ?? {});
|
|
2876
|
+
}
|
|
2877
|
+
throw new Error(`Unsupported Codex Desktop follower action: ${method}`);
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
private ensureConversation(threadId: string, cwd = ""): ConversationState {
|
|
2881
|
+
const existing = this.conversations.get(threadId);
|
|
2882
|
+
if (existing) return existing;
|
|
2883
|
+
const timestamp = this.now();
|
|
2884
|
+
const conversation: ConversationState = {
|
|
2885
|
+
id: threadId,
|
|
2886
|
+
hostId: HOST_ID,
|
|
2887
|
+
turns: [],
|
|
2888
|
+
requests: [],
|
|
2889
|
+
createdAt: timestamp,
|
|
2890
|
+
updatedAt: timestamp,
|
|
2891
|
+
title: null,
|
|
2892
|
+
latestModel: "",
|
|
2893
|
+
latestReasoningEffort: null,
|
|
2894
|
+
latestServiceTier: null,
|
|
2895
|
+
previousTurnModel: null,
|
|
2896
|
+
latestCollaborationMode: {
|
|
2897
|
+
mode: "default",
|
|
2898
|
+
settings: { reasoning_effort: null, model: "", developer_instructions: null },
|
|
2899
|
+
},
|
|
2900
|
+
hasUnreadTurn: false,
|
|
2901
|
+
unreadMessageCount: 0,
|
|
2902
|
+
threadGoal: null,
|
|
2903
|
+
completedThreadGoal: null,
|
|
2904
|
+
threadRuntimeStatus: null,
|
|
2905
|
+
rolloutPath: "",
|
|
2906
|
+
cwd,
|
|
2907
|
+
gitInfo: null,
|
|
2908
|
+
resumeState: "resumed",
|
|
2909
|
+
latestTokenUsageInfo: null,
|
|
2910
|
+
workspaceKind: "project",
|
|
2911
|
+
workspaceBrowserRoot: null,
|
|
2912
|
+
projectlessOutputDirectory: null,
|
|
2913
|
+
currentPermissions: null,
|
|
2914
|
+
};
|
|
2915
|
+
this.conversations.set(threadId, conversation);
|
|
2916
|
+
return conversation;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
private async hydrateThread(threadId: string): Promise<boolean> {
|
|
2920
|
+
const active = this.hydrationPromises.get(threadId);
|
|
2921
|
+
if (active) return active;
|
|
2922
|
+
const read = this.readThreadPage
|
|
2923
|
+
? this.readThreadPage(threadId)
|
|
2924
|
+
: this.readThread(threadId);
|
|
2925
|
+
const hydration = read
|
|
2926
|
+
.then(result => {
|
|
2927
|
+
const thread = record(result?.thread) ?? record(result);
|
|
2928
|
+
if (!thread || stringValue(thread.id) !== threadId || !this.ownedThreadIds.has(threadId)) return false;
|
|
2929
|
+
this.mergeThread(this.ensureConversation(threadId), thread);
|
|
2930
|
+
this.markDirty(threadId);
|
|
2931
|
+
return true;
|
|
2932
|
+
})
|
|
2933
|
+
.catch(error => {
|
|
2934
|
+
this.warn(`Could not load Codex Desktop history for ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2935
|
+
return false;
|
|
2936
|
+
})
|
|
2937
|
+
.finally(() => this.hydrationPromises.delete(threadId));
|
|
2938
|
+
this.hydrationPromises.set(threadId, hydration);
|
|
2939
|
+
return hydration;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
private requestInitialHistoryBaseline(threadId: string): void {
|
|
2943
|
+
if (!this.ownedThreadIds.has(threadId) || !this.threadsAwaitingInitialHistory.has(threadId)) return;
|
|
2944
|
+
if (this.hydrationPromises.has(threadId) || this.initialHistoryTimers.has(threadId)) return;
|
|
2945
|
+
const attempts = this.initialHistoryAttempts.get(threadId) ?? 0;
|
|
2946
|
+
if (attempts >= Math.max(1, this.initialHistoryMaxAttempts)) {
|
|
2947
|
+
this.warn(`Could not establish a complete Desktop history baseline for ${threadId}; releasing IPC ownership`);
|
|
2948
|
+
this.releaseThread(threadId);
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
this.initialHistoryAttempts.set(threadId, attempts + 1);
|
|
2952
|
+
void this.hydrateThread(threadId).then(success => {
|
|
2953
|
+
if (!this.ownedThreadIds.has(threadId) || !this.threadsAwaitingInitialHistory.has(threadId)) return;
|
|
2954
|
+
if (success) {
|
|
2955
|
+
this.stopAwaitingInitialHistory(threadId);
|
|
2956
|
+
this.markDirty(threadId);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const completedAttempts = this.initialHistoryAttempts.get(threadId) ?? attempts + 1;
|
|
2960
|
+
if (completedAttempts >= Math.max(1, this.initialHistoryMaxAttempts)) {
|
|
2961
|
+
this.warn(`Could not establish a complete Desktop history baseline for ${threadId}; releasing IPC ownership`);
|
|
2962
|
+
this.releaseThread(threadId);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
const delay = Math.max(0, this.initialHistoryRetryMs) * (2 ** Math.min(completedAttempts - 1, 5));
|
|
2966
|
+
const timer = setTimeout(() => {
|
|
2967
|
+
this.initialHistoryTimers.delete(threadId);
|
|
2968
|
+
this.requestInitialHistoryBaseline(threadId);
|
|
2969
|
+
}, delay);
|
|
2970
|
+
timer.unref?.();
|
|
2971
|
+
this.initialHistoryTimers.set(threadId, timer);
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
private stopAwaitingInitialHistory(threadId: string): void {
|
|
2976
|
+
this.threadsAwaitingInitialHistory.delete(threadId);
|
|
2977
|
+
this.initialHistoryAttempts.delete(threadId);
|
|
2978
|
+
const timer = this.initialHistoryTimers.get(threadId);
|
|
2979
|
+
if (timer) clearTimeout(timer);
|
|
2980
|
+
this.initialHistoryTimers.delete(threadId);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
private shouldDelayInitialSnapshot(threadId: string): boolean {
|
|
2984
|
+
if (!this.threadsAwaitingInitialHistory.has(threadId)) return false;
|
|
2985
|
+
if (this.lastBroadcastStates.has(threadId)) {
|
|
2986
|
+
this.stopAwaitingInitialHistory(threadId);
|
|
2987
|
+
return false;
|
|
2988
|
+
}
|
|
2989
|
+
this.requestInitialHistoryBaseline(threadId);
|
|
2990
|
+
return this.ownedThreadIds.has(threadId) && this.threadsAwaitingInitialHistory.has(threadId);
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
private mergeThread(
|
|
2994
|
+
conversation: ConversationState,
|
|
2995
|
+
thread: JsonRecord,
|
|
2996
|
+
replaceMissingTurns = false,
|
|
2997
|
+
): void {
|
|
2998
|
+
conversation.createdAt = timestampMs(thread.createdAt, Number(conversation.createdAt) || this.now());
|
|
2999
|
+
conversation.updatedAt = timestampMs(thread.updatedAt, this.now());
|
|
3000
|
+
conversation.title = stringValue(thread.name) || conversation.title;
|
|
3001
|
+
conversation.cwd = stringValue(thread.cwd) || conversation.cwd;
|
|
3002
|
+
conversation.rolloutPath = stringValue(thread.path) || conversation.rolloutPath;
|
|
3003
|
+
conversation.gitInfo = boundInboundPatchValue(thread.gitInfo ?? conversation.gitInfo ?? null) as JsonRecord | null;
|
|
3004
|
+
conversation.threadRuntimeStatus = boundInboundPatchValue(
|
|
3005
|
+
thread.status ?? conversation.threadRuntimeStatus ?? null,
|
|
3006
|
+
);
|
|
3007
|
+
const model = stringValue(thread.model) || stringValue(thread.modelProvider);
|
|
3008
|
+
if (model) conversation.latestModel = model;
|
|
3009
|
+
if (Array.isArray(thread.turns)) {
|
|
3010
|
+
const currentById = new Map(conversation.turns.map(turn => [turn.turnId, turn]));
|
|
3011
|
+
const hydrated: ConversationTurn[] = [];
|
|
3012
|
+
const turns = thread.turns.slice(-INBOUND_HISTORY_MAX_TURNS);
|
|
3013
|
+
for (const candidate of turns) {
|
|
3014
|
+
const rawTurn = record(candidate);
|
|
3015
|
+
const turnId = stringValue(rawTurn?.id) || stringValue(rawTurn?.turnId);
|
|
3016
|
+
if (!rawTurn || !turnId) continue;
|
|
3017
|
+
const built = createConversationTurn(rawTurn, conversation, this.now, currentById.get(turnId)?.params);
|
|
3018
|
+
const previous = currentById.get(turnId);
|
|
3019
|
+
hydrated.push(previous ? mergeTurnContinuity(previous, built) : built);
|
|
3020
|
+
currentById.delete(turnId);
|
|
3021
|
+
}
|
|
3022
|
+
conversation.turns = replaceMissingTurns
|
|
3023
|
+
? hydrated
|
|
3024
|
+
: [...hydrated, ...currentById.values()];
|
|
3025
|
+
}
|
|
3026
|
+
this.trimConversationHistory(conversation);
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/** Keep the local owner mirror bounded even while a long turn stream grows. */
|
|
3030
|
+
private trimConversationHistory(conversation: ConversationState): void {
|
|
3031
|
+
if (conversation.turns.length > INBOUND_HISTORY_MAX_TURNS) {
|
|
3032
|
+
conversation.turns = conversation.turns.slice(-INBOUND_HISTORY_MAX_TURNS);
|
|
3033
|
+
}
|
|
3034
|
+
let remainingItems = DESKTOP_HISTORY_PAGE_MAX_ITEMS;
|
|
3035
|
+
for (let index = conversation.turns.length - 1; index >= 0; index -= 1) {
|
|
3036
|
+
const turn = conversation.turns[index]!;
|
|
3037
|
+
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
3038
|
+
const keep = Math.min(remainingItems, items.length);
|
|
3039
|
+
turn.items = keep > 0 ? items.slice(-keep) : [];
|
|
3040
|
+
remainingItems = Math.max(0, remainingItems - keep);
|
|
3041
|
+
}
|
|
3042
|
+
if (conversation.requests.length > 64) conversation.requests = conversation.requests.slice(-64);
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
private shiftPendingTurn(threadId: string): PendingTurnStart | null {
|
|
3046
|
+
const queue = this.pendingTurnStarts.get(threadId);
|
|
3047
|
+
const pending = queue?.shift() ?? null;
|
|
3048
|
+
if (!queue || queue.length === 0) this.pendingTurnStarts.delete(threadId);
|
|
3049
|
+
return pending;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
private rememberFollowerTurnStart(threadId: string, params: JsonRecord): void {
|
|
3053
|
+
const conversation = this.ensureConversation(threadId);
|
|
3054
|
+
const fallbackTurnId = `opencodex-pending-${this.now()}-${randomUUID()}`;
|
|
3055
|
+
const queue = this.pendingTurnStarts.get(threadId) ?? [];
|
|
3056
|
+
queue.push({ params: clone(params), fallbackTurnId });
|
|
3057
|
+
this.pendingTurnStarts.set(threadId, queue);
|
|
3058
|
+
conversation.turns.push(createConversationTurn({ id: fallbackTurnId, status: "inProgress", items: [] }, conversation, this.now, params));
|
|
3059
|
+
this.applyRuntimeMetadata(conversation, params);
|
|
3060
|
+
this.markDirty(threadId);
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
private removeLatestPendingTurn(threadId: string): void {
|
|
3064
|
+
const queue = this.pendingTurnStarts.get(threadId);
|
|
3065
|
+
const pending = queue?.pop();
|
|
3066
|
+
if (!queue || queue.length === 0) this.pendingTurnStarts.delete(threadId);
|
|
3067
|
+
if (pending) {
|
|
3068
|
+
const conversation = this.conversations.get(threadId);
|
|
3069
|
+
if (conversation) conversation.turns = conversation.turns.filter(turn => turn.turnId !== pending.fallbackTurnId);
|
|
3070
|
+
this.markDirty(threadId);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
private ensureTurn(conversation: ConversationState, turnId: string, allowLast = true): ConversationTurn | null {
|
|
3075
|
+
const id = stringValue(turnId) || (allowLast ? this.activeTurnId(conversation) : "");
|
|
3076
|
+
if (!id) return null;
|
|
3077
|
+
let turn = findTurn(conversation, id);
|
|
3078
|
+
if (!turn) {
|
|
3079
|
+
turn = createConversationTurn({ id, status: "inProgress", items: [] }, conversation, this.now);
|
|
3080
|
+
conversation.turns.push(turn);
|
|
3081
|
+
}
|
|
3082
|
+
return turn;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
private activeTurnId(conversation: ConversationState): string {
|
|
3086
|
+
for (let index = conversation.turns.length - 1; index >= 0; index -= 1) {
|
|
3087
|
+
const turn = conversation.turns[index];
|
|
3088
|
+
const status = normalizeToken(turn.status);
|
|
3089
|
+
if (!status || status === "inprogress" || status === "running" || status === "active") return turn.turnId;
|
|
3090
|
+
}
|
|
3091
|
+
return conversation.turns.at(-1)?.turnId ?? "";
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
private appendItemDelta(
|
|
3095
|
+
conversation: ConversationState,
|
|
3096
|
+
params: JsonRecord,
|
|
3097
|
+
type: string,
|
|
3098
|
+
field: string,
|
|
3099
|
+
): boolean {
|
|
3100
|
+
const itemId = stringValue(params.itemId) || stringValue(params.item_id);
|
|
3101
|
+
const delta = typeof params.delta === "string" ? params.delta : "";
|
|
3102
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
3103
|
+
if (!turn || !itemId || !delta) return false;
|
|
3104
|
+
const item = ensureItem(turn, itemId, () => defaultDeltaItem(type, itemId, conversation.cwd as string));
|
|
3105
|
+
item[field] = `${typeof item[field] === "string" ? item[field] : ""}${delta}`.slice(-MAX_STREAM_TEXT_BYTES);
|
|
3106
|
+
turn.firstTurnWorkItemStartedAtMs ||= this.now();
|
|
3107
|
+
if (type === "agentMessage") turn.finalAssistantStartedAtMs ||= this.now();
|
|
3108
|
+
return true;
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
private appendReasoningSummary(conversation: ConversationState, params: JsonRecord): boolean {
|
|
3112
|
+
const itemId = stringValue(params.itemId) || stringValue(params.item_id);
|
|
3113
|
+
const delta = typeof params.delta === "string" ? params.delta : "";
|
|
3114
|
+
const turn = this.ensureTurn(conversation, turnIdFromParams(params));
|
|
3115
|
+
if (!turn || !itemId || !delta) return false;
|
|
3116
|
+
const item = ensureItem(turn, itemId, () => ({ type: "reasoning", id: itemId, summary: [], content: [] }));
|
|
3117
|
+
const index = typeof params.summaryIndex === "number" ? Math.max(0, Math.floor(params.summaryIndex)) : 0;
|
|
3118
|
+
const summary = Array.isArray(item.summary) ? item.summary as unknown[] : [];
|
|
3119
|
+
while (summary.length <= index) summary.push("");
|
|
3120
|
+
summary[index] = `${typeof summary[index] === "string" ? summary[index] : ""}${delta}`.slice(-MAX_STREAM_TEXT_BYTES);
|
|
3121
|
+
item.summary = summary;
|
|
3122
|
+
item.content = [];
|
|
3123
|
+
turn.firstTurnWorkItemStartedAtMs ||= this.now();
|
|
3124
|
+
return true;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
private applyRuntimeMetadata(conversation: ConversationState, params: JsonRecord): void {
|
|
3128
|
+
const model = stringValue(params.model);
|
|
3129
|
+
if (model) {
|
|
3130
|
+
conversation.previousTurnModel = conversation.latestModel || null;
|
|
3131
|
+
conversation.latestModel = model;
|
|
3132
|
+
}
|
|
3133
|
+
if (typeof params.effort === "string") conversation.latestReasoningEffort = params.effort;
|
|
3134
|
+
if ("serviceTier" in params) conversation.latestServiceTier = params.serviceTier ?? null;
|
|
3135
|
+
if (record(params.collaborationMode)) conversation.latestCollaborationMode = clone(params.collaborationMode);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
private async applyFollowerRuntimeSettings(threadId: string, params: JsonRecord): Promise<void> {
|
|
3139
|
+
const current = this.runtimeOverrides.get(threadId) ?? {};
|
|
3140
|
+
const model = stringValue(params.model) || stringValue(record(record(params.collaborationMode)?.settings)?.model);
|
|
3141
|
+
if (model) current.model = model;
|
|
3142
|
+
if ("reasoningEffort" in params) current.effort = params.reasoningEffort;
|
|
3143
|
+
if ("effort" in params) current.effort = params.effort;
|
|
3144
|
+
if ("serviceTier" in params) current.serviceTier = params.serviceTier;
|
|
3145
|
+
if (record(params.collaborationMode)) current.collaborationMode = clone(params.collaborationMode);
|
|
3146
|
+
this.runtimeOverrides.set(threadId, current);
|
|
3147
|
+
this.applyRuntimeMetadata(this.ensureConversation(threadId), current);
|
|
3148
|
+
this.markDirty(threadId);
|
|
3149
|
+
const update: JsonRecord = { threadId };
|
|
3150
|
+
if (model) update.model = model;
|
|
3151
|
+
if ("reasoningEffort" in params) update.effort = params.reasoningEffort;
|
|
3152
|
+
else if ("effort" in params) update.effort = params.effort;
|
|
3153
|
+
if ("serviceTier" in params) update.serviceTier = params.serviceTier;
|
|
3154
|
+
if (record(params.collaborationMode)) update.collaborationMode = clone(params.collaborationMode);
|
|
3155
|
+
if (Object.keys(update).length === 1) return;
|
|
3156
|
+
try {
|
|
3157
|
+
await this.sendCodexRequest("thread/settings/update", update);
|
|
3158
|
+
} catch (error) {
|
|
3159
|
+
// Codex builds before thread/settings/update still use the in-memory
|
|
3160
|
+
// override on the next turn. Newer builds persist and notify all clients.
|
|
3161
|
+
if (!threadSettingsUpdateUnavailable(error)) throw error;
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
private withRuntimeOverrides(threadId: string, params: JsonRecord): JsonRecord {
|
|
3166
|
+
const overrides = this.runtimeOverrides.get(threadId);
|
|
3167
|
+
if (!overrides) return params;
|
|
3168
|
+
const merged = { ...params };
|
|
3169
|
+
for (const key of ["model", "effort", "serviceTier", "collaborationMode"]) {
|
|
3170
|
+
if (merged[key] == null && overrides[key] != null) merged[key] = clone(overrides[key]);
|
|
3171
|
+
}
|
|
3172
|
+
return merged;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
private respondToPendingRequest(threadId: string, requestId: unknown, result: JsonRecord): JsonRecord {
|
|
3176
|
+
const key = requestIdKey(requestId);
|
|
3177
|
+
if (!key) throw new Error("The pending Codex request could not be identified");
|
|
3178
|
+
const conversation = this.ensureConversation(threadId);
|
|
3179
|
+
const pending = conversation.requests.find(request => requestIdKey(request.id) === key);
|
|
3180
|
+
if (!pending) throw new Error("This Codex request is no longer waiting for a response");
|
|
3181
|
+
this.respondToCodexRequest(pending.id as RequestId, result);
|
|
3182
|
+
// Keep the request visible until app-server emits serverRequest/resolved.
|
|
3183
|
+
// A successful socket write is not authoritative completion, and removing
|
|
3184
|
+
// it here makes transient reconnects strand Desktop with no prompt to retry.
|
|
3185
|
+
return { ok: true };
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
private fileApprovalResult(threadId: string, requestId: unknown, params: JsonRecord): JsonRecord {
|
|
3189
|
+
const conversation = this.ensureConversation(threadId);
|
|
3190
|
+
const pending = conversation.requests.find(request => requestIdKey(request.id) === requestIdKey(requestId));
|
|
3191
|
+
if (stringValue(pending?.method) !== "item/permissions/requestApproval") {
|
|
3192
|
+
return { decision: params.decision };
|
|
3193
|
+
}
|
|
3194
|
+
const decision = stringValue(params.decision);
|
|
3195
|
+
const requested = record(record(pending!.params)?.permissions);
|
|
3196
|
+
return {
|
|
3197
|
+
permissions: decision === "accept" || decision === "acceptForSession" ? clone(requested ?? {}) : {},
|
|
3198
|
+
scope: decision === "acceptForSession" ? "session" : "turn",
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
private markDirty(threadId: string): void {
|
|
3203
|
+
if (!this.ownedThreadIds.has(threadId)) return;
|
|
3204
|
+
this.dirtyThreadIds.add(threadId);
|
|
3205
|
+
if (this.snapshotTimer) return;
|
|
3206
|
+
this.snapshotTimer = setTimeout(() => {
|
|
3207
|
+
this.snapshotTimer = null;
|
|
3208
|
+
for (const pending of [...this.dirtyThreadIds]) {
|
|
3209
|
+
if (this.shouldDelayInitialSnapshot(pending)) continue;
|
|
3210
|
+
if (this.broadcastState(pending)) this.dirtyThreadIds.delete(pending);
|
|
3211
|
+
}
|
|
3212
|
+
}, Math.max(0, this.snapshotDebounceMs));
|
|
3213
|
+
this.snapshotTimer.unref?.();
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
private forceSnapshot(threadId: string): void {
|
|
3217
|
+
if (this.shouldDelayInitialSnapshot(threadId)) {
|
|
3218
|
+
this.dirtyThreadIds.add(threadId);
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
if (this.broadcastState(threadId, true)) this.dirtyThreadIds.delete(threadId);
|
|
3222
|
+
else this.dirtyThreadIds.add(threadId);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
private broadcastState(threadId: string, forceSnapshot = false): boolean {
|
|
3226
|
+
const conversation = this.conversations.get(threadId);
|
|
3227
|
+
if (!conversation || !this.ownedThreadIds.has(threadId)) return true;
|
|
3228
|
+
if (this.shouldDelayInitialSnapshot(threadId)) return false;
|
|
3229
|
+
// Never put the retained full projection on the wire. The page builder
|
|
3230
|
+
// selects a recent tail, replaces oversized fields with opaque handles,
|
|
3231
|
+
// and enforces a serialized-byte budget before patches or snapshots are
|
|
3232
|
+
// encoded into a 64 MiB IPC frame.
|
|
3233
|
+
const bounded = this.historyPages.recentPage(threadId, conversation).state;
|
|
3234
|
+
const revision = this.revisions.get(threadId) ?? 0;
|
|
3235
|
+
const previous = this.lastBroadcastStates.get(threadId);
|
|
3236
|
+
if (!forceSnapshot && previous) {
|
|
3237
|
+
const patches = buildDesktopStatePatches(previous, bounded);
|
|
3238
|
+
if (patches?.length === 0) return true;
|
|
3239
|
+
if (patches && this.transport.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
|
|
3240
|
+
conversationId: threadId,
|
|
3241
|
+
hostId: HOST_ID,
|
|
3242
|
+
version: DESKTOP_IPC_METHOD_VERSIONS.get(THREAD_STREAM_STATE_CHANGED) ?? 1,
|
|
3243
|
+
opencodexOwnerSource: OWNER_SOURCE,
|
|
3244
|
+
change: {
|
|
3245
|
+
type: "patches",
|
|
3246
|
+
baseRevision: revision,
|
|
3247
|
+
revision: revision + 1,
|
|
3248
|
+
patches,
|
|
3249
|
+
},
|
|
3250
|
+
})) {
|
|
3251
|
+
this.revisions.set(threadId, revision + 1);
|
|
3252
|
+
this.lastBroadcastStates.set(threadId, clone(bounded));
|
|
3253
|
+
this.log(`Published Desktop patches for ${threadId} at revision ${revision + 1}`);
|
|
3254
|
+
return true;
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
if (!this.transport.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
|
|
3258
|
+
conversationId: threadId,
|
|
3259
|
+
hostId: HOST_ID,
|
|
3260
|
+
version: DESKTOP_IPC_METHOD_VERSIONS.get(THREAD_STREAM_STATE_CHANGED) ?? 1,
|
|
3261
|
+
opencodexOwnerSource: OWNER_SOURCE,
|
|
3262
|
+
change: {
|
|
3263
|
+
type: "snapshot",
|
|
3264
|
+
revision: revision + 1,
|
|
3265
|
+
conversationState: bounded,
|
|
3266
|
+
},
|
|
3267
|
+
})) return false;
|
|
3268
|
+
this.revisions.set(threadId, revision + 1);
|
|
3269
|
+
this.lastBroadcastStates.set(threadId, clone(bounded));
|
|
3270
|
+
this.log(`Published Desktop snapshot for ${threadId} at revision ${revision + 1}`);
|
|
3271
|
+
return true;
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
private beginFollow(threadId: string): void {
|
|
3275
|
+
if (!this.transport.connected || !this.ownedThreadIds.has(threadId)) return;
|
|
3276
|
+
if ((this.followerClientIds.get(threadId)?.size ?? 0) > 0) return;
|
|
3277
|
+
const attempt = (this.followAttempts.get(threadId) ?? 0) + 1;
|
|
3278
|
+
if (attempt > this.followMaxAttempts) return;
|
|
3279
|
+
this.followAttempts.set(threadId, attempt);
|
|
3280
|
+
this.transport.sendBroadcast("thread-stream-following-status-requested", {
|
|
3281
|
+
hostId: HOST_ID,
|
|
3282
|
+
conversationId: threadId,
|
|
3283
|
+
});
|
|
3284
|
+
const token = `${this.now()}-${attempt}-${randomUUID()}`;
|
|
3285
|
+
const url = `codex://threads/${encodeURIComponent(threadId)}?opencodex-follow=${encodeURIComponent(token)}`;
|
|
3286
|
+
void this.openUrl(url).catch(error => {
|
|
3287
|
+
this.warn(`Could not ask Codex Desktop to follow ${threadId}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
3288
|
+
});
|
|
3289
|
+
const previous = this.followTimers.get(threadId);
|
|
3290
|
+
if (previous) clearTimeout(previous);
|
|
3291
|
+
const timer = setTimeout(() => {
|
|
3292
|
+
this.followTimers.delete(threadId);
|
|
3293
|
+
this.beginFollow(threadId);
|
|
3294
|
+
}, this.followConfirmMs);
|
|
3295
|
+
timer.unref?.();
|
|
3296
|
+
this.followTimers.set(threadId, timer);
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
private scheduleFollowerMountBaseline(threadId: string): void {
|
|
3300
|
+
const previous = this.followerBaselineTimers.get(threadId);
|
|
3301
|
+
if (previous) clearTimeout(previous);
|
|
3302
|
+
const timer = setTimeout(() => {
|
|
3303
|
+
this.followerBaselineTimers.delete(threadId);
|
|
3304
|
+
if (this.ownedThreadIds.has(threadId) && (this.followerClientIds.get(threadId)?.size ?? 0) > 0) {
|
|
3305
|
+
this.forceSnapshot(threadId);
|
|
3306
|
+
}
|
|
3307
|
+
}, FOLLOWER_MOUNT_BASELINE_DELAY_MS);
|
|
3308
|
+
timer.unref?.();
|
|
3309
|
+
this.followerBaselineTimers.set(threadId, timer);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
private scheduleSidebarAnnouncement(threadId: string): void {
|
|
3313
|
+
if (this.announcedSidebarThreadIds.has(threadId) || this.sidebarTimers.has(threadId)) return;
|
|
3314
|
+
const timer = setTimeout(() => {
|
|
3315
|
+
this.sidebarTimers.delete(threadId);
|
|
3316
|
+
this.announceSidebarThread(threadId);
|
|
3317
|
+
}, SIDEBAR_REFRESH_DELAY_MS);
|
|
3318
|
+
timer.unref?.();
|
|
3319
|
+
this.sidebarTimers.set(threadId, timer);
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
private announceSidebarThread(threadId: string, replay = false): void {
|
|
3323
|
+
if (!this.ownedThreadIds.has(threadId)) return;
|
|
3324
|
+
if (!replay && this.announcedSidebarThreadIds.has(threadId)) return;
|
|
3325
|
+
const timer = this.sidebarTimers.get(threadId);
|
|
3326
|
+
if (timer) clearTimeout(timer);
|
|
3327
|
+
this.sidebarTimers.delete(threadId);
|
|
3328
|
+
if (this.transport.sendBroadcast("thread-unarchived", {
|
|
3329
|
+
hostId: HOST_ID,
|
|
3330
|
+
conversationId: threadId,
|
|
3331
|
+
})) {
|
|
3332
|
+
this.announcedSidebarThreadIds.add(threadId);
|
|
3333
|
+
} else {
|
|
3334
|
+
this.scheduleSidebarAnnouncement(threadId);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
function openUrlWithPlatform(url: string, platform: NodeJS.Platform): Promise<void> {
|
|
3340
|
+
const commands = codexDesktopOpenCommands(url, platform);
|
|
3341
|
+
if (commands.length === 0) return Promise.reject(new Error("Unsupported operating system"));
|
|
3342
|
+
return runOpenCommands(commands);
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
async function runOpenCommands(commands: CodexDesktopOpenCommand[]): Promise<void> {
|
|
3346
|
+
let lastError: unknown = null;
|
|
3347
|
+
for (const command of commands) {
|
|
3348
|
+
try {
|
|
3349
|
+
await runDesktopOpenCommand(command.command, command.args);
|
|
3350
|
+
return;
|
|
3351
|
+
} catch (error) {
|
|
3352
|
+
lastError = error;
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
throw lastError instanceof Error ? lastError : new Error("Could not open Codex Desktop");
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
function sanitizeTurnStartParams(params: JsonRecord): JsonRecord {
|
|
3359
|
+
const sanitized: JsonRecord = {};
|
|
3360
|
+
for (const [key, value] of Object.entries(params)) {
|
|
3361
|
+
if (ALLOWED_TURN_START_KEYS.has(key) && value !== undefined) {
|
|
3362
|
+
sanitized[key] = boundInboundPatchValue(value);
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
sanitized.input = normalizeInputEntries(sanitized.input);
|
|
3366
|
+
return sanitized;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function normalizeInputEntries(value: unknown): unknown[] {
|
|
3370
|
+
if (!Array.isArray(value)) return [];
|
|
3371
|
+
return value.map(candidate => {
|
|
3372
|
+
const entry = record(candidate);
|
|
3373
|
+
if (!entry || normalizeToken(entry.type) !== "imageurl") return clone(candidate);
|
|
3374
|
+
const nested = record(entry.image_url) ?? record(entry.imageUrl);
|
|
3375
|
+
const url = stringValue(entry.url)
|
|
3376
|
+
|| stringValue(nested?.url)
|
|
3377
|
+
|| stringValue(entry.image_url)
|
|
3378
|
+
|| stringValue(entry.imageUrl);
|
|
3379
|
+
return url ? { type: "image", url } : clone(candidate);
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
function threadIdFromMessage(method: string, params: JsonRecord): string {
|
|
3384
|
+
if (method === "thread/started") return stringValue(record(params.thread)?.id);
|
|
3385
|
+
return threadIdFromParams(params);
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
function createConversationTurn(
|
|
3389
|
+
rawTurn: JsonRecord,
|
|
3390
|
+
conversation: ConversationState,
|
|
3391
|
+
now: () => number,
|
|
3392
|
+
suppliedParams?: JsonRecord,
|
|
3393
|
+
): ConversationTurn {
|
|
3394
|
+
const turnId = stringValue(rawTurn.id) || stringValue(rawTurn.turnId) || stringValue(rawTurn.turn_id);
|
|
3395
|
+
const params = suppliedParams ? sanitizeTurnStartParams(suppliedParams) : defaultTurnParams(conversation);
|
|
3396
|
+
const rawItems = Array.isArray(rawTurn.items)
|
|
3397
|
+
? rawTurn.items.slice(-INBOUND_HISTORY_MAX_ITEMS_PER_TURN)
|
|
3398
|
+
: [];
|
|
3399
|
+
const items: JsonRecord[] = [];
|
|
3400
|
+
let adoptedPrompt = Array.isArray(params.input) && params.input.length > 0;
|
|
3401
|
+
for (const candidate of rawItems) {
|
|
3402
|
+
const item = sanitizeDesktopItem(record(candidate));
|
|
3403
|
+
if (!item) continue;
|
|
3404
|
+
if (item.type === "userMessage" && !adoptedPrompt) {
|
|
3405
|
+
const prompt = userMessageInput(item);
|
|
3406
|
+
if (prompt.length > 0) {
|
|
3407
|
+
params.input = prompt;
|
|
3408
|
+
adoptedPrompt = true;
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
if (item.type === "userMessage" && sameVisibleInput(params.input, userMessageInput(item)) && !items.some(row => row.type === "userMessage")) {
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
items.push(item);
|
|
3416
|
+
}
|
|
3417
|
+
return {
|
|
3418
|
+
id: turnId,
|
|
3419
|
+
turnId,
|
|
3420
|
+
params,
|
|
3421
|
+
turnStartedAtMs: timestampMs(rawTurn.startedAt, now()),
|
|
3422
|
+
durationMs: rawTurn.durationMs ?? null,
|
|
3423
|
+
firstTurnWorkItemStartedAtMs: null,
|
|
3424
|
+
finalAssistantStartedAtMs: null,
|
|
3425
|
+
status: rawTurn.status ?? "inProgress",
|
|
3426
|
+
error: boundInboundPatchValue(rawTurn.error ?? null),
|
|
3427
|
+
diff: typeof rawTurn.diff === "string"
|
|
3428
|
+
? (() => {
|
|
3429
|
+
const bounded = boundInboundPatchValue(rawTurn.diff, new WeakSet(), 0, true);
|
|
3430
|
+
return typeof bounded === "string" ? bounded : null;
|
|
3431
|
+
})()
|
|
3432
|
+
: null,
|
|
3433
|
+
hookRuns: [],
|
|
3434
|
+
commandExecutionStartedAtMsById: {},
|
|
3435
|
+
items,
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
function defaultTurnParams(conversation: ConversationState): JsonRecord {
|
|
3440
|
+
return {
|
|
3441
|
+
threadId: conversation.id,
|
|
3442
|
+
input: [],
|
|
3443
|
+
cwd: conversation.cwd || null,
|
|
3444
|
+
approvalPolicy: null,
|
|
3445
|
+
approvalsReviewer: null,
|
|
3446
|
+
sandboxPolicy: null,
|
|
3447
|
+
model: conversation.latestModel || null,
|
|
3448
|
+
serviceTier: conversation.latestServiceTier ?? null,
|
|
3449
|
+
effort: conversation.latestReasoningEffort ?? null,
|
|
3450
|
+
summary: "none",
|
|
3451
|
+
personality: null,
|
|
3452
|
+
outputSchema: null,
|
|
3453
|
+
collaborationMode: clone(conversation.latestCollaborationMode ?? null),
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
function sanitizeDesktopItem(item: JsonRecord | null): JsonRecord | null {
|
|
3458
|
+
if (!item) return null;
|
|
3459
|
+
const type = stringValue(item.type);
|
|
3460
|
+
if (!type || !stringValue(item.id)) return null;
|
|
3461
|
+
if (type === "userMessage" && isInjectedContextUserItem(item)) return null;
|
|
3462
|
+
const safe: JsonRecord = {};
|
|
3463
|
+
let entries = 0;
|
|
3464
|
+
for (const key in item) {
|
|
3465
|
+
if (!Object.prototype.hasOwnProperty.call(item, key)) continue;
|
|
3466
|
+
entries += 1;
|
|
3467
|
+
if (entries > INBOUND_PATCH_MAX_ENTRIES || Buffer.byteLength(key, "utf8") > 512) break;
|
|
3468
|
+
safe[key] = boundInboundPatchValue(item[key], new WeakSet(), 0, true);
|
|
3469
|
+
}
|
|
3470
|
+
// Keep the identity fields authoritative even if an untrusted input object
|
|
3471
|
+
// put them after a truncated collection of metadata keys.
|
|
3472
|
+
safe.type = type;
|
|
3473
|
+
safe.id = stringValue(item.id);
|
|
3474
|
+
if (type === "reasoning") safe.content = [];
|
|
3475
|
+
return safe;
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
function isInjectedContextUserItem(item: JsonRecord): boolean {
|
|
3479
|
+
const text = visibleUserText(item).trimStart();
|
|
3480
|
+
return text.startsWith("<environment_context>")
|
|
3481
|
+
|| text.startsWith("<permissions instructions>")
|
|
3482
|
+
|| text.startsWith("<skills_instructions>")
|
|
3483
|
+
|| text.startsWith("<app-context>")
|
|
3484
|
+
|| text.startsWith("<INSTRUCTIONS>");
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
function userMessageInput(item: JsonRecord): unknown[] {
|
|
3488
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
3489
|
+
if (content.length > 0) return normalizeInputEntries(content);
|
|
3490
|
+
const text = visibleUserText(item);
|
|
3491
|
+
return text ? [{ type: "text", text }] : [];
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
function visibleUserText(item: JsonRecord): string {
|
|
3495
|
+
if (typeof item.text === "string") return item.text;
|
|
3496
|
+
if (typeof item.message === "string") return item.message;
|
|
3497
|
+
if (!Array.isArray(item.content)) return "";
|
|
3498
|
+
return item.content.flatMap(candidate => {
|
|
3499
|
+
const row = record(candidate);
|
|
3500
|
+
const text = row && typeof row.text === "string" ? row.text : "";
|
|
3501
|
+
return text ? [text] : [];
|
|
3502
|
+
}).join("\n");
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
function visibleInputText(value: unknown): string {
|
|
3506
|
+
if (!Array.isArray(value)) return "";
|
|
3507
|
+
return value.flatMap(candidate => {
|
|
3508
|
+
const row = record(candidate);
|
|
3509
|
+
return row && typeof row.text === "string" ? [row.text.trim()] : [];
|
|
3510
|
+
}).filter(Boolean).join("\n");
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
function sameVisibleInput(left: unknown, right: unknown): boolean {
|
|
3514
|
+
const a = canonicalUserMessageText(visibleInputText(left));
|
|
3515
|
+
const b = canonicalUserMessageText(visibleInputText(right));
|
|
3516
|
+
return Boolean(a && b && a === b);
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
function mergeTurnContinuity(previous: ConversationTurn, current: ConversationTurn): ConversationTurn {
|
|
3520
|
+
const merged = {
|
|
3521
|
+
...current,
|
|
3522
|
+
params: Array.isArray(previous.params.input) && previous.params.input.length > 0 ? clone(previous.params) : current.params,
|
|
3523
|
+
turnStartedAtMs: previous.turnStartedAtMs ?? current.turnStartedAtMs,
|
|
3524
|
+
firstTurnWorkItemStartedAtMs: previous.firstTurnWorkItemStartedAtMs ?? current.firstTurnWorkItemStartedAtMs,
|
|
3525
|
+
finalAssistantStartedAtMs: previous.finalAssistantStartedAtMs ?? current.finalAssistantStartedAtMs,
|
|
3526
|
+
diff: current.diff ?? previous.diff,
|
|
3527
|
+
hookRuns: Array.isArray(current.hookRuns) && current.hookRuns.length > 0 ? current.hookRuns : clone(previous.hookRuns),
|
|
3528
|
+
commandExecutionStartedAtMsById: {
|
|
3529
|
+
...(record(previous.commandExecutionStartedAtMsById) ?? {}),
|
|
3530
|
+
...(record(current.commandExecutionStartedAtMsById) ?? {}),
|
|
3531
|
+
},
|
|
3532
|
+
items: mergeItems(previous.items, current.items),
|
|
3533
|
+
};
|
|
3534
|
+
return {
|
|
3535
|
+
...merged,
|
|
3536
|
+
items: removeDuplicateInitialUserMessage(merged, merged.items),
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function userMessageDuplicatesTurnInput(turn: ConversationTurn, item: JsonRecord): boolean {
|
|
3541
|
+
if (item.type !== "userMessage" || !Array.isArray(turn.params.input) || turn.params.input.length === 0) {
|
|
3542
|
+
return false;
|
|
3543
|
+
}
|
|
3544
|
+
const clientUserMessageId = stringValue(turn.params.clientUserMessageId);
|
|
3545
|
+
const itemClientId = stringValue(item.clientId) || stringValue(item.client_id);
|
|
3546
|
+
if (clientUserMessageId && itemClientId && clientUserMessageId === itemClientId) return true;
|
|
3547
|
+
return sameVisibleInput(turn.params.input, userMessageInput(item));
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function isInitialTurnPrefixItem(item: JsonRecord): boolean {
|
|
3551
|
+
return item.type === "automaticApprovalReview"
|
|
3552
|
+
|| item.type === "forkedFromConversation"
|
|
3553
|
+
|| item.type === "modelChanged"
|
|
3554
|
+
|| item.type === "modelRerouted"
|
|
3555
|
+
|| item.type === "personalityChanged"
|
|
3556
|
+
|| item.type === "remoteTaskCreated"
|
|
3557
|
+
|| item.type === "worktreeInit";
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
function removeDuplicateInitialUserMessage(turn: ConversationTurn, items: JsonRecord[]): JsonRecord[] {
|
|
3561
|
+
let canMatchInitialInput = true;
|
|
3562
|
+
let removedInitialInput = false;
|
|
3563
|
+
return items.filter(item => {
|
|
3564
|
+
if (
|
|
3565
|
+
canMatchInitialInput
|
|
3566
|
+
&& !removedInitialInput
|
|
3567
|
+
&& userMessageDuplicatesTurnInput(turn, item)
|
|
3568
|
+
) {
|
|
3569
|
+
removedInitialInput = true;
|
|
3570
|
+
canMatchInitialInput = false;
|
|
3571
|
+
return false;
|
|
3572
|
+
}
|
|
3573
|
+
if (!isInitialTurnPrefixItem(item)) canMatchInitialInput = false;
|
|
3574
|
+
return true;
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
function mergeItems(previous: JsonRecord[], current: JsonRecord[]): JsonRecord[] {
|
|
3579
|
+
const next = previous.map(clone);
|
|
3580
|
+
for (const item of current) upsertById(next, item);
|
|
3581
|
+
return next;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
function findTurn(conversation: ConversationState, turnId: string): ConversationTurn | null {
|
|
3585
|
+
return conversation.turns.find(turn => turn.turnId === turnId || turn.id === turnId) ?? null;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
function replaceTurn(conversation: ConversationState, oldId: string, turn: ConversationTurn): void {
|
|
3589
|
+
const matchingIndexes: number[] = [];
|
|
3590
|
+
let merged = turn;
|
|
3591
|
+
conversation.turns.forEach((candidate, index) => {
|
|
3592
|
+
if (candidate.turnId !== oldId && candidate.turnId !== turn.turnId) return;
|
|
3593
|
+
matchingIndexes.push(index);
|
|
3594
|
+
merged = mergeTurnContinuity(candidate, merged);
|
|
3595
|
+
});
|
|
3596
|
+
if (matchingIndexes.length === 0) {
|
|
3597
|
+
conversation.turns.push(merged);
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
const insertionIndex = matchingIndexes[0]!;
|
|
3601
|
+
conversation.turns = conversation.turns.filter((_, index) => !matchingIndexes.includes(index));
|
|
3602
|
+
conversation.turns.splice(insertionIndex, 0, merged);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
function upsertTurnItem(turn: ConversationTurn, item: JsonRecord): void {
|
|
3606
|
+
upsertById(turn.items, item);
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
function upsertById(rows: JsonRecord[], value: JsonRecord): void {
|
|
3610
|
+
const key = requestIdKey(value.id);
|
|
3611
|
+
if (!key) return;
|
|
3612
|
+
const index = rows.findIndex(candidate => requestIdKey(candidate.id) === key);
|
|
3613
|
+
if (index >= 0) rows[index] = { ...rows[index], ...clone(value) };
|
|
3614
|
+
else rows.push(clone(value));
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
function ensureItem(turn: ConversationTurn, itemId: string, create: () => JsonRecord): JsonRecord {
|
|
3618
|
+
let item = turn.items.find(candidate => stringValue(candidate.id) === itemId);
|
|
3619
|
+
if (!item) {
|
|
3620
|
+
item = create();
|
|
3621
|
+
turn.items.push(item);
|
|
3622
|
+
}
|
|
3623
|
+
return item;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
function defaultDeltaItem(type: string, id: string, cwd: string): JsonRecord {
|
|
3627
|
+
if (type === "agentMessage") return { type, id, text: "", phase: null, memoryCitation: null };
|
|
3628
|
+
if (type === "plan") return { type, id, text: "" };
|
|
3629
|
+
if (type === "commandExecution") return {
|
|
3630
|
+
type,
|
|
3631
|
+
id,
|
|
3632
|
+
command: "",
|
|
3633
|
+
cwd: cwd || "/",
|
|
3634
|
+
processId: null,
|
|
3635
|
+
source: "exec",
|
|
3636
|
+
status: "inProgress",
|
|
3637
|
+
commandActions: [],
|
|
3638
|
+
aggregatedOutput: "",
|
|
3639
|
+
exitCode: null,
|
|
3640
|
+
durationMs: null,
|
|
3641
|
+
};
|
|
3642
|
+
return { type, id };
|
|
3643
|
+
}
|