@gethmy/harness 1.0.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.
@@ -0,0 +1,575 @@
1
+ /**
2
+ * SdkAgentRunner — productionized from the #417 Phase 2 spike (GO, comment
3
+ * `ae0c55f9`). An {@link AgentRunner} built on `@anthropic-ai/claude-agent-sdk`'s
4
+ * `query()` that maps SDK messages → {@link AgentRunEventDraft} (the same union the
5
+ * CliAgentRunner produces). It is a *runtime swap*, not a provider change — the model
6
+ * is still Claude, the host `claude` login still authenticates, the worktree, prompt,
7
+ * verification and PR pipeline are unchanged (#430 covers cross-provider runners).
8
+ *
9
+ * Selected by `AgentConfig.runner === "sdk"`; the CLI runner stays the default and the
10
+ * indefinite fallback. The worker drives the run by iterating {@link start}/{@link resume}
11
+ * and feeding the drafts into the same progress tracker + run-event stream the CLI path
12
+ * uses, so the surrounding lifecycle (watchdog, cancel, verify, completion) is identical.
13
+ *
14
+ * The two non-obvious requirements from the spike, both load-bearing:
15
+ *
16
+ * 1. **Cancellation parity (the single most important task).** The SDK's default spawn
17
+ * only kills the leader — *worse* than the CLI, which orphans the model's
18
+ * grandchildren (a disowned `sleep 600 &`). So we inject {@link spawnInGroup} via
19
+ * `Options.spawnClaudeCodeProcess`, making the child a process-group leader, and
20
+ * {@link stop} escalates a cooperative `abort()` → `terminateGroup` on the **negative
21
+ * pgid** (SIGINT→TERM→KILL), reaping the whole subtree at CLI parity. The worker also
22
+ * receives the child via `onSpawn` so its existing pause/resume/cancel paths keep
23
+ * working on `this.process`.
24
+ *
25
+ * 2. **MCP visibility.** The Agent SDK loads all filesystem setting sources (incl. the
26
+ * host `claude` MCP config, where the harmony server is registered) when
27
+ * `settingSources` is omitted — the proven spike behaviour and CLI parity, so that is
28
+ * the default. An operator can instead declare the harmony MCP explicitly via
29
+ * `mcpServers` (+ `strictMcpConfig` / `settingSources: []`) once validated in prod —
30
+ * the "explicit declaration" improvement, gated as a config opt-in rather than a
31
+ * big-bang.
32
+ */
33
+
34
+ import type { ChildProcess } from "node:child_process";
35
+ import {
36
+ type McpServerConfig,
37
+ type Options,
38
+ query,
39
+ type SDKMessage,
40
+ type SettingSource,
41
+ type SpawnedProcess,
42
+ type SpawnOptions,
43
+ } from "@anthropic-ai/claude-agent-sdk";
44
+ import type {
45
+ AgentRunEventDraft,
46
+ AgentRunInput,
47
+ AgentRunner,
48
+ ResumeInput,
49
+ StopReason,
50
+ UserAgentMessage,
51
+ } from "@harmony/shared";
52
+ import { type ApiErrorKind, classifyRunError } from "./error-classifier.js";
53
+ import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
54
+
55
+ /**
56
+ * Default allowed-tool surface — mirrors the CLI's `IMPLEMENT_ALLOWED_TOOLS`. The CLI
57
+ * passes a comma string to `--allowedTools`; the SDK takes `string[]`. The worker
58
+ * overrides this per-spawn (e.g. the read-only planning pass).
59
+ */
60
+ export const SDK_ALLOWED_TOOLS = [
61
+ "Bash",
62
+ "Read",
63
+ "Write",
64
+ "Edit",
65
+ "Glob",
66
+ "Grep",
67
+ "Agent",
68
+ "mcp__harmony__*",
69
+ ];
70
+
71
+ const MAX_TEXT_LEN = 8_000;
72
+ const MAX_OUTPUT_LEN = 4_000;
73
+ /** Grace windows for the belt-and-suspenders pgid escalation in {@link stop}. */
74
+ const STOP_SIGINT_MS = 2_000;
75
+ const STOP_SIGTERM_MS = 2_000;
76
+
77
+ export interface SdkRunnerConfig {
78
+ /** Model id for the run (per-attempt tier selection lives in the worker). */
79
+ model?: string;
80
+ /** Max conversation turns before the SDK stops the query. */
81
+ maxTurns?: number;
82
+ /** Allowed tools (SDK array form). Defaults to {@link SDK_ALLOWED_TOOLS}. */
83
+ allowedTools?: string[];
84
+ /**
85
+ * Denied tools (SDK array form). A disallow wins over `allowedTools`, so this
86
+ * strips specific tools from a wildcard allow set — used for playbook stage runs
87
+ * to remove the daemon-owned session/move tools from `mcp__harmony__*` (#576).
88
+ */
89
+ disallowedTools?: string[];
90
+ /**
91
+ * Native SDK per-query budget cap (USD) — a per-card cost ceiling the daemon
92
+ * otherwise lacks. Exceeding it ends the query with an `error_max_budget_usd`
93
+ * result, surfaced as a failure event.
94
+ */
95
+ maxBudgetUsd?: number;
96
+ /**
97
+ * Filesystem setting sources the SDK loads. Omit (default) → load all, which
98
+ * includes the host `claude` MCP config (harmony server) — CLI parity. Pass `[]`
99
+ * for SDK isolation, in which case declare {@link mcpServers} explicitly.
100
+ */
101
+ settingSources?: SettingSource[];
102
+ /**
103
+ * Explicit MCP server declarations (the #417 "declare harmony MCP explicitly"
104
+ * improvement). Opt-in: when set, typically paired with {@link strictMcpConfig}.
105
+ */
106
+ mcpServers?: Record<string, McpServerConfig>;
107
+ /** Maps to `--strict-mcp-config`: ignore all MCP config except {@link mcpServers}. */
108
+ strictMcpConfig?: boolean;
109
+ /**
110
+ * Environment variables to DELETE from the spawned child's environment
111
+ * (Task 15). The Agent SDK builds the child's env from the daemon's own
112
+ * `process.env` and hands it to {@link spawn} below, so a credential the
113
+ * parent holds reaches the subagent unless something removes it by name.
114
+ *
115
+ * The list is DERIVED from the role's launch — `envKeysDroppedByLaunch`
116
+ * (runner.ts) reports the keys the launch omitted — so `buildRoleLaunch`
117
+ * stays the single source of truth for which variables a role may hold, and
118
+ * no second credential list appears here.
119
+ */
120
+ stripEnvKeys?: readonly string[];
121
+ /**
122
+ * Hands the spawned process-group leader to the worker so its existing
123
+ * pause/resume/cancel paths can keep operating on `this.process`.
124
+ */
125
+ onSpawn?: (child: ChildProcess) => void;
126
+ }
127
+
128
+ /** Map the SDK's typed assistant-error union to the daemon's {@link ApiErrorKind}. */
129
+ export function mapSdkErrorKind(e: string | undefined): ApiErrorKind | null {
130
+ switch (e) {
131
+ case "authentication_failed":
132
+ case "oauth_org_not_allowed":
133
+ return "auth";
134
+ case "billing_error":
135
+ return "out_of_credits";
136
+ case "rate_limit":
137
+ case "overloaded":
138
+ return "rate_limit";
139
+ default:
140
+ // invalid_request / model_not_found / server_error / unknown / max_output_tokens
141
+ // → generic crash (null). This is deliberate CLI parity: the shared
142
+ // `error-classifier.ts` regex only treats 401/402/429/529/overloaded/usage
143
+ // as API conditions, so a plain Anthropic 5xx `server_error` is a generic
144
+ // crash (attempt counts) on the CLI path too. Mapping it to a retryable
145
+ // API kind here would make the SDK path more lenient than the CLI and
146
+ // mis-apply rate-limit cooldown to a one-off 500 — if 5xx-as-outage is
147
+ // wanted, change error-classifier.ts so BOTH runners share the behavior.
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export class SdkAgentRunner implements AgentRunner {
153
+ private abort: AbortController | null = null;
154
+ private capturedSessionId: string | undefined;
155
+ private child: ChildProcess | null = null;
156
+ /**
157
+ * pgid (=== leader pid) of the spawned Claude Code child, recorded so the
158
+ * group can be reaped after the run ends even once `this.child` has exited
159
+ * (a live ChildProcess can't be signalled then). See {@link reapGroup}.
160
+ */
161
+ private leaderPid: number | undefined;
162
+ private capturedStderr = "";
163
+ /** toolUseId → tool name, so tool_result frames can resolve the name (CLI parity). */
164
+ private toolNames = new Map<string, string>();
165
+ /** Model observed off the stream — labels cost_updated like the CLI parser does. */
166
+ private observedModel: string | undefined;
167
+ /** Effective model for this run (`input.model ?? cfg.model`) — the cost-label
168
+ * fallback, mirroring how {@link run} resolves the model for the SDK query. */
169
+ private effectiveModel: string | undefined;
170
+
171
+ constructor(private readonly cfg: SdkRunnerConfig = {}) {}
172
+
173
+ /** Last session id observed — the handle for {@link resume} (cross-restart steering). */
174
+ get sessionId(): string | undefined {
175
+ return this.capturedSessionId;
176
+ }
177
+
178
+ /**
179
+ * Full captured stderr. The worker folds this into the thrown error so the shared
180
+ * `classifyRunError` catch reads the API-error signal exactly as it does for the CLI.
181
+ */
182
+ get capturedStderrText(): string {
183
+ return this.capturedStderr;
184
+ }
185
+
186
+ start(input: AgentRunInput): AsyncIterable<AgentRunEventDraft> {
187
+ return this.run(input);
188
+ }
189
+
190
+ resume(input: ResumeInput): AsyncIterable<AgentRunEventDraft> {
191
+ return this.run(input, input.resumeSessionId);
192
+ }
193
+
194
+ /**
195
+ * Queue a human message mid-run. The worker currently steers via stop→resume (queue
196
+ * -and-resume, same as the CLI), so true streaming-input injection is not yet wired
197
+ * here — kept explicit rather than a silent no-op.
198
+ */
199
+ async send(_message: UserAgentMessage): Promise<void> {
200
+ throw new Error(
201
+ "SdkAgentRunner.send() (streaming-input steering) is not wired; the worker steers via stop→resume",
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Hard stop. Cooperative `abort()` first (the SDK runs a graceful stdin-EOF + ~2s
207
+ * grace before force-kill), then escalate on the negative pgid as a belt-and-suspenders
208
+ * tree-kill so a disowned grandchild can't survive.
209
+ */
210
+ async stop(_reason: StopReason): Promise<void> {
211
+ this.abort?.abort();
212
+ if (this.child) {
213
+ await terminateGroup(this.child, {
214
+ sigintTimeoutMs: STOP_SIGINT_MS,
215
+ sigtermTimeoutMs: STOP_SIGTERM_MS,
216
+ });
217
+ }
218
+ // Belt-and-suspenders: terminateGroup early-returns once the leader has
219
+ // exited, so sweep the group by the recorded pgid to catch any survivor
220
+ // that left the leader behind (the run()'s finally also covers this).
221
+ reapGroup(this.leaderPid);
222
+ }
223
+
224
+ private async *run(
225
+ input: AgentRunInput,
226
+ resumeSessionId?: string,
227
+ ): AsyncIterable<AgentRunEventDraft> {
228
+ this.abort = new AbortController();
229
+ this.capturedStderr = "";
230
+ this.child = null;
231
+ this.leaderPid = undefined;
232
+ this.toolNames.clear();
233
+ this.observedModel = undefined;
234
+ // Same resolution the SDK query uses below (options.model = input.model ??
235
+ // cfg.model), so cost_updated.modelName labels correctly even when the
236
+ // caller supplies the model via input rather than the runner config.
237
+ this.effectiveModel = input.model ?? this.cfg.model;
238
+
239
+ yield {
240
+ kind: "run_started",
241
+ source: "system",
242
+ payload: { runner: "sdk", model: input.model },
243
+ };
244
+
245
+ const allowed = this.cfg.allowedTools ?? SDK_ALLOWED_TOOLS;
246
+ // Tool gating = faithful CLI parity. The daemon's CLI path runs `claude -p`
247
+ // with `--allowedTools` and NO `--permission-mode`, so a tool outside the
248
+ // allowlist (other built-ins AND any other locally-configured MCP server) is
249
+ // denied, headless, with no prompt. `permissionMode: "dontAsk"` reproduces
250
+ // exactly that — "don't prompt; deny if not pre-approved" — with `allowedTools`
251
+ // (incl. the `mcp__harmony__*` glob) as the pre-approved set. This gates MCP
252
+ // tools too, so a read-only planning pass can't reach a non-harmony MCP server.
253
+ // (`bypassPermissions` would skip ALL checks — strictly more permissive than
254
+ // the CLI — so it is deliberately NOT used.) `tools` additionally trims the
255
+ // built-in set from the model's context as defense-in-depth; MCP/wildcard
256
+ // entries are governed by mcpServers + the dontAsk allowlist, not `tools`.
257
+ const builtinTools = allowed.filter(
258
+ (t) => !t.startsWith("mcp__") && !t.includes("*"),
259
+ );
260
+
261
+ const options: Options = {
262
+ cwd: input.cwd,
263
+ model: input.model ?? this.cfg.model,
264
+ // Pre-approved allowlist — under dontAsk this is the authoritative gate
265
+ // for both built-in and MCP tools.
266
+ allowedTools: allowed,
267
+ // Denylist (stage runs, #576): wins over the allow-wildcard, so the
268
+ // daemon-owned session/move tools are removed even though `mcp__harmony__*`
269
+ // is allowed. Omitted entirely when unset (generic runs).
270
+ ...(this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0
271
+ ? { disallowedTools: this.cfg.disallowedTools }
272
+ : {}),
273
+ tools: builtinTools,
274
+ permissionMode: "dontAsk",
275
+ maxTurns: this.cfg.maxTurns,
276
+ abortController: this.abort,
277
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
278
+ ...(this.cfg.maxBudgetUsd ? { maxBudgetUsd: this.cfg.maxBudgetUsd } : {}),
279
+ ...(this.cfg.settingSources
280
+ ? { settingSources: this.cfg.settingSources }
281
+ : {}),
282
+ ...(this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {}),
283
+ ...(this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {}),
284
+ stderr: (data) => {
285
+ this.capturedStderr += data;
286
+ },
287
+ // Tree-kill escape hatch (requirement #2). Inject the daemon's own
288
+ // process-group leader so signalling the negative pgid reaps grandchildren.
289
+ spawnClaudeCodeProcess: (spawnOpts) => this.spawn(spawnOpts),
290
+ };
291
+
292
+ try {
293
+ const q = query({ prompt: input.prompt, options }) as AsyncGenerator<
294
+ SDKMessage,
295
+ void
296
+ >;
297
+ // A non-success `result` subtype (error_max_turns / error_max_budget_usd)
298
+ // or a typed assistant error surfaces as an `error` draft mid-stream rather
299
+ // than throwing, so the terminal status must reflect it — otherwise the
300
+ // stream would self-contradict (error followed by `completed`).
301
+ let failureReason: string | null = null;
302
+ for await (const msg of q) {
303
+ for (const ev of this.mapMessage(msg)) {
304
+ if (ev.kind === "error") {
305
+ failureReason = ev.payload.errorKind ?? "crash";
306
+ }
307
+ yield ev;
308
+ }
309
+ }
310
+ yield {
311
+ kind: "run_finished",
312
+ source: "system",
313
+ payload: failureReason
314
+ ? { status: "failed", failureReason }
315
+ : { status: "completed" },
316
+ };
317
+ } catch (err) {
318
+ const message = err instanceof Error ? err.message : String(err);
319
+ // Fold captured stderr in so classifyRunError reads the API signal,
320
+ // exactly as the worker does for the CLI.
321
+ const cls = classifyRunError(`${message}\n${this.capturedStderr}`);
322
+ yield {
323
+ kind: "error",
324
+ source: "system",
325
+ payload: {
326
+ message,
327
+ errorKind: cls.kind,
328
+ retryable: cls.kind !== "auth" && cls.kind !== null,
329
+ },
330
+ };
331
+ yield {
332
+ kind: "run_finished",
333
+ source: "system",
334
+ payload: { status: "failed", failureReason: cls.kind ?? "crash" },
335
+ };
336
+ } finally {
337
+ // Reap detached/backgrounded grandchildren on EVERY run end — not just an
338
+ // explicit stop(). A run that finishes on its own (maxTurns, completion)
339
+ // otherwise leaks them, since the SDK only exits the Claude child and
340
+ // nothing signals the group (card #436).
341
+ reapGroup(this.leaderPid);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Spawn the Claude Code child as a process-group leader (negative-pgid
347
+ * tree-kill), with {@link SdkRunnerConfig.stripEnvKeys} removed from its
348
+ * environment.
349
+ *
350
+ * The strip is a DELETE inside `spawnInGroup`, not a narrowing of the env
351
+ * passed here: `spawnOpts.env` comes from the SDK and derives from the
352
+ * daemon's `process.env`, and `spawnInGroup` merges `process.env` again on
353
+ * top of whatever it receives. Passing a smaller object would therefore strip
354
+ * nothing.
355
+ */
356
+ private spawn(spawnOpts: SpawnOptions): SpawnedProcess {
357
+ const child = spawnInGroup(spawnOpts.command, spawnOpts.args, {
358
+ cwd: spawnOpts.cwd,
359
+ env: spawnOpts.env,
360
+ stdio: ["pipe", "pipe", "pipe"],
361
+ ...(this.cfg.stripEnvKeys ? { stripEnvKeys: this.cfg.stripEnvKeys } : {}),
362
+ });
363
+ this.child = child;
364
+ this.leaderPid = child.pid; // record pgid for the post-exit group sweep
365
+ // `SpawnedProcess` exposes no stderr, so the SDK's own `stderr` Options sink
366
+ // never fires for a custom spawn. We MUST drain child.stderr ourselves: an
367
+ // unread pipe fills (~64KB) and blocks the subprocess until the watchdog
368
+ // kills it. Draining here also restores stderr capture for the auth/rate-limit
369
+ // classification path that error recovery depends on.
370
+ child.stderr?.on("data", (d: Buffer) => {
371
+ this.capturedStderr += d.toString();
372
+ });
373
+ this.cfg.onSpawn?.(child);
374
+ // Adapt ChildProcess → SpawnedProcess. Override kill() to signal the whole
375
+ // group (negative pid) instead of just the leader.
376
+ return {
377
+ stdin: child.stdin!,
378
+ stdout: child.stdout!,
379
+ get killed() {
380
+ return child.killed;
381
+ },
382
+ get exitCode() {
383
+ return child.exitCode;
384
+ },
385
+ kill: (signal: NodeJS.Signals) => {
386
+ if (!child.pid) return false;
387
+ try {
388
+ process.kill(-child.pid, signal);
389
+ return true;
390
+ } catch {
391
+ return child.kill(signal);
392
+ }
393
+ },
394
+ on: (event, listener) => child.on(event, listener as never),
395
+ once: (event, listener) => child.once(event, listener as never),
396
+ off: (event, listener) => child.off(event, listener as never),
397
+ } as SpawnedProcess;
398
+ }
399
+
400
+ /** Map one SDKMessage to zero or more AgentRunEventDrafts. */
401
+ private *mapMessage(msg: SDKMessage): Generator<AgentRunEventDraft> {
402
+ // Capture session id from any message that carries it (for resume()).
403
+ const sid = (msg as { session_id?: string }).session_id;
404
+ if (sid && !this.capturedSessionId) this.capturedSessionId = sid;
405
+
406
+ // Capture the model off any frame that carries it (the assistant frame does),
407
+ // so cost_updated can be labelled like the CLI parser's observedModel.
408
+ const model =
409
+ (msg as { message?: { model?: string } }).message?.model ??
410
+ (msg as { model?: string }).model;
411
+ if (typeof model === "string" && !this.observedModel) {
412
+ this.observedModel = model;
413
+ }
414
+
415
+ switch (msg.type) {
416
+ case "assistant": {
417
+ const am = msg as { message?: { content?: unknown[] }; error?: string };
418
+ // Typed error on the assistant frame — no regex needed.
419
+ if (am.error) {
420
+ yield {
421
+ kind: "error",
422
+ source: "system",
423
+ payload: {
424
+ message: `assistant error: ${am.error}`,
425
+ errorKind: mapSdkErrorKind(am.error),
426
+ },
427
+ };
428
+ }
429
+ const blocks = am.message?.content;
430
+ if (Array.isArray(blocks)) {
431
+ for (const b of blocks as Array<{
432
+ type?: string;
433
+ text?: string;
434
+ name?: string;
435
+ id?: string;
436
+ input?: unknown;
437
+ }>) {
438
+ if (b.type === "text" && typeof b.text === "string") {
439
+ const text = b.text.trim();
440
+ if (text) {
441
+ yield {
442
+ kind: "assistant_text",
443
+ source: "agent",
444
+ payload: { text: text.slice(0, MAX_TEXT_LEN) },
445
+ };
446
+ }
447
+ } else if (b.type === "tool_use" && typeof b.name === "string") {
448
+ // Remember the name so the matching tool_result can resolve it.
449
+ if (typeof b.id === "string") this.toolNames.set(b.id, b.name);
450
+ yield {
451
+ kind: "tool_started",
452
+ source: "agent",
453
+ payload: { toolName: b.name, toolUseId: b.id, input: b.input },
454
+ };
455
+ }
456
+ }
457
+ }
458
+ break;
459
+ }
460
+ case "user": {
461
+ // tool_result blocks land on user frames in the SDK stream.
462
+ const um = msg as { message?: { content?: unknown[] } };
463
+ const blocks = um.message?.content;
464
+ if (Array.isArray(blocks)) {
465
+ for (const b of blocks as Array<{
466
+ type?: string;
467
+ tool_use_id?: string;
468
+ content?: unknown;
469
+ is_error?: boolean;
470
+ }>) {
471
+ if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
472
+ // Resolve the name from the matching tool_use (CLI parity); the
473
+ // SDK tool_result block itself carries only the id. Drop the entry
474
+ // after resolving so the map stays flat across a run, exactly like
475
+ // the CLI StreamParser's `toolNames.delete` on tool_result.
476
+ const toolName = this.toolNames.get(b.tool_use_id) ?? "";
477
+ this.toolNames.delete(b.tool_use_id);
478
+ yield {
479
+ kind: "tool_ended",
480
+ source: "agent",
481
+ payload: {
482
+ toolName,
483
+ toolUseId: b.tool_use_id,
484
+ output: normalize(b.content)?.slice(0, MAX_OUTPUT_LEN),
485
+ isError: b.is_error,
486
+ },
487
+ };
488
+ }
489
+ }
490
+ }
491
+ break;
492
+ }
493
+ case "result": {
494
+ const r = msg as {
495
+ subtype?: string;
496
+ total_cost_usd?: number;
497
+ num_turns?: number;
498
+ duration_ms?: number;
499
+ usage?: {
500
+ input_tokens?: number;
501
+ output_tokens?: number;
502
+ cache_creation_input_tokens?: number;
503
+ cache_read_input_tokens?: number;
504
+ };
505
+ errors?: string[];
506
+ };
507
+ if (typeof r.total_cost_usd === "number") {
508
+ yield {
509
+ kind: "cost_updated",
510
+ source: "agent",
511
+ payload: {
512
+ totalCostUsd: r.total_cost_usd,
513
+ inputTokens: r.usage?.input_tokens ?? 0,
514
+ outputTokens: r.usage?.output_tokens ?? 0,
515
+ cacheCreationInputTokens:
516
+ r.usage?.cache_creation_input_tokens ?? 0,
517
+ cacheReadInputTokens: r.usage?.cache_read_input_tokens ?? 0,
518
+ numTurns: r.num_turns ?? 0,
519
+ durationMs: r.duration_ms,
520
+ // Label the cost like the CLI parser does (its CostUpdate carries
521
+ // modelName). The result frame has no model, so use the model seen
522
+ // on the stream, falling back to the effective run model
523
+ // (input.model ?? cfg.model) — not cfg.model alone, which would
524
+ // drop the label when the caller passes the model via input.
525
+ modelName: this.observedModel ?? this.effectiveModel,
526
+ },
527
+ };
528
+ }
529
+ // Surface a result-level error subtype (max_turns / max_budget / exec error).
530
+ if (r.subtype && r.subtype !== "success") {
531
+ const joined = (r.errors ?? []).join("\n");
532
+ const cls = classifyRunError(
533
+ `${r.subtype}\n${joined}\n${this.capturedStderr}`,
534
+ );
535
+ yield {
536
+ kind: "error",
537
+ source: "system",
538
+ payload: {
539
+ message: `result ${r.subtype}: ${joined || "(no detail)"}`,
540
+ errorKind: cls.kind,
541
+ retryable: cls.kind !== "auth" && cls.kind !== null,
542
+ },
543
+ };
544
+ }
545
+ break;
546
+ }
547
+ // system / partial / status / etc. — ignored (matches CLI parser scope).
548
+ }
549
+ }
550
+ }
551
+
552
+ /** Flatten an SDK tool_result `content` (string | block[] | object) to a string. */
553
+ function normalize(raw: unknown): string | undefined {
554
+ if (raw == null) return undefined;
555
+ if (typeof raw === "string") return raw;
556
+ if (Array.isArray(raw)) {
557
+ const parts: string[] = [];
558
+ for (const b of raw) {
559
+ if (
560
+ b &&
561
+ typeof b === "object" &&
562
+ "text" in b &&
563
+ typeof (b as { text: unknown }).text === "string"
564
+ ) {
565
+ parts.push((b as { text: string }).text);
566
+ }
567
+ }
568
+ return parts.length ? parts.join("") : JSON.stringify(raw);
569
+ }
570
+ try {
571
+ return JSON.stringify(raw);
572
+ } catch {
573
+ return String(raw);
574
+ }
575
+ }