@moondeck/station 0.0.0-stage → 1.0.0

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.
Files changed (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -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
+ });