@bjoernrochel/sevro 0.1.0-rc.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.
Files changed (71) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +71 -0
  3. package/docs/advisory-fixture.md +23 -0
  4. package/docs/builtin-graders-v1.md +77 -0
  5. package/docs/cache-ownership-validation.md +49 -0
  6. package/docs/claude-continuation-validation.md +47 -0
  7. package/docs/codex-host.md +134 -0
  8. package/docs/concurrency-validation.md +86 -0
  9. package/docs/configuration-root-validation.md +70 -0
  10. package/docs/development-cli.md +327 -0
  11. package/docs/engine-slice.md +114 -0
  12. package/docs/extension-protocol-v1.md +292 -0
  13. package/docs/fixture-cleanup-validation.md +70 -0
  14. package/docs/identity-v1.md +65 -0
  15. package/docs/native-controls.md +29 -0
  16. package/docs/releases.md +88 -0
  17. package/docs/report-v1.md +32 -0
  18. package/docs/results-v1.md +118 -0
  19. package/examples/basic/graded.json +13 -0
  20. package/examples/basic/host.ts +8 -0
  21. package/examples/basic/prompt-only.json +7 -0
  22. package/package.json +45 -0
  23. package/schemas/cli-result-v1.schema.json +184 -0
  24. package/schemas/extension-v1.schema.json +659 -0
  25. package/schemas/report-v1.schema.json +164 -0
  26. package/schemas/run-evidence-v1.schema.json +510 -0
  27. package/src/advisory-fixture.ts +282 -0
  28. package/src/advisory.ts +105 -0
  29. package/src/cli.ts +837 -0
  30. package/src/engine.ts +2341 -0
  31. package/src/extension-client.ts +257 -0
  32. package/src/extension-session.ts +335 -0
  33. package/src/fixture-bin.ts +87 -0
  34. package/src/fixture-cleanup.ts +38 -0
  35. package/src/fixture-setup.ts +163 -0
  36. package/src/generated-fixture.ts +220 -0
  37. package/src/git-hooks.ts +63 -0
  38. package/src/graders/git-head.ts +176 -0
  39. package/src/graders/output.ts +359 -0
  40. package/src/graders/semantic.ts +120 -0
  41. package/src/graders/shell.ts +277 -0
  42. package/src/hosts/claude-continuation.ts +94 -0
  43. package/src/hosts/claude-credential.ts +73 -0
  44. package/src/hosts/claude-events.ts +128 -0
  45. package/src/hosts/claude-nested-skills.ts +218 -0
  46. package/src/hosts/claude-repository-invocation.ts +239 -0
  47. package/src/hosts/claude-settings.ts +50 -0
  48. package/src/hosts/claude-tool-calls.ts +159 -0
  49. package/src/hosts/claude.ts +511 -0
  50. package/src/hosts/codex-configuration.ts +85 -0
  51. package/src/hosts/codex-events.ts +102 -0
  52. package/src/hosts/codex-native-calls.ts +702 -0
  53. package/src/hosts/codex-profile.ts +78 -0
  54. package/src/hosts/codex-skill-reads.ts +345 -0
  55. package/src/hosts/codex.ts +769 -0
  56. package/src/hosts/isolation-roots.ts +60 -0
  57. package/src/hosts/mac-sandbox.ts +108 -0
  58. package/src/hosts/native-controls.ts +124 -0
  59. package/src/hosts/protected-worktrees.ts +56 -0
  60. package/src/hosts/workspace-fingerprint.ts +55 -0
  61. package/src/identity.ts +156 -0
  62. package/src/instrumentation.ts +106 -0
  63. package/src/preparation.ts +211 -0
  64. package/src/provenance.ts +244 -0
  65. package/src/report.ts +351 -0
  66. package/src/repository-fixture.ts +286 -0
  67. package/src/results.ts +160 -0
  68. package/src/run-owner.ts +276 -0
  69. package/src/schema.ts +28 -0
  70. package/src/storage.ts +23 -0
  71. package/src/trial-scheduler.ts +27 -0
