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.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. 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
+ }