fix(coding-agent): use async operations in tools

This commit is contained in:
Mario Zechner
2026-05-23 09:48:51 +02:00
parent 9b62f1f87c
commit e9146a5ff7
17 changed files with 706 additions and 398 deletions

View File

@@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process
if (mimeType) {
// Handle image file
const content = await readFile(absolutePath);
const base64Content = content.toString("base64");
let attachment: ImageContent;
let dimensionNote: string | undefined;
if (autoResizeImages) {
const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
const resized = await resizeImage(content, mimeType);
if (!resized) {
text += `<file name="${absolutePath}">[Image omitted: could not be resized below the inline image size limit.]</file>\n`;
continue;
@@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
attachment = {
type: "image",
mimeType,
data: base64Content,
data: content.toString("base64"),
};
}

View File

@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { constants } from "node:fs";
import { access as fsAccess } from "node:fs/promises";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
@@ -66,62 +67,70 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
return {
exec: (command, cwd, { onData, signal, timeout, env }) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig(options?.shellPath);
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
// Set timeout if provided.
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) killProcessTree(child.pid);
}, timeout * 1000);
}
// Stream stdout and stderr.
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
// Handle abort signal by killing the entire process tree.
const onAbort = () => {
if (child.pid) killProcessTree(child.pid);
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
waitForChildProcess(child)
.then((code) => {
if (child.pid) untrackDetachedChildPid(child.pid);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
if (timedOut) {
reject(new Error(`timeout:${timeout}`));
return;
}
resolve({ exitCode: code });
})
.catch((err) => {
if (child.pid) untrackDetachedChildPid(child.pid);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
reject(err);
void (async () => {
const { shell, args } = getShellConfig(options?.shellPath);
try {
await fsAccess(cwd, constants.F_OK);
} catch {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
// Set timeout if provided.
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) killProcessTree(child.pid);
}, timeout * 1000);
}
// Stream stdout and stderr.
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
// Handle abort signal by killing the entire process tree.
const onAbort = () => {
if (child.pid) killProcessTree(child.pid);
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
waitForChildProcess(child)
.then((code) => {
if (child.pid) untrackDetachedChildPid(child.pid);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
if (timedOut) {
reject(new Error(`timeout:${timeout}`));
return;
}
resolve({ exitCode: code });
})
.catch((err) => {
if (child.pid) untrackDetachedChildPid(child.pid);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
reject(err);
});
})().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err))));
});
},
};

View File