@@ -0,0 +1,277 @@
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { delimiter, join } from "node:path";
3
+ import { prepareMacSandboxCommand } from "../hosts/mac-sandbox";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const MAX_TIMEOUT_MS = 120_000;
7
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
8
+
9
+ export interface ShellCheckDeclaration {
10
+ id: string;
11
+ grader: "sevro.shell";
12
+ configuration: Record<string, unknown>;
13
+ }
14
+
15
+ export interface PreparedShellCheck {
16
+ id: string;
17
+ run: string;
18
+ expectedExitCode: number;
19
+ timeoutMs: number;
20
+ expectExact?: string;
21
+ expectRegex?: RegExp;
22
+ notRegex?: RegExp;
23
+ captureStdout: boolean;
24
+ }
25
+
26
+ export function prepareShellChecks(
27
+ declarations: ShellCheckDeclaration[],
28
+ ): PreparedShellCheck[] {
29
+ return declarations.map(({ id, configuration }) => {
30
+ if (!id || !configuration || typeof configuration !== "object")
31
+ throw new Error("invalid shell check declaration");
32
+ if (
33
+ Object.keys(configuration).some(
34
+ (key) =>
35
+ ![
36
+ "run",
37
+ "expectedExitCode",
38
+ "timeoutMs",
39
+ "expectExact",
40
+ "expectRegex",
41
+ "notRegex",
42
+ "flags",
43
+ ].includes(key),
44
+ )
45
+ )
46
+ throw new Error("unsupported shell check configuration");
47
+ const {
48
+ run,
49
+ expectedExitCode = 0,
50
+ timeoutMs = DEFAULT_TIMEOUT_MS,
51
+ expectExact,
52
+ expectRegex,
53
+ notRegex,
54
+ flags = "",
55
+ } = configuration;
56
+ if (typeof run !== "string" || !run.trim() || run.length > 4096)
57
+ throw new Error("shell check requires a bounded command");
58
+ if (
59
+ !Number.isSafeInteger(expectedExitCode) ||
60
+ (expectedExitCode as number) < 0 ||
61
+ (expectedExitCode as number) > 255
62
+ )
63
+ throw new Error("invalid expected shell exit code");
64
+ if (
65
+ !Number.isSafeInteger(timeoutMs) ||
66
+ (timeoutMs as number) < 1 ||
67
+ (timeoutMs as number) > MAX_TIMEOUT_MS
68
+ )
69
+ throw new Error("invalid shell check timeout");
70
+ if (
71
+ expectExact !== undefined &&
72
+ (typeof expectExact !== "string" ||
73
+ Buffer.byteLength(expectExact, "utf8") > MAX_OUTPUT_BYTES)
74
+ )
75
+ throw new Error("invalid exact shell output expectation");
76
+ if (
77
+ flags !== "" &&
78
+ (typeof flags !== "string" ||
79
+ !/^[isu]*$/.test(flags) ||
80
+ new Set(flags).size !== flags.length)
81
+ )
82
+ throw new Error("invalid shell regex flags");
83
+ if (flags && expectRegex === undefined && notRegex === undefined)
84
+ throw new Error("shell regex flags require a pattern");
85
+ const pattern = (value: unknown): RegExp | undefined => {
86
+ if (value === undefined) return undefined;
87
+ if (typeof value !== "string" || value.length > 4096)
88
+ throw new Error("invalid shell regex pattern");
89
+ try {
90
+ return new RegExp(value, `m${flags}`);
91
+ } catch {
92
+ throw new Error("invalid shell regex pattern or flags");
93
+ }
94
+ };
95
+ const expectedPattern = pattern(expectRegex);
96
+ const forbiddenPattern = pattern(notRegex);
97
+ return {
98
+ id,
99
+ run,
100
+ expectedExitCode: expectedExitCode as number,
101
+ timeoutMs: timeoutMs as number,
102
+ expectExact: expectExact as string | undefined,
103
+ expectRegex: expectedPattern,
104
+ notRegex: forbiddenPattern,
105
+ captureStdout:
106
+ expectExact !== undefined ||
107
+ expectedPattern !== undefined ||
108
+ forbiddenPattern !== undefined,
109
+ };
110
+ });
111
+ }
112
+
113
+ export interface ShellCheckResult {
114
+ exitCode: number;
115
+ stdout: string | null;
116
+ }
117
+
118
+ /** Grade bounded shell output without exposing it in retained evidence. */
119
+ export function assessShellCheck(
120
+ check: PreparedShellCheck,
121
+ result: ShellCheckResult,
122
+ ): { passed: boolean; detail: string } {
123
+ if (result.exitCode !== check.expectedExitCode)
124
+ return {
125
+ passed: false,
126
+ detail: `exit code ${result.exitCode} (expected ${check.expectedExitCode})`,
127
+ };
128
+ const out = result.stdout;
129
+ if (check.captureStdout && out === null)
130
+ throw new Error("shell stdout observation is missing");
131
+ if (
132
+ check.expectExact !== undefined &&
133
+ (out!.endsWith("\n") ? out!.slice(0, -1) : out) !== check.expectExact
134
+ )
135
+ return { passed: false, detail: "exact shell output did not match" };
136
+ if (check.expectRegex && !check.expectRegex.test(out!))
137
+ return {
138
+ passed: false,
139
+ detail: "expected shell output pattern did not match",
140
+ };
141
+ if (check.notRegex && check.notRegex.test(out!))
142
+ return { passed: false, detail: "forbidden shell output pattern matched" };
143
+ return { passed: true, detail: `exit code ${result.exitCode}` };
144
+ }
145
+
146
+ async function boundedOutput(
147
+ stream: ReadableStream<Uint8Array>,
148
+ ): Promise<string> {
149
+ const chunks: Uint8Array[] = [];
150
+ let size = 0;
151
+ for await (const chunk of stream) {
152
+ size += chunk.byteLength;
153
+ if (size > MAX_OUTPUT_BYTES) throw new Error("shell stdout exceeds 1 MiB");
154
+ chunks.push(chunk);
155
+ }
156
+ return new TextDecoder("utf-8", { fatal: true }).decode(
157
+ Buffer.concat(chunks),
158
+ );
159
+ }
160
+
161
+ function stopProcess(proc: Bun.Subprocess): void {
162
+ if (process.platform !== "win32") {
163
+ try {
164
+ process.kill(-proc.pid, "SIGKILL");
165
+ return;
166
+ } catch {
167
+ // The group may already be gone.
168
+ }
169
+ }
170
+ try {
171
+ proc.kill("SIGKILL");
172
+ } catch {
173
+ // The process already exited.
174
+ }
175
+ }
176
+
177
+ /** Run a check without inherited credentials or retaining raw process output. */
178
+ export async function runShellCheck(
179
+ check: PreparedShellCheck,
180
+ options: {
181
+ workspace: string;
182
+ fixtureBinDir?: string;
183
+ toolchainBinDir?: string;
184
+ uvRuntimeCache?: boolean;
185
+ protectedRoots: string[];
186
+ protectedRootsCanonical?: boolean;
187
+ privateStateRoot: string;
188
+ signal?: AbortSignal;
189
+ },
190
+ ): Promise<ShellCheckResult> {
191
+ if (options.signal?.aborted) throw new Error("shell check cancelled");
192
+ const runtimeRoot = join(options.workspace, ".git", "sevro-runtime");
193
+ const home = join(runtimeRoot, "check-home");
194
+ const temp = join(runtimeRoot, "check-tmp");
195
+ await Promise.all([
196
+ mkdir(home, { recursive: true, mode: 0o700 }),
197
+ mkdir(temp, { recursive: true, mode: 0o700 }),
198
+ ]);
199
+ const isolated = await prepareMacSandboxCommand({
200
+ argv: ["/bin/sh", "-e", "-c", check.run],
201
+ workspace: options.workspace,
202
+ protectedRoots: options.protectedRoots,
203
+ protectedRootsCanonical: options.protectedRootsCanonical,
204
+ privateStateRoot: options.privateStateRoot,
205
+ denyNetwork: true,
206
+ });
207
+ const uvCache = join(runtimeRoot, "uv-cache");
208
+ if (options.uvRuntimeCache && !(await stat(uvCache)).isDirectory())
209
+ throw new Error("isolated UV cache is missing");
210
+ let proc: Bun.Subprocess | undefined;
211
+ let timer: ReturnType<typeof setTimeout> | undefined;
212
+ let cancel: (() => void) | undefined;
213
+ try {
214
+ proc = Bun.spawn(isolated.argv, {
215
+ cwd: options.workspace,
216
+ env: {
217
+ PATH: [
218
+ options.fixtureBinDir,
219
+ options.toolchainBinDir,
220
+ "/usr/bin:/bin:/usr/sbin:/sbin",
221
+ ]
222
+ .filter(Boolean)
223
+ .join(delimiter),
224
+ HOME: home,
225
+ TMPDIR: temp,
226
+ LANG: process.env.LANG ?? "C",
227
+ GIT_CONFIG_GLOBAL: "/dev/null",
228
+ GIT_CONFIG_NOSYSTEM: "1",
229
+ ...(options.uvRuntimeCache
230
+ ? {
231
+ UV_CACHE_DIR: uvCache,
232
+ UV_PROJECT_ENVIRONMENT: join(runtimeRoot, "project-environment"),
233
+ UV_OFFLINE: "1",
234
+ PYTHONDONTWRITEBYTECODE: "1",
235
+ }
236
+ : {}),
237
+ },
238
+ detached: true,
239
+ stdin: "ignore",
240
+ stdout: check.captureStdout ? "pipe" : "ignore",
241
+ stderr: "ignore",
242
+ });
243
+ const running = proc;
244
+ const outputStream = running.stdout;
245
+ if (check.captureStdout && !(outputStream instanceof ReadableStream))
246
+ throw new Error("shell stdout pipe is unavailable");
247
+ const timeout = new Promise<never>((_resolve, reject) => {
248
+ timer = setTimeout(
249
+ () => reject(new Error("shell check timed out")),
250
+ check.timeoutMs,
251
+ );
252
+ });
253
+ const aborted = new Promise<never>((_resolve, reject) => {
254
+ if (!options.signal) return;
255
+ cancel = () => reject(new Error("shell check cancelled"));
256
+ if (options.signal.aborted) cancel();
257
+ else options.signal.addEventListener("abort", cancel, { once: true });
258
+ });
259
+ const completed = Promise.all([
260
+ running.exited,
261
+ check.captureStdout
262
+ ? boundedOutput(outputStream as ReadableStream<Uint8Array>)
263
+ : Promise.resolve(null),
264
+ ]).then(([exitCode, stdout]) => ({ exitCode, stdout }));
265
+ return await Promise.race([completed, timeout, aborted]);
266
+ } catch (error) {
267
+ if (proc) {
268
+ stopProcess(proc);
269
+ await proc.exited;
270
+ }
271
+ throw error;
272
+ } finally {
273
+ if (timer) clearTimeout(timer);
274
+ if (cancel) options.signal?.removeEventListener("abort", cancel);
275
+ await isolated.release();
276
+ }
277
+ }
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { summarizeClaudeEvents } from "./claude-events";
3
+ import { workspaceFingerprint } from "./workspace-fingerprint";
4
+
5
+ export type ClaudeProcessResult = { code: number; out: string; err: string };
6
+ export type ClaudeSession = { option: "--session-id" | "--resume"; id: string };
7
+ type Observation = {
8
+ id: string;
9
+ completeness: "complete" | "partial";
10
+ data: Record<string, unknown>;
11
+ };
12
+ type ClaudeTurns = ClaudeProcessResult & {
13
+ initialOut?: string;
14
+ followUpOut?: string;
15
+ continuation?: Observation;
16
+ sessionResultsBound?: boolean;
17
+ };
18
+
19
+ function boundResult(stream: string, session: string): boolean {
20
+ try {
21
+ const results = stream
22
+ .split("\n")
23
+ .filter((line) => line.trim())
24
+ .map((line) => JSON.parse(line))
25
+ .filter((entry) => entry?.type === "result");
26
+ return results.length === 1 && results[0].session_id === session;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** Resume a declared follow-up in the same isolated native Claude session. */
33
+ export async function runClaudeTurns(options: {
34
+ prompt: string;
35
+ followUpPrompt?: string;
36
+ workspace: string;
37
+ run: (
38
+ prompt: string,
39
+ session?: ClaudeSession,
40
+ ) => Promise<ClaudeProcessResult>;
41
+ }): Promise<ClaudeTurns> {
42
+ if (options.followUpPrompt === undefined) return options.run(options.prompt);
43
+ const sessionId = randomUUID();
44
+ const initialFingerprint = await workspaceFingerprint(options.workspace);
45
+ const initial = await options.run(options.prompt, {
46
+ option: "--session-id",
47
+ id: sessionId,
48
+ });
49
+ const summary = summarizeClaudeEvents(initial.out, initial.code);
50
+ const initialBound = boundResult(initial.out, sessionId);
51
+ if (!summary.complete || !initialBound)
52
+ return {
53
+ ...initial,
54
+ code: summary.complete ? 1 : initial.code,
55
+ sessionResultsBound: initialBound,
56
+ };
57
+ const beforeFollowUp = await workspaceFingerprint(options.workspace);
58
+ const followUp = await options.run(options.followUpPrompt, {
59
+ option: "--resume",
60
+ id: sessionId,
61
+ });
62
+ const followUpSummary = summarizeClaudeEvents(followUp.out, followUp.code);
63
+ const sessionResultsBound = boundResult(followUp.out, sessionId);
64
+ const bound = followUpSummary.complete && sessionResultsBound;
65
+ const measured = initialFingerprint !== null && beforeFollowUp !== null;
66
+ const out = `${initial.out.trimEnd()}\n${followUp.out.trimStart()}`;
67
+ const err = [initial.err.trimEnd(), followUp.err.trimStart()]
68
+ .filter(Boolean)
69
+ .join("\n");
70
+ if (
71
+ Buffer.byteLength(out, "utf8") > 8 * 1024 * 1024 ||
72
+ Buffer.byteLength(err, "utf8") > 64 * 1024
73
+ )
74
+ throw new Error("Claude combined process output exceeds its limit");
75
+ return {
76
+ code: bound ? 0 : followUp.code || 1,
77
+ out,
78
+ err,
79
+ initialOut: initial.out,
80
+ followUpOut: followUp.out,
81
+ sessionResultsBound,
82
+ continuation: {
83
+ id: "sevro.claude.continuation",
84
+ completeness: bound && measured ? "complete" : "partial",
85
+ data: {
86
+ method: "same_session_resume",
87
+ sessionId,
88
+ preFollowUpWorktreeUnchanged: measured
89
+ ? initialFingerprint === beforeFollowUp
90
+ : null,
91
+ },
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ const MAX_CREDENTIAL_BYTES = 1024 * 1024;
6
+
7
+ async function keychainCredential(): Promise<Buffer> {
8
+ if (process.platform !== "darwin" || !existsSync("/usr/bin/security"))
9
+ throw new Error("Claude keychain credential is unavailable");
10
+ const account = process.env.USER ?? process.env.LOGNAME;
11
+ const proc = Bun.spawn(
12
+ [
13
+ "/usr/bin/security",
14
+ "find-generic-password",
15
+ "-s",
16
+ "Claude Code-credentials",
17
+ ...(account ? ["-a", account] : []),
18
+ "-w",
19
+ ],
20
+ { stdout: "pipe", stderr: "ignore" },
21
+ );
22
+ const timer = setTimeout(() => proc.kill("SIGKILL"), 10_000);
23
+ const reader = proc.stdout.getReader();
24
+ const chunks: Uint8Array[] = [];
25
+ let size = 0;
26
+ try {
27
+ while (true) {
28
+ const { value, done } = await reader.read();
29
+ if (done) break;
30
+ size += value.byteLength;
31
+ if (size > MAX_CREDENTIAL_BYTES)
32
+ throw new Error("Claude credential exceeds the size limit");
33
+ chunks.push(value);
34
+ }
35
+ if ((await proc.exited) !== 0)
36
+ throw new Error("Claude keychain credential is unavailable");
37
+ return Buffer.concat(chunks);
38
+ } catch {
39
+ proc.kill("SIGKILL");
40
+ await proc.exited;
41
+ throw new Error("Claude keychain credential is unavailable");
42
+ } finally {
43
+ clearTimeout(timer);
44
+ reader.releaseLock();
45
+ }
46
+ }
47
+
48
+ /** Copy only a bounded login credential into one private host state. */
49
+ export async function stageClaudeCredential(
50
+ configRoot: string,
51
+ sourceFile?: string,
52
+ ): Promise<string> {
53
+ await mkdir(configRoot, { recursive: true, mode: 0o700 });
54
+ let credential: Buffer;
55
+ try {
56
+ if (sourceFile) {
57
+ if ((await stat(sourceFile)).size > MAX_CREDENTIAL_BYTES)
58
+ throw new Error("oversized credential");
59
+ credential = await readFile(sourceFile);
60
+ } else credential = await keychainCredential();
61
+ } catch {
62
+ throw new Error("Claude credential is unreadable or unavailable");
63
+ }
64
+ if (
65
+ credential.byteLength === 0 ||
66
+ credential.byteLength > MAX_CREDENTIAL_BYTES ||
67
+ !credential.toString("utf8").trim()
68
+ )
69
+ throw new Error("Claude credential is empty or oversized");
70
+ const target = join(configRoot, ".credentials.json");
71
+ await writeFile(target, credential, { flag: "wx", mode: 0o600 });
72
+ return target;
73
+ }
@@ -0,0 +1,128 @@
1
+ const MAX_EVENT_STREAM_BYTES = 8 * 1024 * 1024;
2
+
3
+ export class ClaudeEventError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "ClaudeEventError";
7
+ }
8
+ }
9
+
10
+ type Entry = Record<string, unknown>;
11
+
12
+ function record(value: unknown): value is Entry {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ function nonnegative(value: unknown): value is number {
17
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
18
+ }
19
+
20
+ function validAssistant(value: Entry): boolean {
21
+ if (!record(value.message) || !Array.isArray(value.message.content))
22
+ return false;
23
+ return value.message.content.every((block: unknown) => {
24
+ if (!record(block)) return false;
25
+ if (block.type !== "tool_use" || block.name !== "Skill") return true;
26
+ return (
27
+ record(block.input) &&
28
+ typeof block.input.skill === "string" &&
29
+ !!block.input.skill.split(":").at(-1)?.trim()
30
+ );
31
+ });
32
+ }
33
+
34
+ function validResult(value: Entry): boolean {
35
+ return (
36
+ typeof value.subtype === "string" &&
37
+ !!value.subtype.trim() &&
38
+ typeof value.is_error === "boolean" &&
39
+ typeof value.result === "string"
40
+ );
41
+ }
42
+
43
+ function validEvent(value: unknown): value is Entry {
44
+ if (!record(value)) return false;
45
+ if (value.type === "result") return validResult(value);
46
+ if (value.type === "assistant") return validAssistant(value);
47
+ return typeof value.type === "string";
48
+ }
49
+
50
+ interface Usage {
51
+ inputTokens: number;
52
+ outputTokens: number;
53
+ }
54
+
55
+ function usage(value: unknown): Usage | null {
56
+ if (!record(value)) return null;
57
+ const input = value.input_tokens;
58
+ const output = value.output_tokens;
59
+ const cacheCreation = value.cache_creation_input_tokens ?? 0;
60
+ const cacheRead = value.cache_read_input_tokens ?? 0;
61
+ if (
62
+ !nonnegative(input) ||
63
+ !nonnegative(output) ||
64
+ !nonnegative(cacheCreation) ||
65
+ !nonnegative(cacheRead)
66
+ )
67
+ return null;
68
+ return {
69
+ inputTokens: input + cacheCreation + cacheRead,
70
+ outputTokens: output,
71
+ };
72
+ }
73
+
74
+ export interface ClaudeEventSummary {
75
+ complete: boolean;
76
+ finalMessage: string | null;
77
+ inputTokens: number | null;
78
+ outputTokens: number | null;
79
+ costUsd: number | null;
80
+ usageComplete: boolean;
81
+ }
82
+
83
+ /** Parse bounded Claude stream JSON without retaining its private event bodies. */
84
+ export function summarizeClaudeEvents(
85
+ stream: string,
86
+ exitCode: number,
87
+ ): ClaudeEventSummary {
88
+ if (Buffer.byteLength(stream, "utf8") > MAX_EVENT_STREAM_BYTES)
89
+ throw new ClaudeEventError("Claude event stream exceeds 8 MiB");
90
+ const results: Entry[] = [];
91
+ let malformed = false;
92
+ for (const line of stream.split("\n")) {
93
+ if (!line.trim()) continue;
94
+ try {
95
+ const event: unknown = JSON.parse(line);
96
+ if (!validEvent(event)) malformed = true;
97
+ else if (event.type === "result") results.push(event);
98
+ } catch {
99
+ malformed = true;
100
+ }
101
+ }
102
+ const final = results.at(-1);
103
+ const usages = results.map((result) => usage(result.usage));
104
+ const usageComplete =
105
+ !malformed && results.length > 0 && usages.every(Boolean);
106
+ const costs = results.map((result) => result.total_cost_usd);
107
+ return {
108
+ complete:
109
+ !malformed &&
110
+ exitCode === 0 &&
111
+ results.length > 0 &&
112
+ results.every(
113
+ (result) => result.subtype === "success" && result.is_error === false,
114
+ ),
115
+ finalMessage: typeof final?.result === "string" ? final.result : null,
116
+ inputTokens: usageComplete
117
+ ? usages.reduce((total, value) => total + value!.inputTokens, 0)
118
+ : null,
119
+ outputTokens: usageComplete
120
+ ? usages.reduce((total, value) => total + value!.outputTokens, 0)
121
+ : null,
122
+ costUsd:
123
+ !malformed && results.length && costs.every(nonnegative)
124
+ ? costs.reduce((total, value) => total + (value as number), 0)
125
+ : null,
126
+ usageComplete,
127
+ };
128
+ }