diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 53e53366a..cf4205f7f 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -3,6 +3,7 @@ */ import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { waitForChildProcess } from "../utils/child-process.ts"; /** @@ -46,6 +47,10 @@ export async function execCommand( let stdout = ""; let stderr = ""; + // Decode across chunks: a multi-byte UTF-8 character can be split between two chunks, + // and decoding each chunk separately would corrupt it (earendil-works/pi#11312). + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); let killed = false; let timeoutId: NodeJS.Timeout | undefined; @@ -79,11 +84,11 @@ export async function execCommand( } proc.stdout?.on("data", (data) => { - stdout += data.toString(); + stdout += stdoutDecoder.write(data); }); proc.stderr?.on("data", (data) => { - stderr += data.toString(); + stderr += stderrDecoder.write(data); }); // Wait for process termination without hanging on inherited stdio handles @@ -91,6 +96,8 @@ export async function execCommand( waitForChildProcess(proc) .then((code) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } @@ -98,6 +105,8 @@ export async function execCommand( }) .catch((error: Error) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); }