@@ -313,113 +313,63 @@ export function createEditToolDefinition(
const { path, edits } = validateEditInput(input);
const absolutePath = resolveToCwd(path, cwd);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{
content: Array<{ type: "text"; text: string }>;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted.
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
return withFileMutationQueue(absolutePath, async () => {
let aborted = signal?.aborted ?? false;
const onAbort = () => {
aborted = true;
};
const throwIfAborted = (): void => {
if (aborted || signal?.aborted) {
throw new Error("Operation aborted");
}
};
let aborted = false;
signal?.addEventListener("abort", onAbort, { once: true });
try {
throwIfAborted();
// Set up abort handler.
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
// Check if file exists.
try {
await ops.access(absolutePath);
} catch (error: unknown) {
throwIfAborted();
const errorMessage =
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
}
throwIfAborted();
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Read the file.
const buffer = await ops.readFile(absolutePath);
throwIfAborted();
// Perform the edit operation.
void (async () => {
try {
// Check if file exists.
try {
await ops.access(absolutePath);
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`));
return;
}
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const rawContent = buffer.toString("utf-8");
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
throwIfAborted();
// Check if aborted before reading.
if (aborted) {
return;
}
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
throwIfAborted();
// Read the file.
const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");
// Check if aborted after reading.
if (aborted) {
return;
}
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(
normalizedContent,
edits,
path,
);
// Check if aborted before writing.
if (aborted) {
return;
}
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
// Check if aborted after writing.
if (aborted) {
return;
}
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
resolve({
content: [
{
type: "text",
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: unknown) {
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error instanceof Error ? error : new Error(String(error)));
}
}
})();
}),
);
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
return {
content: [
{
type: "text",
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
};
} finally {
signal?.removeEventListener("abort", onAbort);
}
});
},
renderCall(args, theme, context) {
const component = getEditCallRenderComponent(context.state, context.lastComponent);

View File

@@ -1,14 +1,27 @@
import { realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { resolve } from "node:path";
const fileMutationQueues = new Map<string, Promise<void>>();
let registrationQueue = Promise.resolve();
function getMutationQueueKey(filePath: string): string {
function isMissingPathError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}
async function getMutationQueueKey(filePath: string): Promise<string> {
const resolvedPath = resolve(filePath);
try {
return realpathSync.native(resolvedPath);
} catch {
return resolvedPath;
return await realpath(resolvedPath);
} catch (error) {
if (isMissingPathError(error)) {
return resolvedPath;
}
throw error;
}
}
@@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string {
* Operations for different files still run in parallel.
*/
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const key = getMutationQueueKey(filePath);
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
const registration = registrationQueue.then(async () => {
const key = await getMutationQueueKey(filePath);
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const nextQueue = new Promise<void>((resolveQueue) => {
releaseNext = resolveQueue;
let releaseNext!: () => void;
const nextQueue = new Promise<void>((resolveQueue) => {
releaseNext = resolveQueue;
});
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
return { key, currentQueue, chainedQueue, releaseNext };
});
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
registrationQueue = registration.then(
() => undefined,
() => undefined,
);
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
await currentQueue;
try {
return await fn();

View File

@@ -1,8 +1,9 @@
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";
import { spawn } from "child_process";
import { existsSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
@@ -46,7 +47,14 @@ export interface FindOperations {
}
const defaultFindOperations: FindOperations = {
exists: existsSync,
exists: async (absolutePath) => {
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.
glob: () => [],
};

View File

@@ -1,8 +1,8 @@
import { readFile as fsReadFile, stat as fsStat } 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";
import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
@@ -55,8 +55,8 @@ export interface GrepOperations {
}
const defaultGrepOperations: GrepOperations = {
isDirectory: (p) => statSync(p).isDirectory(),
readFile: (p) => readFileSync(p, "utf-8"),
isDirectory: async (p) => (await fsStat(p)).isDirectory(),
readFile: (p) => fsReadFile(p, "utf-8"),
};
export interface GrepToolOptions {

View File

@@ -1,6 +1,7 @@
import { constants } from "node:fs";
import { access as fsAccess, 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 { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
@@ -38,9 +39,16 @@ export interface LsOperations {
}
const defaultLsOperations: LsOperations = {
exists: existsSync,
stat: statSync,
readdir: readdirSync,
exists: async (absolutePath) => {
try {
await fsAccess(absolutePath, constants.F_OK);
return true;
} catch {
return false;
}
},
stat: fsStat,
readdir: fsReaddir,
};
export interface LsToolOptions {

View File

@@ -1,4 +1,5 @@
import { accessSync, constants } from "node:fs";
import { access } from "node:fs/promises";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
const NARROW_NO_BREAK_SPACE = "\u202F";
@@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean {
}
}
async function fileExistsAsync(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
export function expandPath(filePath: string): string {
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
@@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string {
return resolved;
}
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
const resolved = resolveToCwd(filePath, cwd);
if (await fileExistsAsync(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))) {
return amPmVariant;
}
// Try NFD variant (macOS stores filenames in NFD form)
const nfdVariant = tryNFDVariant(resolved);
if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) {
return nfdVariant;
}
// Try curly quote variant (macOS uses U+2019 in screenshot names)
const curlyVariant = tryCurlyQuoteVariant(resolved);
if (curlyVariant !== resolved && (await fileExistsAsync(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))) {
return nfdCurlyVariant;
}
return resolved;
}

View File

@@ -12,7 +12,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveReadPath } from "./path-utils.ts";
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@@ -124,7 +124,7 @@ function getCompactReadClassification(
const rawPath = str(args?.file_path ?? args?.path);
if (!rawPath) return undefined;
const absolutePath = resolveReadPath(rawPath, cwd);
const absolutePath = resolveToCwd(rawPath, cwd);
const fileName = basename(absolutePath);
if (fileName === "SKILL.md") {
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
@@ -223,7 +223,6 @@ export function createReadToolDefinition(
_onUpdate?,
ctx?,
) {
const absolutePath = resolveReadPath(path, cwd);
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
if (signal?.aborted) {
@@ -239,6 +238,8 @@ export function createReadToolDefinition(
(async () => {
try {
const absolutePath = await resolveReadPathAsync(path, cwd);
if (aborted) return;
// Check if file exists and is readable.
await ops.access(absolutePath);
if (aborted) return;
@@ -249,10 +250,9 @@ export function createReadToolDefinition(
if (mimeType) {
// Read image as binary.
const buffer = await ops.readFile(absolutePath);
const base64 = buffer.toString("base64");
if (autoResizeImages) {
// Resize image if needed before sending it back to the model.
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const resized = await resizeImage(buffer, mimeType);
if (!resized) {
let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`;
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
@@ -272,7 +272,7 @@ export function createReadToolDefinition(
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
content = [
{ type: "text", text: textNote },
{ type: "image", data: base64, mimeType },
{ type: "image", data: buffer.toString("base64"), mimeType },
];
}
} else {

View File

@@ -200,44 +200,36 @@ export function createWriteToolDefinition(
) {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
try {
// Create parent directories if needed.
await ops.mkdir(dir);
if (aborted) return;
// Write the file contents.
await ops.writeFile(absolutePath, content);
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({
content: [
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
],
details: undefined,
});
} catch (error: any) {
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
),
);
return withFileMutationQueue(absolutePath, async () => {
let aborted = signal?.aborted ?? false;
const onAbort = () => {
aborted = true;
};
const throwIfAborted = (): void => {
if (aborted || 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();
// 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);
}
});
},
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;

View File

@@ -0,0 +1,164 @@
import { applyExifOrientation } from "./exif-orientation.ts";
import { loadPhoton } from "./photon.ts";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
maxHeight?: number; // Default: 2000
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
jpegQuality?: number; // Default: 80
}
export interface ResizedImage {
data: string; // base64
mimeType: string;
originalWidth: number;
originalHeight: number;
width: number;
height: number;
wasResized: boolean;
}
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
};
interface EncodedCandidate {
data: string;
encodedSize: number;
mimeType: string;
}
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
const data = Buffer.from(buffer).toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
mimeType,
};
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try both PNG and JPEG formats, pick the smaller one
* 3. If still too large, try JPEG with decreasing quality
* 4. If still too large, progressively reduce dimensions until 1x1
*/
export async function resizeImageInProcess(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
try {
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage) rawImage.free();
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const format = mimeType.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
return {
data: Buffer.from(inputBytes).toString("base64"),
mimeType: mimeType || `image/${format}`,
originalWidth,
originalHeight,
width: originalWidth,
height: originalHeight,
wasResized: false,
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
}
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
try {
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
} finally {
resized.free();
}
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
} catch {
return null;
} finally {
if (image) {
image.free();
}
}
}

View File

@@ -0,0 +1,42 @@
import { parentPort } from "node:worker_threads";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
interface ResizeImageWorkerRequest {
inputBytes: Uint8Array;
mimeType: string;
options?: ImageResizeOptions;
}
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
}
const port = parentPort;
if (!port) {
throw new Error("image resize worker requires parentPort");
}
port.once("message", (message: unknown) => {
void (async () => {
try {
if (!isResizeImageWorkerRequest(message)) {
throw new Error("Invalid image resize worker request");
}
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
const response: ResizeImageWorkerResponse = { result };
port.postMessage(response);
} catch (error) {
const response: ResizeImageWorkerResponse = {
error: error instanceof Error ? error.message : String(error),
};
port.postMessage(response);
}
})();
});

View File

@@ -1,165 +1,96 @@
import type { ImageContent } from "@earendil-works/pi-ai";
import { applyExifOrientation } from "./exif-orientation.ts";
import { loadPhoton } from "./photon.ts";
import { Worker } from "node:worker_threads";
import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
maxHeight?: number; // Default: 2000
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
jpegQuality?: number; // Default: 80
export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
export interface ResizedImage {
data: string; // base64
mimeType: string;
originalWidth: number;
originalHeight: number;
width: number;
height: number;
wasResized: boolean;
function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
// Transfer detaches the buffer, so transfer a worker-owned copy and leave the
// caller's bytes intact.
return new Uint8Array(input);
}
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
};
interface EncodedCandidate {
data: string;
encodedSize: number;
mimeType: string;
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
return value !== null && typeof value === "object";
}
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
const data = Buffer.from(buffer).toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
mimeType,
};
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);
}
async function resizeImageInWorker(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const worker = createResizeWorker();
try {
const inputBytesForWorker = toTransferableBytes(inputBytes);
return await new Promise<ResizedImage | null>((resolve, reject) => {
let settled = false;
const settle = (result: ResizedImage | null): void => {
if (settled) return;
settled = true;
resolve(result);
};
const fail = (error: Error): void => {
if (settled) return;
settled = true;
reject(error);
};
worker.once("message", (message: unknown) => {
if (!isResizeImageWorkerResponse(message)) {
fail(new Error("Invalid image resize worker response"));
return;
}
if (message.error) {
fail(new Error(message.error));
return;
}
settle(message.result ?? null);
});
worker.once("error", fail);
worker.once("exit", (code) => {
if (!settled) {
fail(new Error(`Image resize worker exited with code ${code}`));
}
});
worker.postMessage(
{
inputBytes: inputBytesForWorker,
mimeType,
options,
},
[inputBytesForWorker.buffer],
);
});
} finally {
void worker.terminate().catch(() => undefined);
}
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try both PNG and JPEG formats, pick the smaller one
* 3. If still too large, try JPEG with decreasing quality
* 4. If still too large, progressively reduce dimensions until 1x1
* 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.
*/
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBuffer = Buffer.from(img.data, "base64");
const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
try {
const inputBytes = new Uint8Array(inputBuffer);
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage) rawImage.free();
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const format = img.mimeType?.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
return {
data: img.data,
mimeType: img.mimeType ?? `image/${format}`,
originalWidth,
originalHeight,
width: originalWidth,
height: originalHeight,
wasResized: false,
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
}
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
try {
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
} finally {
resized.free();
}
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
} catch {
return null;
} finally {
if (image) {
image.free();
}
}
export async function resizeImage(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
return resizeImageInWorker(inputBytes, mimeType, options);
}
/**