talon-agent 1.9.2 → 1.10.1

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,352 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stub claude CLI — replaces the real `claude` binary in integration tests.
4
+ *
5
+ * The Claude Agent SDK communicates with the `claude` CLI via streaming JSONL
6
+ * over stdin/stdout (`--input-format stream-json --output-format stream-json`).
7
+ * This script implements just enough of the protocol to drive the SDK through
8
+ * a scripted conversation so we can write integration tests for our handler,
9
+ * options, hooks, and stream processing without hitting the live API.
10
+ *
11
+ * The script to run is provided via `STUB_CLAUDE_SCRIPT` (path to JSON file)
12
+ * or `STUB_CLAUDE_SCRIPT_INLINE` (inline JSON). Schema is described in
13
+ * `protocol.ts`.
14
+ *
15
+ * Protocol summary (reverse-engineered from real SDK invocations):
16
+ *
17
+ * SDK → binary (stdin):
18
+ * 1. {type: "control_request", request: {subtype: "initialize", ...}}
19
+ * 2. {type: "user", message: {role, content}, parent_tool_use_id, ...}
20
+ * 3. (after tool_use blocks dispatch) {type: "user", message: {role: "user",
21
+ * content: [{type: "tool_result", tool_use_id, content}]}, ...}
22
+ *
23
+ * binary → SDK (stdout):
24
+ * 1. {type: "control_response", response: {subtype: "success", request_id,
25
+ * response: SDKControlInitializeResponse}}
26
+ * 2. {type: "system", subtype: "init", session_id, ...}
27
+ * 3. {type: "assistant", message: {content: [...]}, session_id, ...}
28
+ * 4. {type: "result", subtype: "success"|"error_during_execution"|...}
29
+ */
30
+
31
+ import { readFileSync, appendFileSync, existsSync } from "node:fs";
32
+ import { randomUUID } from "node:crypto";
33
+
34
+ const LOG_FILE = process.env.STUB_CLAUDE_LOG;
35
+ const log = (msg) => {
36
+ if (LOG_FILE) appendFileSync(LOG_FILE, msg + "\n");
37
+ };
38
+
39
+ // ── Load script ──────────────────────────────────────────────────────────────
40
+
41
+ const loadScript = () => {
42
+ if (process.env.STUB_CLAUDE_SCRIPT_INLINE) {
43
+ return JSON.parse(process.env.STUB_CLAUDE_SCRIPT_INLINE);
44
+ }
45
+ const path = process.env.STUB_CLAUDE_SCRIPT;
46
+ if (!path || !existsSync(path)) {
47
+ // No script provided — degrade gracefully. Used for SDK model-discovery
48
+ // boots and other "init only, never receive user messages" flows.
49
+ log(
50
+ `no script provided (path=${path ?? "unset"}), running in init-only mode`,
51
+ );
52
+ return { turns: [] };
53
+ }
54
+ return JSON.parse(readFileSync(path, "utf8"));
55
+ };
56
+
57
+ const SCRIPT = loadScript();
58
+ const SESSION_ID =
59
+ SCRIPT.sessionId ?? "stub-session-" + randomUUID().slice(0, 8);
60
+
61
+ // ── Output helpers ──────────────────────────────────────────────────────────
62
+
63
+ const send = (obj) => {
64
+ const line = JSON.stringify(obj);
65
+ log("STDOUT: " + line);
66
+ process.stdout.write(line + "\n");
67
+ };
68
+
69
+ // Mock models the stub advertises through the standard
70
+ // `SDKControlInitializeResponse.models` field. The production `registerClaudeModels`
71
+ // path discovers these via `q.supportedModels()` exactly the same way it would
72
+ // against a real `claude` binary. That's the whole point — tests run through
73
+ // the unmodified bootstrap, no test-side overrides for model discovery.
74
+ //
75
+ // Schema per SDK's sdk.d.ts: `{ value, displayName, description }` (camelCase).
76
+ const STUB_MODELS = [
77
+ {
78
+ value: "claude-sonnet-4-6",
79
+ displayName: "Sonnet 4.6 (stub)",
80
+ description:
81
+ "Stub model — integration test fixture. Always responds with whatever " +
82
+ "the test script in STUB_CLAUDE_SCRIPT specifies for this turn.",
83
+ },
84
+ {
85
+ value: "claude-opus-4-7",
86
+ displayName: "Opus 4.7 (stub)",
87
+ description:
88
+ "Stub model — integration test fixture (alias of sonnet stub).",
89
+ },
90
+ {
91
+ value: "default",
92
+ displayName: "Default (stub)",
93
+ description:
94
+ "Default stub model — used when tests don't specify one explicitly.",
95
+ },
96
+ ];
97
+
98
+ const defaultInitResponse = {
99
+ commands: [],
100
+ agents: [],
101
+ output_style: "default",
102
+ available_output_styles: ["default"],
103
+ models: STUB_MODELS,
104
+ account: { email: "stub@stub.test", organization: { name: "stub-org" } },
105
+ };
106
+
107
+ const defaultSystemInit = {
108
+ type: "system",
109
+ subtype: "init",
110
+ session_id: SESSION_ID,
111
+ cwd: process.cwd(),
112
+ tools: [],
113
+ mcp_servers: [],
114
+ model: "claude-sonnet-4-6",
115
+ permissionMode: "bypassPermissions",
116
+ slash_commands: [],
117
+ apiKeySource: "none",
118
+ };
119
+
120
+ // ── State ────────────────────────────────────────────────────────────────────
121
+
122
+ let turnIndex = 0;
123
+ /** Map from request_id we emitted -> resolver waiting on the SDK's response. */
124
+ const pendingRequests = new Map();
125
+ /** When set, the init handshake has captured these hook callback ids by event. */
126
+ let hookCallbackIds = {};
127
+
128
+ // ── Message handlers ────────────────────────────────────────────────────────
129
+
130
+ const handleInitialize = (msg) => {
131
+ // Capture hook callback ids — needed if the stub fires hooks during a turn.
132
+ const hooks = msg.request?.hooks ?? {};
133
+ hookCallbackIds = {};
134
+ for (const [event, matchers] of Object.entries(hooks)) {
135
+ hookCallbackIds[event] = matchers.flatMap((m) => m.hookCallbackIds ?? []);
136
+ }
137
+ log("CAPTURED hookCallbackIds: " + JSON.stringify(hookCallbackIds));
138
+ const initResponse = SCRIPT.initResponse ?? defaultInitResponse;
139
+ send({
140
+ type: "control_response",
141
+ response: {
142
+ subtype: "success",
143
+ request_id: msg.request_id,
144
+ response: initResponse,
145
+ },
146
+ });
147
+ send(SCRIPT.systemInit ?? defaultSystemInit);
148
+ };
149
+
150
+ /** Send a control_request to the SDK and resolve with its control_response payload. */
151
+ const sendControlRequest = (request) => {
152
+ const requestId = "stub_req_" + randomUUID().slice(0, 8);
153
+ return new Promise((resolve, reject) => {
154
+ pendingRequests.set(requestId, { resolve, reject });
155
+ send({ type: "control_request", request_id: requestId, request });
156
+ // Per-call timeout in case SDK never responds
157
+ setTimeout(() => {
158
+ if (pendingRequests.has(requestId)) {
159
+ pendingRequests.delete(requestId);
160
+ reject(
161
+ new Error("control_request timeout: " + JSON.stringify(request)),
162
+ );
163
+ }
164
+ }, 5000).unref();
165
+ });
166
+ };
167
+
168
+ const handleUser = async (_msg) => {
169
+ // Pop the next scripted turn and emit its messages
170
+ const turn = SCRIPT.turns?.[turnIndex];
171
+ turnIndex += 1;
172
+ if (!turn) {
173
+ // No more scripted turns — emit a benign result and exit
174
+ send({
175
+ type: "result",
176
+ subtype: "success",
177
+ is_error: false,
178
+ result: "stub: out of script",
179
+ duration_ms: 1,
180
+ duration_api_ms: 0,
181
+ num_turns: turnIndex,
182
+ session_id: SESSION_ID,
183
+ total_cost_usd: 0,
184
+ usage: { input_tokens: 0, output_tokens: 0 },
185
+ });
186
+ return;
187
+ }
188
+ for (const out of turn.emit ?? []) {
189
+ if (out.type === "_fire_hook") {
190
+ // Synthesize a hook_callback control_request to the SDK. The SDK calls
191
+ // the registered hook function and replies with the hook output. If the
192
+ // hook returns continue:false, we stop emitting subsequent items.
193
+ const callbackIds = hookCallbackIds[out.event] ?? [];
194
+ if (!callbackIds.length) {
195
+ log(`no hook registered for event ${out.event}, skipping`);
196
+ continue;
197
+ }
198
+ let stopped = false;
199
+ for (const callbackId of callbackIds) {
200
+ try {
201
+ const resp = await sendControlRequest({
202
+ subtype: "hook_callback",
203
+ callback_id: callbackId,
204
+ input: out.input,
205
+ tool_use_id: out.tool_use_id,
206
+ });
207
+ log("hook response: " + JSON.stringify(resp));
208
+ if (resp?.response?.continue === false) {
209
+ stopped = true;
210
+ break;
211
+ }
212
+ } catch (e) {
213
+ log("hook fire error: " + e.message);
214
+ }
215
+ }
216
+ if (stopped) {
217
+ log("hook returned continue:false, halting emit loop");
218
+ // Emit a result indicating hook-driven termination so the SDK iterator
219
+ // closes cleanly.
220
+ send({
221
+ type: "result",
222
+ subtype: "success",
223
+ is_error: false,
224
+ result: "stub: hook stopped",
225
+ duration_ms: 1,
226
+ duration_api_ms: 0,
227
+ num_turns: turnIndex,
228
+ session_id: SESSION_ID,
229
+ total_cost_usd: 0,
230
+ usage: { input_tokens: 0, output_tokens: 0 },
231
+ });
232
+ return;
233
+ }
234
+ continue;
235
+ }
236
+ const filled = fillDefaults(out);
237
+ send(filled);
238
+ }
239
+ };
240
+
241
+ const fillDefaults = (out) => {
242
+ if (out.type === "assistant") {
243
+ return {
244
+ type: "assistant",
245
+ message: {
246
+ id: "msg_" + randomUUID().slice(0, 8),
247
+ type: "message",
248
+ role: "assistant",
249
+ model: out.message?.model ?? "claude-sonnet-4-6",
250
+ content: out.message?.content ?? [],
251
+ stop_reason: out.message?.stop_reason ?? "end_turn",
252
+ stop_sequence: out.message?.stop_sequence ?? null,
253
+ usage: out.message?.usage ?? {
254
+ input_tokens: 1,
255
+ output_tokens: 1,
256
+ },
257
+ },
258
+ session_id: SESSION_ID,
259
+ parent_tool_use_id: out.parent_tool_use_id ?? null,
260
+ };
261
+ }
262
+ if (out.type === "result") {
263
+ // Talon's stream.ts reads token usage from `modelUsage[sdkModel]`. Build a
264
+ // matching shape from `usage` if the test didn't provide modelUsage explicitly.
265
+ const usage = out.usage ?? { input_tokens: 0, output_tokens: 0 };
266
+ const defaultModelUsage = {
267
+ "claude-sonnet-4-6": {
268
+ inputTokens: usage.input_tokens ?? 0,
269
+ outputTokens: usage.output_tokens ?? 0,
270
+ cacheReadInputTokens: 0,
271
+ cacheCreationInputTokens: 0,
272
+ contextWindow: 200_000,
273
+ },
274
+ };
275
+ return {
276
+ type: "result",
277
+ subtype: out.subtype ?? "success",
278
+ is_error: out.is_error ?? false,
279
+ result: out.result ?? "",
280
+ duration_ms: out.duration_ms ?? 1,
281
+ duration_api_ms: out.duration_api_ms ?? 0,
282
+ num_turns: out.num_turns ?? turnIndex,
283
+ session_id: SESSION_ID,
284
+ total_cost_usd: out.total_cost_usd ?? 0,
285
+ usage,
286
+ modelUsage: out.modelUsage ?? defaultModelUsage,
287
+ };
288
+ }
289
+ return out;
290
+ };
291
+
292
+ // ── Stdin loop ──────────────────────────────────────────────────────────────
293
+
294
+ let buf = "";
295
+ process.stdin.setEncoding("utf8");
296
+ process.stdin.on("data", (chunk) => {
297
+ buf += chunk;
298
+ let nl;
299
+ while ((nl = buf.indexOf("\n")) !== -1) {
300
+ const line = buf.slice(0, nl);
301
+ buf = buf.slice(nl + 1);
302
+ if (!line.trim()) continue;
303
+ log("STDIN: " + line);
304
+ let msg;
305
+ try {
306
+ msg = JSON.parse(line);
307
+ } catch {
308
+ log("STDIN parse error on: " + line);
309
+ continue;
310
+ }
311
+ if (
312
+ msg.type === "control_request" &&
313
+ msg.request?.subtype === "initialize"
314
+ ) {
315
+ handleInitialize(msg);
316
+ } else if (msg.type === "user") {
317
+ handleUser(msg).catch((e) => log("handleUser error: " + e.message));
318
+ } else if (msg.type === "control_response") {
319
+ // Response to a control_request the stub previously sent (e.g. a fired
320
+ // hook). Resolve the pending promise.
321
+ const requestId = msg.response?.request_id;
322
+ const pending = pendingRequests.get(requestId);
323
+ if (pending) {
324
+ pendingRequests.delete(requestId);
325
+ pending.resolve(msg);
326
+ }
327
+ } else if (msg.type === "control_request") {
328
+ // Ack other control requests with empty success so the SDK doesn't hang
329
+ send({
330
+ type: "control_response",
331
+ response: {
332
+ subtype: "success",
333
+ request_id: msg.request_id,
334
+ response: {},
335
+ },
336
+ });
337
+ }
338
+ }
339
+ });
340
+
341
+ process.stdin.on("end", () => {
342
+ log("STDIN ENDED");
343
+ // Give pending stdout writes time to flush, then exit
344
+ setTimeout(() => process.exit(0), 50);
345
+ });
346
+
347
+ // Safety: hard timeout so a misbehaving test doesn't hang CI
348
+ const HARD_TIMEOUT_MS = Number(process.env.STUB_CLAUDE_TIMEOUT_MS ?? 10000);
349
+ setTimeout(() => {
350
+ log(`HARD TIMEOUT after ${HARD_TIMEOUT_MS}ms`);
351
+ process.exit(2);
352
+ }, HARD_TIMEOUT_MS).unref();
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Test helpers for driving the SDK against the stub claude binary.
3
+ *
4
+ * Typical usage in a test:
5
+ *
6
+ * const result = await runWithStub({
7
+ * script: {
8
+ * turns: [{
9
+ * emit: [
10
+ * assistantText("hello"),
11
+ * assistantToolUse("mcp__telegram-tools__end_turn", { text: "hi" }),
12
+ * successResult(),
13
+ * ],
14
+ * }],
15
+ * },
16
+ * sdkOptions: { ...overrides },
17
+ * prompt: "user message",
18
+ * });
19
+ *
20
+ * expect(result.messages.some(m => m.type === "assistant")).toBe(true);
21
+ * expect(result.hookFires.PostToolBatch).toBe(1);
22
+ */
23
+
24
+ import { fileURLToPath } from "node:url";
25
+ import { dirname, resolve } from "node:path";
26
+ import { randomUUID } from "node:crypto";
27
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import { query } from "@anthropic-ai/claude-agent-sdk";
31
+ import type {
32
+ Options,
33
+ HookCallback,
34
+ HookEvent,
35
+ HookCallbackMatcher,
36
+ } from "@anthropic-ai/claude-agent-sdk";
37
+
38
+ import type {
39
+ StubScript,
40
+ StubAssistant,
41
+ StubResult,
42
+ StubFireHook,
43
+ ContentBlock,
44
+ ToolUseBlock,
45
+ TextBlock,
46
+ } from "./protocol.js";
47
+
48
+ // ── Fixture helpers ─────────────────────────────────────────────────────────
49
+
50
+ const __dirname = dirname(fileURLToPath(import.meta.url));
51
+ // On Windows, Node 20.19+ refuses to spawn `.cmd` shims via child_process
52
+ // (CVE-2024-27980 mitigation). The SDK calls `spawn` directly without
53
+ // `shell: true`, so we use a SEA-compiled (.exe) binary instead. Build
54
+ // it once before the test run via `npm run build:stub-sea`.
55
+ const STUB_BINARY = resolve(
56
+ __dirname,
57
+ process.platform === "win32" ? "fake-claude.exe" : "fake-claude.mjs",
58
+ );
59
+
60
+ export function assistantText(text: string): StubAssistant {
61
+ const block: TextBlock = { type: "text", text };
62
+ return { type: "assistant", message: { content: [block] } };
63
+ }
64
+
65
+ export function assistantToolUse(
66
+ name: string,
67
+ input: Record<string, unknown>,
68
+ id?: string,
69
+ ): StubAssistant {
70
+ const block: ToolUseBlock = {
71
+ type: "tool_use",
72
+ id: id ?? "toolu_" + randomUUID().slice(0, 8),
73
+ name,
74
+ input,
75
+ };
76
+ return {
77
+ type: "assistant",
78
+ message: { content: [block], stop_reason: "tool_use" },
79
+ };
80
+ }
81
+
82
+ export function assistantBlocks(...blocks: ContentBlock[]): StubAssistant {
83
+ return { type: "assistant", message: { content: blocks } };
84
+ }
85
+
86
+ export function successResult(text = ""): StubResult {
87
+ return { type: "result", subtype: "success", result: text };
88
+ }
89
+
90
+ /**
91
+ * Pseudo-emit that triggers a registered hook on the SDK side. The stub binary
92
+ * sends a `control_request: hook_callback` to the SDK and waits for the
93
+ * response. If the hook returns `continue: false`, the stub halts subsequent
94
+ * emits in the same turn and emits a synthetic success result.
95
+ *
96
+ * Use this to verify hook wiring: the SDK only fires hooks if `options.hooks`
97
+ * was correctly translated into the init handshake's `hookCallbackIds`.
98
+ */
99
+ /**
100
+ * Convenience: emit an assistant message that delivers `text` through the
101
+ * canonical `end_turn` tool call, plus the matching PostToolBatch hook fire
102
+ * (which is how the real production hook terminates the SDK loop).
103
+ *
104
+ * Mirrors the pattern Talon's system prompt teaches the model: final replies
105
+ * go through `end_turn(text=...)`. Drop-in for tests that want to exercise
106
+ * the production delivery path.
107
+ */
108
+ export function endTurnWithText(
109
+ text: string,
110
+ toolUseId?: string,
111
+ ): [StubAssistant, ReturnType<typeof fireHook>] {
112
+ const id = toolUseId ?? "toolu_" + randomUUID().slice(0, 8);
113
+ const assistant = assistantToolUse(
114
+ "mcp__telegram-tools__end_turn",
115
+ { text },
116
+ id,
117
+ );
118
+ const hook = fireHook(
119
+ "PostToolBatch",
120
+ {
121
+ tool_calls: [
122
+ {
123
+ tool_name: "mcp__telegram-tools__end_turn",
124
+ tool_input: { text },
125
+ tool_use_id: id,
126
+ },
127
+ ],
128
+ },
129
+ id,
130
+ );
131
+ return [assistant, hook];
132
+ }
133
+
134
+ export function fireHook(
135
+ event:
136
+ | "PreToolUse"
137
+ | "PostToolUse"
138
+ | "PostToolBatch"
139
+ | "Stop"
140
+ | "SessionStart"
141
+ | "SessionEnd",
142
+ input: Record<string, unknown>,
143
+ toolUseId?: string,
144
+ ): StubFireHook {
145
+ return {
146
+ type: "_fire_hook",
147
+ event,
148
+ input: { hook_event_name: event, ...input },
149
+ tool_use_id: toolUseId,
150
+ };
151
+ }
152
+
153
+ // ── Runner ──────────────────────────────────────────────────────────────────
154
+
155
+ export interface RunWithStubArgs {
156
+ script: StubScript;
157
+ prompt: string;
158
+ /** Extra SDK options merged on top of the test defaults. */
159
+ sdkOptions?: Partial<Options>;
160
+ /** Hard timeout for the whole run (default 8s). */
161
+ timeoutMs?: number;
162
+ }
163
+
164
+ export interface RunWithStubResult {
165
+ /** Every message the SDK yielded, in order. */
166
+ messages: unknown[];
167
+ /** Per-event hook fire counts. Only set for hooks `runWithStub` injected. */
168
+ hookFires: Partial<Record<HookEvent, number>>;
169
+ /**
170
+ * Per-event arrays of hook inputs (in order). Lets tests assert on what the
171
+ * hook actually saw — e.g. which tool names were in the PostToolBatch.
172
+ */
173
+ hookInputs: Partial<Record<HookEvent, unknown[]>>;
174
+ /** Lines captured from the binary's protocol log. */
175
+ protocolLog: string[];
176
+ /** The temp dir used (cleaned up on success). */
177
+ tmpDir: string;
178
+ }
179
+
180
+ /**
181
+ * Runs `query()` against the stub binary with the given script. Returns
182
+ * everything the SDK yielded plus hook fire counts. Used by integration tests
183
+ * to assert end-to-end behavior of options, hooks, and stream processing.
184
+ */
185
+ export async function runWithStub(
186
+ args: RunWithStubArgs,
187
+ ): Promise<RunWithStubResult> {
188
+ const { script, prompt, sdkOptions = {}, timeoutMs = 8000 } = args;
189
+
190
+ const tmpDir = mkdtempSync(resolve(tmpdir(), "stub-claude-"));
191
+ const scriptPath = resolve(tmpDir, "script.json");
192
+ const logPath = resolve(tmpDir, "protocol.log");
193
+ writeFileSync(scriptPath, JSON.stringify(script));
194
+
195
+ // Spy hooks: wrap any hook the test passes so we can count fires.
196
+ const hookFires: Partial<Record<HookEvent, number>> = {};
197
+ const hookInputs: Partial<Record<HookEvent, unknown[]>> = {};
198
+ const wrappedHooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {};
199
+
200
+ if (sdkOptions.hooks) {
201
+ for (const event of Object.keys(sdkOptions.hooks) as HookEvent[]) {
202
+ const matchers = sdkOptions.hooks[event] ?? [];
203
+ wrappedHooks[event] = matchers.map((matcher) => ({
204
+ ...matcher,
205
+ hooks: matcher.hooks.map<HookCallback>((fn) => async (...rest) => {
206
+ hookFires[event] = (hookFires[event] ?? 0) + 1;
207
+ (hookInputs[event] ??= []).push(rest[0]);
208
+ return fn(...rest);
209
+ }),
210
+ }));
211
+ }
212
+ }
213
+
214
+ // Wire the binary path + the env var carrying the script.
215
+ process.env.STUB_CLAUDE_SCRIPT = scriptPath;
216
+ process.env.STUB_CLAUDE_LOG = logPath;
217
+ process.env.STUB_CLAUDE_TIMEOUT_MS = String(timeoutMs);
218
+
219
+ const options: Options = {
220
+ pathToClaudeCodeExecutable: STUB_BINARY,
221
+ permissionMode: "bypassPermissions",
222
+ allowDangerouslySkipPermissions: true,
223
+ ...sdkOptions,
224
+ hooks: wrappedHooks,
225
+ };
226
+
227
+ const messages: unknown[] = [];
228
+ const abort = new AbortController();
229
+ const hardTimer = setTimeout(() => abort.abort(), timeoutMs).unref();
230
+
231
+ try {
232
+ const it = query({
233
+ prompt,
234
+ options: { ...options, abortController: abort },
235
+ });
236
+ for await (const msg of it) {
237
+ messages.push(msg);
238
+ }
239
+ } finally {
240
+ clearTimeout(hardTimer);
241
+ delete process.env.STUB_CLAUDE_SCRIPT;
242
+ delete process.env.STUB_CLAUDE_LOG;
243
+ delete process.env.STUB_CLAUDE_TIMEOUT_MS;
244
+ }
245
+
246
+ let protocolLog: string[] = [];
247
+ try {
248
+ const { readFileSync } = await import("node:fs");
249
+ protocolLog = readFileSync(logPath, "utf8").split("\n").filter(Boolean);
250
+ } catch {
251
+ /* log file may not exist if binary failed to start */
252
+ }
253
+
254
+ return { messages, hookFires, hookInputs, protocolLog, tmpDir };
255
+ }
256
+
257
+ /**
258
+ * Convenience: clean up the tmp dir created by `runWithStub`. Tests may want
259
+ * to inspect it on failure, so cleanup is opt-in rather than automatic.
260
+ */
261
+ export function cleanup(result: RunWithStubResult): void {
262
+ rmSync(result.tmpDir, { recursive: true, force: true });
263
+ }