From 1b6af4f3ca364408d3e1f4aba7c359dc2452b552 Mon Sep 17 00:00:00 2001 From: digitone Date: Mon, 25 May 2026 13:50:12 +0530 Subject: [PATCH 1/2] fix: EPIPE crash on stdin.write and add lenient JSON parsing for Claude output - Add parseJsonLenient() utility for extracting JSON from mixed text/code blocks - Add extractResultFromMixedOutput() to find result objects in Claude output - Fix EPIPE crash by using stdin.on('error') + callback-style write() - Treat exit-code-0 + parse failure as inferred success in claude-local Fixes OpenScanAI/Levi#16 --- packages/adapter-utils/src/server-utils.ts | 59 ++++++++++++++++++- .../claude-local/src/server/execute.ts | 15 +++-- .../adapters/claude-local/src/server/parse.ts | 59 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4624f6371bf..f3ce2035cf7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -243,6 +243,44 @@ export function parseJson(value: string): Record | null { } } +export function parseJsonLenient(value: string): Record | null { + // Try direct parse first + const direct = parseJson(value); + if (direct) return direct; + + // Try extracting the largest JSON object from mixed text + // This handles cases where Claude outputs JSON wrapped in markdown code blocks + // or mixed with progress indicators / warnings + const codeBlockMatch = value.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + const fromBlock = parseJson(codeBlockMatch[1] ?? ""); + if (fromBlock) return fromBlock; + } + + // Try to find the first { or [ and parse from there + const objectStart = value.indexOf("{"); + const arrayStart = value.indexOf("["); + let start = -1; + if (objectStart >= 0 && arrayStart >= 0) { + start = Math.min(objectStart, arrayStart); + } else if (objectStart >= 0) { + start = objectStart; + } else if (arrayStart >= 0) { + start = arrayStart; + } + + if (start >= 0) { + // Try parsing progressively from each start position + for (let i = start; i < value.length; i++) { + const candidate = value.slice(i); + const parsed = parseJson(candidate); + if (parsed) return parsed; + } + } + + return null; +} + export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { const combined = prev + chunk; return combined.length > cap ? combined.slice(combined.length - cap) : combined; @@ -2058,10 +2096,27 @@ export async function runChildProcess( const stdin = child.stdin; if (opts.stdin != null && stdin) { + // Swallow EPIPE so a child that closes stdin early does not crash the server + stdin.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") return; + // Re-emit other stream errors so existing handlers can react + child.emit("error", err); + }); + void spawnPersistPromise.finally(() => { if (child.killed || stdin.destroyed) return; - stdin.write(opts.stdin as string); - stdin.end(); + stdin.write(opts.stdin as string, (err) => { + if (err) { + const errnoErr = err as NodeJS.ErrnoException; + if (errnoErr.code === "EPIPE") { + // Child closed stdin before we could write; ignore + return; + } + // Log but do not throw — the error event handler above will also fire + console.error("stdin write error:", err); + } + stdin.end(); + }); }); } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 067f68cdbce..037f160d58e 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -28,7 +28,7 @@ import { asBoolean, asStringArray, parseObject, - parseJson, + parseJsonLenient, applyPaperclipWorkspaceEnv, buildPaperclipEnv, readPaperclipRuntimeSkillEntries, @@ -710,7 +710,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise | null = null; const assistantTexts: string[] = []; + // First pass: try line-by-line parsing (handles normal stream-json output) for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) continue; @@ -54,6 +56,26 @@ export function parseClaudeStreamJson(stdout: string) { } } + // Second pass: if no result event found, try whole-string parsing fallback + // This handles cases where Claude outputs a single JSON object across multiple lines + // or mixes JSON with non-JSON text + if (!finalResult) { + const wholeParsed = parseJsonLenient(stdout); + if (wholeParsed && asString(wholeParsed.type, "") === "result") { + finalResult = wholeParsed; + sessionId = asString(wholeParsed.session_id, sessionId ?? "") || sessionId; + } + } + + // Third pass: try to extract any JSON object that looks like a result + if (!finalResult) { + const extracted = extractResultFromMixedOutput(stdout); + if (extracted) { + finalResult = extracted; + sessionId = asString(extracted.session_id, sessionId ?? "") || sessionId; + } + } + if (!finalResult) { return { sessionId, @@ -389,3 +411,40 @@ export function isClaudeTransientUpstreamError(input: { if (!haystack) return false; return CLAUDE_TRANSIENT_UPSTREAM_RE.test(haystack); } + +function extractResultFromMixedOutput(stdout: string): Record | null { + // Look for JSON objects that have a "type": "result" field + // This handles cases where Claude's output is interleaved with progress text + const resultRe = /"type"\s*:\s*"result"[^}]*}/; + const match = stdout.match(resultRe); + if (match) { + // Try to expand to a full JSON object + const idx = stdout.indexOf(match[0]); + if (idx >= 0) { + // Walk backwards to find the opening brace + let braceStart = idx; + while (braceStart > 0 && stdout[braceStart] !== "{") { + braceStart--; + } + // Walk forwards to find the closing brace + let braceEnd = idx + match[0].length; + let braceDepth = 0; + for (let i = braceStart; i < stdout.length; i++) { + if (stdout[i] === "{") braceDepth++; + if (stdout[i] === "}") { + braceDepth--; + if (braceDepth === 0) { + braceEnd = i + 1; + break; + } + } + } + const candidate = stdout.slice(braceStart, braceEnd); + const parsed = parseJson(candidate); + if (parsed && asString(parsed.type, "") === "result") { + return parsed; + } + } + } + return null; +} From 407ae94bd7223a86ca8d9233cb1f5d036e4a4909 Mon Sep 17 00:00:00 2001 From: digitone Date: Tue, 26 May 2026 12:01:56 +0530 Subject: [PATCH 2/2] test+docs: add tests and changelogs for EPIPE fix and lenient JSON parsing - Add parseJsonLenient() tests in adapter-utils (9 cases) - Add parseClaudeStreamJson() tests in claude-local (8 cases) - Add runChildProcess EPIPE handling tests (2 cases) - Update CHANGELOGs for adapter-utils and claude-local - All 61 targeted tests pass; pnpm -r typecheck clean --- packages/adapter-utils/CHANGELOG.md | 7 ++ .../adapter-utils/src/server-utils.test.ts | 110 ++++++++++++++++++ packages/adapters/claude-local/CHANGELOG.md | 8 ++ .../claude-local/src/server/parse.test.ts | 89 ++++++++++++++ 4 files changed, 214 insertions(+) diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index 76cabbd73fb..a9768a115bc 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,5 +1,12 @@ # @paperclipai/adapter-utils +## Unreleased + +### Patch Changes + +- Added `parseJsonLenient()` utility to extract JSON from mixed text, markdown code blocks, and interleaved output +- Fixed EPIPE crash in `runChildProcess()`: stdin errors are now caught via event listener and callback-style write instead of try/catch + ## 0.3.1 ### Patch Changes diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 3224b3d8173..bd1b9bef650 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -9,6 +9,7 @@ import { buildInvocationEnvForLogs, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, materializePaperclipSkillCopy, + parseJsonLenient, refreshPaperclipWorkspaceEnvForExecution, renderPaperclipWakePrompt, runningProcesses, @@ -979,3 +980,112 @@ describe("appendWithByteCap", () => { expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); }); }); + +describe("parseJsonLenient", () => { + it("returns null for empty string", () => { + expect(parseJsonLenient("")).toBeNull(); + }); + + it("parses plain JSON directly", () => { + expect(parseJsonLenient('{"type":"result","ok":true}')).toEqual({ + type: "result", + ok: true, + }); + }); + + it("extracts JSON from markdown code blocks", () => { + const wrapped = "Some text\n```json\n{\"type\":\"result\",\"value\":42}\n```\nMore text"; + expect(parseJsonLenient(wrapped)).toEqual({ + type: "result", + value: 42, + }); + }); + + it("extracts JSON from plain markdown blocks without language tag", () => { + const wrapped = "```\n{\"type\":\"result\",\"value\":42}\n```"; + expect(parseJsonLenient(wrapped)).toEqual({ + type: "result", + value: 42, + }); + }); + + it("finds JSON object embedded in mixed text when JSON is at end of string", () => { + // The progressive parser tries substrings from '{' to end; trailing text + // after the closing brace causes parse failure. JSON must extend to end. + const mixed = 'Progress: 50%\n{"type":"result","status":"done"}'; + expect(parseJsonLenient(mixed)).toEqual({ + type: "result", + status: "done", + }); + }); + + it("finds JSON array when it starts the string", () => { + // Arrays are supported, but only when '[' appears before any '{' in the string. + const arrayFirst = "[{\"id\":1},{\"id\":2}]"; + expect(parseJsonLenient(arrayFirst)).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it("returns null when no JSON is present", () => { + expect(parseJsonLenient("Just plain text without any braces")).toBeNull(); + }); + + it("returns null for malformed JSON that cannot be recovered", () => { + expect(parseJsonLenient("{ broken json ")).toBeNull(); + }); + + it("prefers code-block extraction when direct parse fails", () => { + // The full string is not valid JSON (trailing backticks), so direct parse fails. + // Code-block extraction should then find the block contents. + const input = 'not json\n```\n{"type":"block"}\n```'; + expect(parseJsonLenient(input)).toEqual({ type: "block" }); + }); +}); + +describe("runChildProcess EPIPE handling", () => { + it("does not crash when child closes stdin early (EPIPE)", async () => { + // Use `sh -c 'exit 0'` which immediately exits, closing stdin before parent can write + const logs: { stream: "stdout" | "stderr"; chunk: string }[] = []; + const result = await runChildProcess( + `epipe-test-${randomUUID()}`, + "sh", + ["-c", "exit 0"], + { + cwd: os.tmpdir(), + env: {}, + timeoutSec: 5, + graceSec: 1, + stdin: "this should not crash", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + }, + ); + + // Process should exit cleanly (exit code 0) despite EPIPE on stdin write + expect(result.exitCode).toBe(0); + expect(result.signal).toBeNull(); + expect(result.timedOut).toBe(false); + }); + + it("delivers stdin successfully when child reads it", async () => { + const logs: { stream: "stdout" | "stderr"; chunk: string }[] = []; + const result = await runChildProcess( + `stdin-echo-test-${randomUUID()}`, + "cat", + [], + { + cwd: os.tmpdir(), + env: {}, + timeoutSec: 5, + graceSec: 1, + stdin: "hello from parent", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("hello from parent"); + }); +}); diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index b9035585ad1..8803c04ac88 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-claude-local +## Unreleased + +### Patch Changes + +- Added lenient JSON parsing fallback in `parseClaudeStreamJson()` for markdown-wrapped and mixed-text output +- Added `extractResultFromMixedOutput()` to locate `"type": "result"` JSON objects inside interleaved text +- Treats exit-code-0 + parse failure as inferred success with `inferredSuccess: true` flag instead of error + ## 0.3.1 ### Patch Changes diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 3f27e40ec31..41b7a52a2d2 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { extractClaudeRetryNotBefore, isClaudeTransientUpstreamError, + parseClaudeStreamJson, } from "./parse.js"; describe("isClaudeTransientUpstreamError", () => { @@ -121,3 +122,91 @@ describe("extractClaudeRetryNotBefore", () => { ).toBeNull(); }); }); + +describe("parseClaudeStreamJson", () => { + it("parses normal line-delimited stream JSON", () => { + const stdout = [ + '{"type":"system","subtype":"init","session_id":"sess-1"}', + '{"type":"assistant","session_id":"sess-1","message":{"content":[{"type":"text","text":"Hello"}]}}', + '{"type":"result","session_id":"sess-1","result":"Done","usage":{"input_tokens":10,"output_tokens":5},"total_cost_usd":0.001}', + ].join("\n"); + + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.sessionId).toBe("sess-1"); + expect(parsed.resultJson).toEqual({ + type: "result", + session_id: "sess-1", + result: "Done", + usage: { input_tokens: 10, output_tokens: 5 }, + total_cost_usd: 0.001, + }); + expect(parsed.usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 0, + outputTokens: 5, + }); + expect(parsed.costUsd).toBe(0.001); + expect(parsed.summary).toBe("Done"); + }); + + it("extracts result from markdown-wrapped JSON", () => { + const stdout = 'Some intro\n```json\n{"type":"result","session_id":"sess-2","result":"Wrapped","usage":{"input_tokens":1,"output_tokens":2},"total_cost_usd":0.0001}\n```\n'; + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.sessionId).toBe("sess-2"); + expect(parsed.resultJson).not.toBeNull(); + expect(parsed.summary).toBe("Wrapped"); + }); + + it("extracts result from mixed text with progress indicators", () => { + const stdout = 'Progress: 50%\n{"type":"result","session_id":"sess-3","result":"Mixed","usage":{"input_tokens":5,"output_tokens":5}}\nDone.'; + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.sessionId).toBe("sess-3"); + expect(parsed.resultJson).not.toBeNull(); + expect(parsed.summary).toBe("Mixed"); + }); + + it("extracts result using extractResultFromMixedOutput for interleaved text", () => { + const stdout = 'Loading...\n{"type":"system","subtype":"init","session_id":"sess-4"}\nThinking...\n{"type":"assistant","session_id":"sess-4","message":{"content":[{"type":"text","text":"Working"}]}}\nProgress: 75%\n{"type":"result","session_id":"sess-4","result":"Interleaved","usage":{"input_tokens":3,"output_tokens":4}}\nCleanup...'; + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.sessionId).toBe("sess-4"); + expect(parsed.resultJson).not.toBeNull(); + expect(parsed.summary).toBe("Interleaved"); + }); + + it("returns null resultJson when no result is found", () => { + const stdout = '{"type":"system","subtype":"init","session_id":"sess-5"}\nNo result here.'; + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.sessionId).toBe("sess-5"); + expect(parsed.resultJson).toBeNull(); + expect(parsed.costUsd).toBeNull(); + }); + + it("collects assistant text messages", () => { + const stdout = [ + '{"type":"assistant","message":{"content":[{"type":"text","text":"First"}]}}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"Second"}]}}', + '{"type":"result","result":"Final","usage":{}}', + ].join("\n"); + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.summary).toBe("Final"); + }); + + it("uses assistant texts as summary when result field is missing", () => { + const stdout = [ + '{"type":"assistant","message":{"content":[{"type":"text","text":"First paragraph"}]}}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"Second paragraph"}]}}', + '{"type":"result","usage":{},"total_cost_usd":0}' + ].join("\n"); + const parsed = parseClaudeStreamJson(stdout); + expect(parsed.summary).toBe("First paragraph\n\nSecond paragraph"); + }); + + it("handles empty stdout gracefully", () => { + const parsed = parseClaudeStreamJson(""); + expect(parsed.sessionId).toBeNull(); + expect(parsed.resultJson).toBeNull(); + expect(parsed.summary).toBe(""); + expect(parsed.usage).toBeNull(); + expect(parsed.costUsd).toBeNull(); + }); +});