diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json
index 0c19d3042..55c9b8a6a 100644
--- a/packages/coding-agent/package.json
+++ b/packages/coding-agent/package.json
@@ -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 --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 ./dist/utils/image-resize-worker.js --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",
diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts
index fe0e32ebf..e1da052a8 100644
--- a/packages/coding-agent/src/cli/file-processor.ts
+++ b/packages/coding-agent/src/cli/file-processor.ts
@@ -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 += `[Image omitted: could not be resized below the inline image size limit.]\n`;
continue;
@@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
attachment = {
type: "image",
mimeType,
- data: base64Content,
+ data: content.toString("base64"),
};
}
diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts
index b9e551420..bc1d3cc4f 100644
--- a/packages/coding-agent/src/core/tools/bash.ts
+++ b/packages/coding-agent/src/core/tools/bash.ts
@@ -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))));
});
},
};
diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts
index 5d915d45b..7c6367a6d 100644
--- a/packages/coding-agent/src/core/tools/edit.ts
+++ b/packages/coding-agent/src/core/tools/edit.ts
@@ -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);
diff --git a/packages/coding-agent/src/core/tools/file-mutation-queue.ts b/packages/coding-agent/src/core/tools/file-mutation-queue.ts
index 220112559..5505a7a27 100644
--- a/packages/coding-agent/src/core/tools/file-mutation-queue.ts
+++ b/packages/coding-agent/src/core/tools/file-mutation-queue.ts
@@ -1,14 +1,27 @@
-import { realpathSync } from "node:fs";
+import { realpath } from "node:fs/promises";
import { resolve } from "node:path";
const fileMutationQueues = new Map>();
+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 {
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(filePath: string, fn: () => Promise): Promise {
- 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((resolveQueue) => {
- releaseNext = resolveQueue;
+ let releaseNext!: () => void;
+ const nextQueue = new Promise((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();
diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts
index 3fd2f6832..5749b8131 100644
--- a/packages/coding-agent/src/core/tools/find.ts
+++ b/packages/coding-agent/src/core/tools/find.ts
@@ -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: () => [],
};
diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts
index 041b31481..010500df5 100644
--- a/packages/coding-agent/src/core/tools/grep.ts
+++ b/packages/coding-agent/src/core/tools/grep.ts
@@ -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 {
diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts
index f3d22c1d9..46c8cc800 100644
--- a/packages/coding-agent/src/core/tools/ls.ts
+++ b/packages/coding-agent/src/core/tools/ls.ts
@@ -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 {
diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts
index 1ed1e2bc9..065b9eaaa 100644
--- a/packages/coding-agent/src/core/tools/path-utils.ts
+++ b/packages/coding-agent/src/core/tools/path-utils.ts
@@ -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 {
+ 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 {
+ 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;
+}
diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts
index cd3b41edc..f8d12d11c 100644
--- a/packages/coding-agent/src/core/tools/read.ts
+++ b/packages/coding-agent/src/core/tools/read.ts
@@ -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 {
diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts
index 5c07848e7..ba78649b3 100644
--- a/packages/coding-agent/src/core/tools/write.ts
+++ b/packages/coding-agent/src/core/tools/write.ts
@@ -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;
diff --git a/packages/coding-agent/src/utils/image-resize-core.ts b/packages/coding-agent/src/utils/image-resize-core.ts
new file mode 100644
index 000000000..e60820c7b
--- /dev/null
+++ b/packages/coding-agent/src/utils/image-resize-core.ts
@@ -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 = {
+ 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 {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+ const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
+
+ const photon = await loadPhoton();
+ if (!photon) {
+ return null;
+ }
+
+ let image: ReturnType | 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();
+ }
+ }
+}
diff --git a/packages/coding-agent/src/utils/image-resize-worker.ts b/packages/coding-agent/src/utils/image-resize-worker.ts
new file mode 100644
index 000000000..ee881b3d9
--- /dev/null
+++ b/packages/coding-agent/src/utils/image-resize-worker.ts
@@ -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;
+ 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);
+ }
+ })();
+});
diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts
index 3eb17dae4..24a753973 100644
--- a/packages/coding-agent/src/utils/image-resize.ts
+++ b/packages/coding-agent/src/utils/image-resize.ts
@@ -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 {
+ // 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 = {
- 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 {
+ const worker = createResizeWorker();
+ try {
+ const inputBytesForWorker = toTransferableBytes(inputBytes);
+ return await new Promise((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 {
- 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 | 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 {
+ return resizeImageInWorker(inputBytes, mimeType, options);
}
/**
diff --git a/packages/coding-agent/test/file-mutation-queue.test.ts b/packages/coding-agent/test/file-mutation-queue.test.ts
index 73d20c6d4..8e13a45e6 100644
--- a/packages/coding-agent/test/file-mutation-queue.test.ts
+++ b/packages/coding-agent/test/file-mutation-queue.test.ts
@@ -10,6 +10,18 @@ function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+function createDeferred(): { promise: Promise; resolve: () => void } {
+ let resolve!: () => void;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+}
+
+async function resolvesWithin(promise: Promise, ms: number): Promise {
+ return Promise.race([promise.then(() => true), delay(ms).then(() => false)]);
+}
+
const tempDirs: string[] = [];
async function createTempDir(): Promise {
@@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => {
const content = await readFile(filePath, "utf8");
expect(content).toBe("replacement\n");
});
+
+ it("keeps write queue locked while an aborted write is still in flight", async () => {
+ const dir = await createTempDir();
+ const filePath = join(dir, "abort-write.txt");
+ const firstWriteStarted = createDeferred();
+ const finishFirstWrite = createDeferred();
+ const secondWriteStarted = createDeferred();
+ let firstWriteSettled = false;
+
+ const writeTool = createWriteTool(dir, {
+ operations: {
+ mkdir: async () => {},
+ writeFile: async (path, content) => {
+ if (content === "first\n") {
+ firstWriteStarted.resolve();
+ await finishFirstWrite.promise;
+ await writeFile(path, content, "utf8");
+ firstWriteSettled = true;
+ return;
+ }
+
+ if (content === "second\n") {
+ expect(firstWriteSettled).toBe(true);
+ secondWriteStarted.resolve();
+ }
+ await writeFile(path, content, "utf8");
+ },
+ },
+ });
+
+ const controller = new AbortController();
+ const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal);
+ await firstWriteStarted.promise;
+ controller.abort();
+
+ const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" });
+ expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
+
+ finishFirstWrite.resolve();
+ await expect(firstWrite).rejects.toThrow("Operation aborted");
+ await secondWrite;
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("second\n");
+ });
+
+ it("keeps edit queue locked while an aborted edit write is still in flight", async () => {
+ const dir = await createTempDir();
+ const filePath = join(dir, "abort-edit.txt");
+ await writeFile(filePath, "alpha\nbeta\n", "utf8");
+ const firstWriteStarted = createDeferred();
+ const finishFirstWrite = createDeferred();
+ const secondWriteStarted = createDeferred();
+ let firstWriteSettled = false;
+
+ const editTool = createEditTool(dir, {
+ operations: {
+ access,
+ readFile,
+ writeFile: async (path, content) => {
+ if (content === "ALPHA\nbeta\n") {
+ firstWriteStarted.resolve();
+ await finishFirstWrite.promise;
+ await writeFile(path, content, "utf8");
+ firstWriteSettled = true;
+ return;
+ }
+
+ if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
+ expect(firstWriteSettled).toBe(true);
+ secondWriteStarted.resolve();
+ }
+ await writeFile(path, content, "utf8");
+ },
+ },
+ });
+
+ const controller = new AbortController();
+ const firstEdit = editTool.execute(
+ "call-1",
+ { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
+ controller.signal,
+ );
+ await firstWriteStarted.promise;
+ controller.abort();
+
+ const secondEdit = editTool.execute("call-2", {
+ path: filePath,
+ edits: [{ oldText: "beta", newText: "BETA" }],
+ });
+ expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
+
+ finishFirstWrite.resolve();
+ await expect(firstEdit).rejects.toThrow("Operation aborted");
+ await secondEdit;
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("ALPHA\nBETA\n");
+ });
});
diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts
index 40f264a83..13d39dc6b 100644
--- a/packages/coding-agent/test/image-processing.test.ts
+++ b/packages/coding-agent/test/image-processing.test.ts
@@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 =
const LARGE_PNG_200x200 =
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII=";
+function imageBytes(base64Data: string): Uint8Array {
+ return Buffer.from(base64Data, "base64");
+}
+
describe("convertToPng", () => {
it("should return original data for PNG input", async () => {
const result = await convertToPng(TINY_PNG, "image/png");
@@ -46,11 +50,28 @@ describe("convertToPng", () => {
});
describe("resizeImage", () => {
+ it("should keep caller input bytes intact", async () => {
+ const input = new Uint8Array(imageBytes(TINY_PNG));
+ const originalByteLength = input.byteLength;
+ const originalFirstByte = input[0];
+
+ const result = await resizeImage(input, "image/png", {
+ maxWidth: 100,
+ maxHeight: 100,
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(result).not.toBeNull();
+ expect(input.byteLength).toBe(originalByteLength);
+ expect(input[0]).toBe(originalFirstByte);
+ });
+
it("should return original image if within limits", async () => {
- const result = await resizeImage(
- { type: "image", data: TINY_PNG, mimeType: "image/png" },
- { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
- );
+ const result = await resizeImage(imageBytes(TINY_PNG), "image/png", {
+ maxWidth: 100,
+ maxHeight: 100,
+ maxBytes: 1024 * 1024,
+ });
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(false);
@@ -62,10 +83,11 @@ describe("resizeImage", () => {
});
it("should resize image exceeding dimension limits", async () => {
- const result = await resizeImage(
- { type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" },
- { maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 },
- );
+ const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", {
+ maxWidth: 50,
+ maxHeight: 50,
+ maxBytes: 1024 * 1024,
+ });
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(true);
@@ -80,10 +102,11 @@ describe("resizeImage", () => {
const originalSize = originalBuffer.length;
// Set maxBytes to less than the original encoded image size
- const result = await resizeImage(
- { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
- { maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) },
- );
+ const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", {
+ maxWidth: 2000,
+ maxHeight: 2000,
+ maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9),
+ });
// Should have tried to reduce size
expect(result).not.toBeNull();
@@ -93,19 +116,21 @@ describe("resizeImage", () => {
});
it("should return null when image cannot be resized below maxBytes", async () => {
- const result = await resizeImage(
- { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
- { maxWidth: 2000, maxHeight: 2000, maxBytes: 1 },
- );
+ const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", {
+ maxWidth: 2000,
+ maxHeight: 2000,
+ maxBytes: 1,
+ });
expect(result).toBeNull();
});
it("should handle JPEG input", async () => {
- const result = await resizeImage(
- { type: "image", data: TINY_JPEG, mimeType: "image/jpeg" },
- { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
- );
+ const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", {
+ maxWidth: 100,
+ maxHeight: 100,
+ maxBytes: 1024 * 1024,
+ });
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(false);
diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh
index c1b7b8687..dfc347933 100755
--- a/scripts/build-binaries.sh
+++ b/scripts/build-binaries.sh
@@ -105,10 +105,13 @@ fi
for platform in "${PLATFORMS[@]}"; do
echo "Building for $platform..."
+ # Bun compiled executables only embed worker scripts when they are passed as
+ # 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 --outfile binaries/$platform/pi.exe
+ bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi.exe
else
- bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi
+ bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi
fi
done