@moondeck/station 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1470 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5550 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- package/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodexSession — a real AgentSession backed by `codex app-server`, run
|
|
3
|
+
* inside the task environment (ADR-010):
|
|
4
|
+
*
|
|
5
|
+
* codex app-server
|
|
6
|
+
*
|
|
7
|
+
* JSON-RPC 2.0 over stdio, newline-delimited. The protocol below is
|
|
8
|
+
* verified against `codex app-server generate-json-schema` (the
|
|
9
|
+
* generated schema, not guesswork).
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle:
|
|
12
|
+
* initialize (request) → server result
|
|
13
|
+
* initialized (notification) → sent by us
|
|
14
|
+
* thread/start (request) → result `{ thread: { id, … } }`
|
|
15
|
+
* turn/start (request) → output streams as notifications;
|
|
16
|
+
* response/activity acknowledges acceptance
|
|
17
|
+
*
|
|
18
|
+
* Streaming notifications consumed (`mapCodexNotification`):
|
|
19
|
+
* item/agentMessage/delta → message_delta
|
|
20
|
+
* item/completed (agentMessage) → message_complete
|
|
21
|
+
* item/completed (tool item) → tool_call
|
|
22
|
+
* thread/tokenUsage/updated → accumulated for its turn
|
|
23
|
+
* turn/completed → turn_complete (+ error if failed)
|
|
24
|
+
* error → error
|
|
25
|
+
*
|
|
26
|
+
* The thread runs with `approvalPolicy: "never"` and
|
|
27
|
+
* `sandbox: "danger-full-access"` — the task container is the isolation
|
|
28
|
+
* boundary (ADR-011), and codex's bubblewrap sandbox can't create user
|
|
29
|
+
* namespaces inside it anyway.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { connectionFailureReason, tomlString } from "../custom-connection-launch";
|
|
33
|
+
import { connectionFailureDetail } from "../custom-connection-runtime";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
39
|
+
|
|
40
|
+
import { getDb, schema } from "../db";
|
|
41
|
+
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
42
|
+
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
43
|
+
import { newId } from "../ulid";
|
|
44
|
+
import { agentErrorMessage, reportAgentError } from "../agent-errors";
|
|
45
|
+
import { attachCompatibilityKeysMatch } from "./attach-compatibility";
|
|
46
|
+
import { createAgentTransport, type LineTransport } from "./transport";
|
|
47
|
+
import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
|
|
48
|
+
import { register } from "./registry";
|
|
49
|
+
import { CODEX_MODELS } from "../../src/engine-models";
|
|
50
|
+
import type {
|
|
51
|
+
AgentEvent,
|
|
52
|
+
AgentAccountUsage,
|
|
53
|
+
AgentEventHandler,
|
|
54
|
+
AgentKind,
|
|
55
|
+
AgentSession,
|
|
56
|
+
AgentUsage,
|
|
57
|
+
RosterAgent,
|
|
58
|
+
} from "./types";
|
|
59
|
+
|
|
60
|
+
// The model list is host-agent/src/engine-models.ts (CODEX_MODELS).
|
|
61
|
+
|
|
62
|
+
// gpt-6.1-sol is Codex's current workhorse coding model (the CLI default).
|
|
63
|
+
// Update alongside CODEX_MODELS. When an agent's model is null the adapter
|
|
64
|
+
// omits the override entirely and Codex uses the user's own configured
|
|
65
|
+
// default.
|
|
66
|
+
const CODEX_DEFAULT_MODEL = "gpt-6.1-sol";
|
|
67
|
+
|
|
68
|
+
// Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
|
|
69
|
+
// the picker's "Extra high"; gpt-5.6 added "max" and "ultra" (ultra =
|
|
70
|
+
// maximum reasoning with automatic task delegation). UPDATE WHEN CODEX
|
|
71
|
+
// CHANGES its reasoning levels.
|
|
72
|
+
const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
73
|
+
|
|
74
|
+
// medium stays OUR default (the 5.6 CLI defaults to low — too light for
|
|
75
|
+
// agentic task work). Update alongside CODEX_EFFORTS.
|
|
76
|
+
const CODEX_DEFAULT_EFFORT = "medium";
|
|
77
|
+
|
|
78
|
+
/** Account headroom bands are product policy, not protocol magic numbers. */
|
|
79
|
+
export const CODEX_USAGE_WARNING_PERCENT = 90;
|
|
80
|
+
export const CODEX_USAGE_LIMITED_PERCENT = 100;
|
|
81
|
+
export const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
|
|
82
|
+
/** Only bounds acceptance, never the duration of an acknowledged turn. A
|
|
83
|
+
* minute allows cold starts/network setup while bounding false working state.
|
|
84
|
+
* Queued starts wait for accepted work to finish before this clock runs. */
|
|
85
|
+
export const CODEX_TURN_START_TIMEOUT_MS = 60_000;
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Pure protocol mapping — JSON-RPC notification → AgentEvent[].
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
92
|
+
return typeof v === "object" && v !== null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function str(v: unknown, fallback = ""): string {
|
|
96
|
+
return typeof v === "string" ? v : fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nonNegativeInt(v: unknown): number | undefined {
|
|
100
|
+
return typeof v === "number" &&
|
|
101
|
+
Number.isSafeInteger(v) &&
|
|
102
|
+
v >= 0
|
|
103
|
+
? v
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CodexTokenBreakdown {
|
|
108
|
+
inputTokens: number;
|
|
109
|
+
outputTokens: number;
|
|
110
|
+
cachedInputTokens: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function codexTokenBreakdown(value: unknown): CodexTokenBreakdown | null {
|
|
114
|
+
if (!isObj(value)) return null;
|
|
115
|
+
const inputTokens = nonNegativeInt(value.inputTokens);
|
|
116
|
+
const outputTokens = nonNegativeInt(value.outputTokens);
|
|
117
|
+
const cachedInputTokens = nonNegativeInt(value.cachedInputTokens);
|
|
118
|
+
if (
|
|
119
|
+
inputTokens === undefined ||
|
|
120
|
+
outputTokens === undefined ||
|
|
121
|
+
cachedInputTokens === undefined ||
|
|
122
|
+
cachedInputTokens > inputTokens
|
|
123
|
+
) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
inputTokens,
|
|
128
|
+
outputTokens,
|
|
129
|
+
cachedInputTokens,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addBreakdowns(
|
|
134
|
+
left: CodexTokenBreakdown | undefined,
|
|
135
|
+
right: CodexTokenBreakdown,
|
|
136
|
+
): CodexTokenBreakdown | null {
|
|
137
|
+
return codexTokenBreakdown({
|
|
138
|
+
inputTokens: (left?.inputTokens ?? 0) + right.inputTokens,
|
|
139
|
+
outputTokens: (left?.outputTokens ?? 0) + right.outputTokens,
|
|
140
|
+
cachedInputTokens:
|
|
141
|
+
(left?.cachedInputTokens ?? 0) + right.cachedInputTokens,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function breakdownDelta(
|
|
146
|
+
current: CodexTokenBreakdown,
|
|
147
|
+
previous: CodexTokenBreakdown,
|
|
148
|
+
): CodexTokenBreakdown | null {
|
|
149
|
+
const delta = {
|
|
150
|
+
inputTokens: current.inputTokens - previous.inputTokens,
|
|
151
|
+
outputTokens: current.outputTokens - previous.outputTokens,
|
|
152
|
+
cachedInputTokens:
|
|
153
|
+
current.cachedInputTokens - previous.cachedInputTokens,
|
|
154
|
+
};
|
|
155
|
+
return codexTokenBreakdown(delta);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Codex fills the context meter with zero charge counters when a request
|
|
159
|
+
* reaches its context limit (TokenUsageInfo.fill_to_context_window). This
|
|
160
|
+
* explicit marker resets the thread baseline without adding a model call. */
|
|
161
|
+
function contextWindowUsageReset(
|
|
162
|
+
value: Record<string, unknown>,
|
|
163
|
+
last: CodexTokenBreakdown,
|
|
164
|
+
total: CodexTokenBreakdown,
|
|
165
|
+
): boolean {
|
|
166
|
+
if (
|
|
167
|
+
Object.values(last).some((count) => count !== 0) ||
|
|
168
|
+
Object.values(total).some((count) => count !== 0)
|
|
169
|
+
) return false;
|
|
170
|
+
const window = nonNegativeInt(value.modelContextWindow);
|
|
171
|
+
const totalTokens = isObj(value.total) ? nonNegativeInt(value.total.totalTokens) : undefined;
|
|
172
|
+
const lastTokens = isObj(value.last) ? nonNegativeInt(value.last.totalTokens) : undefined;
|
|
173
|
+
return window !== undefined && window > 0 && totalTokens === window && lastTokens !== undefined && lastTokens <= window;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Codex 0.157 emits one token update after every model call. `last` is that
|
|
178
|
+
* call, not the whole turn; `total` is the running thread total. A tool-using
|
|
179
|
+
* turn therefore emits several updates. Seed an unseen thread from `last`,
|
|
180
|
+
* then add positive `total` deltas so repeated snapshots add zero. Usage is
|
|
181
|
+
* kept by turn id and consumed exactly once at its turn/completed boundary.
|
|
182
|
+
*/
|
|
183
|
+
class CodexTurnUsage {
|
|
184
|
+
private readonly threadTotals = new Map<string, CodexTokenBreakdown>();
|
|
185
|
+
private readonly turns = new Map<string, CodexTokenBreakdown>();
|
|
186
|
+
private readonly unknownTurns = new Set<string>();
|
|
187
|
+
|
|
188
|
+
private unknown(threadId: string, turnId: string): void {
|
|
189
|
+
this.unknownTurns.add(turnId);
|
|
190
|
+
this.turns.delete(turnId);
|
|
191
|
+
// Its next first call must seed from `last`, never charge unknown history
|
|
192
|
+
// from a stale total that accompanied an invalid snapshot.
|
|
193
|
+
this.threadTotals.delete(threadId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
observe(params: unknown): void {
|
|
197
|
+
if (!isObj(params)) return;
|
|
198
|
+
const threadId = str(params.threadId);
|
|
199
|
+
const turnId = str(params.turnId);
|
|
200
|
+
const tokenUsage = isObj(params.tokenUsage) ? params.tokenUsage : null;
|
|
201
|
+
if (!threadId || !turnId) return;
|
|
202
|
+
if (!tokenUsage) {
|
|
203
|
+
this.unknown(threadId, turnId);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const last = codexTokenBreakdown(tokenUsage.last);
|
|
207
|
+
const total = codexTokenBreakdown(tokenUsage.total);
|
|
208
|
+
if (!last || !total) {
|
|
209
|
+
this.unknown(threadId, turnId);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const previousTotal = this.threadTotals.get(threadId);
|
|
214
|
+
const contribution = previousTotal && !contextWindowUsageReset(tokenUsage, last, total)
|
|
215
|
+
? breakdownDelta(total, previousTotal)
|
|
216
|
+
: last;
|
|
217
|
+
if (!contribution) {
|
|
218
|
+
// A decreasing total or impossible cache delta cannot safely be
|
|
219
|
+
// repaired by adding `last`: it may replay an already counted call.
|
|
220
|
+
this.unknown(threadId, turnId);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.threadTotals.set(threadId, total);
|
|
224
|
+
if (this.unknownTurns.has(turnId)) return;
|
|
225
|
+
const accumulated = addBreakdowns(this.turns.get(turnId), contribution);
|
|
226
|
+
if (!accumulated) {
|
|
227
|
+
this.unknown(threadId, turnId);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.turns.set(turnId, accumulated);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
complete(params: unknown, model: string | undefined): AgentUsage | undefined {
|
|
234
|
+
const p = isObj(params) ? params : {};
|
|
235
|
+
const turn = isObj(p.turn) ? p.turn : {};
|
|
236
|
+
const turnId = str(turn.id);
|
|
237
|
+
if (!turnId) return undefined;
|
|
238
|
+
const usage = this.turns.get(turnId);
|
|
239
|
+
this.turns.delete(turnId);
|
|
240
|
+
if (this.unknownTurns.delete(turnId) || !usage) return undefined;
|
|
241
|
+
return {
|
|
242
|
+
...(model ? { model } : {}),
|
|
243
|
+
// OpenAI includes cached input inside inputTokens. Uai's shared shape,
|
|
244
|
+
// like Claude's, keeps fresh input and cache reads in separate columns.
|
|
245
|
+
// Source: openai/codex rust-v0.157.0, TokenUsage.non_cached_input and
|
|
246
|
+
// ResponseCompletedUsage -> TokenUsage. Cache writes are also input,
|
|
247
|
+
// but without their TTL/rate breakdown this is not an exact bill.
|
|
248
|
+
// Leave writes in fresh input rather than also reporting a cache create.
|
|
249
|
+
inputTokens: usage.inputTokens - usage.cachedInputTokens,
|
|
250
|
+
cacheReadTokens: usage.cachedInputTokens,
|
|
251
|
+
// reasoningOutputTokens is a subset of outputTokens in this protocol
|
|
252
|
+
// (totalTokens = inputTokens + outputTokens), so do not add it again.
|
|
253
|
+
outputTokens: usage.outputTokens,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `item/completed` item types rendered as tool-call cards. */
|
|
259
|
+
const TOOL_ITEM_TYPES = new Set([
|
|
260
|
+
"commandExecution",
|
|
261
|
+
"fileChange",
|
|
262
|
+
"mcpToolCall",
|
|
263
|
+
"dynamicToolCall",
|
|
264
|
+
"webSearch",
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
function turnComplete(usage: AgentUsage | undefined): AgentEvent {
|
|
268
|
+
return usage ? { type: "turn_complete", usage } : { type: "turn_complete" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Map one Codex JSON-RPC *notification* (method + params) to AgentEvents.
|
|
273
|
+
* Pure and total — an unrecognised method yields [].
|
|
274
|
+
*/
|
|
275
|
+
export function mapCodexNotification(
|
|
276
|
+
method: string,
|
|
277
|
+
params: unknown,
|
|
278
|
+
now: number = Date.now(),
|
|
279
|
+
turnUsage?: AgentUsage,
|
|
280
|
+
): AgentEvent[] {
|
|
281
|
+
const p = isObj(params) ? params : {};
|
|
282
|
+
|
|
283
|
+
if (method === "account/rateLimits/updated") {
|
|
284
|
+
const usage = mapCodexRateLimits(p.rateLimits, now);
|
|
285
|
+
return usage ? [{ type: "account_usage", usage }] : [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (method === "item/agentMessage/delta") {
|
|
289
|
+
const text = str(p.delta);
|
|
290
|
+
return text ? [{ type: "message_delta", text }] : [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (method === "item/completed" && isObj(p.item)) {
|
|
294
|
+
const item = p.item;
|
|
295
|
+
const itemType = str(item.type);
|
|
296
|
+
if (itemType === "agentMessage") {
|
|
297
|
+
return [{ type: "message_complete", text: str(item.text) }];
|
|
298
|
+
}
|
|
299
|
+
if (TOOL_ITEM_TYPES.has(itemType)) {
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
type: "tool_call",
|
|
303
|
+
id: str(item.id) || newId(),
|
|
304
|
+
title: codexToolTitle(itemType, item),
|
|
305
|
+
detail: codexToolDetail(item),
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (method === "turn/completed") {
|
|
313
|
+
const turn = isObj(p.turn) ? p.turn : {};
|
|
314
|
+
if (turn.status === "failed" && isObj(turn.error)) {
|
|
315
|
+
const message = str(turn.error.message, "codex turn failed");
|
|
316
|
+
const accountUsage = rateLimitErrorUsage(message, now);
|
|
317
|
+
return [
|
|
318
|
+
...(accountUsage
|
|
319
|
+
? [{ type: "account_usage", usage: accountUsage } as const]
|
|
320
|
+
: []),
|
|
321
|
+
accountUsage
|
|
322
|
+
? {
|
|
323
|
+
type: "error",
|
|
324
|
+
message,
|
|
325
|
+
retryable: "rate_limit",
|
|
326
|
+
...(accountUsage.resetsAt === undefined
|
|
327
|
+
? {}
|
|
328
|
+
: { resetsAt: accountUsage.resetsAt }),
|
|
329
|
+
}
|
|
330
|
+
: { type: "error", message },
|
|
331
|
+
turnComplete(turnUsage),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
return [turnComplete(turnUsage)];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (method === "error") {
|
|
338
|
+
const message = isObj(p.error)
|
|
339
|
+
? str(p.error.message, "codex error")
|
|
340
|
+
: "codex error";
|
|
341
|
+
const accountUsage = rateLimitErrorUsage(message, now);
|
|
342
|
+
return accountUsage
|
|
343
|
+
? [
|
|
344
|
+
{ type: "account_usage", usage: accountUsage },
|
|
345
|
+
{
|
|
346
|
+
type: "error",
|
|
347
|
+
message,
|
|
348
|
+
retryable: "rate_limit",
|
|
349
|
+
...(accountUsage.resetsAt === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { resetsAt: accountUsage.resetsAt }),
|
|
352
|
+
},
|
|
353
|
+
]
|
|
354
|
+
: [{ type: "error", message }];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function codexWindow(
|
|
361
|
+
name: "primary" | "secondary",
|
|
362
|
+
value: unknown,
|
|
363
|
+
observedAt: number,
|
|
364
|
+
): AgentAccountUsage["windows"][number] | null {
|
|
365
|
+
if (!isObj(value) || typeof value.usedPercent !== "number") return null;
|
|
366
|
+
const usedPercent = Math.min(100, Math.max(0, value.usedPercent));
|
|
367
|
+
if (!Number.isFinite(usedPercent)) return null;
|
|
368
|
+
const windowMinutes =
|
|
369
|
+
typeof value.windowDurationMins === "number" &&
|
|
370
|
+
Number.isSafeInteger(value.windowDurationMins) &&
|
|
371
|
+
value.windowDurationMins > 0
|
|
372
|
+
? value.windowDurationMins
|
|
373
|
+
: undefined;
|
|
374
|
+
// The 0.157 schema says int64; the live app-server read confirms seconds.
|
|
375
|
+
const resetsAt = resetSecondsToEpochMs(value.resetsAt, observedAt);
|
|
376
|
+
return {
|
|
377
|
+
window: name,
|
|
378
|
+
...(windowMinutes === undefined ? {} : { windowMinutes }),
|
|
379
|
+
usedPercent,
|
|
380
|
+
...(resetsAt === undefined ? {} : { resetsAt }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Map the shared RateLimitSnapshot used by the read response and update
|
|
385
|
+
* notification in Codex app-server 0.157. */
|
|
386
|
+
export function mapCodexRateLimits(
|
|
387
|
+
value: unknown,
|
|
388
|
+
observedAt: number = Date.now(),
|
|
389
|
+
): AgentAccountUsage | null {
|
|
390
|
+
if (!isObj(value)) return null;
|
|
391
|
+
const windows = (["primary", "secondary"] as const)
|
|
392
|
+
.map((name) => codexWindow(name, value[name], observedAt))
|
|
393
|
+
.filter((window): window is NonNullable<typeof window> => window !== null);
|
|
394
|
+
if (windows.length === 0) return null;
|
|
395
|
+
const limited = windows.filter(
|
|
396
|
+
(window) =>
|
|
397
|
+
(window.usedPercent ?? 0) >= CODEX_USAGE_LIMITED_PERCENT,
|
|
398
|
+
);
|
|
399
|
+
const status =
|
|
400
|
+
limited.length > 0
|
|
401
|
+
? "limited"
|
|
402
|
+
: windows.some(
|
|
403
|
+
(window) =>
|
|
404
|
+
(window.usedPercent ?? 0) >= CODEX_USAGE_WARNING_PERCENT,
|
|
405
|
+
)
|
|
406
|
+
? "warning"
|
|
407
|
+
: "allowed";
|
|
408
|
+
const limitedResets = limited
|
|
409
|
+
.map((window) => window.resetsAt)
|
|
410
|
+
.filter((reset): reset is number => reset !== undefined);
|
|
411
|
+
const resetsAt =
|
|
412
|
+
limitedResets.length > 0 ? Math.max(...limitedResets) : undefined;
|
|
413
|
+
return {
|
|
414
|
+
status,
|
|
415
|
+
...(resetsAt === undefined ? {} : { resetsAt }),
|
|
416
|
+
windows,
|
|
417
|
+
source: "codex-rate-limits",
|
|
418
|
+
observedAt,
|
|
419
|
+
coverage: { kind: "all-windows" },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function codexToolTitle(
|
|
424
|
+
itemType: string,
|
|
425
|
+
item: Record<string, unknown>,
|
|
426
|
+
): string {
|
|
427
|
+
if (itemType === "commandExecution") {
|
|
428
|
+
const c = item.command;
|
|
429
|
+
const cmd = Array.isArray(c)
|
|
430
|
+
? c.map((x) => String(x)).join(" ")
|
|
431
|
+
: str(c, "command");
|
|
432
|
+
return `$ ${cmd}`;
|
|
433
|
+
}
|
|
434
|
+
if (itemType === "fileChange") {
|
|
435
|
+
const n = Array.isArray(item.changes) ? item.changes.length : 0;
|
|
436
|
+
return `Edit ${n} file${n === 1 ? "" : "s"}`;
|
|
437
|
+
}
|
|
438
|
+
if (itemType === "mcpToolCall") {
|
|
439
|
+
return `MCP ${str(item.server)}/${str(item.tool)}`;
|
|
440
|
+
}
|
|
441
|
+
if (itemType === "dynamicToolCall") return `Tool ${str(item.tool)}`;
|
|
442
|
+
if (itemType === "webSearch") return `Search ${str(item.query)}`;
|
|
443
|
+
return itemType;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function codexToolDetail(item: Record<string, unknown>): string {
|
|
447
|
+
for (const key of ["aggregatedOutput", "result", "query"]) {
|
|
448
|
+
const v = item[key];
|
|
449
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
return JSON.stringify(item, null, 2);
|
|
453
|
+
} catch {
|
|
454
|
+
return "";
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// JSON-RPC framing.
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
/** Server→client requests we recognise as approval prompts. */
|
|
463
|
+
const APPROVAL_METHODS = new Set([
|
|
464
|
+
"item/commandExecution/requestApproval",
|
|
465
|
+
"item/fileChange/requestApproval",
|
|
466
|
+
"item/permissions/requestApproval",
|
|
467
|
+
"applyPatchApproval",
|
|
468
|
+
"execCommandApproval",
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
/** Bounds the handshake so a wrong-shape protocol surfaces as an error
|
|
472
|
+
* rather than an endless "thinking…". `turn/start` is exempt — turns
|
|
473
|
+
* can legitimately run for minutes. */
|
|
474
|
+
const RPC_TIMEOUT_MS = 30_000;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* ADR-078 thread/resume lock retry. Right after a host restart the previous
|
|
478
|
+
* (durable, in-container) app-server may still hold the thread's rollout lock;
|
|
479
|
+
* `thread/resume` then fails with JSON-RPC -32600 until the stop we sent the old
|
|
480
|
+
* runner lands. Retry that specific error a bounded number of times.
|
|
481
|
+
*/
|
|
482
|
+
const RESUME_LOCK_ATTEMPTS = 10;
|
|
483
|
+
const RESUME_LOCK_BACKOFF_MS = 1_000;
|
|
484
|
+
|
|
485
|
+
const delay = (ms: number): Promise<void> =>
|
|
486
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Thread persistence (ADR-078) — resume the SAME thread across a host restart.
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/** The compatible Codex thread id persisted for (task, agent), or null. */
|
|
493
|
+
function readCodexThreadId(
|
|
494
|
+
taskId: string,
|
|
495
|
+
agentId: string,
|
|
496
|
+
attachCompatibilityKey: string | undefined,
|
|
497
|
+
): string | null {
|
|
498
|
+
try {
|
|
499
|
+
const row = getDb()
|
|
500
|
+
.select({
|
|
501
|
+
id: schema.hostAgentSessions.codexThreadId,
|
|
502
|
+
kind: schema.hostAgentSessions.kind,
|
|
503
|
+
sessionDir: schema.hostAgentSessions.sessionDir,
|
|
504
|
+
attachCompatibilityKey:
|
|
505
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
506
|
+
})
|
|
507
|
+
.from(schema.hostAgentSessions)
|
|
508
|
+
.where(
|
|
509
|
+
and(
|
|
510
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
511
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
.get();
|
|
515
|
+
if (
|
|
516
|
+
row?.kind !== "codex" ||
|
|
517
|
+
!attachCompatibilityKeysMatch(
|
|
518
|
+
row.attachCompatibilityKey,
|
|
519
|
+
attachCompatibilityKey,
|
|
520
|
+
)
|
|
521
|
+
) {
|
|
522
|
+
// Transport will promote the row to the new launch key while preserving
|
|
523
|
+
// codexThreadId. Clear the incompatible id first so a crash between that
|
|
524
|
+
// upsert and thread/started cannot make the next host resume it.
|
|
525
|
+
if (row?.id && row.sessionDir) {
|
|
526
|
+
getDb()
|
|
527
|
+
.update(schema.hostAgentSessions)
|
|
528
|
+
.set({ codexThreadId: null, updatedAt: Date.now() })
|
|
529
|
+
.where(
|
|
530
|
+
and(
|
|
531
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
532
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
533
|
+
eq(schema.hostAgentSessions.sessionDir, row.sessionDir),
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
.run();
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
return row?.id ?? null;
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Persist the Codex thread id for (task, agent) so a later host process resumes
|
|
548
|
+
* it. Best-effort — a write failure just means the next restart starts a fresh
|
|
549
|
+
* thread (today's behavior). The transport's session-row upsert never clears
|
|
550
|
+
* this column, so it survives the kill+respawn of a host restart.
|
|
551
|
+
*/
|
|
552
|
+
function saveCodexThreadId(
|
|
553
|
+
taskId: string,
|
|
554
|
+
agentId: string,
|
|
555
|
+
threadId: string,
|
|
556
|
+
sessionDir: string | undefined,
|
|
557
|
+
attachCompatibilityKey: string | undefined,
|
|
558
|
+
): void {
|
|
559
|
+
if (!sessionDir) return;
|
|
560
|
+
try {
|
|
561
|
+
const db = getDb();
|
|
562
|
+
const row = db
|
|
563
|
+
.select({
|
|
564
|
+
kind: schema.hostAgentSessions.kind,
|
|
565
|
+
sessionDir: schema.hostAgentSessions.sessionDir,
|
|
566
|
+
attachCompatibilityKey:
|
|
567
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
568
|
+
})
|
|
569
|
+
.from(schema.hostAgentSessions)
|
|
570
|
+
.where(
|
|
571
|
+
and(
|
|
572
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
573
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
.get();
|
|
577
|
+
const persistedKey = row?.attachCompatibilityKey ?? null;
|
|
578
|
+
if (
|
|
579
|
+
row?.kind !== "codex" ||
|
|
580
|
+
row.sessionDir !== sessionDir ||
|
|
581
|
+
!attachCompatibilityKeysMatch(
|
|
582
|
+
row.attachCompatibilityKey,
|
|
583
|
+
attachCompatibilityKey,
|
|
584
|
+
)
|
|
585
|
+
) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
db
|
|
589
|
+
.update(schema.hostAgentSessions)
|
|
590
|
+
.set({ codexThreadId: threadId, updatedAt: Date.now() })
|
|
591
|
+
.where(
|
|
592
|
+
and(
|
|
593
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
594
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
595
|
+
eq(schema.hostAgentSessions.kind, "codex"),
|
|
596
|
+
eq(schema.hostAgentSessions.sessionDir, sessionDir),
|
|
597
|
+
persistedKey === null
|
|
598
|
+
? isNull(schema.hostAgentSessions.attachCompatibilityKey)
|
|
599
|
+
: eq(
|
|
600
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
601
|
+
persistedKey,
|
|
602
|
+
),
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
.run();
|
|
606
|
+
} catch {
|
|
607
|
+
/* best effort — see doc comment */
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// The session.
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
export class CodexSession implements AgentSession {
|
|
616
|
+
readonly agentId: string;
|
|
617
|
+
readonly kind: AgentKind = "codex";
|
|
618
|
+
|
|
619
|
+
private readonly proc: LineTransport;
|
|
620
|
+
private readonly handlers = new Set<AgentEventHandler>();
|
|
621
|
+
private readonly systemPreamble: string;
|
|
622
|
+
private readonly taskId: string;
|
|
623
|
+
private readonly customConnection: boolean;
|
|
624
|
+
/** The connection's key, only to scrub it out of an upstream excerpt. */
|
|
625
|
+
private readonly connectionKey: string;
|
|
626
|
+
/** ADR-078: a thread id persisted by a PRIOR host process, to resume. */
|
|
627
|
+
private readonly resumeThreadId: string | null;
|
|
628
|
+
private closed = false;
|
|
629
|
+
private handshakeOk = false;
|
|
630
|
+
|
|
631
|
+
private nextRpcId = 1;
|
|
632
|
+
private readonly pending = new Map<
|
|
633
|
+
number,
|
|
634
|
+
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
635
|
+
>();
|
|
636
|
+
/** permission_request id → the JSON-RPC request id to answer with. */
|
|
637
|
+
private readonly approvals = new Map<string, number | string>();
|
|
638
|
+
/** Responses can follow notifications, so keep ids until completion. */
|
|
639
|
+
private readonly turnStarts = new Map<number, {
|
|
640
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
641
|
+
turnId: string | null;
|
|
642
|
+
accepted: boolean;
|
|
643
|
+
settles: number;
|
|
644
|
+
}>();
|
|
645
|
+
/** Merged request id → the canonical start, until its turn completes. */
|
|
646
|
+
private readonly mergedStarts = new Map<number, number>();
|
|
647
|
+
private readonly firedRequests = new Map<number, string>();
|
|
648
|
+
/** Live provider turns plus unacknowledged starts, excluding merged aliases. */
|
|
649
|
+
private outstandingTurns = 0;
|
|
650
|
+
private failed = false;
|
|
651
|
+
private cliVersion: string | null = null;
|
|
652
|
+
|
|
653
|
+
private threadId: string | null = null;
|
|
654
|
+
private readonly ready: Promise<void>;
|
|
655
|
+
private readonly attachCompatibilityKey: string | undefined;
|
|
656
|
+
private turnRunning = false;
|
|
657
|
+
private lastRateLimitsReadAt = 0;
|
|
658
|
+
private rateLimitsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
659
|
+
private readonly turnUsage = new CodexTurnUsage();
|
|
660
|
+
private actualModel: string | undefined;
|
|
661
|
+
|
|
662
|
+
constructor(args: {
|
|
663
|
+
taskId: string;
|
|
664
|
+
agent: RosterAgent;
|
|
665
|
+
environment: TaskEnvironmentAgentSessionSurface;
|
|
666
|
+
systemPreamble: string;
|
|
667
|
+
agentEnv?: Record<string, string>;
|
|
668
|
+
attachCompatibilityKey?: string;
|
|
669
|
+
/** ADR-128: `-c key=value` overrides a delegating adapter prepends. */
|
|
670
|
+
cliConfigOverrides?: string[];
|
|
671
|
+
/** ADR-157: the Astronaut runs without Codex's native multi-agent tools. */
|
|
672
|
+
executionProfile?: "communicator" | "astronaut";
|
|
673
|
+
}) {
|
|
674
|
+
this.agentId = args.agent.id;
|
|
675
|
+
this.systemPreamble = args.systemPreamble;
|
|
676
|
+
this.taskId = args.taskId;
|
|
677
|
+
this.customConnection = args.agentEnv?.UAI_CUSTOM_CONNECTION === "1";
|
|
678
|
+
this.connectionKey = args.agentEnv?.UAI_CUSTOM_API_KEY ?? "";
|
|
679
|
+
this.attachCompatibilityKey = args.attachCompatibilityKey;
|
|
680
|
+
// ADR-078: read BEFORE createAgentTransport (whose session-row upsert
|
|
681
|
+
// preserves this column). Non-null → a host restart; resume the thread.
|
|
682
|
+
this.resumeThreadId = readCodexThreadId(
|
|
683
|
+
args.taskId,
|
|
684
|
+
args.agent.id,
|
|
685
|
+
args.attachCompatibilityKey,
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// The agent's model / effort (when set) are selected via config overrides
|
|
689
|
+
// (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
|
|
690
|
+
// app-server process (ADR-021). All `-c` overrides go BEFORE the
|
|
691
|
+
// `app-server` subcommand. Without them codex uses its configured defaults.
|
|
692
|
+
const codexArgs: string[] = [];
|
|
693
|
+
if (args.agent.model) {
|
|
694
|
+
codexArgs.push("-c", `model=${tomlString(args.agent.model)}`);
|
|
695
|
+
}
|
|
696
|
+
if (args.agent.effort) {
|
|
697
|
+
codexArgs.push("-c", `model_reasoning_effort=${tomlString(args.agent.effort)}`);
|
|
698
|
+
}
|
|
699
|
+
for (const override of args.cliConfigOverrides ?? []) {
|
|
700
|
+
codexArgs.push("-c", override);
|
|
701
|
+
}
|
|
702
|
+
// These are two independent features in Codex 0.157. Append after every
|
|
703
|
+
// user override so task env can never be frozen into an on-disk snapshot.
|
|
704
|
+
codexArgs.push(
|
|
705
|
+
"--disable",
|
|
706
|
+
"shell_snapshot",
|
|
707
|
+
"--disable",
|
|
708
|
+
"shell_snapshot_v2",
|
|
709
|
+
);
|
|
710
|
+
// ADR-157: an Astronaut's helpers must be crew agents the task can see and
|
|
711
|
+
// bill, so Codex's own subagents (spawn_agent; multi_agent is on by
|
|
712
|
+
// default in 0.157, multi_agent_v2 off) are disabled for it. Also after
|
|
713
|
+
// every override, so nothing can turn them back on.
|
|
714
|
+
if (args.executionProfile === "astronaut") {
|
|
715
|
+
codexArgs.push("--disable", "multi_agent", "--disable", "multi_agent_v2");
|
|
716
|
+
}
|
|
717
|
+
codexArgs.push("app-server");
|
|
718
|
+
// ADR-061: the CLI is owned by an in-container runner (durable across
|
|
719
|
+
// host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
|
|
720
|
+
// re-handshakes per host process (initialize + thread/start live in this
|
|
721
|
+
// object), so a leftover runner is never attached — allowAttach:false
|
|
722
|
+
// stops it and spawns fresh. Its final output before the stop still
|
|
723
|
+
// reaches the cloud via the outbox.
|
|
724
|
+
this.proc = createAgentTransport({
|
|
725
|
+
taskId: args.taskId,
|
|
726
|
+
agentId: this.agentId,
|
|
727
|
+
environment: args.environment,
|
|
728
|
+
cli: "codex",
|
|
729
|
+
cliArgs: codexArgs,
|
|
730
|
+
passEnv: [],
|
|
731
|
+
explicitEnv: args.agentEnv ?? {},
|
|
732
|
+
allowAttach: false,
|
|
733
|
+
attachCompatibilityKey: args.attachCompatibilityKey,
|
|
734
|
+
kind: "codex",
|
|
735
|
+
debugLabel: `codex:${this.agentId}`,
|
|
736
|
+
});
|
|
737
|
+
this.proc.onLine((line) => this.onLine(line));
|
|
738
|
+
this.proc.onExit((code) => {
|
|
739
|
+
if (this.closed) return;
|
|
740
|
+
if (!this.failed && (code !== 0 || this.outstandingTurns > 0)) {
|
|
741
|
+
const tail = this.proc.stderrTail.trim();
|
|
742
|
+
this.failTurn(
|
|
743
|
+
(code === null ? `${this.cliLabel} process stopped unexpectedly` :
|
|
744
|
+
`${this.cliLabel} process exited (${code})${code === 0 ? " before replying to the message" : ""}`) +
|
|
745
|
+
(tail
|
|
746
|
+
? `:\n${tail}`
|
|
747
|
+
: ". Please try again."),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
this.clearTurnState();
|
|
751
|
+
this.closed = true;
|
|
752
|
+
for (const waiter of this.pending.values()) {
|
|
753
|
+
waiter.reject(new Error("codex process exited"));
|
|
754
|
+
}
|
|
755
|
+
this.pending.clear();
|
|
756
|
+
this.emit({ type: "exit", code: code ?? -1 });
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
this.ready = this.handshake().catch((err: unknown) => {
|
|
760
|
+
this.failTurn(`${this.cliLabel} could not start: ${
|
|
761
|
+
err instanceof Error ? err.message : String(err)
|
|
762
|
+
}`, (err as { rpcCode?: number }).rpcCode);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// -- JSON-RPC plumbing ----------------------------------------------------
|
|
767
|
+
|
|
768
|
+
private onLine(line: string): void {
|
|
769
|
+
// close() severs this generation before the durable runner necessarily
|
|
770
|
+
// exits. Ignore buffered notifications so a late thread/started cannot
|
|
771
|
+
// restore the resume id that a live roster edit just cleared.
|
|
772
|
+
if (this.closed || this.failed) return;
|
|
773
|
+
let msg: unknown;
|
|
774
|
+
try {
|
|
775
|
+
msg = JSON.parse(line);
|
|
776
|
+
} catch {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!isObj(msg)) return;
|
|
780
|
+
|
|
781
|
+
const hasId = "id" in msg && (typeof msg.id === "number" || typeof msg.id === "string");
|
|
782
|
+
|
|
783
|
+
// Response to one of our requests (id, no method).
|
|
784
|
+
if (hasId && !("method" in msg)) {
|
|
785
|
+
const id = typeof msg.id === "number" ? msg.id : -1;
|
|
786
|
+
const fired = this.firedRequests.get(id);
|
|
787
|
+
this.firedRequests.delete(id);
|
|
788
|
+
if (this.turnStarts.has(id) || this.mergedStarts.has(id)) {
|
|
789
|
+
if (isObj(msg.error)) {
|
|
790
|
+
this.failRpc(msg.error);
|
|
791
|
+
} else {
|
|
792
|
+
this.acknowledgeTurn(id, msg.result);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const waiter = this.pending.get(id);
|
|
797
|
+
if (waiter) {
|
|
798
|
+
this.pending.delete(id);
|
|
799
|
+
if (isObj(msg.error)) {
|
|
800
|
+
// Preserve the JSON-RPC code so thread/resume can retry -32600
|
|
801
|
+
// (the thread's single-writer lock still held by a dying app-server).
|
|
802
|
+
const code =
|
|
803
|
+
typeof msg.error.code === "number" ? msg.error.code : undefined;
|
|
804
|
+
waiter.reject(
|
|
805
|
+
Object.assign(new Error(str(msg.error.message, "rpc error")), {
|
|
806
|
+
rpcCode: code,
|
|
807
|
+
}),
|
|
808
|
+
);
|
|
809
|
+
} else {
|
|
810
|
+
waiter.resolve(msg.result);
|
|
811
|
+
}
|
|
812
|
+
} else if (fired === "turn/interrupt" && isObj(msg.error)) {
|
|
813
|
+
const code = typeof msg.error.code === "number" ? msg.error.code : undefined;
|
|
814
|
+
let message = `Couldn't stop the message: ${this.cliLabel}${code === undefined ? "" : ` (code ${code})`}: ${str(msg.error.message, "Unknown error. Please try again.")}`;
|
|
815
|
+
if (this.customConnection) message = connectionFailureReason(message);
|
|
816
|
+
this.reportError(message, code);
|
|
817
|
+
this.emit({ type: "notice", message: agentErrorMessage(message) });
|
|
818
|
+
}
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Server→client request (id + method).
|
|
823
|
+
if (hasId && "method" in msg) {
|
|
824
|
+
this.onServerRequest(
|
|
825
|
+
msg.id as number | string,
|
|
826
|
+
str(msg.method),
|
|
827
|
+
msg.params,
|
|
828
|
+
);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Notification (method, no id).
|
|
833
|
+
if ("method" in msg) {
|
|
834
|
+
const method = str(msg.method);
|
|
835
|
+
// Account usage responses are not proof of a running turn. Turn/item
|
|
836
|
+
// activity (including an approval request below) is acceptance even on
|
|
837
|
+
// CLIs that defer the turn/start response until the turn ends.
|
|
838
|
+
if (method === "turn/started" || method.startsWith("item/") ||
|
|
839
|
+
method === "thread/tokenUsage/updated" || method === "turn/completed") {
|
|
840
|
+
this.acknowledgeActivity(msg.params, method === "turn/started");
|
|
841
|
+
}
|
|
842
|
+
if (method === "thread/tokenUsage/updated") {
|
|
843
|
+
this.turnUsage.observe(msg.params);
|
|
844
|
+
}
|
|
845
|
+
const completedUsage =
|
|
846
|
+
method === "turn/completed"
|
|
847
|
+
? this.turnUsage.complete(msg.params, this.actualModel)
|
|
848
|
+
: undefined;
|
|
849
|
+
let settles = 1;
|
|
850
|
+
if (method === "turn/completed") {
|
|
851
|
+
if (isObj(msg.params) && isObj(msg.params.turn) &&
|
|
852
|
+
msg.params.turn.status === "failed" && isObj(msg.params.turn.error)) {
|
|
853
|
+
this.failRpc(msg.params.turn.error, completedUsage);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
|
|
857
|
+
const turnId = this.notificationTurnId(msg.params);
|
|
858
|
+
const completed = [...this.turnStarts].find(([, turn]) => turnId !== null && turn.turnId === turnId)
|
|
859
|
+
?? [...this.turnStarts].find(([, turn]) => turn.accepted);
|
|
860
|
+
if (completed) {
|
|
861
|
+
settles = completed[1].settles;
|
|
862
|
+
this.turnStarts.delete(completed[0]);
|
|
863
|
+
this.firedRequests.delete(completed[0]);
|
|
864
|
+
for (const [alias, owner] of this.mergedStarts) {
|
|
865
|
+
if (owner !== completed[0]) continue;
|
|
866
|
+
this.mergedStarts.delete(alias);
|
|
867
|
+
this.firedRequests.delete(alias);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
this.turnRunning = this.outstandingTurns > 0;
|
|
871
|
+
if (!this.turnRunning) this.clearTurnState();
|
|
872
|
+
else this.reconcileAcceptanceTimers();
|
|
873
|
+
}
|
|
874
|
+
if (method === "error") {
|
|
875
|
+
const params = isObj(msg.params) ? msg.params : {};
|
|
876
|
+
this.failRpc(isObj(params.error) ? params.error : {});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
// The thread id arrives on `thread/started`; also captured from
|
|
880
|
+
// the `thread/start` response in handshake().
|
|
881
|
+
if (method === "thread/started" && isObj(msg.params)) {
|
|
882
|
+
const thread = msg.params.thread;
|
|
883
|
+
if (isObj(thread) && typeof thread.id === "string") {
|
|
884
|
+
this.threadId = thread.id;
|
|
885
|
+
this.persistThreadId(thread.id);
|
|
886
|
+
if (typeof thread.model === "string" && thread.model.length > 0) {
|
|
887
|
+
this.actualModel = thread.model;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
for (const ev of mapCodexNotification(
|
|
892
|
+
method,
|
|
893
|
+
msg.params,
|
|
894
|
+
Date.now(),
|
|
895
|
+
completedUsage,
|
|
896
|
+
)) {
|
|
897
|
+
this.emit(ev.type === "turn_complete" ? {
|
|
898
|
+
...ev,
|
|
899
|
+
...(settles > 1 ? { settles } : {}),
|
|
900
|
+
...(isObj(msg.params) && isObj(msg.params.turn) && msg.params.turn.status === "interrupted" ? { aborted: true } : {}),
|
|
901
|
+
} : ev);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private onServerRequest(
|
|
907
|
+
id: number | string,
|
|
908
|
+
method: string,
|
|
909
|
+
params: unknown,
|
|
910
|
+
): void {
|
|
911
|
+
if (APPROVAL_METHODS.has(method)) {
|
|
912
|
+
this.acknowledgeActivity(params);
|
|
913
|
+
const permId = newId();
|
|
914
|
+
this.approvals.set(permId, id);
|
|
915
|
+
const p = isObj(params) ? params : {};
|
|
916
|
+
this.emit({
|
|
917
|
+
type: "permission_request",
|
|
918
|
+
id: permId,
|
|
919
|
+
title: method.includes("fileChange")
|
|
920
|
+
? "Allow file changes?"
|
|
921
|
+
: `Allow: ${str(p.command, method)}`,
|
|
922
|
+
detail: codexToolDetail(p),
|
|
923
|
+
});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// Anything else (tool calls, elicitation, token refresh) — we can't
|
|
927
|
+
// service it; reply with an error so codex doesn't block waiting.
|
|
928
|
+
this.respondError(id, `uai does not handle "${method}"`);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Send a JSON-RPC request, resolving with its result; rejects after
|
|
932
|
+
* RPC_TIMEOUT_MS. For short calls (initialize, thread/start). */
|
|
933
|
+
private request(method: string, params: unknown): Promise<unknown> {
|
|
934
|
+
const id = this.nextRpcId++;
|
|
935
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
936
|
+
const timer = setTimeout(() => {
|
|
937
|
+
if (this.pending.delete(id)) {
|
|
938
|
+
reject(
|
|
939
|
+
new Error(
|
|
940
|
+
`codex did not answer "${method}" within ${
|
|
941
|
+
RPC_TIMEOUT_MS / 1000
|
|
942
|
+
}s`,
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}, RPC_TIMEOUT_MS);
|
|
947
|
+
this.pending.set(id, {
|
|
948
|
+
resolve: (v) => {
|
|
949
|
+
clearTimeout(timer);
|
|
950
|
+
resolve(v);
|
|
951
|
+
},
|
|
952
|
+
reject: (e) => {
|
|
953
|
+
clearTimeout(timer);
|
|
954
|
+
reject(e);
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
this.proc.writeLine({ jsonrpc: "2.0", id, method, params });
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Send a JSON-RPC request without awaiting it — for `turn/start`,
|
|
962
|
+
* whose output streams as notifications and whose response can take
|
|
963
|
+
* minutes. Acceptance/errors are handled separately, without a waiter. */
|
|
964
|
+
private fireRequest(method: string, params: unknown): number {
|
|
965
|
+
const id = this.nextRpcId++;
|
|
966
|
+
this.firedRequests.set(id, method);
|
|
967
|
+
this.proc.writeLine({
|
|
968
|
+
jsonrpc: "2.0",
|
|
969
|
+
id,
|
|
970
|
+
method,
|
|
971
|
+
params,
|
|
972
|
+
});
|
|
973
|
+
return id;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
private notify(method: string, params: unknown): void {
|
|
977
|
+
this.proc.writeLine({ jsonrpc: "2.0", method, params });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private respond(id: number | string, result: unknown): void {
|
|
981
|
+
this.proc.writeLine({ jsonrpc: "2.0", id, result });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private respondError(id: number | string, message: string): void {
|
|
985
|
+
this.proc.writeLine({
|
|
986
|
+
jsonrpc: "2.0",
|
|
987
|
+
id,
|
|
988
|
+
error: { code: -32601, message },
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private async handshake(): Promise<void> {
|
|
993
|
+
const initialized = await this.request("initialize", {
|
|
994
|
+
clientInfo: { name: "uai", version: "0.2" },
|
|
995
|
+
// ADR-078: unlock experimental fields. thread/resume's `excludeTurns`
|
|
996
|
+
// (resume without replaying history — see tryResumeThread) is gated on
|
|
997
|
+
// this; without it the app-server rejects the field with
|
|
998
|
+
// "thread/resume.excludeTurns requires experimentalApi capability" and
|
|
999
|
+
// we fall back to a fresh thread, losing the whole conversation.
|
|
1000
|
+
capabilities: { experimentalApi: true },
|
|
1001
|
+
});
|
|
1002
|
+
// The server's user agent starts with <client-name>/<CLI-version>; it
|
|
1003
|
+
// does not use the clientInfo.version we sent (verified on codex 0.164).
|
|
1004
|
+
if (isObj(initialized)) {
|
|
1005
|
+
this.cliVersion = str(initialized.userAgent)
|
|
1006
|
+
.match(/^[^/\s]+\/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/)?.[1] ?? null;
|
|
1007
|
+
}
|
|
1008
|
+
this.notify("initialized", {});
|
|
1009
|
+
// ADR-078: a thread id from a prior host process means this is a restart —
|
|
1010
|
+
// resume the SAME durable thread so the conversation survives, instead of
|
|
1011
|
+
// starting fresh. Fall back to a new thread if resume isn't possible
|
|
1012
|
+
// (thread gone/corrupt, or the old writer never released the lock).
|
|
1013
|
+
if (this.resumeThreadId && (await this.tryResumeThread(this.resumeThreadId))) {
|
|
1014
|
+
this.handshakeOk = true;
|
|
1015
|
+
this.readRateLimits();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
await this.startThread();
|
|
1019
|
+
this.handshakeOk = true;
|
|
1020
|
+
this.readRateLimits();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Best-effort account snapshot. It is intentionally outside `ready`: a
|
|
1024
|
+
* slow usage endpoint must not delay the agent's first turn. */
|
|
1025
|
+
private readRateLimits(): void {
|
|
1026
|
+
if (this.closed) return;
|
|
1027
|
+
this.lastRateLimitsReadAt = Date.now();
|
|
1028
|
+
void this.request("account/rateLimits/read", null)
|
|
1029
|
+
.then((result) => {
|
|
1030
|
+
const usage =
|
|
1031
|
+
isObj(result) && "rateLimits" in result
|
|
1032
|
+
? mapCodexRateLimits(result.rateLimits)
|
|
1033
|
+
: null;
|
|
1034
|
+
if (usage) this.emit({ type: "account_usage", usage });
|
|
1035
|
+
})
|
|
1036
|
+
.catch((error: unknown) => {
|
|
1037
|
+
// Usage is advisory. Authentication/turn errors still follow their
|
|
1038
|
+
// normal app-server notifications and must not be duplicated here.
|
|
1039
|
+
if (!this.closed && !this.failed) {
|
|
1040
|
+
this.reportError(`${this.cliLabel} usage read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1041
|
+
(error as { rpcCode?: number }).rpcCode, false);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private clearRateLimitsTimer(): void {
|
|
1047
|
+
if (this.rateLimitsTimer !== null) clearTimeout(this.rateLimitsTimer);
|
|
1048
|
+
this.rateLimitsTimer = null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private scheduleRateLimitsRead(): void {
|
|
1052
|
+
this.clearRateLimitsTimer();
|
|
1053
|
+
if (!this.turnRunning || this.closed) return;
|
|
1054
|
+
const wait = Math.max(
|
|
1055
|
+
0,
|
|
1056
|
+
this.lastRateLimitsReadAt + CODEX_USAGE_POLL_INTERVAL_MS - Date.now(),
|
|
1057
|
+
);
|
|
1058
|
+
this.rateLimitsTimer = setTimeout(() => {
|
|
1059
|
+
this.rateLimitsTimer = null;
|
|
1060
|
+
if (!this.turnRunning || this.closed) return;
|
|
1061
|
+
this.readRateLimits();
|
|
1062
|
+
this.scheduleRateLimitsRead();
|
|
1063
|
+
}, wait);
|
|
1064
|
+
this.rateLimitsTimer.unref?.();
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private async startThread(): Promise<void> {
|
|
1068
|
+
// The uai channel briefing (how to @-mention, the roster, the project's
|
|
1069
|
+
// defaultPrompt) goes in as developer instructions so it applies to every
|
|
1070
|
+
// turn on the thread (persisted with the thread, so resume needn't resend).
|
|
1071
|
+
const threadParams: Record<string, unknown> = {
|
|
1072
|
+
approvalPolicy: "never",
|
|
1073
|
+
sandbox: "danger-full-access",
|
|
1074
|
+
};
|
|
1075
|
+
if (this.systemPreamble.trim().length > 0) {
|
|
1076
|
+
threadParams.developerInstructions = this.systemPreamble;
|
|
1077
|
+
}
|
|
1078
|
+
const result = await this.request("thread/start", threadParams);
|
|
1079
|
+
if (isObj(result) && typeof result.model === "string") {
|
|
1080
|
+
this.actualModel = result.model;
|
|
1081
|
+
}
|
|
1082
|
+
if (isObj(result) && isObj(result.thread)) {
|
|
1083
|
+
const id = str(result.thread.id) || this.threadId;
|
|
1084
|
+
if (!this.actualModel && typeof result.thread.model === "string") {
|
|
1085
|
+
this.actualModel = result.thread.model;
|
|
1086
|
+
}
|
|
1087
|
+
this.threadId = id;
|
|
1088
|
+
if (id) this.persistThreadId(id);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Reopen the persisted thread by id (ADR-078). `excludeTurns:true` returns
|
|
1094
|
+
* only metadata — no history replay, which would double-post the transcript
|
|
1095
|
+
* the cloud already holds. The app-server allows one writer per thread, so a
|
|
1096
|
+
* -32600 (lock still held by the dying predecessor app-server) is retried with
|
|
1097
|
+
* backoff; any other failure returns false and the caller starts a fresh
|
|
1098
|
+
* thread. Returns true iff the thread is now open for turns.
|
|
1099
|
+
*/
|
|
1100
|
+
private async tryResumeThread(threadId: string): Promise<boolean> {
|
|
1101
|
+
for (let attempt = 0; attempt < RESUME_LOCK_ATTEMPTS; attempt++) {
|
|
1102
|
+
try {
|
|
1103
|
+
const result = await this.request("thread/resume", {
|
|
1104
|
+
threadId,
|
|
1105
|
+
excludeTurns: true,
|
|
1106
|
+
approvalPolicy: "never",
|
|
1107
|
+
sandbox: "danger-full-access",
|
|
1108
|
+
});
|
|
1109
|
+
const id =
|
|
1110
|
+
isObj(result) && isObj(result.thread) && str(result.thread.id)
|
|
1111
|
+
? str(result.thread.id)
|
|
1112
|
+
: threadId;
|
|
1113
|
+
if (isObj(result) && typeof result.model === "string") {
|
|
1114
|
+
this.actualModel = result.model;
|
|
1115
|
+
} else if (
|
|
1116
|
+
isObj(result) &&
|
|
1117
|
+
isObj(result.thread) &&
|
|
1118
|
+
typeof result.thread.model === "string"
|
|
1119
|
+
) {
|
|
1120
|
+
this.actualModel = result.thread.model;
|
|
1121
|
+
}
|
|
1122
|
+
this.threadId = id;
|
|
1123
|
+
this.persistThreadId(id);
|
|
1124
|
+
return true;
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
const code = (err as { rpcCode?: number }).rpcCode;
|
|
1127
|
+
if (code === -32600 && attempt < RESUME_LOCK_ATTEMPTS - 1) {
|
|
1128
|
+
await delay(RESUME_LOCK_BACKOFF_MS);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
console.warn(
|
|
1132
|
+
`[codex] thread/resume ${this.customConnection ? "connection" : threadId} failed (${
|
|
1133
|
+
this.customConnection ? connectionFailureReason(err) : err instanceof Error ? err.message : String(err)
|
|
1134
|
+
}) — starting a fresh thread`,
|
|
1135
|
+
);
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// -- AgentSession ---------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
private persistThreadId(threadId: string): void {
|
|
1145
|
+
saveCodexThreadId(
|
|
1146
|
+
this.taskId,
|
|
1147
|
+
this.agentId,
|
|
1148
|
+
threadId,
|
|
1149
|
+
this.proc.durableSessionDir,
|
|
1150
|
+
this.attachCompatibilityKey,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
onEvent(handler: AgentEventHandler): () => void {
|
|
1155
|
+
this.handlers.add(handler);
|
|
1156
|
+
return () => this.handlers.delete(handler);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private emit(event: AgentEvent): void {
|
|
1160
|
+
if (this.closed && event.type !== "exit") return;
|
|
1161
|
+
for (const h of this.handlers) h(event);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
private get cliLabel(): string {
|
|
1165
|
+
return this.cliVersion ? `codex-cli ${this.cliVersion}` : "Codex";
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private notificationTurnId(params: unknown): string | null {
|
|
1169
|
+
if (!isObj(params)) return null;
|
|
1170
|
+
return str(params.turnId) || (isObj(params.turn) ? str(params.turn.id) : "") || null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private acknowledgeTurn(id: number, params?: unknown): void {
|
|
1174
|
+
const turn = this.turnStarts.get(id);
|
|
1175
|
+
if (!turn) return;
|
|
1176
|
+
if (turn.timer) clearTimeout(turn.timer);
|
|
1177
|
+
turn.timer = null;
|
|
1178
|
+
turn.accepted = true;
|
|
1179
|
+
turn.turnId = this.notificationTurnId(params) ?? turn.turnId;
|
|
1180
|
+
const owner = turn.turnId === null ? undefined : [...this.turnStarts].find(
|
|
1181
|
+
([otherId, other]) => otherId !== id && other.accepted && other.turnId === turn.turnId,
|
|
1182
|
+
);
|
|
1183
|
+
if (owner) {
|
|
1184
|
+
// Codex 0.164 steers a mid-turn start into the existing turn. One
|
|
1185
|
+
// provider completion must settle every logical delivery it absorbed.
|
|
1186
|
+
owner[1].settles += turn.settles;
|
|
1187
|
+
this.turnStarts.delete(id);
|
|
1188
|
+
this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
|
|
1189
|
+
for (const [alias, target] of this.mergedStarts) {
|
|
1190
|
+
if (target === id) this.mergedStarts.set(alias, owner[0]);
|
|
1191
|
+
}
|
|
1192
|
+
this.mergedStarts.set(id, owner[0]);
|
|
1193
|
+
}
|
|
1194
|
+
this.reconcileAcceptanceTimers();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private acknowledgeActivity(params: unknown, started = false): void {
|
|
1198
|
+
const turnId = this.notificationTurnId(params);
|
|
1199
|
+
if (turnId && [...this.turnStarts.values()].some(turn => turn.turnId === turnId)) {
|
|
1200
|
+
// Ordinary chunks still belong to the running turn. Only an explicit
|
|
1201
|
+
// new start notification can identify another start as merged here.
|
|
1202
|
+
const pending = started && [...this.turnStarts].find(([, turn]) => !turn.accepted);
|
|
1203
|
+
if (pending) this.acknowledgeTurn(pending[0], params);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
// Associate the first activity with an already acknowledged start before
|
|
1207
|
+
// attributing it to a newer queued request. Old output must not cancel a
|
|
1208
|
+
// different request's watchdog.
|
|
1209
|
+
const unknown = [...this.turnStarts].find(([, turn]) => turn.turnId === null && turn.accepted);
|
|
1210
|
+
if (unknown) {
|
|
1211
|
+
if (turnId) unknown[1].turnId = turnId;
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const pending = [...this.turnStarts].find(([, turn]) => !turn.accepted);
|
|
1215
|
+
if (pending) this.acknowledgeTurn(pending[0], params);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
private reconcileAcceptanceTimers(): void {
|
|
1219
|
+
// A late turn/start response can be queued behind accepted work on this
|
|
1220
|
+
// thread. Bound waiting only when the thread is free, and give queued
|
|
1221
|
+
// starts a fresh deadline when the last accepted message completes.
|
|
1222
|
+
const occupied = [...this.turnStarts.values()].some(turn => turn.accepted);
|
|
1223
|
+
for (const turn of this.turnStarts.values()) {
|
|
1224
|
+
if (occupied) {
|
|
1225
|
+
if (turn.timer) clearTimeout(turn.timer);
|
|
1226
|
+
turn.timer = null;
|
|
1227
|
+
} else if (!turn.timer) {
|
|
1228
|
+
turn.timer = setTimeout(() => {
|
|
1229
|
+
this.failTurn(`${this.cliLabel} didn't start this message within 60 seconds. Please try again.`);
|
|
1230
|
+
}, CODEX_TURN_START_TIMEOUT_MS);
|
|
1231
|
+
turn.timer.unref?.();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private clearTurnState(): void {
|
|
1237
|
+
this.turnRunning = false;
|
|
1238
|
+
this.outstandingTurns = 0;
|
|
1239
|
+
this.clearRateLimitsTimer();
|
|
1240
|
+
for (const turn of this.turnStarts.values()) if (turn.timer) clearTimeout(turn.timer);
|
|
1241
|
+
this.turnStarts.clear();
|
|
1242
|
+
this.mergedStarts.clear();
|
|
1243
|
+
this.firedRequests.clear();
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private failRpc(error: Record<string, unknown>, usage?: AgentUsage): void {
|
|
1247
|
+
const code = typeof error.code === "number" ? error.code : undefined;
|
|
1248
|
+
this.failTurn(`${this.cliLabel} rejected the message${code === undefined ? "" : ` (code ${code})`}: ${str(error.message, "Unknown error. Please try again.")}`, code, usage);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private reportError(message: string, rpcCode?: number, capture = true): void {
|
|
1252
|
+
if (this.customConnection) message = connectionFailureReason(message);
|
|
1253
|
+
reportAgentError(message, { taskId: this.taskId, agentId: this.agentId, engine: this.kind, rpcCode }, { capture });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** One terminal failure per generation. Late responses, completion and
|
|
1257
|
+
* process exit cannot end this turn twice or leak into a replacement. */
|
|
1258
|
+
private failTurn(message: string, code?: number, usage?: AgentUsage): void {
|
|
1259
|
+
if (this.failed || this.closed) return;
|
|
1260
|
+
this.failed = true;
|
|
1261
|
+
// The chat gets the upstream's status and a scrubbed excerpt (the same
|
|
1262
|
+
// function the sanitizer uses); telemetry and every other reader see only
|
|
1263
|
+
// the reason code, and no raw upstream text leaves this adapter.
|
|
1264
|
+
const detail = this.customConnection ? connectionFailureDetail(message, this.connectionKey) : null;
|
|
1265
|
+
if (this.customConnection) message = connectionFailureReason(message);
|
|
1266
|
+
this.reportError(message, code);
|
|
1267
|
+
const aborted = this.outstandingTurns > 0;
|
|
1268
|
+
const settles = this.outstandingTurns + [...this.turnStarts.values()]
|
|
1269
|
+
.reduce((merged, turn) => merged + turn.settles - 1, 0);
|
|
1270
|
+
this.clearTurnState();
|
|
1271
|
+
const display = agentErrorMessage(message);
|
|
1272
|
+
for (const event of mapCodexNotification("error", { error: { message } })) {
|
|
1273
|
+
this.emit(event.type === "error" ? { ...event, message: display,
|
|
1274
|
+
...(display === message ? {} : { diagnosticMessage: message }), ...(detail ? { detail } : {}), code, aborted } : event);
|
|
1275
|
+
}
|
|
1276
|
+
if (aborted) this.emit({ type: "turn_complete", aborted: true,
|
|
1277
|
+
...(settles > 1 ? { settles } : {}), ...(usage ? { usage } : {}) });
|
|
1278
|
+
for (const waiter of this.pending.values()) waiter.reject(new Error(message));
|
|
1279
|
+
this.pending.clear();
|
|
1280
|
+
void this.proc.close().catch(() => {});
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async send(text: string): Promise<void> {
|
|
1284
|
+
if (this.closed || this.failed) throw new Error(`${this.cliLabel} is no longer available. Please try again.`);
|
|
1285
|
+
this.outstandingTurns += 1;
|
|
1286
|
+
await this.ready;
|
|
1287
|
+
if (this.closed || this.failed) return;
|
|
1288
|
+
if (!this.handshakeOk || !this.threadId) {
|
|
1289
|
+
this.failTurn(`${this.cliLabel} could not start. Please try again.`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// `ready` may have waited across a runtime generation change. Recheck at
|
|
1294
|
+
// the turn/start write rather than trusting session construction time.
|
|
1295
|
+
try {
|
|
1296
|
+
requireContainerRuntimeOperational();
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
this.outstandingTurns -= 1;
|
|
1299
|
+
throw error;
|
|
1300
|
+
}
|
|
1301
|
+
const turnStartId = this.fireRequest("turn/start", {
|
|
1302
|
+
threadId: this.threadId,
|
|
1303
|
+
input: [{ type: "text", text }],
|
|
1304
|
+
});
|
|
1305
|
+
this.turnRunning = true;
|
|
1306
|
+
this.turnStarts.set(turnStartId, { timer: null, turnId: null, accepted: false, settles: 1 });
|
|
1307
|
+
this.reconcileAcceptanceTimers();
|
|
1308
|
+
this.scheduleRateLimitsRead();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async interrupt(): Promise<void> {
|
|
1312
|
+
if (this.closed || !this.threadId) return;
|
|
1313
|
+
// Cancel the thread's in-flight turn (codex's ESC). The app-server ends the
|
|
1314
|
+
// current turn and emits a `turn/completed`. No-op when nothing is running.
|
|
1315
|
+
// ⚠️ VERIFY-ON-MAC: confirm the codex interrupt method/params.
|
|
1316
|
+
this.fireRequest("turn/interrupt", { threadId: this.threadId });
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async resolvePermission(
|
|
1320
|
+
requestId: string,
|
|
1321
|
+
decision: "accept" | "decline",
|
|
1322
|
+
): Promise<void> {
|
|
1323
|
+
if (this.closed) return;
|
|
1324
|
+
const rpcId = this.approvals.get(requestId);
|
|
1325
|
+
if (rpcId === undefined) return;
|
|
1326
|
+
this.approvals.delete(requestId);
|
|
1327
|
+
this.respond(rpcId, {
|
|
1328
|
+
decision: decision === "accept" ? "accept" : "decline",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async close(): Promise<void> {
|
|
1333
|
+
if (this.closed) {
|
|
1334
|
+
await this.proc.close();
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
this.closed = true;
|
|
1338
|
+
this.clearTurnState();
|
|
1339
|
+
for (const waiter of this.pending.values()) {
|
|
1340
|
+
waiter.reject(new Error("session closed"));
|
|
1341
|
+
}
|
|
1342
|
+
this.pending.clear();
|
|
1343
|
+
await this.proc.close();
|
|
1344
|
+
this.emit({ type: "exit", code: 0 });
|
|
1345
|
+
this.handlers.clear();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Register the Codex adapter at module load (ADR-021). The `-c model=` config
|
|
1350
|
+
// override above means `model` flows from the roster entry to the app-server.
|
|
1351
|
+
register({
|
|
1352
|
+
kind: "codex",
|
|
1353
|
+
label: "Codex",
|
|
1354
|
+
supportedModels: () => [...CODEX_MODELS],
|
|
1355
|
+
defaultModel: CODEX_DEFAULT_MODEL,
|
|
1356
|
+
supportedEfforts: () => [...CODEX_EFFORTS],
|
|
1357
|
+
defaultEffort: CODEX_DEFAULT_EFFORT,
|
|
1358
|
+
// ADR-157: enforceable by construction (the --disable flags above). The
|
|
1359
|
+
// communicator profile is Claude-only; Codex never declared it.
|
|
1360
|
+
executionProfiles: [{ id: "astronaut", mechanism: "codex-disable-multi-agent-v1" }],
|
|
1361
|
+
// Usable only when the owner's ~/.codex login exists — task-up copies it into
|
|
1362
|
+
// each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
|
|
1363
|
+
// (ADR-044 P2).
|
|
1364
|
+
available: () =>
|
|
1365
|
+
existsSync(
|
|
1366
|
+
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
1367
|
+
),
|
|
1368
|
+
// Forward `cliConfigOverrides` too: the Uai Managed Engine's gateway
|
|
1369
|
+
// provider block rides it, and this destructuring silently dropped it on
|
|
1370
|
+
// the first live gpt-5-mini smoke (2026-09-12, host 0.9.136 — codex still
|
|
1371
|
+
// dialed api.openai.com).
|
|
1372
|
+
create: async ({
|
|
1373
|
+
taskId,
|
|
1374
|
+
agent,
|
|
1375
|
+
environment,
|
|
1376
|
+
systemPreamble,
|
|
1377
|
+
agentEnv,
|
|
1378
|
+
attachCompatibilityKey,
|
|
1379
|
+
cliConfigOverrides,
|
|
1380
|
+
executionProfile,
|
|
1381
|
+
}) =>
|
|
1382
|
+
new CodexSession({
|
|
1383
|
+
taskId,
|
|
1384
|
+
agent,
|
|
1385
|
+
environment,
|
|
1386
|
+
systemPreamble,
|
|
1387
|
+
agentEnv,
|
|
1388
|
+
attachCompatibilityKey,
|
|
1389
|
+
cliConfigOverrides,
|
|
1390
|
+
executionProfile,
|
|
1391
|
+
}),
|
|
1392
|
+
});
|