@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.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- 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
|
+
}
|