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 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 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 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
|
## [0.75.4] - 2026-05-20
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "shx rm -rf dist",
|
"clean": "shx rm -rf dist",
|
||||||
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
|
"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-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/",
|
"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",
|
"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 { createInterface } from "node:readline";
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { Text } from "@earendil-works/pi-tui";
|
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 { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||||
import { ensureTool } from "../../utils/tools-manager.ts";
|
import { ensureTool } from "../../utils/tools-manager.ts";
|
||||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.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 { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
|
||||||
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
||||||
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
||||||
@@ -47,14 +45,7 @@ export interface FindOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultFindOperations: FindOperations = {
|
const defaultFindOperations: FindOperations = {
|
||||||
exists: async (absolutePath) => {
|
exists: pathExists,
|
||||||
try {
|
|
||||||
await fsAccess(absolutePath, constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
|
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
|
||||||
glob: () => [],
|
glob: () => [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { constants } from "node:fs";
|
import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
|
||||||
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
|
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { Text } from "@earendil-works/pi-tui";
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
import nodePath from "path";
|
import nodePath from "path";
|
||||||
import { type Static, Type } from "typebox";
|
import { type Static, Type } from "typebox";
|
||||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.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 { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
|
||||||
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
||||||
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
||||||
@@ -39,14 +38,7 @@ export interface LsOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultLsOperations: LsOperations = {
|
const defaultLsOperations: LsOperations = {
|
||||||
exists: async (absolutePath) => {
|
exists: pathExists,
|
||||||
try {
|
|
||||||
await fsAccess(absolutePath, constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stat: fsStat,
|
stat: fsStat,
|
||||||
readdir: fsReaddir,
|
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 {
|
try {
|
||||||
await access(filePath, constants.F_OK);
|
await access(filePath, constants.F_OK);
|
||||||
return true;
|
return true;
|
||||||
@@ -86,31 +86,31 @@ export function resolveReadPath(filePath: string, cwd: string): string {
|
|||||||
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
|
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
|
||||||
const resolved = resolveToCwd(filePath, cwd);
|
const resolved = resolveToCwd(filePath, cwd);
|
||||||
|
|
||||||
if (await fileExistsAsync(resolved)) {
|
if (await pathExists(resolved)) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
||||||
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
||||||
if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) {
|
if (amPmVariant !== resolved && (await pathExists(amPmVariant))) {
|
||||||
return amPmVariant;
|
return amPmVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try NFD variant (macOS stores filenames in NFD form)
|
// Try NFD variant (macOS stores filenames in NFD form)
|
||||||
const nfdVariant = tryNFDVariant(resolved);
|
const nfdVariant = tryNFDVariant(resolved);
|
||||||
if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) {
|
if (nfdVariant !== resolved && (await pathExists(nfdVariant))) {
|
||||||
return nfdVariant;
|
return nfdVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
||||||
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
||||||
if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) {
|
if (curlyVariant !== resolved && (await pathExists(curlyVariant))) {
|
||||||
return curlyVariant;
|
return curlyVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
||||||
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
||||||
if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) {
|
if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) {
|
||||||
return nfdCurlyVariant;
|
return nfdCurlyVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,18 +201,14 @@ export function createWriteToolDefinition(
|
|||||||
const absolutePath = resolveToCwd(path, cwd);
|
const absolutePath = resolveToCwd(path, cwd);
|
||||||
const dir = dirname(absolutePath);
|
const dir = dirname(absolutePath);
|
||||||
return withFileMutationQueue(absolutePath, async () => {
|
return withFileMutationQueue(absolutePath, async () => {
|
||||||
let aborted = signal?.aborted ?? false;
|
// Do not reject from an abort event listener here: that would release the
|
||||||
const onAbort = () => {
|
// mutation queue while an in-flight filesystem operation may still finish.
|
||||||
aborted = true;
|
// Checking signal.aborted after each await observes the same aborts while
|
||||||
};
|
// keeping the queue locked until the current operation has settled.
|
||||||
const throwIfAborted = (): void => {
|
const throwIfAborted = (): void => {
|
||||||
if (aborted || signal?.aborted) {
|
if (signal?.aborted) throw new Error("Operation aborted");
|
||||||
throw new Error("Operation aborted");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
try {
|
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
// Create parent directories if needed.
|
// Create parent directories if needed.
|
||||||
await ops.mkdir(dir);
|
await ops.mkdir(dir);
|
||||||
@@ -226,9 +222,6 @@ export function createWriteToolDefinition(
|
|||||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
renderCall(args, theme, context) {
|
renderCall(args, theme, context) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Worker } from "node:worker_threads";
|
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";
|
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";
|
return value !== null && typeof value === "object";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createResizeWorker(): Worker {
|
function createResizeWorker(workerSpecifier: string | URL): Worker {
|
||||||
const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
|
return new Worker(workerSpecifier);
|
||||||
const workerUrl = new URL(
|
|
||||||
isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
|
|
||||||
import.meta.url,
|
|
||||||
);
|
|
||||||
return new Worker(workerUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resizeImageInWorker(
|
async function resizeImageInWorker(
|
||||||
|
workerSpecifier: string | URL,
|
||||||
inputBytes: Uint8Array,
|
inputBytes: Uint8Array,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
options?: ImageResizeOptions,
|
options?: ImageResizeOptions,
|
||||||
): Promise<ResizedImage | null> {
|
): Promise<ResizedImage | null> {
|
||||||
const worker = createResizeWorker();
|
const worker = createResizeWorker(workerSpecifier);
|
||||||
try {
|
try {
|
||||||
const inputBytesForWorker = toTransferableBytes(inputBytes);
|
const inputBytesForWorker = toTransferableBytes(inputBytes);
|
||||||
return await new Promise<ResizedImage | null>((resolve, reject) => {
|
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.
|
* 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
|
* 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
|
* block the TUI event loop. If the worker cannot be loaded (for example in some
|
||||||
* the main thread.
|
* Bun compiled executable layouts), fall back to in-process resizing so image
|
||||||
|
* reads still work.
|
||||||
*/
|
*/
|
||||||
export async function resizeImage(
|
export async function resizeImage(
|
||||||
inputBytes: Uint8Array,
|
inputBytes: Uint8Array,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
options?: ImageResizeOptions,
|
options?: ImageResizeOptions,
|
||||||
): Promise<ResizedImage | null> {
|
): 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
|
# explicit build entrypoints. The runtime can still use new URL(...), but the
|
||||||
# worker must be present in the compiled executable.
|
# worker must be present in the compiled executable.
|
||||||
if [[ "$platform" == windows-* ]]; then
|
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
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user