@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
+ * ClaudeAgentRunner (SPEC §9): AgentRunner over the locally installed Claude
3
+ * Code CLI. Each turn runs `claude -p` with structured streaming output;
4
+ * Claude Code's native session ids preserve context across review rounds.
5
+ *
6
+ * The subprocess sits behind an injectable boundary so normal tests stay
7
+ * deterministic and offline. Production uses direct argv (never a shell),
8
+ * inherits the operator's Claude Code login, and receives a fresh `sessionEnv`
9
+ * on every turn so the current scoped Autobuild identity reaches tool calls.
10
+ */
11
+ import type { StreamPart } from '../../store/streams/types'
12
+ import { spawnCliStream } from './cli-stream'
13
+ import {
14
+ agentInvocation,
15
+ type AgentContinueOpts,
16
+ type AgentRunner,
17
+ type AgentSessionHandle,
18
+ type AgentStartOpts,
19
+ type AgentTurnFailure,
20
+ type AgentTurnResult,
21
+ type SessionStreamEmitter,
22
+ type Transcript,
23
+ } from '../types'
24
+ import {
25
+ abortPart,
26
+ errorPart,
27
+ finishPart,
28
+ finishStepPart,
29
+ promptPart,
30
+ reasoningDeltaPart,
31
+ reasoningEndPart,
32
+ reasoningStartPart,
33
+ startPart,
34
+ startStepPart,
35
+ textDeltaPart,
36
+ textEndPart,
37
+ textStartPart,
38
+ toolInputPart,
39
+ toolOutputPart,
40
+ } from './stream-parts'
41
+ import { classifyProviderError, configurationFailure } from './provider-error'
42
+ import { sessionEnv } from './session-env'
43
+ import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
44
+ import type { RuntimeUsabilityInput, RuntimeUsabilityResult } from './runtime'
45
+
46
+ export interface ClaudeCliInvocation {
47
+ /** Arguments after the `claude` executable. */
48
+ args: string[]
49
+ cwd: string
50
+ env: Record<string, string>
51
+ signal?: AbortSignal
52
+ }
53
+
54
+ export interface ClaudeCliResult {
55
+ stdout: string
56
+ stderr: string
57
+ exitCode: number
58
+ }
59
+
60
+ /** Injectable direct-process boundary used by the offline contract suite. */
61
+ export type ClaudeCliRunFn = (invocation: ClaudeCliInvocation) => Promise<ClaudeCliResult>
62
+
63
+ /** A streaming CLI turn: decoded stdout lines as they arrive, plus the
64
+ * completed result. The consumer accumulates the lines it needs. */
65
+ export interface ClaudeCliStreamHandle {
66
+ lines: AsyncIterable<string>
67
+ result: Promise<ClaudeCliResult>
68
+ }
69
+
70
+ /** Injectable streaming boundary: production spawns with
71
+ * `--include-partial-messages` (the adapter adds that flag when a turn
72
+ * carries an emitter); tests script line-at-a-time output. */
73
+ export type ClaudeCliStreamFn = (invocation: ClaudeCliInvocation) => ClaudeCliStreamHandle
74
+
75
+ const runClaudeCli: ClaudeCliRunFn = async (invocation) => {
76
+ const proc = Bun.spawn(['claude', ...invocation.args], {
77
+ cwd: invocation.cwd,
78
+ env: invocation.env,
79
+ // The positional prompt is the turn's only input. Claude Code also reads
80
+ // non-TTY stdin in print mode, so inheriting a supervisor's pipe could
81
+ // inject unrelated bytes into the conversation or delay process exit.
82
+ stdin: 'ignore',
83
+ stdout: 'pipe',
84
+ stderr: 'pipe',
85
+ ...(invocation.signal !== undefined ? { signal: invocation.signal } : {}),
86
+ })
87
+ const [stdout, stderr, exitCode] = await Promise.all([
88
+ new Response(proc.stdout).text(),
89
+ new Response(proc.stderr).text(),
90
+ proc.exited,
91
+ ])
92
+ return { stdout, stderr, exitCode }
93
+ }
94
+
95
+ const runClaudeCliStream: ClaudeCliStreamFn = (invocation) => spawnCliStream('claude', invocation)
96
+
97
+ /** Verify both the local executable and Claude Code login for init suggestions. */
98
+ export async function isClaudeRuntimeUsable(
99
+ input: RuntimeUsabilityInput,
100
+ runCli: ClaudeCliRunFn = runClaudeCli,
101
+ ): Promise<RuntimeUsabilityResult> {
102
+ const env = Object.fromEntries(
103
+ Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
104
+ )
105
+ try {
106
+ const result = await runCli({ args: ['auth', 'status', '--json'], cwd: input.cwd, env })
107
+ if (result.exitCode !== 0) {
108
+ return { usable: false, reason: result.stderr.trim() || 'Claude Code is not logged in' }
109
+ }
110
+ let status: unknown
111
+ try {
112
+ status = JSON.parse(result.stdout)
113
+ } catch {
114
+ return { usable: false, reason: 'Claude auth status returned malformed JSON' }
115
+ }
116
+ return isRecord(status) && status.loggedIn === true
117
+ ? { usable: true, reason: 'Claude Code is installed and logged in' }
118
+ : { usable: false, reason: 'Claude Code is not logged in' }
119
+ } catch (error) {
120
+ return {
121
+ usable: false,
122
+ reason: error instanceof Error ? error.message : String(error),
123
+ }
124
+ }
125
+ }
126
+
127
+ interface JsonRecord {
128
+ [key: string]: unknown
129
+ }
130
+
131
+ interface ParsedCliOutput {
132
+ events: JsonRecord[]
133
+ malformedLines: string[]
134
+ result?: JsonRecord
135
+ assistantText: string[]
136
+ assistantErrors: string[]
137
+ statuses: number[]
138
+ codes: Array<string | number>
139
+ }
140
+
141
+ interface ClaudeTurn {
142
+ text: string
143
+ usage: { inputTokens: number; outputTokens: number }
144
+ failure?: AgentTurnFailure
145
+ cli: ClaudeCliResult
146
+ events: JsonRecord[]
147
+ malformedLines: string[]
148
+ }
149
+
150
+ interface TurnRecord {
151
+ turn: number
152
+ prompt: string
153
+ text: string
154
+ usage: { inputTokens: number; outputTokens: number }
155
+ failure?: AgentTurnFailure
156
+ cli: ClaudeCliResult
157
+ events: JsonRecord[]
158
+ malformedLines: string[]
159
+ }
160
+
161
+ interface SessionState {
162
+ opts: AgentStartOpts
163
+ model?: string
164
+ turns: TurnRecord[]
165
+ }
166
+
167
+ const CLAUDE_PRINT_ARG = '-p'
168
+ const CLAUDE_PRINT_ALIAS = '--print'
169
+ const CLAUDE_OUTPUT_FORMAT_ARG = '--output-format'
170
+ const CLAUDE_MODEL_ARG = '--model'
171
+ const CLAUDE_INCLUDE_PARTIAL_ARG = '--include-partial-messages'
172
+
173
+ /** Structural separator before Claude's positional prompt. */
174
+ export const CLAUDE_PROMPT_BOUNDARY = '--'
175
+
176
+ /** Options that select the model or the structured protocol parsed below. */
177
+ export const CLAUDE_OWNED_ARGS = [
178
+ CLAUDE_PRINT_ARG,
179
+ CLAUDE_PRINT_ALIAS,
180
+ CLAUDE_OUTPUT_FORMAT_ARG,
181
+ CLAUDE_MODEL_ARG,
182
+ ] as const
183
+
184
+ const MISSING_CLI_MESSAGE =
185
+ 'claude runtime: Claude Code CLI executable "claude" was not found. ' +
186
+ 'Install Claude Code (https://code.claude.com/docs/en/setup), run `claude`, ' +
187
+ 'and complete login before running Autobuild.'
188
+
189
+ export class ClaudeAgentRunner implements AgentRunner, OneShotCompletion {
190
+ readonly name = 'claude'
191
+
192
+ private readonly runCli: ClaudeCliRunFn
193
+ private readonly runCliStream: ClaudeCliStreamFn | undefined
194
+ private readonly createSessionId: () => string
195
+ private readonly sessions = new Map<string, SessionState>()
196
+
197
+ constructor(
198
+ opts: {
199
+ runCli?: ClaudeCliRunFn
200
+ runCliStream?: ClaudeCliStreamFn
201
+ createSessionId?: () => string
202
+ } = {},
203
+ ) {
204
+ this.runCli = opts.runCli ?? runClaudeCli
205
+ // A test injecting only the buffered boundary takes the buffered
206
+ // translation path for streaming turns (degraded latency, same content);
207
+ // production gets the live streaming boundary.
208
+ this.runCliStream =
209
+ opts.runCliStream ?? (opts.runCli !== undefined ? undefined : runClaudeCliStream)
210
+ this.createSessionId = opts.createSessionId ?? (() => crypto.randomUUID())
211
+ }
212
+
213
+ /** Non-phase judgment: one verbatim prompt, one tool-free completion, and no
214
+ * resumable session persistence. With no built-in or MCP tools, the single
215
+ * print-mode prompt cannot enter a tool-result loop. */
216
+ async complete(input: OneShotCompletionInput): Promise<OneShotCompletionResult> {
217
+ const args = this.baseArgs()
218
+ args.push('--tools', '', '--disallowedTools', 'mcp__*', '--no-session-persistence')
219
+ if (input.model !== undefined) args.push(CLAUDE_MODEL_ARG, input.model)
220
+ args.push(...(input.args ?? []), CLAUDE_PROMPT_BOUNDARY, input.prompt)
221
+
222
+ const turn = await this.runPrompt({
223
+ args,
224
+ cwd: input.cwd,
225
+ env: sessionEnv(input.env),
226
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
227
+ })
228
+ if (turn.failure !== undefined) throw new Error(turn.failure.message)
229
+ return { text: turn.text }
230
+ }
231
+
232
+ async start(
233
+ opts: AgentStartOpts,
234
+ ): Promise<{ session: AgentSessionHandle; result: AgentTurnResult }> {
235
+ const sessionId = this.createSessionId()
236
+ const prompt = `/${opts.skill} ${agentInvocation(opts)}`
237
+ const turn = await this.runTurn(prompt, opts, { sessionId })
238
+
239
+ const session: AgentSessionHandle = {
240
+ id: sessionId,
241
+ runner: this.name,
242
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
243
+ }
244
+ this.sessions.set(session.id, {
245
+ opts,
246
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
247
+ turns: [this.turnRecord(1, prompt, turn)],
248
+ })
249
+ return { session, result: this.toResult(turn) }
250
+ }
251
+
252
+ async continue(
253
+ session: AgentSessionHandle,
254
+ message: string,
255
+ opts?: AgentContinueOpts,
256
+ ): Promise<AgentTurnResult> {
257
+ const state = this.liveState(session, 'continue')
258
+ // §10/D8: a continued turn gets this round's AB_PHASE/AB_SESSION while
259
+ // retaining start-only values. A fresh process env is built below. §9:
260
+ // it also streams through THIS turn's emitter only — the start turn's
261
+ // emitter (if any) belongs to its own bracket and never receives this
262
+ // turn's parts, matching the pi adapter's per-turn forwarding.
263
+ const turnOpts: AgentStartOpts = {
264
+ ...(opts?.env !== undefined
265
+ ? { ...state.opts, env: { ...state.opts.env, ...opts.env } }
266
+ : state.opts),
267
+ stream: opts?.stream,
268
+ }
269
+ const turn = await this.runTurn(message, turnOpts, { resume: session.id }, opts?.signal)
270
+ state.turns.push(this.turnRecord(state.turns.length + 1, message, turn))
271
+ return this.toResult(turn)
272
+ }
273
+
274
+ async end(session: AgentSessionHandle): Promise<Transcript> {
275
+ const state = this.liveState(session, 'end')
276
+ this.sessions.delete(session.id)
277
+
278
+ const usage = { inputTokens: 0, outputTokens: 0, turns: 0 }
279
+ for (const turn of state.turns) {
280
+ usage.inputTokens += turn.usage.inputTokens
281
+ usage.outputTokens += turn.usage.outputTokens
282
+ usage.turns += 1
283
+ }
284
+
285
+ return {
286
+ content: JSON.stringify(
287
+ {
288
+ session: session.id,
289
+ skill: state.opts.skill,
290
+ invocation: agentInvocation(state.opts),
291
+ ...(state.opts.buildSlug !== undefined ? { buildSlug: state.opts.buildSlug } : {}),
292
+ turns: state.turns,
293
+ },
294
+ null,
295
+ 2,
296
+ ),
297
+ metadata: {
298
+ runner: this.name,
299
+ ...(state.model !== undefined ? { model: state.model } : {}),
300
+ usage,
301
+ },
302
+ }
303
+ }
304
+
305
+ private liveState(session: AgentSessionHandle, op: 'continue' | 'end'): SessionState {
306
+ const state = this.sessions.get(session.id)
307
+ if (!state) {
308
+ throw new Error(`${this.name}: ${op} on unknown session "${session.id}"`)
309
+ }
310
+ return state
311
+ }
312
+
313
+ private async runTurn(
314
+ prompt: string,
315
+ opts: AgentStartOpts,
316
+ session: { sessionId: string } | { resume: string },
317
+ signal: AbortSignal | undefined = opts.signal,
318
+ ): Promise<ClaudeTurn> {
319
+ const args = this.baseArgs()
320
+ if ('sessionId' in session) args.push('--session-id', session.sessionId)
321
+ else args.push('--resume', session.resume)
322
+ if (opts.model !== undefined) args.push(CLAUDE_MODEL_ARG, opts.model)
323
+ // Partial message chunks only exist with the streaming flag; a turn
324
+ // without an emitter never asks for them.
325
+ if (opts.stream !== undefined) args.push(CLAUDE_INCLUDE_PARTIAL_ARG)
326
+ args.push(...(opts.args ?? []), CLAUDE_PROMPT_BOUNDARY, prompt)
327
+
328
+ const invocation: ClaudeCliInvocation = {
329
+ args,
330
+ cwd: opts.workspacePath,
331
+ env: sessionEnv(opts.env),
332
+ ...(signal !== undefined ? { signal } : {}),
333
+ }
334
+ if (opts.stream === undefined) return this.runPrompt(invocation)
335
+
336
+ // Streaming turn: translate while the CLI runs. Without an injected
337
+ // streaming boundary (offline fakes), the buffered path still translates
338
+ // after completion — degraded latency, same content.
339
+ const emitter = opts.stream
340
+ emitter.append([promptPart(prompt), startPart(crypto.randomUUID())])
341
+ let turn: ClaudeTurn
342
+ if (this.runCliStream !== undefined) {
343
+ turn = await this.runStreamingPrompt(invocation, emitter)
344
+ } else {
345
+ turn = await this.runPrompt(invocation)
346
+ translateBufferedClaudeTurn(turn, (parts) => emitter.append(parts))
347
+ }
348
+ this.emitTurnEnd(turn, emitter, signal)
349
+ return turn
350
+ }
351
+
352
+ /** Completed-turn, failed-turn, and cancelled-turn closing parts. */
353
+ private emitTurnEnd(
354
+ turn: ClaudeTurn,
355
+ emitter: SessionStreamEmitter,
356
+ signal: AbortSignal | undefined,
357
+ ): void {
358
+ if (turn.failure === undefined) {
359
+ emitter.append([finishPart()])
360
+ return
361
+ }
362
+ if (signal?.aborted) {
363
+ const reason = signal.reason
364
+ emitter.append([
365
+ abortPart(reason instanceof Error ? reason.message : 'claude runtime: turn aborted'),
366
+ ])
367
+ return
368
+ }
369
+ emitter.append([errorPart(turn.failure.message)])
370
+ }
371
+
372
+ private baseArgs(): string[] {
373
+ return [
374
+ CLAUDE_PRINT_ARG,
375
+ CLAUDE_OUTPUT_FORMAT_ARG,
376
+ 'stream-json',
377
+ '--verbose',
378
+ '--dangerously-skip-permissions',
379
+ ]
380
+ }
381
+
382
+ private async runPrompt(invocation: ClaudeCliInvocation): Promise<ClaudeTurn> {
383
+ let cli: ClaudeCliResult
384
+ try {
385
+ cli = await this.runCli(invocation)
386
+ } catch (error) {
387
+ return launchFailure(error)
388
+ }
389
+ return this.finishTurn(cli, parseCliOutput(cli.stdout))
390
+ }
391
+
392
+ /** Live translation path: consume stdout lines as they arrive, mapping
393
+ * `stream_event` chunks onto the part vocabulary while the same records
394
+ * feed the ordinary accumulators (so outcome classification is identical). */
395
+ private async runStreamingPrompt(
396
+ invocation: ClaudeCliInvocation,
397
+ emitter: SessionStreamEmitter,
398
+ ): Promise<ClaudeTurn> {
399
+ let handle: ClaudeCliStreamHandle
400
+ try {
401
+ handle = this.runCliStream!(invocation)
402
+ } catch (error) {
403
+ return launchFailure(error)
404
+ }
405
+ const acc = new ClaudeOutputAccumulator()
406
+ const translator = createClaudeStreamTranslator((parts) => emitter.append(parts))
407
+ try {
408
+ for await (const line of handle.lines) {
409
+ let value: unknown
410
+ try {
411
+ value = JSON.parse(line)
412
+ } catch {
413
+ acc.push(line)
414
+ continue
415
+ }
416
+ if (!isRecord(value)) {
417
+ acc.push(line)
418
+ continue
419
+ }
420
+ acc.pushRecord(value)
421
+ translator.onEvent(value)
422
+ }
423
+ } catch {
424
+ // The result promise below still carries the CLI's exit and stderr.
425
+ }
426
+ const cli = await handle.result
427
+ const parsed = acc.snapshot()
428
+ // Fail open against a harness that ignored the partial-messages flag: if
429
+ // nothing streamed, translate the buffered events instead.
430
+ if (!translator.streamedAnything()) {
431
+ const turn = this.finishTurn(cli, parsed)
432
+ translateBufferedClaudeTurn(turn, (parts) => emitter.append(parts))
433
+ return turn
434
+ }
435
+ return this.finishTurn(cli, parsed)
436
+ }
437
+
438
+ /** Shared post-processing of a completed CLI run: text, usage, failure
439
+ * classification. Identical for the buffered and streaming paths. */
440
+ private finishTurn(cli: ClaudeCliResult, parsed: ParsedCliOutput): ClaudeTurn {
441
+ const usage = resultUsage(parsed.result)
442
+ const resultText = stringField(parsed.result, 'result')
443
+ const text = resultText ?? parsed.assistantText.join('\n')
444
+
445
+ let failureMessage: string | undefined
446
+ if (cli.exitCode !== 0 || parsed.result?.is_error === true) {
447
+ failureMessage =
448
+ nonempty(resultText) ??
449
+ firstStringArray(parsed.result?.errors) ??
450
+ nonempty(stringField(parsed.result, 'error')) ??
451
+ parsed.assistantErrors.find((value) => value.length > 0) ??
452
+ nonempty(cli.stderr) ??
453
+ `${this.name} runtime: Claude Code CLI exited with code ${cli.exitCode} without error text`
454
+ } else if (parsed.malformedLines.length > 0) {
455
+ failureMessage = `${this.name} runtime: Claude Code CLI emitted malformed stream-json output`
456
+ } else if (parsed.result === undefined) {
457
+ failureMessage = `${this.name} runtime: Claude Code CLI stream ended without a result event`
458
+ }
459
+
460
+ const resultStatus = numberField(
461
+ parsed.result,
462
+ 'api_error_status',
463
+ 'status',
464
+ 'status_code',
465
+ 'statusCode',
466
+ )
467
+ const resultCodes = [
468
+ stringOrNumberField(parsed.result, 'error'),
469
+ stringOrNumberField(parsed.result, 'code'),
470
+ stringOrNumberField(parsed.result, 'category'),
471
+ stringOrNumberField(parsed.result, 'subtype'),
472
+ ...parsed.codes,
473
+ ]
474
+
475
+ return {
476
+ text,
477
+ usage,
478
+ ...(failureMessage !== undefined
479
+ ? {
480
+ failure: classifyProviderError(failureMessage, {
481
+ status: resultStatus ?? parsed.statuses[0],
482
+ codes: [...parsed.assistantErrors, ...resultCodes],
483
+ }),
484
+ }
485
+ : {}),
486
+ cli,
487
+ events: parsed.events,
488
+ malformedLines: parsed.malformedLines,
489
+ }
490
+ }
491
+
492
+ private turnRecord(turnNumber: number, prompt: string, turn: ClaudeTurn): TurnRecord {
493
+ return {
494
+ turn: turnNumber,
495
+ prompt,
496
+ text: turn.text,
497
+ usage: turn.usage,
498
+ ...(turn.failure !== undefined ? { failure: turn.failure } : {}),
499
+ cli: turn.cli,
500
+ events: turn.events,
501
+ malformedLines: turn.malformedLines,
502
+ }
503
+ }
504
+
505
+ private toResult(turn: ClaudeTurn): AgentTurnResult {
506
+ const base = { text: turn.text, usage: { ...turn.usage, turns: 1 } }
507
+ return turn.failure === undefined
508
+ ? { kind: 'completed', ...base }
509
+ : { kind: 'failed', ...base, failure: turn.failure }
510
+ }
511
+ }
512
+
513
+ function parseCliOutput(stdout: string): ParsedCliOutput {
514
+ const acc = new ClaudeOutputAccumulator()
515
+ for (const line of stdout.split(/\r?\n/)) acc.push(line)
516
+ return acc.snapshot()
517
+ }
518
+
519
+ /**
520
+ * Incremental accumulator over Claude Code's stream-json lines, shared by
521
+ * the buffered parse and the streaming path (which also feeds the stream
522
+ * translator below). Collects the same shape `parseCliOutput` always
523
+ * returned so outcome classification is byte-identical in both paths.
524
+ */
525
+ class ClaudeOutputAccumulator {
526
+ readonly events: JsonRecord[] = []
527
+ readonly malformedLines: string[] = []
528
+ readonly assistantText: string[] = []
529
+ readonly assistantErrors: string[] = []
530
+ readonly statuses: number[] = []
531
+ readonly codes: Array<string | number> = []
532
+ result: JsonRecord | undefined
533
+
534
+ push(line: string): void {
535
+ if (line.trim() === '') return
536
+ let value: unknown
537
+ try {
538
+ value = JSON.parse(line)
539
+ } catch {
540
+ this.malformedLines.push(line)
541
+ return
542
+ }
543
+ if (!isRecord(value)) {
544
+ this.malformedLines.push(line)
545
+ return
546
+ }
547
+ this.pushRecord(value)
548
+ }
549
+
550
+ pushRecord(value: JsonRecord): void {
551
+ this.events.push(value)
552
+ if (value.type === 'assistant') collectAssistant(value, this)
553
+ if (value.type === 'result') this.result = value
554
+ if (value.type === 'system' || value.type === 'api_retry') collectHints(value, this)
555
+ }
556
+
557
+ snapshot(): ParsedCliOutput {
558
+ return {
559
+ events: [...this.events],
560
+ malformedLines: [...this.malformedLines],
561
+ ...(this.result !== undefined ? { result: this.result } : {}),
562
+ assistantText: [...this.assistantText],
563
+ assistantErrors: [...this.assistantErrors],
564
+ statuses: [...this.statuses],
565
+ codes: [...this.codes],
566
+ }
567
+ }
568
+ }
569
+
570
+ function collectAssistant(event: JsonRecord, acc: ClaudeOutputAccumulator): void {
571
+ const error = stringField(event, 'error')
572
+ if (error !== undefined) acc.assistantErrors.push(error)
573
+ const message = event.message
574
+ if (!isRecord(message) || !Array.isArray(message.content)) return
575
+ for (const block of message.content) {
576
+ if (!isRecord(block) || block.type !== 'text') continue
577
+ const text = stringField(block, 'text')
578
+ if (text !== undefined) acc.assistantText.push(text)
579
+ }
580
+ }
581
+
582
+ function collectHints(event: JsonRecord, acc: ClaudeOutputAccumulator): void {
583
+ const status = numberField(event, 'api_error_status', 'status', 'status_code', 'statusCode')
584
+ if (status !== undefined) acc.statuses.push(status)
585
+ for (const key of ['code', 'error', 'category', 'subtype']) {
586
+ const code = stringOrNumberField(event, key)
587
+ if (code !== undefined) acc.codes.push(code)
588
+ }
589
+ }
590
+
591
+ function resultUsage(result: JsonRecord | undefined): ClaudeTurn['usage'] {
592
+ const usage = result?.usage
593
+ if (!isRecord(usage)) return { inputTokens: 0, outputTokens: 0 }
594
+ return {
595
+ inputTokens: tokenCount(usage.input_tokens),
596
+ outputTokens: tokenCount(usage.output_tokens),
597
+ }
598
+ }
599
+
600
+ function tokenCount(value: unknown): number {
601
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0
602
+ }
603
+
604
+ function isRecord(value: unknown): value is JsonRecord {
605
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
606
+ }
607
+
608
+ function stringField(value: JsonRecord | undefined, key: string): string | undefined {
609
+ const field = value?.[key]
610
+ return typeof field === 'string' ? field : undefined
611
+ }
612
+
613
+ function stringOrNumberField(
614
+ value: JsonRecord | undefined,
615
+ key: string,
616
+ ): string | number | undefined {
617
+ const field = value?.[key]
618
+ return typeof field === 'string' || typeof field === 'number' ? field : undefined
619
+ }
620
+
621
+ function numberField(value: JsonRecord | undefined, ...keys: string[]): number | undefined {
622
+ for (const key of keys) {
623
+ const field = value?.[key]
624
+ if (typeof field === 'number' && Number.isFinite(field)) return field
625
+ }
626
+ return undefined
627
+ }
628
+
629
+ function firstStringArray(value: unknown): string | undefined {
630
+ if (!Array.isArray(value)) return undefined
631
+ const strings = value.filter(
632
+ (entry): entry is string => typeof entry === 'string' && entry.length > 0,
633
+ )
634
+ return strings.length > 0 ? strings.join('\n') : undefined
635
+ }
636
+
637
+ function nonempty(value: string | undefined): string | undefined {
638
+ return value !== undefined && value.length > 0 ? value : undefined
639
+ }
640
+
641
+ function isEnoent(error: unknown): boolean {
642
+ if (!isRecord(error)) return false
643
+ return error.code === 'ENOENT'
644
+ }
645
+
646
+ /** A CLI that never ran: missing executable vs. a failed launch. */
647
+ function launchFailure(error: unknown): ClaudeTurn {
648
+ const missing = isEnoent(error)
649
+ const message = missing
650
+ ? MISSING_CLI_MESSAGE
651
+ : `claude runtime: failed to launch Claude Code CLI: ${errorText(error)}`
652
+ return {
653
+ text: '',
654
+ usage: { inputTokens: 0, outputTokens: 0 },
655
+ failure: missing ? configurationFailure(message) : classifyProviderError(message),
656
+ cli: {
657
+ stdout: '',
658
+ stderr: errorText(error),
659
+ exitCode: -1,
660
+ },
661
+ events: [],
662
+ malformedLines: [],
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Live stream translation (SPEC §9): `stream_event` chunks carry the raw
668
+ * Anthropic stream; `assistant`/`user` lines still feed the accumulators and
669
+ * surface tool_use blocks the stream events did not (fail-open against
670
+ * harness drift). Unrecognized records produce no parts.
671
+ */
672
+ function createClaudeStreamTranslator(emit: (parts: StreamPart[]) => void): {
673
+ onEvent(event: JsonRecord): void
674
+ streamedAnything(): boolean
675
+ } {
676
+ let nextId = 0
677
+ let streamed = false
678
+ let messageOpen = false
679
+ const blocks = new Map<
680
+ number,
681
+ { kind: 'text' | 'thinking' | 'tool'; id: string; name?: string; json: string }
682
+ >()
683
+ const emittedTools = new Set<string>()
684
+
685
+ return {
686
+ streamedAnything() {
687
+ return streamed
688
+ },
689
+ onEvent(event) {
690
+ if (event.type === 'stream_event') {
691
+ const inner = isRecord(event.event) ? event.event : undefined
692
+ if (inner === undefined) return
693
+ const index = typeof inner.index === 'number' ? inner.index : -1
694
+ switch (inner.type) {
695
+ case 'message_start':
696
+ if (!messageOpen) {
697
+ messageOpen = true
698
+ streamed = true
699
+ emit([startStepPart()])
700
+ }
701
+ break
702
+ case 'content_block_start': {
703
+ const block = isRecord(inner.content_block) ? inner.content_block : {}
704
+ const id =
705
+ typeof block.id === 'string' && block.id.length > 0 ? block.id : `cl-${++nextId}`
706
+ if (block.type === 'text') {
707
+ blocks.set(index, { kind: 'text', id, json: '' })
708
+ streamed = true
709
+ emit([textStartPart(id)])
710
+ } else if (block.type === 'thinking') {
711
+ blocks.set(index, { kind: 'thinking', id, json: '' })
712
+ streamed = true
713
+ emit([reasoningStartPart(id)])
714
+ } else if (block.type === 'tool_use') {
715
+ blocks.set(index, {
716
+ kind: 'tool',
717
+ id,
718
+ name: typeof block.name === 'string' ? block.name : 'tool',
719
+ json: '',
720
+ })
721
+ }
722
+ break
723
+ }
724
+ case 'content_block_delta': {
725
+ const block = blocks.get(index)
726
+ const delta = isRecord(inner.delta) ? inner.delta : {}
727
+ if (block === undefined) break
728
+ if (block.kind === 'text' && typeof delta.text === 'string') {
729
+ streamed = true
730
+ emit([textDeltaPart(block.id, delta.text)])
731
+ } else if (block.kind === 'thinking' && typeof delta.thinking === 'string') {
732
+ streamed = true
733
+ emit([reasoningDeltaPart(block.id, delta.thinking)])
734
+ } else if (block.kind === 'tool' && typeof delta.partial_json === 'string') {
735
+ block.json += delta.partial_json
736
+ }
737
+ break
738
+ }
739
+ case 'content_block_stop': {
740
+ const block = blocks.get(index)
741
+ if (block === undefined) break
742
+ if (block.kind === 'text') {
743
+ emit([textEndPart(block.id)])
744
+ } else if (block.kind === 'thinking') {
745
+ emit([reasoningEndPart(block.id)])
746
+ } else {
747
+ let input: unknown = {}
748
+ if (block.json.length > 0) {
749
+ try {
750
+ input = JSON.parse(block.json)
751
+ } catch {
752
+ input = block.json
753
+ }
754
+ }
755
+ emittedTools.add(block.id)
756
+ streamed = true
757
+ emit([toolInputPart(block.id, block.name ?? 'tool', input)])
758
+ }
759
+ blocks.delete(index)
760
+ break
761
+ }
762
+ case 'message_stop':
763
+ if (messageOpen) {
764
+ messageOpen = false
765
+ emit([finishStepPart()])
766
+ }
767
+ break
768
+ default:
769
+ break
770
+ }
771
+ return
772
+ }
773
+
774
+ if (event.type === 'assistant') {
775
+ const message = isRecord(event.message) ? event.message : {}
776
+ if (!Array.isArray(message.content)) return
777
+ for (const block of message.content) {
778
+ if (!isRecord(block) || block.type !== 'tool_use') continue
779
+ const id =
780
+ typeof block.id === 'string' && block.id.length > 0 ? block.id : `cl-${++nextId}`
781
+ if (emittedTools.has(id)) continue
782
+ emittedTools.add(id)
783
+ streamed = true
784
+ emit([
785
+ toolInputPart(
786
+ id,
787
+ typeof block.name === 'string' ? block.name : 'tool',
788
+ block.input ?? {},
789
+ ),
790
+ ])
791
+ }
792
+ return
793
+ }
794
+
795
+ if (event.type === 'user') {
796
+ const message = isRecord(event.message) ? event.message : {}
797
+ if (!Array.isArray(message.content)) return
798
+ for (const block of message.content) {
799
+ if (!isRecord(block) || block.type !== 'tool_result') continue
800
+ const id =
801
+ typeof block.tool_use_id === 'string' && block.tool_use_id.length > 0
802
+ ? block.tool_use_id
803
+ : `cl-${++nextId}`
804
+ streamed = true
805
+ emit([toolOutputPart(id, block.content ?? null)])
806
+ }
807
+ }
808
+ },
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Buffered-path translation (degraded latency, same content): walk the
814
+ * completed turn's events and emit whole messages — start-step/finish-step
815
+ * around each assistant message, text/reasoning/tool parts from blocks, and
816
+ * tool outputs from `user` tool_result lines.
817
+ */
818
+ function translateBufferedClaudeTurn(turn: ClaudeTurn, emit: (parts: StreamPart[]) => void): void {
819
+ let nextId = 0
820
+ let stepOpen = false
821
+ const closeStep = (): void => {
822
+ if (stepOpen) {
823
+ stepOpen = false
824
+ emit([finishStepPart()])
825
+ }
826
+ }
827
+ for (const event of turn.events) {
828
+ if (event.type === 'assistant') {
829
+ closeStep()
830
+ stepOpen = true
831
+ emit([startStepPart()])
832
+ const message = isRecord(event.message) ? event.message : {}
833
+ if (!Array.isArray(message.content)) continue
834
+ for (const block of message.content) {
835
+ if (!isRecord(block)) continue
836
+ const id = typeof block.id === 'string' && block.id.length > 0 ? block.id : `cl-${++nextId}`
837
+ if (block.type === 'text' && typeof block.text === 'string') {
838
+ emit([textStartPart(id), textDeltaPart(id, block.text), textEndPart(id)])
839
+ } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
840
+ emit([
841
+ reasoningStartPart(id),
842
+ reasoningDeltaPart(id, block.thinking),
843
+ reasoningEndPart(id),
844
+ ])
845
+ } else if (block.type === 'tool_use') {
846
+ emit([
847
+ toolInputPart(
848
+ id,
849
+ typeof block.name === 'string' ? block.name : 'tool',
850
+ block.input ?? {},
851
+ ),
852
+ ])
853
+ }
854
+ }
855
+ } else if (event.type === 'user') {
856
+ const message = isRecord(event.message) ? event.message : {}
857
+ if (!Array.isArray(message.content)) continue
858
+ for (const block of message.content) {
859
+ if (!isRecord(block) || block.type !== 'tool_result') continue
860
+ const id =
861
+ typeof block.tool_use_id === 'string' && block.tool_use_id.length > 0
862
+ ? block.tool_use_id
863
+ : `cl-${++nextId}`
864
+ emit([toolOutputPart(id, block.content ?? null)])
865
+ }
866
+ }
867
+ }
868
+ closeStep()
869
+ }
870
+
871
+ function errorText(error: unknown): string {
872
+ return error instanceof Error ? error.message : String(error)
873
+ }