Finish async tool cleanup

This commit is contained in:
Mario Zechner
2026-05-23 11:36:41 +02:00
parent c4f86e3992
commit 8100046cb8
8 changed files with 59 additions and 66 deletions

View File

@@ -24,6 +24,7 @@
- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)).
- Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)).
- Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)).
- Fixed coding-agent tools to avoid synchronous filesystem operations during streaming and moved image resizing off the main TUI thread ([#4756](https://github.com/earendil-works/pi-mono/pull/4756) by [@mitsuhiko](https://github.com/mitsuhiko)).
## [0.75.4] - 2026-05-20

View File

@@ -31,7 +31,7 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile dist/pi && npm run copy-binary-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
"test": "vitest --run",

View File

@@ -1,5 +1,3 @@
import { constants } from "node:fs";
import { access as fsAccess } from "node:fs/promises";
import { createInterface } from "node:readline";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Text } from "@earendil-works/pi-tui";
@@ -9,7 +7,7 @@ import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@@ -47,14 +45,7 @@ export interface FindOperations {
}
const defaultFindOperations: FindOperations = {
exists: async (absolutePath) => {
try {
await fsAccess(absolutePath, constants.F_OK);
return true;
} catch {
return false;
}
},
exists: pathExists,
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
glob: () => [],
};

View File

@@ -1,12 +1,11 @@
import { constants } from "node:fs";
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Text } from "@earendil-works/pi-tui";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@@ -39,14 +38,7 @@ export interface LsOperations {
}
const defaultLsOperations: LsOperations = {
exists: async (absolutePath) => {
try {
await fsAccess(absolutePath, constants.F_OK);
return true;
} catch {
return false;
}
},
exists: pathExists,
stat: fsStat,
readdir: fsReaddir,
};

View File

@@ -28,7 +28,7 @@ function fileExists(filePath: string): boolean {
}
}
async function fileExistsAsync(filePath: string): Promise<boolean> {
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
@@ -86,31 +86,31 @@ export function resolveReadPath(filePath: string, cwd: string): string {
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
const resolved = resolveToCwd(filePath, cwd);
if (await fileExistsAsync(resolved)) {
if (await pathExists(resolved)) {
return resolved;
}
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
const amPmVariant = tryMacOSScreenshotPath(resolved);
if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) {
if (amPmVariant !== resolved && (await pathExists(amPmVariant))) {
return amPmVariant;
}
// Try NFD variant (macOS stores filenames in NFD form)
const nfdVariant = tryNFDVariant(resolved);
if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) {
if (nfdVariant !== resolved && (await pathExists(nfdVariant))) {
return nfdVariant;
}
// Try curly quote variant (macOS uses U+2019 in screenshot names)
const curlyVariant = tryCurlyQuoteVariant(resolved);
if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) {
if (curlyVariant !== resolved && (await pathExists(curlyVariant))) {
return curlyVariant;
}
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) {
if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) {
return nfdCurlyVariant;
}

View File

@@ -201,34 +201,27 @@ export function createWriteToolDefinition(
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
let aborted = signal?.aborted ?? false;
const onAbort = () => {
aborted = true;
};
// Do not reject from an abort event listener here: that would release the
// mutation queue while an in-flight filesystem operation may still finish.
// Checking signal.aborted after each await observes the same aborts while
// keeping the queue locked until the current operation has settled.
const throwIfAborted = (): void => {
if (aborted || signal?.aborted) {
throw new Error("Operation aborted");
}
if (signal?.aborted) throw new Error("Operation aborted");
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
throwIfAborted();
// Create parent directories if needed.
await ops.mkdir(dir);
throwIfAborted();
throwIfAborted();
// Create parent directories if needed.
await ops.mkdir(dir);
throwIfAborted();
// Write the file contents.
await ops.writeFile(absolutePath, content);
throwIfAborted();
// Write the file contents.
await ops.writeFile(absolutePath, content);
throwIfAborted();
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
} finally {
signal?.removeEventListener("abort", onAbort);
}
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
});
},
renderCall(args, theme, context) {

View File

@@ -1,5 +1,5 @@
import { Worker } from "node:worker_threads";
import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
@@ -18,21 +18,17 @@ function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorker
return value !== null && typeof value === "object";
}
function createResizeWorker(): Worker {
const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
import.meta.url,
);
return new Worker(workerUrl);
function createResizeWorker(workerSpecifier: string | URL): Worker {
return new Worker(workerSpecifier);
}
async function resizeImageInWorker(
workerSpecifier: string | URL,
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const worker = createResizeWorker();
const worker = createResizeWorker(workerSpecifier);
try {
const inputBytesForWorker = toTransferableBytes(inputBytes);
return await new Promise<ResizedImage | null>((resolve, reject) => {
@@ -82,15 +78,35 @@ async function resizeImageInWorker(
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not
* block the TUI event loop. Worker failures are propagated instead of retried on
* the main thread.
* block the TUI event loop. If the worker cannot be loaded (for example in some
* Bun compiled executable layouts), fall back to in-process resizing so image
* reads still work.
*/
export async function resizeImage(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
return resizeImageInWorker(inputBytes, mimeType, options);
const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
import.meta.url,
);
// Bun compiled executables resolve worker entrypoints by string path, not via
// new URL(..., import.meta.url). Try the string path first under Bun so the
// release binary uses the embedded worker instead of falling back in-process.
if (typeof process.versions.bun === "string") {
try {
return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options);
} catch {}
}
try {
return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options);
} catch {
return resizeImageInProcess(inputBytes, mimeType, options);
}
}
/**

View File

@@ -132,9 +132,9 @@ for platform in "${PLATFORMS[@]}"; do
# explicit build entrypoints. The runtime can still use new URL(...), but the
# worker must be present in the compiled executable.
if [[ "$platform" == windows-* ]]; then
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi.exe"
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe"
else
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi"
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi"
fi
done