Finish async tool cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user