diff --git a/packages/coding-agent/src/tools/edit.ts b/packages/coding-agent/src/tools/edit.ts index af2e3781..bf42ed41 100644 --- a/packages/coding-agent/src/tools/edit.ts +++ b/packages/coding-agent/src/tools/edit.ts @@ -1,8 +1,9 @@ import * as os from "node:os"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { existsSync, readFileSync, writeFileSync } from "fs"; -import { resolve } from "path"; +import { constants } from "fs"; +import { access, readFile, writeFile } from "fs/promises"; +import { resolve as resolvePath } from "path"; /** * Expand ~ to home directory @@ -34,42 +35,116 @@ export const editTool: AgentTool = { { path, oldText, newText }: { path: string; oldText: string; newText: string }, signal?: AbortSignal, ) => { - // Check if already aborted - if (signal?.aborted) { - throw new Error("Operation aborted"); - } + const absolutePath = resolvePath(expandPath(path)); - const absolutePath = resolve(expandPath(path)); + return new Promise<{ output: string; details: undefined }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } - if (!existsSync(absolutePath)) { - throw new Error(`File not found: ${path}`); - } + let aborted = false; - const content = readFileSync(absolutePath, "utf-8"); + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; - // Check if old text exists - if (!content.includes(oldText)) { - throw new Error( - `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, - ); - } + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } - // Count occurrences - const occurrences = content.split(oldText).length - 1; + // Perform the edit operation + (async () => { + try { + // Check if file exists + try { + await access(absolutePath, constants.R_OK | constants.W_OK); + } catch { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject(new Error(`File not found: ${path}`)); + return; + } - if (occurrences > 1) { - throw new Error( - `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, - ); - } + // Check if aborted before reading + if (aborted) { + return; + } - // Perform replacement - const newContent = content.replace(oldText, newText); - writeFileSync(absolutePath, newContent, "utf-8"); + // Read the file + const content = await readFile(absolutePath, "utf-8"); - return { - output: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`, - details: undefined, - }; + // Check if aborted after reading + if (aborted) { + return; + } + + // Check if old text exists + if (!content.includes(oldText)) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject( + new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ), + ); + return; + } + + // Count occurrences + const occurrences = content.split(oldText).length - 1; + + if (occurrences > 1) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject( + new Error( + `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ), + ); + return; + } + + // Check if aborted before writing + if (aborted) { + return; + } + + // Perform replacement + const newContent = content.replace(oldText, newText); + await writeFile(absolutePath, newContent, "utf-8"); + + // Check if aborted after writing + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ + output: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`, + details: undefined, + }); + } catch (error: any) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); }, }; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index bb1c0a47..2c60b61f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1,8 +1,9 @@ import * as os from "node:os"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { existsSync, readFileSync } from "fs"; -import { resolve } from "path"; +import { constants } from "fs"; +import { access, readFile } from "fs/promises"; +import { resolve as resolvePath } from "path"; /** * Expand ~ to home directory @@ -27,18 +28,71 @@ export const readTool: AgentTool = { description: "Read the contents of a file. Returns the full file content as text.", parameters: readSchema, execute: async (_toolCallId: string, { path }: { path: string }, signal?: AbortSignal) => { - // Check if already aborted - if (signal?.aborted) { - throw new Error("Operation aborted"); - } + const absolutePath = resolvePath(expandPath(path)); - const absolutePath = resolve(expandPath(path)); + return new Promise<{ output: string; details: undefined }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } - if (!existsSync(absolutePath)) { - throw new Error(`File not found: ${path}`); - } + let aborted = false; - const content = readFileSync(absolutePath, "utf-8"); - return { output: content, details: undefined }; + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Perform the read operation + (async () => { + try { + // Check if file exists + try { + await access(absolutePath, constants.R_OK); + } catch { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject(new Error(`File not found: ${path}`)); + return; + } + + // Check if aborted before reading + if (aborted) { + return; + } + + // Read the file + const content = await readFile(absolutePath, "utf-8"); + + // Check if aborted after reading + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ output: content, details: undefined }); + } catch (error: any) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); }, }; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 1b1c8397..37bcf5ba 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1,8 +1,8 @@ import * as os from "node:os"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { mkdirSync, writeFileSync } from "fs"; -import { dirname, resolve } from "path"; +import { mkdir, writeFile } from "fs/promises"; +import { dirname, resolve as resolvePath } from "path"; /** * Expand ~ to home directory @@ -29,18 +29,64 @@ export const writeTool: AgentTool = { "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", parameters: writeSchema, execute: async (_toolCallId: string, { path, content }: { path: string; content: string }, signal?: AbortSignal) => { - // Check if already aborted - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - const absolutePath = resolve(expandPath(path)); + const absolutePath = resolvePath(expandPath(path)); const dir = dirname(absolutePath); - // Create parent directories if needed - mkdirSync(dir, { recursive: true }); + return new Promise<{ output: string; details: undefined }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } - writeFileSync(absolutePath, content, "utf-8"); - return { output: `Successfully wrote ${content.length} bytes to ${path}`, details: undefined }; + let aborted = false; + + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Perform the write operation + (async () => { + try { + // Create parent directories if needed + await mkdir(dir, { recursive: true }); + + // Check if aborted before writing + if (aborted) { + return; + } + + // Write the file + await writeFile(absolutePath, content, "utf-8"); + + // Check if aborted after writing + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ output: `Successfully wrote ${content.length} bytes to ${path}`, details: undefined }); + } catch (error: any) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); }, };