@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.
- package/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -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
|
+
}
|