orizu 0.4.0 → 0.5.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/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-Agent-SDK driver for the AgentHarness seam (ALI-929 / P3.6, per ADR-005
|
|
3
|
+
* §4a — the swappability proof).
|
|
4
|
+
*
|
|
5
|
+
* This is the SECOND `AgentHarness` implementation behind the same seam as the
|
|
6
|
+
* OpenCode driver (`hosted-harness-opencode.ts`). It drives
|
|
7
|
+
* `@anthropic-ai/claude-agent-sdk`'s `query()` loop — an IN-PROCESS agent loop,
|
|
8
|
+
* with NO server process to spawn and no localhost SSE to parse — and normalizes
|
|
9
|
+
* the SDK's message/tool stream into the SAME `HarnessEvent` vocabulary the
|
|
10
|
+
* event bridge (`hosted-run-event-sink.ts`) already consumes. It carries ZERO
|
|
11
|
+
* Claude-Agent-SDK types across the seam: everything below stays behind
|
|
12
|
+
* `createClaudeAgentHarness`, exactly as the OpenCode SSE schema stays behind
|
|
13
|
+
* `createOpenCodeHarness`.
|
|
14
|
+
*
|
|
15
|
+
* The founder's locked escape-hatch ("OpenInspect, but we can move to our own
|
|
16
|
+
* harness whenever we want") is proven by this module existing and passing the
|
|
17
|
+
* SAME session-cell invariants through the SAME sink — see
|
|
18
|
+
* `test/cli/hosted-harness-swap.test.ts`.
|
|
19
|
+
*
|
|
20
|
+
* INJECTABLE LOADER: like the Daytona/Vercel SDK adapters, the SDK is imported
|
|
21
|
+
* LAZILY through a NON-LITERAL specifier so the CLI/app build resolve without the
|
|
22
|
+
* package, and a `loadModule` dep is injectable so tests exercise the
|
|
23
|
+
* normalization against a fake `query()` with no real SDK and no model calls.
|
|
24
|
+
*
|
|
25
|
+
* ── query() → runPrompt() mapping ────────────────────────────────────────────
|
|
26
|
+
* ONE `query()` call drives ONE `runPrompt()` to completion. The SDK's async
|
|
27
|
+
* iterable yields `SDKMessage`s; we translate them:
|
|
28
|
+
* • system/init → capture session_id; emit `ready` once.
|
|
29
|
+
* • assistant.message → text blocks → `token` (keyed by the SDK message id
|
|
30
|
+
* for coalescing); tool_use blocks → `tool_call`. The
|
|
31
|
+
* per-turn `message.usage` the SDK ALWAYS populates is
|
|
32
|
+
* deliberately NOT emitted as a `step_finish` (see
|
|
33
|
+
* leak #3): the driver coalesces to ONE terminal
|
|
34
|
+
* step_finish so both harnesses share one granularity.
|
|
35
|
+
* • user.message → tool_result blocks → `tool_result` (paired to the
|
|
36
|
+
* tool_use by callId, exactly once).
|
|
37
|
+
* • stream_event (partial) → text deltas are accumulated into the running token
|
|
38
|
+
* snapshot for coalescing; consumed, not 1:1 mapped.
|
|
39
|
+
* • result/success → final `step_finish` with cost+tokens, then
|
|
40
|
+
* `execution_complete` (success, or aborted on stop).
|
|
41
|
+
* • result/error_* → `error` (critical terminal).
|
|
42
|
+
* The stream ALWAYS ends with exactly one terminal event.
|
|
43
|
+
*
|
|
44
|
+
* ── WHAT LEAKS THROUGH THE BOUNDARY (map-conservatively evidence) ─────────────
|
|
45
|
+
* These SDK signals have NO clean `HarnessEvent` kind and are DROPPED here (not a
|
|
46
|
+
* reason to widen the seam — this is the audit of the boundary's fit):
|
|
47
|
+
* 1. `thinking` / `redacted_thinking` content blocks — the harness vocabulary
|
|
48
|
+
* has no reasoning kind (OpenCode's driver drops reasoning too), so extended
|
|
49
|
+
* thinking is not forwarded. Cost of surfacing it: a new `thinking` kind on
|
|
50
|
+
* the seam + a bridge mapping — a deliberate future decision, not done here.
|
|
51
|
+
* 2. `system/compact_boundary` — context-compaction markers are dropped (the
|
|
52
|
+
* OpenCode driver handles `session.compacted` internally and forwards
|
|
53
|
+
* nothing either; parity, not a leak).
|
|
54
|
+
* 3. Per-assistant-turn USAGE granularity — the real contract is that every
|
|
55
|
+
* `SDKAssistantMessage.message` is a `BetaMessage` whose `usage: BetaUsage`
|
|
56
|
+
* is ALWAYS populated (input/output token counts are non-optional), so the
|
|
57
|
+
* SDK reports usage on EACH assistant turn, not only at the terminal. The
|
|
58
|
+
* driver deliberately DOES NOT emit a `step_finish` per assistant turn: it
|
|
59
|
+
* coalesces to exactly ONE terminal `step_finish` at the `result` message,
|
|
60
|
+
* carrying `total_cost_usd` + aggregate `usage`. OpenCode emits one
|
|
61
|
+
* `step_finish` per `step-finish` part; for a one-tool task that is also a
|
|
62
|
+
* single terminal step, so the two streams are legitimately identical at the
|
|
63
|
+
* seam. This coalescing is itself a swappability win — the seam absorbs the
|
|
64
|
+
* raw per-turn/per-step granularity difference so downstream sees one
|
|
65
|
+
* normalized `agent_step_finish` from either harness. (Cost is only known at
|
|
66
|
+
* the terminal `result`; per-turn `usage` carries tokens but no cost, which
|
|
67
|
+
* is why folding to the terminal loses nothing the seam surfaces.)
|
|
68
|
+
* 4. `result.num_turns` / `duration_ms` / `permission_denials` — folded into
|
|
69
|
+
* the terminal transition's shape (success/aborted/error), not surfaced as
|
|
70
|
+
* distinct events.
|
|
71
|
+
* 5. Git author identity is plumbed via `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
|
|
72
|
+
* ENV on the query options (the SDK has no first-class author param), where
|
|
73
|
+
* OpenCode takes it structurally. Same effect, different plumbing point.
|
|
74
|
+
* NOTE the `env` CONTRACT: the SDK's `env` REPLACES the subprocess
|
|
75
|
+
* environment entirely (it is NOT merged with `process.env`), so we spread
|
|
76
|
+
* `...process.env` FIRST — without it the `claude` subprocess would lose
|
|
77
|
+
* `PATH`/`HOME`/`ANTHROPIC_API_KEY` and die. G3 credential model: like the
|
|
78
|
+
* OpenCode path (which hands `opencode` the non-secret dummy key), the SDK
|
|
79
|
+
* client is given the same `anthropicDummyKey` as `ANTHROPIC_API_KEY`; the
|
|
80
|
+
* real key is injected by the sandbox firewall on egress to
|
|
81
|
+
* api.anthropic.com, so NO raw key ever enters the in-process client. See
|
|
82
|
+
* the G3/credentials axis in ali-929-harness-comparison.md.
|
|
83
|
+
* 6. The resumable agent session id is assigned LAZILY by the SDK at
|
|
84
|
+
* `system/init` — it does not exist before the first `runPrompt`. So
|
|
85
|
+
* `start()` returns the RESUME id if given, else `''`; the real id first
|
|
86
|
+
* reaches the bridge via the `ready` event. OpenCode creates its session
|
|
87
|
+
* eagerly in `start()`. This is the one place the seam's "start returns the
|
|
88
|
+
* session id" shape fits OpenCode better than the SDK — the run still
|
|
89
|
+
* completes identically (the sink records the id from `ready`); see the
|
|
90
|
+
* comparison doc's swappability verdict.
|
|
91
|
+
* 7. Provider-qualified model strings (e.g. `anthropic/claude-opus-4-8`) are
|
|
92
|
+
* reduced to the bare model id the SDK expects (leading `anthropic/` is
|
|
93
|
+
* stripped; other providers pass through unchanged and would be the SDK's to
|
|
94
|
+
* reject).
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
import type {
|
|
98
|
+
AgentHarness,
|
|
99
|
+
HarnessEvent,
|
|
100
|
+
HarnessPrompt,
|
|
101
|
+
HarnessStartOptions,
|
|
102
|
+
McpServerConfig,
|
|
103
|
+
} from './hosted-harness.js'
|
|
104
|
+
|
|
105
|
+
// -- Structural view of the @anthropic-ai/claude-agent-sdk surface we touch ----
|
|
106
|
+
// Kept LOCAL (not imported) so the seam type-checks and the app build resolves
|
|
107
|
+
// without the package present — same discipline as the Daytona/Vercel adapters.
|
|
108
|
+
// PIN CONTRACT: verified against @anthropic-ai/claude-agent-sdk@0.3.201 —
|
|
109
|
+
// `query({ prompt, options })` returns a `Query` (an `AsyncGenerator<SDKMessage>`
|
|
110
|
+
// exposing `interrupt()`); the union discriminates on `type` (+ `subtype` for
|
|
111
|
+
// `system`/`result`). packages/cli/package.json pins this version EXACTLY (no
|
|
112
|
+
// caret); re-verify the message-union shape before bumping.
|
|
113
|
+
|
|
114
|
+
/** An Anthropic content block as it appears inside an SDK assistant/user message
|
|
115
|
+
* (`message.content`). Kept structural — we read only the fields we map. */
|
|
116
|
+
interface SdkContentBlock {
|
|
117
|
+
type: string
|
|
118
|
+
// text block
|
|
119
|
+
text?: string
|
|
120
|
+
// tool_use block
|
|
121
|
+
id?: string
|
|
122
|
+
name?: string
|
|
123
|
+
input?: unknown
|
|
124
|
+
// tool_result block
|
|
125
|
+
tool_use_id?: string
|
|
126
|
+
content?: unknown
|
|
127
|
+
is_error?: boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface SdkAnthropicMessage {
|
|
131
|
+
id?: string
|
|
132
|
+
role?: string
|
|
133
|
+
content?: SdkContentBlock[] | string
|
|
134
|
+
usage?: Record<string, unknown>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A raw Anthropic streaming event, forwarded by the SDK when partial messages
|
|
138
|
+
* are enabled (`type: 'stream_event'`). We read only the text-delta path. */
|
|
139
|
+
interface SdkStreamRawEvent {
|
|
140
|
+
type?: string
|
|
141
|
+
index?: number
|
|
142
|
+
delta?: { type?: string; text?: string }
|
|
143
|
+
content_block?: { type?: string }
|
|
144
|
+
message?: { id?: string }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The SDK message union. Discriminated on `type` (+ `subtype` for system). */
|
|
148
|
+
interface SdkMessage {
|
|
149
|
+
type: string
|
|
150
|
+
subtype?: string
|
|
151
|
+
session_id?: string
|
|
152
|
+
message?: SdkAnthropicMessage
|
|
153
|
+
event?: SdkStreamRawEvent
|
|
154
|
+
// result message fields
|
|
155
|
+
is_error?: boolean
|
|
156
|
+
total_cost_usd?: number
|
|
157
|
+
usage?: Record<string, unknown>
|
|
158
|
+
result?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface SdkMcpServerConfig {
|
|
162
|
+
type: string
|
|
163
|
+
command?: string
|
|
164
|
+
args?: readonly string[]
|
|
165
|
+
url?: string
|
|
166
|
+
env?: Record<string, string>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface SdkQueryOptions {
|
|
170
|
+
model?: string
|
|
171
|
+
cwd?: string
|
|
172
|
+
resume?: string
|
|
173
|
+
abortController?: AbortController
|
|
174
|
+
permissionMode?: string
|
|
175
|
+
/** REQUIRED by the SDK whenever `permissionMode: 'bypassPermissions'` — a
|
|
176
|
+
* safety measure to make the bypass intentional. Verified against
|
|
177
|
+
* @anthropic-ai/claude-agent-sdk@0.3.201 Options.allowDangerouslySkipPermissions. */
|
|
178
|
+
allowDangerouslySkipPermissions?: boolean
|
|
179
|
+
includePartialMessages?: boolean
|
|
180
|
+
mcpServers?: Record<string, SdkMcpServerConfig>
|
|
181
|
+
/** The SDK's `env` REPLACES the subprocess environment entirely (it is NOT
|
|
182
|
+
* merged with `process.env`). Matches the real contract's
|
|
183
|
+
* `{ [k: string]: string | undefined }` so `...process.env` spreads cleanly. */
|
|
184
|
+
env?: Record<string, string | undefined>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The object `query()` returns: an async iterable of messages plus `interrupt`. */
|
|
188
|
+
interface SdkQueryResult extends AsyncIterable<SdkMessage> {
|
|
189
|
+
interrupt?: () => Promise<void>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ClaudeAgentSdkModule {
|
|
193
|
+
query: (args: { prompt: string; options: SdkQueryOptions }) => SdkQueryResult
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The package name lives in a variable so TypeScript treats the dynamic import as
|
|
197
|
+
// `any` (no static module resolution / ts2307 at app-build time) and no bundler
|
|
198
|
+
// traces it — this keeps the SDK genuinely optional, loaded only when the
|
|
199
|
+
// `claude-agent-sdk` harness is actually selected.
|
|
200
|
+
const CLAUDE_AGENT_SDK_SPECIFIER = '@anthropic-ai/claude-agent-sdk'
|
|
201
|
+
|
|
202
|
+
async function loadClaudeAgentSdk(): Promise<ClaudeAgentSdkModule> {
|
|
203
|
+
try {
|
|
204
|
+
const specifier: string = CLAUDE_AGENT_SDK_SPECIFIER
|
|
205
|
+
const mod = (await import(specifier)) as unknown as ClaudeAgentSdkModule
|
|
206
|
+
if (!mod || typeof mod.query !== 'function') {
|
|
207
|
+
throw new Error('module did not export a query() function')
|
|
208
|
+
}
|
|
209
|
+
return mod
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Claude Agent SDK unavailable (${detail}). Install it before the live run: \`bun install --cwd packages/cli\` (declares @anthropic-ai/claude-agent-sdk).`
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ClaudeAgentHarnessOptions {
|
|
219
|
+
/** Test seam: inject a fake SDK so normalization runs with no real query/model. */
|
|
220
|
+
loadModule?: () => Promise<ClaudeAgentSdkModule>
|
|
221
|
+
/** Permission posture; default 'bypassPermissions' (the sandbox is the boundary,
|
|
222
|
+
* mirroring the OpenCode driver's allow-all config). */
|
|
223
|
+
permissionMode?: string
|
|
224
|
+
/**
|
|
225
|
+
* Non-secret placeholder key handed to the in-process SDK client as
|
|
226
|
+
* `ANTHROPIC_API_KEY` (G3). The sandbox firewall swaps in the real key on
|
|
227
|
+
* egress to api.anthropic.com, so no raw key enters the client — the SAME
|
|
228
|
+
* brokering the OpenCode path relies on. Threaded from the loop boot context;
|
|
229
|
+
* never a real key. When absent, `ANTHROPIC_API_KEY` is inherited from
|
|
230
|
+
* `process.env` as-is (still expected to be the dummy in the sandbox).
|
|
231
|
+
*/
|
|
232
|
+
anthropicDummyKey?: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// -- helpers -----------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
238
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
239
|
+
? (value as Record<string, unknown>)
|
|
240
|
+
: {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Reduce a provider-qualified model ("anthropic/claude-opus-4-8") to the bare id
|
|
244
|
+
* the Agent SDK expects; other providers pass through for the SDK to judge. */
|
|
245
|
+
function toSdkModel(model: string | undefined): string | undefined {
|
|
246
|
+
if (!model) return undefined
|
|
247
|
+
return model.startsWith('anthropic/') ? model.slice('anthropic/'.length) : model
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Map the seam's structural MCP descriptors to the SDK's config record. */
|
|
251
|
+
function toSdkMcpServers(
|
|
252
|
+
servers: readonly McpServerConfig[] | undefined
|
|
253
|
+
): Record<string, SdkMcpServerConfig> | undefined {
|
|
254
|
+
if (!servers || servers.length === 0) return undefined
|
|
255
|
+
const out: Record<string, SdkMcpServerConfig> = {}
|
|
256
|
+
for (const s of servers) {
|
|
257
|
+
out[s.name] = {
|
|
258
|
+
type: s.transport ?? (s.url ? 'http' : 'stdio'),
|
|
259
|
+
command: s.command,
|
|
260
|
+
args: s.args,
|
|
261
|
+
url: s.url,
|
|
262
|
+
env: s.env,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Flatten an SDK message's content blocks (array or bare string) to blocks. */
|
|
269
|
+
function contentBlocks(message: SdkAnthropicMessage | undefined): SdkContentBlock[] {
|
|
270
|
+
const content = message?.content
|
|
271
|
+
if (Array.isArray(content)) return content
|
|
272
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
|
273
|
+
return []
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Coerce a tool_result block's `content` to a string output. */
|
|
277
|
+
function toolResultOutput(content: unknown): string {
|
|
278
|
+
if (typeof content === 'string') return content
|
|
279
|
+
if (Array.isArray(content)) {
|
|
280
|
+
return content
|
|
281
|
+
.map(part => {
|
|
282
|
+
const rec = asRecord(part)
|
|
283
|
+
return typeof rec.text === 'string' ? rec.text : ''
|
|
284
|
+
})
|
|
285
|
+
.join('')
|
|
286
|
+
}
|
|
287
|
+
return ''
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -- The driver --------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
export function createClaudeAgentHarness(
|
|
293
|
+
options: ClaudeAgentHarnessOptions = {}
|
|
294
|
+
): AgentHarness {
|
|
295
|
+
const load = options.loadModule ?? loadClaudeAgentSdk
|
|
296
|
+
const permissionMode = options.permissionMode ?? 'bypassPermissions'
|
|
297
|
+
const anthropicDummyKey = options.anthropicDummyKey
|
|
298
|
+
|
|
299
|
+
let sdk: ClaudeAgentSdkModule | null = null
|
|
300
|
+
let startModel: string | undefined
|
|
301
|
+
let startWorkspaceDir: string | undefined
|
|
302
|
+
let startMcpServers: readonly McpServerConfig[] | undefined
|
|
303
|
+
// The resumable session id: seeded from start()'s resume option, then updated
|
|
304
|
+
// to whatever the SDK assigns at each system/init so subsequent prompts
|
|
305
|
+
// continue the same conversation (parity with OpenCode session reuse).
|
|
306
|
+
let currentSessionId: string | null = null
|
|
307
|
+
let readyEmitted = false
|
|
308
|
+
|
|
309
|
+
// Set true by the public stop(); makes the resulting terminal a cancellation.
|
|
310
|
+
let stopRequested = false
|
|
311
|
+
// The in-flight query's handles, so stop() can interrupt/abort it.
|
|
312
|
+
let activeQuery: SdkQueryResult | null = null
|
|
313
|
+
let activeAbort: AbortController | null = null
|
|
314
|
+
|
|
315
|
+
async function ensureSdk(): Promise<ClaudeAgentSdkModule> {
|
|
316
|
+
if (!sdk) sdk = await load()
|
|
317
|
+
return sdk
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
name: 'claude-agent-sdk',
|
|
322
|
+
|
|
323
|
+
async start(opts: HarnessStartOptions): Promise<{ agentSessionId: string }> {
|
|
324
|
+
startModel = opts.model
|
|
325
|
+
startWorkspaceDir = opts.workspaceDir
|
|
326
|
+
startMcpServers = opts.mcpServers
|
|
327
|
+
// Load eagerly so a missing SDK fails at start() (like OpenCode validating
|
|
328
|
+
// its session), not mid-stream. The session id is assigned lazily by the
|
|
329
|
+
// SDK at init — see leak #6 — so we can only echo a resume id here.
|
|
330
|
+
await ensureSdk()
|
|
331
|
+
currentSessionId = opts.resumeAgentSessionId ?? null
|
|
332
|
+
return { agentSessionId: opts.resumeAgentSessionId ?? '' }
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
async *runPrompt(prompt: HarnessPrompt, signal: AbortSignal): AsyncIterable<HarnessEvent> {
|
|
336
|
+
const mod = await ensureSdk()
|
|
337
|
+
|
|
338
|
+
// Per-prompt state.
|
|
339
|
+
stopRequested = false
|
|
340
|
+
const abortController = new AbortController()
|
|
341
|
+
activeAbort = abortController
|
|
342
|
+
// Bridge the caller's signal into the SDK's abort controller.
|
|
343
|
+
const onAbort = (): void => abortController.abort()
|
|
344
|
+
if (signal.aborted) abortController.abort()
|
|
345
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
346
|
+
|
|
347
|
+
// Per-message accumulated text for coalesced token snapshots.
|
|
348
|
+
const messageText = new Map<string, string>()
|
|
349
|
+
// The id of the assistant message currently streaming (from the partial
|
|
350
|
+
// `message_start` event), so text deltas coalesce under the SAME id the
|
|
351
|
+
// final assistant snapshot uses — not a synthetic partial key.
|
|
352
|
+
let partialMessageId: string | null = null
|
|
353
|
+
// Tool calls already forwarded, so a re-observed block isn't double-emitted.
|
|
354
|
+
const emittedToolCalls = new Set<string>()
|
|
355
|
+
const emittedToolResults = new Set<string>()
|
|
356
|
+
|
|
357
|
+
const author = prompt.author
|
|
358
|
+
const queryOptions: SdkQueryOptions = {
|
|
359
|
+
model: toSdkModel(startModel),
|
|
360
|
+
cwd: startWorkspaceDir,
|
|
361
|
+
resume: currentSessionId ?? undefined,
|
|
362
|
+
abortController,
|
|
363
|
+
permissionMode,
|
|
364
|
+
// The SDK requires this be true whenever permissionMode is
|
|
365
|
+
// 'bypassPermissions' (safety gate). Leave unset for any other mode.
|
|
366
|
+
allowDangerouslySkipPermissions: permissionMode === 'bypassPermissions' ? true : undefined,
|
|
367
|
+
includePartialMessages: true,
|
|
368
|
+
mcpServers: toSdkMcpServers(startMcpServers),
|
|
369
|
+
// `env` REPLACES the subprocess environment entirely — spread process.env
|
|
370
|
+
// FIRST so PATH/HOME/ANTHROPIC_API_KEY survive; without it the subprocess
|
|
371
|
+
// dies. G3: hand the SDK client the non-secret dummy key (the firewall
|
|
372
|
+
// brokers the real key on egress). Git author identity plumbed here too
|
|
373
|
+
// (leak #5) since the SDK has no first-class author param.
|
|
374
|
+
env: {
|
|
375
|
+
...process.env,
|
|
376
|
+
...(anthropicDummyKey ? { ANTHROPIC_API_KEY: anthropicDummyKey } : {}),
|
|
377
|
+
GIT_AUTHOR_NAME: author.name,
|
|
378
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
379
|
+
GIT_COMMITTER_NAME: author.name,
|
|
380
|
+
GIT_COMMITTER_EMAIL: author.email,
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let q: SdkQueryResult
|
|
385
|
+
try {
|
|
386
|
+
q = mod.query({ prompt: prompt.content, options: queryOptions })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
activeQuery = null
|
|
389
|
+
activeAbort = null
|
|
390
|
+
signal.removeEventListener('abort', onAbort)
|
|
391
|
+
yield errorEvent(prompt.messageId, error)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
activeQuery = q
|
|
395
|
+
|
|
396
|
+
let terminalYielded = false
|
|
397
|
+
try {
|
|
398
|
+
for await (const msg of q) {
|
|
399
|
+
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
400
|
+
if (typeof msg.session_id === 'string') currentSessionId = msg.session_id
|
|
401
|
+
if (!readyEmitted) {
|
|
402
|
+
readyEmitted = true
|
|
403
|
+
yield {
|
|
404
|
+
kind: 'ready',
|
|
405
|
+
messageId: prompt.messageId,
|
|
406
|
+
payload: { agentSessionId: currentSessionId ?? '' },
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// compact_boundary and any other system subtype: no mapping (leak #2).
|
|
413
|
+
if (msg.type === 'system') continue
|
|
414
|
+
|
|
415
|
+
if (msg.type === 'stream_event') {
|
|
416
|
+
const ev = msg.event ?? {}
|
|
417
|
+
// Track the streaming message id so deltas coalesce under the same key
|
|
418
|
+
// the assistant snapshot will use.
|
|
419
|
+
if (ev.type === 'message_start' && typeof ev.message?.id === 'string') {
|
|
420
|
+
partialMessageId = ev.message.id
|
|
421
|
+
}
|
|
422
|
+
// Accumulate text deltas into the running per-message snapshot so the
|
|
423
|
+
// sink coalesces token growth (leak: partials consumed, not 1:1).
|
|
424
|
+
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
|
425
|
+
const msgId =
|
|
426
|
+
partialMessageId ?? (typeof msg.message?.id === 'string' ? msg.message.id : '_partial')
|
|
427
|
+
const next = (messageText.get(msgId) ?? '') + (ev.delta.text ?? '')
|
|
428
|
+
messageText.set(msgId, next)
|
|
429
|
+
if (next) yield { kind: 'token', messageId: msgId, payload: { text: next } }
|
|
430
|
+
}
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (msg.type === 'assistant') {
|
|
435
|
+
const msgId = typeof msg.message?.id === 'string' ? msg.message.id : ''
|
|
436
|
+
// Accumulate all text first so the coalesced token snapshot for the
|
|
437
|
+
// turn is emitted BEFORE any tool_call — matching the OpenCode driver's
|
|
438
|
+
// token-then-tool ordering (thinking/redacted_thinking dropped, leak #1).
|
|
439
|
+
let combined = ''
|
|
440
|
+
const toolUseBlocks: SdkContentBlock[] = []
|
|
441
|
+
for (const block of contentBlocks(msg.message)) {
|
|
442
|
+
if (block.type === 'text') {
|
|
443
|
+
combined += typeof block.text === 'string' ? block.text : ''
|
|
444
|
+
} else if (block.type === 'tool_use') {
|
|
445
|
+
toolUseBlocks.push(block)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Emit the authoritative full-text snapshot for the turn (coalesces
|
|
449
|
+
// with any partial snapshots the sink already buffered).
|
|
450
|
+
if (combined && msgId) {
|
|
451
|
+
const prev = messageText.get(msgId) ?? ''
|
|
452
|
+
if (combined.length >= prev.length) {
|
|
453
|
+
messageText.set(msgId, combined)
|
|
454
|
+
yield { kind: 'token', messageId: msgId, payload: { text: combined } }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const block of toolUseBlocks) {
|
|
458
|
+
const callId = typeof block.id === 'string' ? block.id : ''
|
|
459
|
+
if (!callId || emittedToolCalls.has(callId)) continue
|
|
460
|
+
emittedToolCalls.add(callId)
|
|
461
|
+
yield {
|
|
462
|
+
kind: 'tool_call',
|
|
463
|
+
messageId: msgId || prompt.messageId,
|
|
464
|
+
payload: {
|
|
465
|
+
tool: typeof block.name === 'string' ? block.name : '',
|
|
466
|
+
args: asRecord(block.input),
|
|
467
|
+
callId,
|
|
468
|
+
status: 'running',
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// NB: `msg.message.usage` (BetaUsage) is ALWAYS populated on real
|
|
473
|
+
// assistant turns, but we deliberately do NOT emit a per-turn
|
|
474
|
+
// step_finish — the driver coalesces to ONE terminal step_finish at
|
|
475
|
+
// the `result` message so both harnesses normalize to the same
|
|
476
|
+
// granularity at the seam (leak #3).
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (msg.type === 'user') {
|
|
481
|
+
for (const block of contentBlocks(msg.message)) {
|
|
482
|
+
if (block.type !== 'tool_result') continue
|
|
483
|
+
const callId = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
|
|
484
|
+
if (!callId || emittedToolResults.has(callId)) continue
|
|
485
|
+
emittedToolResults.add(callId)
|
|
486
|
+
yield {
|
|
487
|
+
kind: 'tool_result',
|
|
488
|
+
messageId: prompt.messageId,
|
|
489
|
+
payload: {
|
|
490
|
+
callId,
|
|
491
|
+
status: block.is_error === true ? 'error' : 'completed',
|
|
492
|
+
output: toolResultOutput(block.content),
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (msg.type === 'result') {
|
|
500
|
+
const success = msg.subtype === 'success' && msg.is_error !== true
|
|
501
|
+
if (success) {
|
|
502
|
+
// Final step_finish carries the total cost + usage (leak #3).
|
|
503
|
+
yield {
|
|
504
|
+
kind: 'step_finish',
|
|
505
|
+
messageId: prompt.messageId,
|
|
506
|
+
payload: { cost: msg.total_cost_usd ?? null, tokens: msg.usage ?? null, reason: 'result' },
|
|
507
|
+
}
|
|
508
|
+
terminalYielded = true
|
|
509
|
+
yield {
|
|
510
|
+
kind: 'execution_complete',
|
|
511
|
+
messageId: prompt.messageId,
|
|
512
|
+
critical: true,
|
|
513
|
+
payload: stopRequested ? { success: false, aborted: true } : { success: true },
|
|
514
|
+
}
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
// error_max_turns / error_during_execution → critical error terminal.
|
|
518
|
+
terminalYielded = true
|
|
519
|
+
yield {
|
|
520
|
+
kind: 'error',
|
|
521
|
+
messageId: prompt.messageId,
|
|
522
|
+
critical: true,
|
|
523
|
+
payload: { error: describeResultError(msg) },
|
|
524
|
+
}
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
// Unknown message type: no mapping — skip (documented boundary).
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Stream ended without a result message (server/loop closed early).
|
|
531
|
+
if (!terminalYielded) {
|
|
532
|
+
if (stopRequested || signal.aborted) {
|
|
533
|
+
yield {
|
|
534
|
+
kind: 'execution_complete',
|
|
535
|
+
messageId: prompt.messageId,
|
|
536
|
+
critical: true,
|
|
537
|
+
payload: { success: false, aborted: true },
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
yield {
|
|
541
|
+
kind: 'execution_complete',
|
|
542
|
+
messageId: prompt.messageId,
|
|
543
|
+
critical: true,
|
|
544
|
+
payload: { success: true },
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// An abort (caller signal or stop()) resolves as a cancellation, never a
|
|
550
|
+
// plain failure (parity with the OpenCode driver's P2-2 rule).
|
|
551
|
+
if (stopRequested || signal.aborted || isAbortError(error)) {
|
|
552
|
+
if (!terminalYielded) {
|
|
553
|
+
yield {
|
|
554
|
+
kind: 'execution_complete',
|
|
555
|
+
messageId: prompt.messageId,
|
|
556
|
+
critical: true,
|
|
557
|
+
payload: { success: false, aborted: true },
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} else if (!terminalYielded) {
|
|
561
|
+
yield errorEvent(prompt.messageId, error)
|
|
562
|
+
}
|
|
563
|
+
} finally {
|
|
564
|
+
signal.removeEventListener('abort', onAbort)
|
|
565
|
+
activeQuery = null
|
|
566
|
+
activeAbort = null
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
async stop(): Promise<void> {
|
|
571
|
+
// Cancellation is via `abortController.abort()` — that works in ALL modes
|
|
572
|
+
// and is the REAL mechanism here. `query().interrupt()` is a control request
|
|
573
|
+
// the SDK only honors in streaming-INPUT mode; this driver uses string-prompt
|
|
574
|
+
// mode, so interrupt() is a no-op harmless best-effort at most. We call it
|
|
575
|
+
// first (in case a future streaming-input path is added) but the abort below
|
|
576
|
+
// is what actually cancels the in-flight query.
|
|
577
|
+
stopRequested = true
|
|
578
|
+
const q = activeQuery
|
|
579
|
+
if (q && typeof q.interrupt === 'function') {
|
|
580
|
+
try {
|
|
581
|
+
await q.interrupt()
|
|
582
|
+
} catch {
|
|
583
|
+
// best-effort — the abort below is the real cancellation
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
activeAbort?.abort()
|
|
588
|
+
} catch {
|
|
589
|
+
// controller already aborted / gone
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
async shutdown(): Promise<void> {
|
|
594
|
+
currentSessionId = null
|
|
595
|
+
readyEmitted = false
|
|
596
|
+
stopRequested = false
|
|
597
|
+
activeQuery = null
|
|
598
|
+
activeAbort = null
|
|
599
|
+
sdk = null
|
|
600
|
+
},
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** A terminal `error` HarnessEvent from a thrown/rejected query. */
|
|
605
|
+
function errorEvent(messageId: string, error: unknown): HarnessEvent {
|
|
606
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
607
|
+
return { kind: 'error', messageId, critical: true, payload: { error: message } }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isAbortError(error: unknown): boolean {
|
|
611
|
+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'DOMException')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Human-readable reason for a non-success result message. */
|
|
615
|
+
function describeResultError(msg: SdkMessage): string {
|
|
616
|
+
if (msg.subtype === 'error_max_turns') return 'agent stopped: max turns reached'
|
|
617
|
+
if (msg.subtype === 'error_max_budget_usd') return 'agent stopped: max budget reached'
|
|
618
|
+
if (msg.subtype === 'error_during_execution') return 'agent error during execution'
|
|
619
|
+
if (typeof msg.result === 'string' && msg.result) return msg.result
|
|
620
|
+
return 'agent error'
|
|
621
|
+
}
|