@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * CodexAgentRunner (SPEC §9): AgentRunner over the locally installed OpenAI
3
+ * Codex CLI. Phase turns use Codex's JSONL exec protocol and native thread
4
+ * resumption; non-phase judgments run as isolated, tool-free ephemeral turns.
5
+ */
6
+ import type { StreamPart } from '../../store/streams/types'
7
+ import { spawnCliStream } from './cli-stream'
8
+ import {
9
+ agentInvocation,
10
+ type AgentContinueOpts,
11
+ type AgentRunner,
12
+ type AgentSessionHandle,
13
+ type AgentStartOpts,
14
+ type AgentTurnFailure,
15
+ type AgentTurnResult,
16
+ type SessionStreamEmitter,
17
+ type Transcript,
18
+ } from '../types'
19
+ import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
20
+ import { classifyProviderError, configurationFailure, credentialFailure } from './provider-error'
21
+ import {
22
+ abortPart,
23
+ errorPart,
24
+ finishPart,
25
+ finishStepPart,
26
+ promptPart,
27
+ reasoningDeltaPart,
28
+ reasoningEndPart,
29
+ reasoningStartPart,
30
+ startPart,
31
+ startStepPart,
32
+ textDeltaPart,
33
+ textEndPart,
34
+ textStartPart,
35
+ toolInputPart,
36
+ toolOutputPart,
37
+ } from './stream-parts'
38
+ import type { RuntimeUsabilityInput, RuntimeUsabilityResult } from './runtime'
39
+ import { sessionEnv } from './session-env'
40
+
41
+ export interface CodexCliInvocation {
42
+ /** Arguments after the `codex` executable. */
43
+ args: string[]
44
+ cwd: string
45
+ env: Record<string, string>
46
+ signal?: AbortSignal
47
+ }
48
+
49
+ export interface CodexCliResult {
50
+ stdout: string
51
+ stderr: string
52
+ exitCode: number
53
+ }
54
+
55
+ /** Injectable direct-process boundary used by deterministic adapter tests. */
56
+ export type CodexCliRunFn = (invocation: CodexCliInvocation) => Promise<CodexCliResult>
57
+
58
+ /** A streaming CLI turn: decoded JSONL lines as they arrive, plus the
59
+ * completed result. The consumer accumulates the lines it needs. */
60
+ export interface CodexCliStreamHandle {
61
+ lines: AsyncIterable<string>
62
+ result: Promise<CodexCliResult>
63
+ }
64
+
65
+ /** Injectable streaming boundary: production spawns the same argv; tests
66
+ * script line-at-a-time output. */
67
+ export type CodexCliStreamFn = (invocation: CodexCliInvocation) => CodexCliStreamHandle
68
+
69
+ const runCodexCli: CodexCliRunFn = async (invocation) => {
70
+ const proc = Bun.spawn(['codex', ...invocation.args], {
71
+ cwd: invocation.cwd,
72
+ env: invocation.env,
73
+ stdin: 'ignore',
74
+ stdout: 'pipe',
75
+ stderr: 'pipe',
76
+ ...(invocation.signal !== undefined ? { signal: invocation.signal } : {}),
77
+ })
78
+ const [stdout, stderr, exitCode] = await Promise.all([
79
+ new Response(proc.stdout).text(),
80
+ new Response(proc.stderr).text(),
81
+ proc.exited,
82
+ ])
83
+ return { stdout, stderr, exitCode }
84
+ }
85
+
86
+ const runCodexCliStream: CodexCliStreamFn = (invocation) => spawnCliStream('codex', invocation)
87
+
88
+ /** Verify both the local executable and Codex login for init suggestions. */
89
+ export async function isCodexRuntimeUsable(
90
+ input: RuntimeUsabilityInput,
91
+ runCli: CodexCliRunFn = runCodexCli,
92
+ ): Promise<RuntimeUsabilityResult> {
93
+ const env = Object.fromEntries(
94
+ Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
95
+ )
96
+ try {
97
+ const result = await runCli({ args: ['login', 'status'], cwd: input.cwd, env })
98
+ return result.exitCode === 0
99
+ ? { usable: true, reason: 'Codex CLI is installed and logged in' }
100
+ : {
101
+ usable: false,
102
+ reason: result.stderr.trim() || result.stdout.trim() || 'Codex is not logged in',
103
+ }
104
+ } catch (error) {
105
+ return {
106
+ usable: false,
107
+ reason: error instanceof Error ? error.message : String(error),
108
+ }
109
+ }
110
+ }
111
+
112
+ interface JsonRecord {
113
+ [key: string]: unknown
114
+ }
115
+
116
+ interface ParsedCodexOutput {
117
+ events: JsonRecord[]
118
+ malformedLines: string[]
119
+ threadId?: string
120
+ assistantText: string[]
121
+ usage: { inputTokens: number; outputTokens: number }
122
+ completed: boolean
123
+ failureMessages: string[]
124
+ statuses: number[]
125
+ codes: Array<string | number>
126
+ toolItems: string[]
127
+ }
128
+
129
+ interface CodexTurn {
130
+ text: string
131
+ usage: { inputTokens: number; outputTokens: number }
132
+ failure?: AgentTurnFailure
133
+ cli: CodexCliResult
134
+ events: JsonRecord[]
135
+ malformedLines: string[]
136
+ threadId?: string
137
+ toolItems: string[]
138
+ }
139
+
140
+ interface TurnRecord {
141
+ turn: number
142
+ prompt: string
143
+ text: string
144
+ usage: { inputTokens: number; outputTokens: number }
145
+ failure?: AgentTurnFailure
146
+ cli: CodexCliResult
147
+ events: JsonRecord[]
148
+ malformedLines: string[]
149
+ threadId?: string
150
+ toolItems: string[]
151
+ }
152
+
153
+ interface SessionState {
154
+ opts: AgentStartOpts
155
+ model?: string
156
+ /** Absent when start failed before Codex emitted `thread.started`. */
157
+ nativeThreadId?: string
158
+ turns: TurnRecord[]
159
+ }
160
+
161
+ const CODEX_JSON_ARG = '--json'
162
+ const CODEX_MODEL_ARG = '--model'
163
+ const CODEX_MODEL_ALIAS = '-m'
164
+
165
+ /** Structural separator before Codex's positional prompt. */
166
+ export const CODEX_PROMPT_BOUNDARY = '--'
167
+
168
+ /** Options that select the model or the JSONL protocol parsed below. */
169
+ export const CODEX_OWNED_ARGS = [CODEX_JSON_ARG, CODEX_MODEL_ARG, CODEX_MODEL_ALIAS] as const
170
+
171
+ const MISSING_CLI_MESSAGE =
172
+ 'codex runtime: Codex CLI executable "codex" was not found. ' +
173
+ 'Install the Codex CLI (https://developers.openai.com/codex/cli), run `codex login`, ' +
174
+ 'and complete authentication before running Autobuild.'
175
+
176
+ const SHELL_ENV_INHERIT = 'shell_environment_policy.inherit=all'
177
+
178
+ /** Feature gates disabled for tool-free one-shot judgments. The isolated
179
+ * invocation also ignores user config/rules and runs in a read-only sandbox;
180
+ * emitted item kinds are still checked fail-closed for future Codex versions. */
181
+ const ONE_SHOT_DISABLED_FEATURES = [
182
+ 'shell_tool',
183
+ 'unified_exec',
184
+ 'standalone_web_search',
185
+ 'apps',
186
+ 'plugins',
187
+ 'multi_agent',
188
+ 'multi_agent_v2',
189
+ 'image_generation',
190
+ 'computer_use',
191
+ 'browser_use',
192
+ 'browser_use_external',
193
+ 'browser_use_full_cdp_access',
194
+ ] as const
195
+
196
+ export class CodexAgentRunner implements AgentRunner, OneShotCompletion {
197
+ readonly name = 'codex'
198
+
199
+ private readonly runCli: CodexCliRunFn
200
+ private readonly runCliStream: CodexCliStreamFn | undefined
201
+ private readonly createSessionId: () => string
202
+ private readonly sessions = new Map<string, SessionState>()
203
+
204
+ constructor(
205
+ opts: {
206
+ runCli?: CodexCliRunFn
207
+ runCliStream?: CodexCliStreamFn
208
+ createSessionId?: () => string
209
+ } = {},
210
+ ) {
211
+ this.runCli = opts.runCli ?? runCodexCli
212
+ // A test injecting only the buffered boundary takes the buffered
213
+ // translation path for streaming turns (degraded latency, same content);
214
+ // production gets the live streaming boundary.
215
+ this.runCliStream =
216
+ opts.runCliStream ?? (opts.runCli !== undefined ? undefined : runCodexCliStream)
217
+ this.createSessionId = opts.createSessionId ?? (() => crypto.randomUUID())
218
+ }
219
+
220
+ async complete(input: OneShotCompletionInput): Promise<OneShotCompletionResult> {
221
+ const args = [
222
+ 'exec',
223
+ CODEX_JSON_ARG,
224
+ '--ephemeral',
225
+ '--ignore-user-config',
226
+ '--ignore-rules',
227
+ '--sandbox',
228
+ 'read-only',
229
+ // The legacy web_search feature gates emit deprecation warning items in
230
+ // current Codex releases. This supported config value disables search
231
+ // without making a healthy one-shot look like tool activity.
232
+ '-c',
233
+ 'web_search="disabled"',
234
+ ]
235
+ for (const feature of ONE_SHOT_DISABLED_FEATURES) args.push('--disable', feature)
236
+ if (input.model !== undefined) args.push(CODEX_MODEL_ARG, input.model)
237
+ args.push(...(input.args ?? []), CODEX_PROMPT_BOUNDARY, input.prompt)
238
+
239
+ const turn = await this.runPrompt({
240
+ args,
241
+ cwd: input.cwd,
242
+ env: sessionEnv(input.env),
243
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
244
+ })
245
+ if (turn.failure !== undefined) throw new Error(turn.failure.message)
246
+ if (turn.toolItems.length > 0) {
247
+ throw new Error(
248
+ `codex runtime: tool-free completion emitted tool item(s): ${turn.toolItems.join(', ')}`,
249
+ )
250
+ }
251
+ return { text: turn.text }
252
+ }
253
+
254
+ async start(
255
+ opts: AgentStartOpts,
256
+ ): Promise<{ session: AgentSessionHandle; result: AgentTurnResult }> {
257
+ // Codex Agent Skills use `$name`, unlike Claude/Pi's slash invocation.
258
+ const prompt = `$${opts.skill} ${agentInvocation(opts)}`
259
+ const executed = await this.runTurn(prompt, opts)
260
+ const turn: CodexTurn =
261
+ executed.failure === undefined && executed.threadId === undefined
262
+ ? {
263
+ ...executed,
264
+ failure: classifyProviderError(
265
+ `${this.name} runtime: Codex CLI stream ended without a thread.started event`,
266
+ ),
267
+ }
268
+ : executed
269
+ const id = turn.threadId ?? this.createSessionId()
270
+ const session: AgentSessionHandle = {
271
+ id,
272
+ runner: this.name,
273
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
274
+ }
275
+ this.sessions.set(id, {
276
+ opts,
277
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
278
+ ...(turn.threadId !== undefined ? { nativeThreadId: turn.threadId } : {}),
279
+ turns: [this.turnRecord(1, prompt, turn)],
280
+ })
281
+ return { session, result: this.toResult(turn) }
282
+ }
283
+
284
+ async continue(
285
+ session: AgentSessionHandle,
286
+ message: string,
287
+ opts?: AgentContinueOpts,
288
+ ): Promise<AgentTurnResult> {
289
+ const state = this.liveState(session, 'continue')
290
+ if (state.nativeThreadId === undefined) {
291
+ throw new Error(
292
+ `${this.name}: cannot continue session "${session.id}" because Codex start failed before thread.started`,
293
+ )
294
+ }
295
+ // §10/D8: a continued turn gets this round's env. §9: it also streams
296
+ // through THIS turn's emitter only — the start turn's emitter (if any)
297
+ // belongs to its own bracket and never receives this turn's parts,
298
+ // matching the pi adapter's per-turn forwarding.
299
+ const turnOpts: AgentStartOpts = {
300
+ ...(opts?.env !== undefined
301
+ ? { ...state.opts, env: { ...state.opts.env, ...opts.env } }
302
+ : state.opts),
303
+ stream: opts?.stream,
304
+ }
305
+ const turn = await this.runTurn(message, turnOpts, state.nativeThreadId, opts?.signal)
306
+ state.turns.push(this.turnRecord(state.turns.length + 1, message, turn))
307
+ return this.toResult(turn)
308
+ }
309
+
310
+ async end(session: AgentSessionHandle): Promise<Transcript> {
311
+ const state = this.liveState(session, 'end')
312
+ this.sessions.delete(session.id)
313
+
314
+ const usage = { inputTokens: 0, outputTokens: 0, turns: 0 }
315
+ for (const turn of state.turns) {
316
+ usage.inputTokens += turn.usage.inputTokens
317
+ usage.outputTokens += turn.usage.outputTokens
318
+ usage.turns += 1
319
+ }
320
+
321
+ return {
322
+ content: JSON.stringify(
323
+ {
324
+ session: session.id,
325
+ ...(state.nativeThreadId !== undefined ? { nativeThreadId: state.nativeThreadId } : {}),
326
+ skill: state.opts.skill,
327
+ invocation: agentInvocation(state.opts),
328
+ ...(state.opts.buildSlug !== undefined ? { buildSlug: state.opts.buildSlug } : {}),
329
+ turns: state.turns,
330
+ },
331
+ null,
332
+ 2,
333
+ ),
334
+ metadata: {
335
+ runner: this.name,
336
+ ...(state.model !== undefined ? { model: state.model } : {}),
337
+ usage,
338
+ },
339
+ }
340
+ }
341
+
342
+ private liveState(session: AgentSessionHandle, op: 'continue' | 'end'): SessionState {
343
+ const state = this.sessions.get(session.id)
344
+ if (state === undefined) {
345
+ throw new Error(`${this.name}: ${op} on unknown session "${session.id}"`)
346
+ }
347
+ return state
348
+ }
349
+
350
+ private async runTurn(
351
+ prompt: string,
352
+ opts: AgentStartOpts,
353
+ resume?: string,
354
+ signal: AbortSignal | undefined = opts.signal,
355
+ ): Promise<CodexTurn> {
356
+ const args = ['exec']
357
+ if (resume !== undefined) args.push('resume')
358
+ args.push(
359
+ CODEX_JSON_ARG,
360
+ '--dangerously-bypass-approvals-and-sandbox',
361
+ '--config',
362
+ SHELL_ENV_INHERIT,
363
+ )
364
+ if (opts.model !== undefined) args.push(CODEX_MODEL_ARG, opts.model)
365
+ args.push(...(opts.args ?? []))
366
+ if (resume !== undefined) args.push(resume)
367
+ args.push(CODEX_PROMPT_BOUNDARY, prompt)
368
+ const invocation: CodexCliInvocation = {
369
+ args,
370
+ cwd: opts.workspacePath,
371
+ env: sessionEnv(opts.env),
372
+ ...(signal !== undefined ? { signal } : {}),
373
+ }
374
+ if (opts.stream === undefined) return this.runPrompt(invocation)
375
+
376
+ // Streaming turn: translate while the CLI runs. Without an injected
377
+ // streaming boundary (offline fakes), the buffered path still translates
378
+ // after completion — degraded latency, same content.
379
+ const emitter = opts.stream
380
+ emitter.append([promptPart(prompt), startPart(crypto.randomUUID())])
381
+ let turn: CodexTurn
382
+ if (this.runCliStream !== undefined) {
383
+ turn = await this.runStreamingPrompt(invocation, emitter)
384
+ } else {
385
+ turn = await this.runPrompt(invocation)
386
+ translateBufferedCodexTurn(turn, (parts) => emitter.append(parts))
387
+ }
388
+ this.emitTurnEnd(turn, emitter, signal)
389
+ return turn
390
+ }
391
+
392
+ /** Completed-turn, failed-turn, and cancelled-turn closing parts. */
393
+ private emitTurnEnd(
394
+ turn: CodexTurn,
395
+ emitter: SessionStreamEmitter,
396
+ signal: AbortSignal | undefined,
397
+ ): void {
398
+ if (turn.failure === undefined) {
399
+ emitter.append([finishPart()])
400
+ return
401
+ }
402
+ if (signal?.aborted) {
403
+ const reason = signal.reason
404
+ emitter.append([
405
+ abortPart(reason instanceof Error ? reason.message : 'codex runtime: turn aborted'),
406
+ ])
407
+ return
408
+ }
409
+ emitter.append([errorPart(turn.failure.message)])
410
+ }
411
+
412
+ /** Live translation path: consume JSONL lines as they arrive while the
413
+ * same records feed the ordinary accumulators. */
414
+ private async runStreamingPrompt(
415
+ invocation: CodexCliInvocation,
416
+ emitter: SessionStreamEmitter,
417
+ ): Promise<CodexTurn> {
418
+ let handle: CodexCliStreamHandle
419
+ try {
420
+ handle = this.runCliStream!(invocation)
421
+ } catch (error) {
422
+ return launchFailure(error)
423
+ }
424
+ const acc = new CodexOutputAccumulator()
425
+ const translator = createCodexStreamTranslator((parts) => emitter.append(parts))
426
+ try {
427
+ for await (const line of handle.lines) {
428
+ let value: unknown
429
+ try {
430
+ value = JSON.parse(line)
431
+ } catch {
432
+ acc.push(line)
433
+ continue
434
+ }
435
+ if (!isRecord(value)) {
436
+ acc.push(line)
437
+ continue
438
+ }
439
+ acc.pushRecord(value)
440
+ translator.onEvent(value)
441
+ }
442
+ } catch {
443
+ // The result promise below still carries the CLI's exit and stderr.
444
+ }
445
+ const cli = await handle.result
446
+ return this.finishTurn(cli, acc.snapshot())
447
+ }
448
+
449
+ private async runPrompt(invocation: CodexCliInvocation): Promise<CodexTurn> {
450
+ let cli: CodexCliResult
451
+ try {
452
+ cli = await this.runCli(invocation)
453
+ } catch (error) {
454
+ return launchFailure(error)
455
+ }
456
+ return this.finishTurn(cli, parseCodexOutput(cli.stdout))
457
+ }
458
+
459
+ /** Shared post-processing of a completed CLI run. */
460
+ private finishTurn(cli: CodexCliResult, parsed: ParsedCodexOutput): CodexTurn {
461
+ const text = parsed.assistantText.join('\n')
462
+ let failureMessage: string | undefined
463
+ let loggedOut = false
464
+ if (cli.exitCode !== 0 || parsed.failureMessages.length > 0) {
465
+ const provider = parsed.failureMessages.find((message) => message.length > 0)
466
+ const stderr = nonempty(cli.stderr)
467
+ const detail = provider ?? stderr
468
+ loggedOut = detail !== undefined && looksLoggedOut(detail)
469
+ if (loggedOut) {
470
+ failureMessage =
471
+ `codex runtime: Codex CLI executable "codex" is not authenticated. ` +
472
+ `Run \`codex login\` and complete authentication. ${detail}`
473
+ } else {
474
+ failureMessage =
475
+ detail ??
476
+ `${this.name} runtime: Codex CLI exited with code ${cli.exitCode} without error text`
477
+ }
478
+ } else if (parsed.malformedLines.length > 0) {
479
+ failureMessage = `${this.name} runtime: Codex CLI emitted malformed JSONL output`
480
+ } else if (!parsed.completed) {
481
+ failureMessage = `${this.name} runtime: Codex CLI stream ended without a turn.completed event`
482
+ }
483
+
484
+ return {
485
+ text,
486
+ usage: parsed.usage,
487
+ ...(failureMessage !== undefined
488
+ ? {
489
+ failure: loggedOut
490
+ ? credentialFailure(failureMessage)
491
+ : classifyProviderError(failureMessage, {
492
+ status: parsed.statuses[0],
493
+ codes: parsed.codes,
494
+ }),
495
+ }
496
+ : {}),
497
+ cli,
498
+ events: parsed.events,
499
+ malformedLines: parsed.malformedLines,
500
+ ...(parsed.threadId !== undefined ? { threadId: parsed.threadId } : {}),
501
+ toolItems: parsed.toolItems,
502
+ }
503
+ }
504
+
505
+ private turnRecord(turnNumber: number, prompt: string, turn: CodexTurn): TurnRecord {
506
+ return {
507
+ turn: turnNumber,
508
+ prompt,
509
+ text: turn.text,
510
+ usage: turn.usage,
511
+ ...(turn.failure !== undefined ? { failure: turn.failure } : {}),
512
+ cli: turn.cli,
513
+ events: turn.events,
514
+ malformedLines: turn.malformedLines,
515
+ ...(turn.threadId !== undefined ? { threadId: turn.threadId } : {}),
516
+ toolItems: turn.toolItems,
517
+ }
518
+ }
519
+
520
+ private toResult(turn: CodexTurn): AgentTurnResult {
521
+ const base = { text: turn.text, usage: { ...turn.usage, turns: 1 } }
522
+ return turn.failure === undefined
523
+ ? { kind: 'completed', ...base }
524
+ : { kind: 'failed', ...base, failure: turn.failure }
525
+ }
526
+ }
527
+
528
+ function parseCodexOutput(stdout: string): ParsedCodexOutput {
529
+ const acc = new CodexOutputAccumulator()
530
+ for (const line of stdout.split(/\r?\n/)) acc.push(line)
531
+ return acc.snapshot()
532
+ }
533
+
534
+ /**
535
+ * Incremental accumulator over Codex's JSONL exec protocol, shared by the
536
+ * buffered parse and the streaming path (which also feeds the stream
537
+ * translator below). Collects the same shape `parseCodexOutput` always
538
+ * returned so outcome classification is byte-identical in both paths.
539
+ */
540
+ class CodexOutputAccumulator {
541
+ readonly events: JsonRecord[] = []
542
+ readonly malformedLines: string[] = []
543
+ readonly assistantText: string[] = []
544
+ readonly failureMessages: string[] = []
545
+ readonly statuses: number[] = []
546
+ readonly codes: Array<string | number> = []
547
+ readonly toolItems: string[] = []
548
+ usage = { inputTokens: 0, outputTokens: 0 }
549
+ completed = false
550
+ threadId: string | undefined
551
+
552
+ push(line: string): void {
553
+ if (line.trim() === '') return
554
+ let value: unknown
555
+ try {
556
+ value = JSON.parse(line)
557
+ } catch {
558
+ this.malformedLines.push(line)
559
+ return
560
+ }
561
+ if (!isRecord(value)) {
562
+ this.malformedLines.push(line)
563
+ return
564
+ }
565
+ this.pushRecord(value)
566
+ }
567
+
568
+ pushRecord(value: JsonRecord): void {
569
+ this.events.push(value)
570
+
571
+ if (value.type === 'thread.started') {
572
+ const id = stringField(value, 'thread_id') ?? stringField(value, 'threadId')
573
+ if (id !== undefined && id.length > 0) this.threadId = id
574
+ }
575
+
576
+ if (value.type === 'item.completed' || value.type === 'item.started') {
577
+ const item = isRecord(value.item) ? value.item : undefined
578
+ const itemType = stringField(item, 'type')
579
+ if (value.type === 'item.completed' && itemType === 'agent_message') {
580
+ const text = itemText(item)
581
+ if (text !== undefined) this.assistantText.push(text)
582
+ }
583
+ // Codex represents non-fatal warnings/deprecation notices as `error`
584
+ // thread items. They are transcript evidence, not executed tools. Every
585
+ // other unknown item remains fail-closed so newly added capabilities
586
+ // cannot silently weaken one-shot isolation.
587
+ if (
588
+ itemType !== undefined &&
589
+ itemType !== 'agent_message' &&
590
+ itemType !== 'reasoning' &&
591
+ itemType !== 'error'
592
+ ) {
593
+ this.toolItems.push(itemType)
594
+ }
595
+ }
596
+
597
+ if (value.type === 'turn.completed') {
598
+ this.completed = true
599
+ const usage = isRecord(value.usage) ? value.usage : undefined
600
+ this.usage = {
601
+ inputTokens: tokenCount(usage?.input_tokens ?? usage?.inputTokens),
602
+ outputTokens: tokenCount(usage?.output_tokens ?? usage?.outputTokens),
603
+ }
604
+ }
605
+
606
+ if (value.type === 'turn.failed' || value.type === 'error') {
607
+ const nested = isRecord(value.error) ? value.error : undefined
608
+ const message =
609
+ stringField(nested, 'message') ??
610
+ stringField(value, 'message') ??
611
+ stringField(value, 'error')
612
+ if (message !== undefined) this.failureMessages.push(message)
613
+ collectHints(value, this)
614
+ if (nested !== undefined) collectHints(nested, this)
615
+ }
616
+ }
617
+
618
+ snapshot(): ParsedCodexOutput {
619
+ return {
620
+ events: [...this.events],
621
+ malformedLines: [...this.malformedLines],
622
+ ...(this.threadId !== undefined ? { threadId: this.threadId } : {}),
623
+ assistantText: [...this.assistantText],
624
+ usage: { ...this.usage },
625
+ completed: this.completed,
626
+ failureMessages: [...this.failureMessages],
627
+ statuses: [...this.statuses],
628
+ codes: [...this.codes],
629
+ toolItems: [...this.toolItems],
630
+ }
631
+ }
632
+ }
633
+
634
+ function itemText(item: JsonRecord | undefined): string | undefined {
635
+ const direct = stringField(item, 'text')
636
+ if (direct !== undefined) return direct
637
+ const content = item?.content
638
+ if (!Array.isArray(content)) return undefined
639
+ const parts: string[] = []
640
+ for (const block of content) {
641
+ if (!isRecord(block)) continue
642
+ const text = stringField(block, 'text')
643
+ if (text !== undefined) parts.push(text)
644
+ }
645
+ return parts.length > 0 ? parts.join('') : undefined
646
+ }
647
+
648
+ function collectHints(value: JsonRecord, acc: CodexOutputAccumulator): void {
649
+ for (const key of ['status', 'status_code', 'statusCode']) {
650
+ const status = value[key]
651
+ if (typeof status === 'number' && Number.isFinite(status)) acc.statuses.push(status)
652
+ }
653
+ for (const key of ['code', 'type', 'category']) {
654
+ const code = value[key]
655
+ if (typeof code === 'string' || typeof code === 'number') acc.codes.push(code)
656
+ }
657
+ }
658
+
659
+ function tokenCount(value: unknown): number {
660
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0
661
+ }
662
+
663
+ function isRecord(value: unknown): value is JsonRecord {
664
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
665
+ }
666
+
667
+ function stringField(value: JsonRecord | undefined, key: string): string | undefined {
668
+ const field = value?.[key]
669
+ return typeof field === 'string' ? field : undefined
670
+ }
671
+
672
+ function nonempty(value: string | undefined): string | undefined {
673
+ return value !== undefined && value.length > 0 ? value : undefined
674
+ }
675
+
676
+ function looksLoggedOut(message: string): boolean {
677
+ return /not logged in|not authenticated|codex login|authentication required/i.test(message)
678
+ }
679
+
680
+ function isEnoent(error: unknown): boolean {
681
+ return isRecord(error) && error.code === 'ENOENT'
682
+ }
683
+
684
+ function errorText(error: unknown): string {
685
+ return error instanceof Error ? error.message : String(error)
686
+ }
687
+
688
+ /** A CLI that never ran: missing executable vs. a failed launch. */
689
+ function launchFailure(error: unknown): CodexTurn {
690
+ const missing = isEnoent(error)
691
+ const message = missing
692
+ ? MISSING_CLI_MESSAGE
693
+ : `codex runtime: failed to launch Codex CLI executable "codex": ${errorText(error)}`
694
+ return {
695
+ text: '',
696
+ usage: { inputTokens: 0, outputTokens: 0 },
697
+ failure: missing ? configurationFailure(message) : classifyProviderError(message),
698
+ cli: { stdout: '', stderr: errorText(error), exitCode: -1 },
699
+ events: [],
700
+ malformedLines: [],
701
+ toolItems: [],
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Live stream translation (SPEC §9): turn.started → start-step; tool-shaped
707
+ * item.started → tool-input-available; item.completed → tool output, whole
708
+ * agent-message text, or reasoning parts; item.updated → text deltas when it
709
+ * carries incremental text. Unrecognized records produce no parts.
710
+ *
711
+ * Items without an `id` still correlate across started/updated/completed:
712
+ * each item kind's events arrive sequentially, so a stable per-kind key is
713
+ * assigned when the item is first seen and reused until the item completes.
714
+ */
715
+ function createCodexStreamTranslator(emit: (parts: StreamPart[]) => void): {
716
+ onEvent(event: JsonRecord): void
717
+ } {
718
+ const openText = new Set<string>()
719
+ const keys = createItemKeyTracker()
720
+ const itemKey = keys.keyFor
721
+
722
+ return {
723
+ onEvent(event) {
724
+ switch (event.type) {
725
+ case 'turn.started':
726
+ emit([startStepPart()])
727
+ break
728
+ case 'item.started': {
729
+ const item = isRecord(event.item) ? event.item : undefined
730
+ const itemType = stringField(item, 'type')
731
+ // agent_message/reasoning items stream at item.updated/item.completed;
732
+ // a bare item.started opens nothing yet (the first delta does).
733
+ if (itemType === undefined || itemType === 'agent_message' || itemType === 'reasoning') {
734
+ break
735
+ }
736
+ emit([toolInputPart(itemKey(item, itemType), itemType, itemCommand(item) ?? item)])
737
+ break
738
+ }
739
+ case 'item.updated': {
740
+ const item = isRecord(event.item) ? event.item : undefined
741
+ if (item === undefined) break
742
+ if (stringField(item, 'type') !== 'agent_message') break
743
+ const delta = itemText(item)
744
+ if (delta === undefined || delta.length === 0) break
745
+ const id = itemKey(item, 'agent_message')
746
+ if (!openText.has(id)) emit([textStartPart(id)])
747
+ openText.add(id)
748
+ emit([textDeltaPart(id, delta)])
749
+ break
750
+ }
751
+ case 'item.completed': {
752
+ const item = isRecord(event.item) ? event.item : undefined
753
+ const itemType = stringField(item, 'type')
754
+ if (itemType === 'agent_message') {
755
+ const id = itemKey(item, itemType)
756
+ const text = itemText(item)
757
+ if (openText.has(id)) {
758
+ emit([textEndPart(id)])
759
+ openText.delete(id)
760
+ } else if (text !== undefined && text.length > 0) {
761
+ emit([textStartPart(id), textDeltaPart(id, text), textEndPart(id)])
762
+ }
763
+ keys.complete(item, itemType)
764
+ break
765
+ }
766
+ if (itemType === 'reasoning') {
767
+ const id = itemKey(item, itemType)
768
+ const text = itemText(item)
769
+ if (text !== undefined && text.length > 0) {
770
+ emit([reasoningStartPart(id), reasoningDeltaPart(id, text), reasoningEndPart(id)])
771
+ }
772
+ keys.complete(item, itemType)
773
+ break
774
+ }
775
+ if (itemType === undefined) break
776
+ emit([toolOutputPart(itemKey(item, itemType), itemCommand(item) ?? item)])
777
+ keys.complete(item, itemType)
778
+ break
779
+ }
780
+ case 'turn.completed':
781
+ keys.clear()
782
+ emit([finishStepPart()])
783
+ break
784
+ default:
785
+ break
786
+ }
787
+ },
788
+ }
789
+ }
790
+
791
+ /** Codex items carry their human-readable payload in `command`/`input`. */
792
+ function itemCommand(item: JsonRecord | undefined): unknown {
793
+ const command = item?.command
794
+ if (typeof command === 'string') return command
795
+ const input = item?.input
796
+ if (input !== undefined) return input
797
+ return undefined
798
+ }
799
+
800
+ /**
801
+ * Stable per-item keys (SPEC §9 translation): items carrying an `id` key by
802
+ * it; items without one key by kind and occurrence — each kind's events
803
+ * arrive sequentially, so the slot assigned when the item is first seen is
804
+ * reused until it completes, and a per-turn reset frees the counters.
805
+ */
806
+ function createItemKeyTracker(): {
807
+ keyFor(item: JsonRecord | undefined, itemType: string | undefined): string
808
+ complete(item: JsonRecord | undefined, itemType: string | undefined): void
809
+ clear(): void
810
+ } {
811
+ const counters = new Map<string, number>()
812
+ const lastIdless = new Map<string, string>()
813
+ return {
814
+ keyFor(item, itemType) {
815
+ const id = stringField(item, 'id')
816
+ if (id !== undefined && id.length > 0) return id
817
+ const kind = itemType ?? 'item'
818
+ const known = lastIdless.get(kind)
819
+ if (known !== undefined) return known
820
+ const n = (counters.get(kind) ?? 0) + 1
821
+ counters.set(kind, n)
822
+ const key = `cx-${kind}-${n}`
823
+ lastIdless.set(kind, key)
824
+ return key
825
+ },
826
+ /** An id-less item's kind slot frees up when the item completes. */
827
+ complete(item, itemType) {
828
+ if (stringField(item, 'id') === undefined) lastIdless.delete(itemType ?? 'item')
829
+ },
830
+ clear() {
831
+ lastIdless.clear()
832
+ },
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Buffered-path translation (degraded latency, same content): walk the
838
+ * completed turn's events and emit whole messages — start-step/finish-step
839
+ * around the turn, text/reasoning parts from items, and tool input/output
840
+ * from non-message items.
841
+ */
842
+ function translateBufferedCodexTurn(turn: CodexTurn, emit: (parts: StreamPart[]) => void): void {
843
+ const keys = createItemKeyTracker()
844
+ emit([startStepPart()])
845
+ for (const event of turn.events) {
846
+ if (event.type !== 'item.completed' && event.type !== 'item.started') continue
847
+ const item = isRecord(event.item) ? event.item : undefined
848
+ const itemType = stringField(item, 'type')
849
+ if (itemType === 'agent_message' && event.type === 'item.completed') {
850
+ const text = itemText(item)
851
+ if (text !== undefined && text.length > 0) {
852
+ const id = keys.keyFor(item, itemType)
853
+ emit([textStartPart(id), textDeltaPart(id, text), textEndPart(id)])
854
+ }
855
+ keys.complete(item, itemType)
856
+ } else if (itemType === 'reasoning' && event.type === 'item.completed') {
857
+ const text = itemText(item)
858
+ if (text !== undefined && text.length > 0) {
859
+ const id = keys.keyFor(item, itemType)
860
+ emit([reasoningStartPart(id), reasoningDeltaPart(id, text), reasoningEndPart(id)])
861
+ }
862
+ keys.complete(item, itemType)
863
+ } else if (itemType !== undefined && itemType !== 'agent_message' && itemType !== 'reasoning') {
864
+ // Mirror the live mapping: input at item.started, output at completed.
865
+ const id = keys.keyFor(item, itemType)
866
+ if (event.type === 'item.started')
867
+ emit([toolInputPart(id, itemType, itemCommand(item) ?? item)])
868
+ else emit([toolOutputPart(id, itemCommand(item) ?? item)])
869
+ if (event.type === 'item.completed') keys.complete(item, itemType)
870
+ }
871
+ }
872
+ emit([finishStepPart()])
873
+ }