diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a7700b2a0..f74150106 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,6 +35,7 @@ - Fixed the read tool loading whole files into memory, which ran out of memory on large files even with `offset`/`limit`; it now streams the file and keeps only the shown lines. Custom `ReadOperations` can stream too by providing the new optional `createReadStream` ([#9654](https://github.com/earendil-works/pi/issues/9654)) - Fixed OAuth logins such as `openai` and `anthropic` failing with `invalid_grant` after pi was stopped while it refreshed an expired token, for example an RPC client that stops pi right after startup while the background catalog refresh runs. The token endpoint had already replaced the refresh token, but pi exited before saving the new one. On exit, pi now waits for a token refresh in progress to be saved ([#11692](https://github.com/earendil-works/pi/issues/11692)) - Fixed Ctrl+P not switching to the only model in scope (or the only available model) when the active model is a different one; it showed "Only one model in scope" instead. Cycling forward from a model outside the scope now also selects the first scoped model instead of skipping it ([#11806](https://github.com/earendil-works/pi/issues/11806)) +- Fixed the bash and PowerShell tools on Windows leaving processes running after an abort or timeout. `taskkill /T` finds a command's processes through their parent, so it missed background processes whose shell had already exited (for example a loop that kept writing until the timeout) and processes started by intermediate processes that had exited. Commands now run in a Windows job object through a small bundled helper (`pi-job.exe`, x64 and arm64), which every process the command starts joins and cannot leave, and abort and timeout terminate the whole job. Background processes still keep running after a command finishes, as on Unix ([#11613](https://github.com/earendil-works/pi/issues/11613)) ## [1.5.0] - 2026-11-02 diff --git a/packages/coding-agent/native/win32/README.md b/packages/coding-agent/native/win32/README.md new file mode 100644 index 000000000..6a0d4c5c2 --- /dev/null +++ b/packages/coding-agent/native/win32/README.md @@ -0,0 +1,20 @@ +# Windows native prebuilds + +`pi-job.exe` runs the bash and PowerShell tools' commands in a named job object, so pi can kill a command's whole process tree on abort or timeout, including background processes whose parent shell already exited (`taskkill /T` cannot find those). Background processes left when a command finishes keep running, as they do on Unix. See the header of `src/pi-job.c` for the modes. Links to `kernel32` only, without the C runtime. + +## Building + +On Windows, install Visual Studio's "Desktop development with C++" workload, then run from the repository root to build x64 and arm64: + +```sh +npm --prefix packages/coding-agent run build:native:win32 +``` + +For cross-builds or custom toolchains, provide MinGW-compatible compilers (for example [llvm-mingw](https://github.com/mstorsjo/llvm-mingw), which targets both): + +```sh +PI_WIN32_TOOLCHAIN=mingw \ +CC_X64=/path/to/x86_64-w64-mingw32-clang \ +CC_ARM64=/path/to/aarch64-w64-mingw32-clang \ +npm --prefix packages/coding-agent run build:native:win32 +``` diff --git a/packages/coding-agent/native/win32/build.mjs b/packages/coding-agent/native/win32/build.mjs new file mode 100644 index 000000000..514944a90 --- /dev/null +++ b/packages/coding-agent/native/win32/build.mjs @@ -0,0 +1,231 @@ +// Builds pi-job.exe for win32-x64 and win32-arm64 into prebuilds/. Mirrors packages/tui/native/win32/build.mjs, +// which builds a DLL instead of an executable; both are self-contained so each ships in its own npm package. +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const sourceFile = path.join(scriptDir, "src", "pi-job.c"); +const outputName = "pi-job.exe"; +const temporaryDir = mkdtempSync(path.join(tmpdir(), "pi-job-win32-")); + +const targets = [ + { + arch: "x64", + msvcArch: "x64", + mingwTarget: "x86_64-w64-windows-gnu", + prefixedCompilers: ["x86_64-w64-mingw32-clang", "x86_64-w64-mingw32-gcc"], + envCompiler: "CC_X64", + }, + { + arch: "arm64", + msvcArch: "arm64", + mingwTarget: "aarch64-w64-windows-gnu", + prefixedCompilers: ["aarch64-w64-mingw32-clang", "aarch64-w64-mingw32-gcc"], + envCompiler: "CC_ARM64", + }, +]; + +function removeTemporaryDir() { + rmSync(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + +process.on("exit", removeTemporaryDir); +process.on("SIGINT", () => { + removeTemporaryDir(); + process.exit(130); +}); + +function commandExists(command) { + const result = spawnSync(command, ["--version"], { stdio: "ignore" }); + return !result.error || result.error.code !== "ENOENT"; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? 1}`); +} + +function quoteBatch(value) { + return `"${value.replaceAll('"', '""')}"`; +} + +function msvcHostArch() { + return process.arch === "arm64" ? "arm64" : "x64"; +} + +function findVisualStudioDevCmd() { + const candidates = []; + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + const vswhere = path.join(programFilesX86, "Microsoft Visual Studio", "Installer", "vswhere.exe"); + if (existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8" }, + ); + for (const line of result.stdout.split(/\r?\n/)) { + const installationPath = line.trim(); + if (installationPath) candidates.push(path.join(installationPath, "Common7", "Tools", "VsDevCmd.bat")); + } + } + } + for (const base of [process.env.ProgramFiles, programFilesX86]) { + if (!base) continue; + for (const edition of ["BuildTools", "Community", "Professional", "Enterprise"]) { + candidates.push(path.join(base, "Microsoft Visual Studio", "2022", edition, "Common7", "Tools", "VsDevCmd.bat")); + } + } + return candidates.find((candidate) => existsSync(candidate)); +} + +function canUseMsvc(vsDevCmd) { + const probeDir = path.join(temporaryDir, "msvc-probe"); + mkdirSync(probeDir, { recursive: true }); + const batchFile = path.join(probeDir, "probe.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=x64 -host_arch=${msvcHostArch()} >nul`, + "if errorlevel 1 exit /b %errorlevel%", + "where cl.exe >nul 2>nul", + ].join("\r\n"), + ); + const result = spawnSync("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: probeDir, stdio: "ignore" }); + return !result.error && result.status === 0; +} + +function resolveMingwCompiler(target) { + const envCompiler = process.env[target.envCompiler]; + if (envCompiler) return { command: envCompiler, useTargetFlag: false }; + if (process.env.CC) return { command: process.env.CC, useTargetFlag: true }; + for (const command of target.prefixedCompilers) { + if (commandExists(command)) return { command, useTargetFlag: false }; + } + if (commandExists("clang")) return { command: "clang", useTargetFlag: true }; + return undefined; +} + +function installOutput(temporaryOutput, target) { + const outputDir = path.join(scriptDir, "prebuilds", `win32-${target.arch}`); + mkdirSync(outputDir, { recursive: true }); + const output = path.join(outputDir, outputName); + copyFileSync(temporaryOutput, output); + chmodSync(output, 0o755); + console.log(`Built ${path.relative(process.cwd(), output)}`); +} + +function buildWithMsvc(target, vsDevCmd) { + const buildDir = path.join(temporaryDir, `msvc-${target.arch}`); + const temporaryOutput = path.join(buildDir, outputName); + mkdirSync(buildDir, { recursive: true }); + + const batchFile = path.join(buildDir, "build.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=${target.msvcArch} -host_arch=${msvcHostArch()}`, + "if errorlevel 1 exit /b %errorlevel%", + [ + "cl", + "/nologo", + "/TC", + "/std:c11", + "/W4", + "/O1", + "/GS-", + `/Fe:${quoteBatch(temporaryOutput)}`, + quoteBatch(sourceFile), + "/link", + "/ENTRY:entry", + "/SUBSYSTEM:CONSOLE", + "/NODEFAULTLIB", + "kernel32.lib", + "/OPT:REF", + "/OPT:ICF", + ].join(" "), + "if errorlevel 1 exit /b %errorlevel%", + ].join("\r\n"), + ); + + run("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +function buildWithMingw(target) { + const compiler = resolveMingwCompiler(target); + if (!compiler) { + throw new Error( + `No Windows cross-compiler found for ${target.arch}. Install Microsoft C++ Build Tools on Windows, or set ${target.envCompiler} to a MinGW-compatible compiler.`, + ); + } + + const buildDir = path.join(temporaryDir, `mingw-${target.arch}`); + const temporaryOutput = path.join(buildDir, outputName); + mkdirSync(buildDir, { recursive: true }); + + const args = [ + ...(compiler.useTargetFlag ? [`--target=${target.mingwTarget}`] : []), + "-std=c11", + "-Wall", + "-Wextra", + "-Oz", + "-fno-builtin", + "-nostdlib", + "-municode", + "-Wl,--entry=entry", + "-Wl,--subsystem,console", + "-Wl,--strip-all", + sourceFile, + "-lkernel32", + "-o", + temporaryOutput, + ]; + + run(compiler.command, args, { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`Usage: npm --prefix packages/coding-agent run build:native:win32 + +Builds win32-x64 and win32-arm64 pi-job.exe prebuilds. + +Environment: + PI_WIN32_TOOLCHAIN=msvc|mingw + CC_X64=/path/to/x86_64-w64-mingw32-clang + CC_ARM64=/path/to/aarch64-w64-mingw32-clang + CC=/path/to/clang`); + process.exit(0); +} + +const requestedToolchain = process.env.PI_WIN32_TOOLCHAIN; +if (requestedToolchain && requestedToolchain !== "msvc" && requestedToolchain !== "mingw") { + throw new Error("PI_WIN32_TOOLCHAIN must be either 'msvc' or 'mingw'"); +} + +const vsDevCmd = findVisualStudioDevCmd(); +const toolchain = requestedToolchain ?? (process.platform === "win32" && vsDevCmd ? "msvc" : "mingw"); + +if (toolchain === "msvc") { + if (!vsDevCmd || !canUseMsvc(vsDevCmd)) { + throw new Error("Microsoft C++ Build Tools not found. Install the Visual Studio C++ workload or set PI_WIN32_TOOLCHAIN=mingw."); + } + for (const target of targets) buildWithMsvc(target, vsDevCmd); +} else { + for (const target of targets) buildWithMingw(target); +} diff --git a/packages/coding-agent/native/win32/prebuilds/win32-arm64/pi-job.exe b/packages/coding-agent/native/win32/prebuilds/win32-arm64/pi-job.exe new file mode 100644 index 000000000..4e9241011 Binary files /dev/null and b/packages/coding-agent/native/win32/prebuilds/win32-arm64/pi-job.exe differ diff --git a/packages/coding-agent/native/win32/prebuilds/win32-x64/pi-job.exe b/packages/coding-agent/native/win32/prebuilds/win32-x64/pi-job.exe new file mode 100644 index 000000000..51503738a Binary files /dev/null and b/packages/coding-agent/native/win32/prebuilds/win32-x64/pi-job.exe differ diff --git a/packages/coding-agent/native/win32/src/pi-job.c b/packages/coding-agent/native/win32/src/pi-job.c new file mode 100644 index 000000000..0912c6026 --- /dev/null +++ b/packages/coding-agent/native/win32/src/pi-job.c @@ -0,0 +1,299 @@ +// pi-job.exe: runs a shell command in a named Windows job object so that pi can kill the command's whole +// process tree, including processes whose parent already exited, which `taskkill /T` cannot find. +// +// pi-job.exe run Start in a new job named ; exit with its code. +// pi-job.exe terminate Kill every process in the job named . +// pi-job.exe hold Internal: keep an inherited job handle open until the job is empty. +// +// `run` exits when the command's root process exits, like spawning the command directly. Background processes it +// started stay in the job and keep running; a `hold` process keeps the job (and its name) alive for them, so a +// later `terminate` still reaches them. The job kills its processes when its last handle closes, so killing +// `run` before the command exits also kills the tree. +// +// pi generates (letters, digits, '-') and the mode words, so the arguments before the command line are +// split on spaces; the command line after them is passed to CreateProcessW exactly as pi's spawn quoted it. +// No C runtime: entry point `entry`, kernel32 only. + +#define WIN32_LEAN_AND_MEAN +#include + +#define EXIT_SETUP_FAILED 126 +#define EXIT_NOT_FOUND 127 +#define MAX_NAME_LENGTH 128 +#define HOLD_POLL_MS 1000 + +// CRT-free builds still need memset for zero-initialized structs the compiler may lower to it. +#ifdef _MSC_VER +#pragma function(memset) +#endif +void* __cdecl memset(void* target, int value, size_t count) { + volatile unsigned char* bytes = (volatile unsigned char*)target; + while (count--) *bytes++ = (unsigned char)value; + return target; +} + +static void write_error(const char* what, DWORD code) { + char message[160]; + char digits[12]; + int length = 0; + int digit_count = 0; + const char* prefix = "pi-job: "; + for (const char* c = prefix; *c && length < 100; c++) message[length++] = *c; + for (const char* c = what; *c && length < 100; c++) message[length++] = *c; + const char* middle = " failed (error "; + for (const char* c = middle; *c; c++) message[length++] = *c; + do { + digits[digit_count++] = (char)('0' + code % 10); + code /= 10; + } while (code && digit_count < (int)sizeof(digits)); + while (digit_count) message[length++] = digits[--digit_count]; + message[length++] = ')'; + message[length++] = '\r'; + message[length++] = '\n'; + DWORD written = 0; + WriteFile(GetStdHandle(STD_ERROR_HANDLE), message, (DWORD)length, &written, NULL); +} + +static DWORD fail(const char* what) { + write_error(what, GetLastError()); + return EXIT_SETUP_FAILED; +} + +static int is_space(WCHAR c) { + return c == L' ' || c == L'\t'; +} + +static const WCHAR* skip_spaces(const WCHAR* cursor) { + while (is_space(*cursor)) cursor++; + return cursor; +} + +// Skips argv[0] with the rules CreateProcessW uses: a quoted path runs to the next quote, otherwise to a space. +static const WCHAR* skip_program(const WCHAR* cursor) { + if (*cursor == L'"') { + cursor++; + while (*cursor && *cursor != L'"') cursor++; + if (*cursor) cursor++; + } + while (*cursor && !is_space(*cursor)) cursor++; + return skip_spaces(cursor); +} + +// Copies the next space-delimited word into `word`; returns the position after it, or NULL when it is missing or +// too long. +static const WCHAR* read_word(const WCHAR* cursor, WCHAR* word, int capacity) { + int length = 0; + while (*cursor && !is_space(*cursor)) { + if (length + 1 >= capacity) return NULL; + word[length++] = *cursor++; + } + word[length] = 0; + return length ? skip_spaces(cursor) : NULL; +} + +static int words_equal(const WCHAR* left, const WCHAR* right) { + while (*left && *left == *right) { + left++; + right++; + } + return *left == *right; +} + +// Job names come from pi; restricting them keeps them out of other kernel namespaces such as "Global\". +static int is_valid_name(const WCHAR* name) { + for (const WCHAR* c = name; *c; c++) { + int allowed = (*c >= L'a' && *c <= L'z') || (*c >= L'A' && *c <= L'Z') || (*c >= L'0' && *c <= L'9') || + *c == L'-'; + if (!allowed) return 0; + } + return 1; +} + +static DWORD active_processes(HANDLE job) { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information = {0}; + if (!QueryInformationJobObject(job, JobObjectBasicAccountingInformation, &information, sizeof(information), NULL)) { + return 0; + } + return information.ActiveProcesses; +} + +static BOOL WINAPI ignore_interrupts(DWORD event) { + // The command shares the console and handles Ctrl+C itself; `run` must outlive it to report its exit code. + // A handler is used instead of SetConsoleCtrlHandler(NULL, TRUE), which the command would inherit. + return event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT; +} + +static HANDLE inheritable_copy(HANDLE handle) { + HANDLE copy = NULL; + if (!handle || handle == INVALID_HANDLE_VALUE) return NULL; + HANDLE self = GetCurrentProcess(); + if (!DuplicateHandle(self, handle, self, ©, 0, TRUE, DUPLICATE_SAME_ACCESS)) return NULL; + return copy; +} + +static void format_handle(WCHAR* target, ULONG_PTR value) { + WCHAR digits[24]; + int count = 0; + do { + digits[count++] = (WCHAR)(L'0' + value % 10); + value /= 10; + } while (value); + while (count) *target++ = digits[--count]; + *target = 0; +} + +// Starts `hold` with only the job handle inherited: no stdio, so pi's pipes still close when the processes +// writing to them exit, and no console. It runs in the system directory so it never keeps the command's working +// directory in use. +static BOOL start_holder(HANDLE job) { + WCHAR self[MAX_PATH]; + WCHAR system_directory[MAX_PATH]; + DWORD self_length = GetModuleFileNameW(NULL, self, MAX_PATH); + if (!self_length || self_length >= MAX_PATH) return FALSE; + DWORD system_length = GetSystemDirectoryW(system_directory, MAX_PATH); + if (!system_length || system_length >= MAX_PATH) return FALSE; + HANDLE inherited = inheritable_copy(job); + if (!inherited) return FALSE; + + WCHAR command_line[MAX_PATH + 40]; + int length = 0; + command_line[length++] = L'"'; + for (DWORD i = 0; i < self_length; i++) command_line[length++] = self[i]; + const WCHAR* hold = L"\" hold "; + for (const WCHAR* c = hold; *c; c++) command_line[length++] = *c; + format_handle(command_line + length, (ULONG_PTR)inherited); + + SIZE_T attribute_size = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &attribute_size); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = HeapAlloc(GetProcessHeap(), 0, attribute_size); + BOOL started = FALSE; + if (attributes && InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_size)) { + if (UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &inherited, sizeof(inherited), + NULL, NULL)) { + STARTUPINFOEXW startup = {0}; + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process = {0}; + started = CreateProcessW(self, command_line, NULL, NULL, TRUE, + EXTENDED_STARTUPINFO_PRESENT | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, system_directory, + &startup.StartupInfo, &process); + if (started) { + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } + } + DeleteProcThreadAttributeList(attributes); + } + if (attributes) HeapFree(GetProcessHeap(), 0, attributes); + CloseHandle(inherited); + return started; +} + +// Lets the job's processes outlive `run` when no holder could be started, as they did before job objects. +static void keep_processes_on_close(HANDLE job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {0}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION; + SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)); +} + +static DWORD run(const WCHAR* name, const WCHAR* command_line_text) { + SetConsoleCtrlHandler(ignore_interrupts, TRUE); + + // CreateProcessW may write to the command line buffer, so it gets a copy of our own (read-only) one. + SIZE_T command_line_size = ((SIZE_T)lstrlenW(command_line_text) + 1) * sizeof(WCHAR); + WCHAR* command_line = HeapAlloc(GetProcessHeap(), 0, command_line_size); + if (!command_line) return fail("HeapAlloc"); + for (SIZE_T i = 0; i < command_line_size / sizeof(WCHAR); i++) command_line[i] = command_line_text[i]; + + HANDLE job = CreateJobObjectW(NULL, name); + if (!job) return fail("CreateJobObjectW"); + if (GetLastError() == ERROR_ALREADY_EXISTS) { + write_error("CreateJobObjectW (name in use)", ERROR_ALREADY_EXISTS); + return EXIT_SETUP_FAILED; + } + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {0}; + // Unhandled exceptions end the process instead of opening an error dialog that would keep the job alive. + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { + return fail("SetInformationJobObject"); + } + + STARTUPINFOW startup = {0}; + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = inheritable_copy(GetStdHandle(STD_INPUT_HANDLE)); + startup.hStdOutput = inheritable_copy(GetStdHandle(STD_OUTPUT_HANDLE)); + startup.hStdError = inheritable_copy(GetStdHandle(STD_ERROR_HANDLE)); + PROCESS_INFORMATION process = {0}; + // Suspended until it is in the job: a process it starts before the assignment would escape the job. + BOOL created = CreateProcessW(NULL, command_line, NULL, NULL, TRUE, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, + NULL, NULL, &startup, &process); + DWORD create_error = GetLastError(); + if (startup.hStdInput) CloseHandle(startup.hStdInput); + if (startup.hStdOutput) CloseHandle(startup.hStdOutput); + if (startup.hStdError) CloseHandle(startup.hStdError); + if (!created) { + write_error("CreateProcessW", create_error); + return create_error == ERROR_FILE_NOT_FOUND || create_error == ERROR_PATH_NOT_FOUND ? EXIT_NOT_FOUND + : EXIT_SETUP_FAILED; + } + if (!AssignProcessToJobObject(job, process.hProcess)) { + DWORD code = fail("AssignProcessToJobObject"); + TerminateProcess(process.hProcess, code); + return code; + } + if (ResumeThread(process.hThread) == (DWORD)-1) { + DWORD code = fail("ResumeThread"); + TerminateJobObject(job, code); + return code; + } + CloseHandle(process.hThread); + + WaitForSingleObject(process.hProcess, INFINITE); + DWORD exit_code = EXIT_SETUP_FAILED; + GetExitCodeProcess(process.hProcess, &exit_code); + if (active_processes(job) > 0 && !start_holder(job)) keep_processes_on_close(job); + return exit_code; +} + +static DWORD terminate(const WCHAR* name) { + HANDLE job = OpenJobObjectW(JOB_OBJECT_TERMINATE, FALSE, name); + // No job means nothing is left to kill: the command and its holder already ended. + if (!job) return 1; + return TerminateJobObject(job, 1) ? 0 : fail("TerminateJobObject"); +} + +static DWORD hold(const WCHAR* handle_text) { + ULONG_PTR value = 0; + for (const WCHAR* c = handle_text; *c; c++) { + if (*c < L'0' || *c > L'9') return EXIT_SETUP_FAILED; + value = value * 10 + (ULONG_PTR)(*c - L'0'); + } + HANDLE job = (HANDLE)value; + // Polling keeps this process trivial; it only decides when the holder exits, never what gets killed. + while (active_processes(job) > 0) Sleep(HOLD_POLL_MS); + return 0; +} + +static DWORD dispatch(void) { + WCHAR mode[16]; + WCHAR argument[MAX_NAME_LENGTH]; + const WCHAR* cursor = skip_program(GetCommandLineW()); + cursor = read_word(cursor, mode, sizeof(mode) / sizeof(mode[0])); + if (!cursor) return EXIT_SETUP_FAILED; + const WCHAR* rest = read_word(cursor, argument, sizeof(argument) / sizeof(argument[0])); + + if (words_equal(mode, L"hold") && rest) return hold(argument); + if (!rest || !is_valid_name(argument)) { + write_error("argument parsing", ERROR_INVALID_PARAMETER); + return EXIT_SETUP_FAILED; + } + if (words_equal(mode, L"terminate")) return terminate(argument); + if (words_equal(mode, L"run") && *rest) return run(argument, rest); + write_error("argument parsing", ERROR_INVALID_PARAMETER); + return EXIT_SETUP_FAILED; +} + +void __cdecl entry(void) { + ExitProcess(dispatch()); +} diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 13e633577..c151f2d9d 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -25,6 +25,10 @@ "docs", "examples", "schemas", + "native/win32/prebuilds/**/*.exe", + "native/win32/src/**/*.c", + "native/win32/build.mjs", + "native/win32/README.md", "containerization.md", "CHANGELOG.md" ], @@ -34,7 +38,8 @@ "build:unbundled": "tsc -p tsconfig.build.json && node ../../scripts/package-files.mjs executable dist/cli.js dist/rpc-entry.js && npm run copy-assets", "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv ./dist/bun/cli.js ./src/utils/image-resize-worker.ts ./src/extensions/codemode/worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "node ../../scripts/package-files.mjs copy \"src/modes/interactive/theme/*.json\" dist/modes/interactive/theme && node ../../scripts/package-files.mjs copy \"src/modes/interactive/assets/*.png\" dist/modes/interactive/assets && node ../../scripts/package-files.mjs copy src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html && node ../../scripts/package-files.mjs copy \"src/core/export-html/vendor/*.js\" dist/core/export-html/vendor", - "copy-binary-assets": "node ../../scripts/package-files.mjs copy package.json README.md CHANGELOG.md docs examples ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist && node ../../scripts/package-files.mjs copy \"src/modes/interactive/theme/*.json\" dist/theme && node ../../scripts/package-files.mjs copy \"src/modes/interactive/assets/*.png\" dist/assets && node ../../scripts/package-files.mjs copy src/core/export-html/template.html dist/export-html && node ../../scripts/package-files.mjs copy \"src/core/export-html/vendor/*.js\" dist/export-html/vendor", + "copy-binary-assets": "node ../../scripts/package-files.mjs copy package.json README.md CHANGELOG.md docs examples ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist && node ../../scripts/package-files.mjs copy \"src/modes/interactive/theme/*.json\" dist/theme && node ../../scripts/package-files.mjs copy \"src/modes/interactive/assets/*.png\" dist/assets && node ../../scripts/package-files.mjs copy src/core/export-html/template.html dist/export-html && node ../../scripts/package-files.mjs copy \"src/core/export-html/vendor/*.js\" dist/export-html/vendor && node ../../scripts/package-files.mjs copy native/win32/prebuilds dist/native/win32", + "build:native:win32": "node native/win32/build.mjs", "test": "vitest --run", "generate:schemas": "node --import ./src/experimental/source-resolver.ts scripts/generate-schemas.ts", "generate:references": "node --import ./src/experimental/source-resolver.ts scripts/generate-references.ts", diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 5a9552d5d..4c033bb24 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -500,6 +500,14 @@ export function getInteractiveAssetsDir(): string { return join(packageDir, srcOrDist, "modes", "interactive", "assets"); } +/** + * Get path to pi-job.exe, the Windows helper that runs shell commands in a job object (native/win32/src/pi-job.c). + * The package root and a Bun binary's directory both contain native/win32/prebuilds/. + */ +export function getWindowsJobHelperPath(): string { + return join(getPackageDir(), "native", "win32", "prebuilds", `win32-${process.arch}`, "pi-job.exe"); +} + /** Get path to a bundled interactive asset */ export function getBundledInteractiveAssetPath(name: string): string { return join(getInteractiveAssetsDir(), name); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 92de2d09b..956ee135b 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,16 +1,15 @@ import { constants } from "node:fs"; import { access as fsAccess } from "node:fs/promises"; import { constants as osConstants } from "node:os"; -import { spawn } from "child_process"; import { type Static, Type } from "typebox"; import { waitForChildProcess } from "../../utils/child-process.ts"; import { getShellConfig, getShellEnv, - killProcessTree, type ShellConfig, - trackDetachedChildPid, - untrackDetachedChildPid, + spawnProcessTree, + trackProcessTree, + untrackProcessTree, } from "../../utils/shell.ts"; import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; import { OutputAccumulator } from "./output-accumulator.ts"; @@ -122,18 +121,22 @@ export function createLocalShellOperations(shellName: string, resolveShellConfig } const commandFromStdin = shellConfig.commandTransport === "stdin"; - const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { - cwd, - detached: process.platform !== "win32", - env: env ?? getShellEnv(), - stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], - windowsHide: true, - }); + const tree = spawnProcessTree( + shellConfig.shell, + commandFromStdin ? shellConfig.args : [...shellConfig.args, command], + { + cwd, + env: env ?? getShellEnv(), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + const child = tree.child; if (commandFromStdin) { child.stdin?.on("error", () => {}); child.stdin?.end(command); } - if (child.pid) trackDetachedChildPid(child.pid); + trackProcessTree(tree); const startedAt = performance.now(); let shellExitedAfterMs: number | undefined; child.once("exit", () => { @@ -142,9 +145,7 @@ export function createLocalShellOperations(shellName: string, resolveShellConfig let timedOut = false; let exitedBeforeDeadlineAfterMs: number | undefined; let timeoutHandle: NodeJS.Timeout | undefined; - const onAbort = () => { - if (child.pid) killProcessTree(child.pid); - }; + const onAbort = () => tree.kill(); try { // Set timeout if provided. @@ -152,9 +153,9 @@ export function createLocalShellOperations(shellName: string, resolveShellConfig timeoutHandle = setTimeout(() => { timedOut = true; exitedBeforeDeadlineAfterMs = shellExitedAfterMs; - // Also when the shell already exited: on Unix the group kill still reaches background - // processes holding the pipes. Windows cannot find them once the shell pid is gone. - if (child.pid) killProcessTree(child.pid); + // Also when the shell already exited: the process group (Unix) or job object (Windows) still + // reaches background processes holding the pipes. + tree.kill(); }, timeoutMs); } // Stream stdout and stderr. @@ -185,7 +186,7 @@ export function createLocalShellOperations(shellName: string, resolveShellConfig } return { exitCode: resolvedExitCode }; } finally { - if (child.pid) untrackDetachedChildPid(child.pid); + untrackProcessTree(tree); if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c65c40c2a..5f1b468b4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -131,7 +131,7 @@ import { parseGitUrl } from "../../utils/git.ts"; import { ensurePngTranscoder } from "../../utils/image-convert.ts"; import { getCwdRelativePath, shortenHomePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; -import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { killTrackedProcessTrees } from "../../utils/shell.ts"; import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; import { stripBom } from "../../utils/text.ts"; import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; @@ -4474,7 +4474,7 @@ export class InteractiveMode { private emergencyTerminalExit(): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); - killTrackedDetachedChildren(); + killTrackedProcessTrees(); // The terminal is gone. Do not run normal shutdown because TUI and // extension cleanup can write restore sequences and re-trigger EIO. process.exit(129); @@ -4504,7 +4504,7 @@ export class InteractiveMode { this.unregisterSignalHandlers(); } catch {} try { - killTrackedDetachedChildren(); + killTrackedProcessTrees(); } catch {} try { this.ui.stop(); @@ -4541,7 +4541,7 @@ export class InteractiveMode { // first, then attempts terminal restore. A genuinely dead terminal // surfaces as an EIO on the restore writes, which the stdout/stderr // error handler converts into emergencyTerminalExit (see #4144, #5080). - killTrackedDetachedChildren(); + killTrackedProcessTrees(); void this.shutdown({ fromSignal: true }); }; process.prependListener(signal, handler); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 84c0f9b7b..bd3be8a00 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,7 +9,7 @@ import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai"; import type { AgentSessionRuntime } from "../core/agent-session-runtime.ts"; import { flushRawStdout, waitForRawStdoutBackpressure, writeRawStdout } from "../core/output-guard.ts"; -import { killTrackedDetachedChildren } from "../utils/shell.ts"; +import { killTrackedProcessTrees } from "../utils/shell.ts"; import { toJsonEvent } from "./json-event.ts"; /** @@ -61,7 +61,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr const exitCode = signal === "SIGHUP" ? 129 : 143; const handler = () => { signalCount++; - killTrackedDetachedChildren(); + killTrackedProcessTrees(); if (signalCount > 1) process.exit(exitCode); void disposeRuntime().finally(() => { process.exit(exitCode); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index afd8267a1..a5a1fcea7 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -25,7 +25,7 @@ import { waitForRawStdoutBackpressure, writeRawStdout, } from "../../core/output-guard.ts"; -import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { killTrackedProcessTrees } from "../../utils/shell.ts"; import { type Theme, theme } from "../interactive/theme/theme.ts"; import { toJsonEvent } from "../json-event.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; @@ -382,7 +382,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { signalCount++; - killTrackedDetachedChildren(); + killTrackedProcessTrees(); if (!shuttingDown) { void shutdown(exitCode, signal); } else if (signalCount > 1) { diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 9289d27e4..3b52ded86 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,7 +1,8 @@ +import { randomUUID } from "node:crypto"; import { accessSync, constants } from "node:fs"; import { delimiter, join, win32 } from "node:path"; -import { spawn, spawnSync } from "child_process"; -import { getBinDir } from "../config.ts"; +import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from "child_process"; +import { getBinDir, getWindowsJobHelperPath } from "../config.ts"; export interface ShellConfig { shell: string; @@ -193,25 +194,76 @@ export function sanitizeBinaryOutput(str: string): string { return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, ""); } +/** A spawned command whose processes, including ones it started in the background, can be killed as one unit. */ +export interface ProcessTree { + readonly child: ChildProcess; + /** Kill the command and every process it started; also works after the command itself exited. */ + kill(): void; +} + /** - * Detached child processes must be tracked so they can be killed on parent - * shutdown signals (SIGHUP/SIGTERM). + * Spawn a command as a process tree: on Unix in a new process group, on Windows in a named job object. + * + * Windows has no process groups, and `taskkill /T` finds descendants through their parent pid. Once the shell + * exits, a background process it started has no living parent and cannot be found: in + * `(while true; do echo tick; sleep 1; done) & exit 1`, killing the shell's pid leaves the loop writing to + * pi's pipes (#11613). pi-job.exe starts the command in a job object, which every descendant joins and cannot + * leave, and `kill()` terminates the job by name. Without the helper (unexpected layouts), the taskkill path + * remains. A missing command also takes it, so it fails with Node's spawn error (ENOENT) instead of an exit code. */ -const trackedDetachedChildPids = new Set(); - -export function trackDetachedChildPid(pid: number): void { - trackedDetachedChildPids.add(pid); -} - -export function untrackDetachedChildPid(pid: number): void { - trackedDetachedChildPids.delete(pid); -} - -export function killTrackedDetachedChildren(): void { - for (const pid of trackedDetachedChildPids) { - killProcessTree(pid); +export function spawnProcessTree(command: string, args: string[], options: SpawnOptions): ProcessTree { + const helper = process.platform === "win32" ? getWindowsJobHelperPath() : undefined; + if (helper && shellPathExists(helper) && shellPathExists(command)) { + const jobName = `pi-${process.pid}-${randomUUID()}`; + const child = spawn(helper, ["run", jobName, command, ...args], options); + return { + child, + kill: () => { + // Killing pi-job covers the moment before it created the job (the command never starts) and closes + // the job's last handle while it runs; `terminate` reaches processes left after the command exited. + child.kill(); + spawnCleanup(helper, ["terminate", jobName]); + }, + }; + } + const child = spawn(command, args, { ...options, detached: process.platform !== "win32" }); + return { + child, + kill: () => { + if (child.pid) killProcessTree(child.pid); + }, + }; +} + +/** + * Process trees of running commands, killed on parent shutdown signals (SIGHUP/SIGTERM). + */ +const trackedProcessTrees = new Set(); + +export function trackProcessTree(tree: ProcessTree): void { + trackedProcessTrees.add(tree); +} + +export function untrackProcessTree(tree: ProcessTree): void { + trackedProcessTrees.delete(tree); +} + +export function killTrackedProcessTrees(): void { + for (const tree of trackedProcessTrees) { + tree.kill(); + } + trackedProcessTrees.clear(); +} + +/** Start a cleanup command that outlives pi (detached) and whose failure is ignored. */ +function spawnCleanup(command: string, args: string[]): void { + try { + const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true }); + // A failed spawn emits "error" asynchronously; consume it to avoid crashing Node. + child.once("error", () => {}); + } catch { + // Ignore errors if the cleanup command cannot start. } - trackedDetachedChildPids.clear(); } /** @@ -220,21 +272,12 @@ export function killTrackedDetachedChildren(): void { export function killProcessTree(pid: number): void { if (process.platform === "win32") { // Use the trusted System32 executable so cleanup does not depend on PATH. - try { - const child = spawn( - join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), - ["/F", "/T", "/PID", String(pid)], - { - stdio: "ignore", - detached: true, - windowsHide: true, - }, - ); - // A failed spawn emits "error" asynchronously; consume it to avoid crashing Node. - child.once("error", () => {}); - } catch { - // Ignore errors if taskkill fails. - } + spawnCleanup(join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), [ + "/F", + "/T", + "/PID", + String(pid), + ]); } else { if (process.platform === "darwin") { // macOS signals group members individually after releasing the group lock. A killed diff --git a/packages/coding-agent/test/shell-process-tree.test.ts b/packages/coding-agent/test/shell-process-tree.test.ts new file mode 100644 index 000000000..a81f3a416 --- /dev/null +++ b/packages/coding-agent/test/shell-process-tree.test.ts @@ -0,0 +1,99 @@ +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { spawnProcessTree } from "../src/utils/shell.ts"; + +// A command that starts a background node process, prints its pid, and then exits or keeps running. On Unix the +// background process must not be detached (setsid would move it out of the process group). On Windows it must be: +// libuv puts non-detached children into a job of the command's own that kills them when the command exits, which +// shells such as Git Bash do not do. Detached Windows processes still stay in pi's job object. +function backgroundScript(then: "exit 3" | "stay"): string { + return [ + 'const { spawn } = require("node:child_process");', + 'const options = { stdio: "ignore", detached: process.platform === "win32" };', + 'const background = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], options);', + "background.unref();", + 'process.stdout.write(background.pid + "\\n");', + then === "exit 3" ? "process.exit(3);" : "setInterval(() => {}, 1000);", + ].join("\n"); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntil(condition: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return condition(); +} + +function readFirstLine(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + const newline = output.indexOf("\n"); + if (newline >= 0) resolve(output.slice(0, newline).trim()); + }); + child.once("error", reject); + }); +} + +function waitForExit(child: ChildProcess): Promise { + return new Promise((resolve) => child.once("exit", (code) => resolve(code))); +} + +describe("spawnProcessTree", () => { + it("passes arguments through unchanged and reports the command's exit code", async () => { + const args = ["a b", 'q"uote', "trailing\\", "", "%PATH%"]; + const tree = spawnProcessTree( + process.execPath, + ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1))); process.exit(7)", ...args], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + let output = ""; + tree.child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + expect(await waitForExit(tree.child)).toBe(7); + expect(JSON.parse(output)).toEqual(args); + }); + + // #11613: Windows could not kill background processes once the shell had exited. + it("leaves background processes running after the command exits and kills them later", async () => { + const tree = spawnProcessTree(process.execPath, ["-e", backgroundScript("exit 3")], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + const exit = waitForExit(tree.child); + const backgroundPid = Number(await readFirstLine(tree.child)); + expect(await exit).toBe(3); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(isAlive(backgroundPid)).toBe(true); + + tree.kill(); + expect(await waitUntil(() => !isAlive(backgroundPid))).toBe(true); + }); + + it("kills the command and its background processes while the command runs", async () => { + const tree = spawnProcessTree(process.execPath, ["-e", backgroundScript("stay")], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + const exit = waitForExit(tree.child); + const backgroundPid = Number(await readFirstLine(tree.child)); + expect(isAlive(backgroundPid)).toBe(true); + + tree.kill(); + await exit; + expect(await waitUntil(() => !isAlive(backgroundPid))).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts b/packages/coding-agent/test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts index 8ea5596df..e19cf4478 100644 --- a/packages/coding-agent/test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts +++ b/packages/coding-agent/test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts @@ -10,10 +10,10 @@ import { createBashTool } from "../../../src/core/tools/bash.ts"; * When the timeout then hit, the tool reported only "Command timed out", so the shell's real exit code * and the fact that it had already finished were lost. The tool now reports both. * - * Unix only: on Windows the deadline cannot kill background processes once the shell pid is gone, so the - * tool keeps waiting until their output pauses; this test would hang there. + * On Windows the deadline used to miss the background processes once the shell pid was gone, so the tool kept + * waiting until their output paused. The shell now runs in a job object that the deadline terminates. */ -describe.skipIf(process.platform === "win32")("issue #11613 bash timeout after the shell exited", () => { +describe("issue #11613 bash timeout after the shell exited", () => { const bashTool = createBashTool(tmpdir()); async function runExpectingTimeout(command: string, timeout: number): Promise { diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 1f9eb96e0..02c882b4e 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -157,6 +157,10 @@ for platform in "${PLATFORMS[@]}"; do native_path="native/${native_platform%-*}/prebuilds" mkdir -p "$OUTPUT_DIR/$platform/$native_path" cp -R "../tui/$native_path/$native_platform" "$OUTPUT_DIR/$platform/$native_path/" + if [[ "$platform" == windows-* ]]; then + # pi-job.exe runs shell commands in a job object so they can be killed as a tree. + cp "$native_path/$native_platform/pi-job.exe" "$OUTPUT_DIR/$platform/$native_path/$native_platform/" + fi done # Create archives diff --git a/scripts/create-source-archive.sh b/scripts/create-source-archive.sh index 0330d2a27..9c786db78 100755 --- a/scripts/create-source-archive.sh +++ b/scripts/create-source-archive.sh @@ -132,6 +132,9 @@ required_paths=( "packages/tui/native/win32/prebuilds/win32-arm64/win32-platform.node" "packages/tui/native/win32/prebuilds/win32-x64/win32-platform.node" "packages/coding-agent/package.json" + "packages/coding-agent/native/win32/src/pi-job.c" + "packages/coding-agent/native/win32/prebuilds/win32-arm64/pi-job.exe" + "packages/coding-agent/native/win32/prebuilds/win32-x64/pi-job.exe" "packages/coding-agent/src/utils/image-resize-worker.ts" "packages/coding-agent/src/core/export-html/template.css" )