import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentTool, AgentToolArgStream, AgentToolArgStreamInit, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, ToolApprovalDecision, } from "@oh-my-pi/pi-agent-core"; import type { Model, ToolExample } from "@oh-my-pi/pi-ai"; import { EditSession, editDescription, editGrammar, editInspect, type EditFileOutcome, type EditInspection, type EditPolicy, type EditPreviewBatch, type EditUrlResolution, type EditWriteRequest, type EditWriteResponse, } from "@oh-my-pi/pi-natives"; import { isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { extractUriScheme, InternalUrlRouter, type ResolveContext, sessionResolveContext } from "../internal-urls"; import { createLspWritethrough, flushLspWritethroughBatch, type WritethroughCallback, writethroughNoop } from "../lsp"; import { type FileDiagnosticsResult } from "@oh-my-pi/pi-tui/tools/lsp"; import { FileChangeType, notifyWorkspaceWatchedFiles } from "../lsp/client"; import { DeferredDiagnostics } from "../lsp/deferred-diagnostics"; import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger"; import type { ToolSession } from "../tools"; import { routeWriteThroughBridge } from "../tools/acp-bridge"; import { approvalTargetKey } from "../tools/approval-targets"; import { strictestApproval, truncateForPrompt } from "../tools/approval"; import { deleteFileWithFallback, hasFileWriteFallback, isPermissionDeniedError, writeFileWithFallback, } from "../tools/file-write-fallback"; import { outputMeta, resolveOutputMaxColumns } from "../tools/output-meta"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { DEFAULT_MAX_COLUMN } from "@oh-my-pi/pi-tui/tools/streaming-output"; import { type EditMode } from "@oh-my-pi/pi-tui/tools/edit"; import { resolveEditMode } from "../utils/edit-mode"; import { attemptEditAutoRepair, type EditAutoRepairOutcome } from "./auto-repair"; import { type AppliedEditSnapshot, createEditBlackboxRecorder } from "./blackbox"; import hashlineCompactPrompt from "./hashline-compact.md" with { type: "text" }; import { getLspBatchRequest } from "../lsp/batch"; import { type EditToolDetails, type EditToolPerFileResult, type Operation } from "@oh-my-pi/pi-tui/tools/edit"; import { type ApplyPatchParams, applyPatchSchema, type HashlineParams, hashlineEditParamsSchema, type PatchParams, patchEditSchema, type ReplaceBatchParams, type ReplaceParams, replaceEditSchema, type SloppyParams, sloppyEditSchema, } from "./schemas"; import { getEditStore } from "./store"; import { cfgEditBlockAutoGenerated, cfgEditEnforceSeenLines, cfgEditFuzzyMatch, cfgEditFuzzyThreshold, } from "./settings"; import { cfgLspDiagnosticsDeduplicate, cfgLspDiagnosticsOnEdit, cfgLspEnabled, cfgLspFormatOnWrite, cfgLspRequestTimeout, } from "../lsp/settings"; export type { EditRenderContext, EditToolDetails, EditToolPerFileResult, Operation, PerFileDiffPreview, } from "@oh-my-pi/pi-tui/tools/edit"; export * from "./schemas"; export * from "./store"; export { type EditMode } from "@oh-my-pi/pi-tui/tools/edit"; type TInput = | typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema | typeof applyPatchSchema | typeof sloppyEditSchema; type EditParams = ReplaceParams | ReplaceBatchParams | PatchParams | HashlineParams | ApplyPatchParams | SloppyParams; const PATCH_EXAMPLES = [ { caption: "Create", call: { path: "hello.txt", edits: [{ op: "create", diff: "Hello\n" }] }, }, { caption: "Update", call: { path: "src/app.py", edits: [{ op: "update", diff: "@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n" }], }, }, { caption: "Rename", call: { path: "src/app.py", edits: [{ op: "update", rename: "src/main.py", diff: "@@\n …\n" }], }, }, { caption: "Delete", call: { path: "obsolete.txt", edits: [{ op: "delete" }] }, }, { caption: "Multiple entries", note: "All entries in one call apply to the top-level `path`; use separate calls for different files.", }, ] satisfies readonly ToolExample[]; const APPLY_PATCH_EXAMPLES = [ { caption: "Apply a combined patch file", call: { input: '*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print("Hi")\n+print("Hello, world!")\n*** Delete File: obsolete.txt\n*** End Patch\n', }, }, ] satisfies readonly ToolExample[]; /** * Compact tool description markdown for `mode`, when one exists. TS-side * source (not the native addon) so PR CI — which tests against the latest * *release* addons — exercises the same rendering production does. */ export function editDescriptionCompact(mode: EditMode): string | undefined { switch (mode) { case "hashline": return hashlineCompactPrompt; default: return undefined; } } /** * Tool description for `mode`, at the density the model's catalog policy * selects (`edit-prompt-variant`). Models without the compact variant — or * modes that have none — keep the full prompt; both renderings preserve every * operation and invariant. */ export function resolveEditToolDescription( mode: EditMode, model: Pick | undefined, ): string { const source = model?.editPromptVariant === "compact" ? (editDescriptionCompact(mode) ?? editDescription(mode)) : editDescription(mode); return prompt.render(source); } /** Builds the LSP writethrough from the current `lsp.*` settings; called per write so changes apply immediately. */ function createEditWritethrough(session: ToolSession): WritethroughCallback { const enableLsp = (session.enableLsp ?? true) && cfgLspEnabled.get(session.settings); const enableDiagnostics = enableLsp && cfgLspDiagnosticsOnEdit.get(session.settings); const enableFormat = enableLsp && cfgLspFormatOnWrite.get(session.settings); const deduplicate = enableDiagnostics && cfgLspDiagnosticsDeduplicate.get(session.settings); return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics, transformDiagnostics: deduplicate ? (filePath, result) => getDiagnosticsLedger(session).reduce(filePath, result) : undefined, requestTimeoutSec: cfgLspRequestTimeout.get(session.settings), }) : writethroughNoop; } function operationFromNative(op: string): Operation | undefined { return op === "create" || op === "delete" || op === "update" ? op : undefined; } /** * Narrowest per-line cap, in UTF-8 bytes, at which any tool may have cut a line it displayed: grep's * fixed byte cap or read's `tools.outputMaxColumns` (UTF-16 units, which never exceed UTF-8 bytes). */ function displayMaxColumns(settings: ToolSession["settings"]): number { const readMaxColumns = resolveOutputMaxColumns(settings); return readMaxColumns > 0 ? Math.min(readMaxColumns, DEFAULT_MAX_COLUMN) : DEFAULT_MAX_COLUMN; } function parseDiagnostics(json: string | undefined): FileDiagnosticsResult | undefined { if (!json) return undefined; try { return JSON.parse(json) as FileDiagnosticsResult; } catch { return undefined; } } function mergeDiagnosticsWithWarnings( diagnostics: FileDiagnosticsResult | undefined, warnings: readonly string[], ): FileDiagnosticsResult | undefined { if (warnings.length === 0) return diagnostics; const warningMessages = warnings.map(warning => `patch: ${warning}`); if (!diagnostics) { return { server: "patch", messages: warningMessages, summary: `Patch warnings: ${warnings.length}`, errored: false, }; } return { ...diagnostics, messages: [...warningMessages, ...diagnostics.messages], summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`, }; } function toPerFileResult(file: EditFileOutcome, mode: EditMode): EditToolPerFileResult { let diagnostics = parseDiagnostics(file.diagnosticsJson); if (mode === "patch" || mode === "apply_patch") { diagnostics = mergeDiagnosticsWithWarnings(diagnostics, file.warnings); } const resultPath = file.moveTo ?? file.path; return { path: resultPath, diff: file.diff, firstChangedLine: file.firstChangedLine, diagnostics, op: operationFromNative(file.op), move: file.moveTo, sourcePath: file.moveTo ? file.path : undefined, oldText: file.oldText, newText: file.newText, snapshotsPruned: file.snapshotsPruned || undefined, meta: outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(), }; } function aggregateDetails(files: readonly EditFileOutcome[], mode: EditMode): EditToolDetails | undefined { if (files.length === 0) return undefined; const perFileResults = files.map(file => toPerFileResult(file, mode)); if (perFileResults.length === 1) { const [file] = perFileResults; return { diff: file.diff, firstChangedLine: file.firstChangedLine, diagnostics: file.diagnostics, op: file.op, move: file.move, sourcePath: file.sourcePath, path: file.path, oldText: file.oldText, newText: file.newText, snapshotsPruned: file.snapshotsPruned, meta: file.meta, }; } return { diff: perFileResults .map(file => file.diff) .filter(Boolean) .join("\n"), firstChangedLine: perFileResults.find(file => file.firstChangedLine !== undefined)?.firstChangedLine, perFileResults: capPerFileSnapshots(perFileResults), }; } /** * Combined `oldText` + `newText` character budget shared across a multi-file * result. The engine already prunes each file on its own; this keeps a * many-small-files batch from accumulating unbounded snapshot bytes in the * session JSONL (#3787). Early entries keep their diff visualization; later * ones degrade to text-only. */ const MAX_EDIT_SNAPSHOT_TEXT_CHARS = 32_768; function capPerFileSnapshots(entries: EditToolPerFileResult[]): EditToolPerFileResult[] { let remaining = MAX_EDIT_SNAPSHOT_TEXT_CHARS; return entries.map(entry => { const kept = (entry.oldText?.length ?? 0) + (entry.newText?.length ?? 0); if (kept === 0) return entry; if (kept <= remaining) { remaining -= kept; return entry; } const { oldText: _old, newText: _new, ...rest } = entry; return { ...rest, snapshotsPruned: true }; }); } async function mkdirAllowingFallback(directory: string): Promise { try { await fs.mkdir(directory, { recursive: true }); } catch (error) { if (!hasFileWriteFallback() || !isPermissionDeniedError(error)) throw error; } } /** * Permission bits a move carries to its destination, or undefined where they cannot be read: * Windows has no POSIX mode, and a source the process may not stat is moved by the * privileged fallback writer, which keeps the destination's own mode. */ async function movedFileMode(filePath: string): Promise { if (process.platform === "win32") return undefined; try { return (await fs.stat(filePath)).mode & 0o7777; } catch (error) { if (isPermissionDeniedError(error)) return undefined; throw error; } } /** * Give a moved file its source's mode. The content is already at the destination, possibly * written by a privileged fallback writer, so a destination that denies mode changes keeps * its own mode instead of failing the move halfway with both files left behind. */ async function chmodMovedFile(filePath: string, mode: number): Promise { try { await fs.chmod(filePath, mode); } catch (error) { if (!isPermissionDeniedError(error)) throw error; } } /** Memoized native inspection, tagged onto the streamed args object it describes. */ const kInspection = Symbol("edit.inspection"); interface InspectedArgs { [kInspection]?: { mode: EditMode; inspection: EditInspection }; } /** A native edit session paired with the router context its URL targets resolve with. */ interface OpenEditSession { native: EditSession; resolveContext: ResolveContext; } function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { // Zero-copy Buffer view: `equals` is a native memcmp. return Buffer.from(left.buffer, left.byteOffset, left.byteLength).equals(right); } export class EditTool implements AgentTool { readonly name = "edit"; readonly label = "Edit"; readonly loadMode = "essential"; readonly concurrency = "exclusive"; readonly strict = true; readonly #editMode?: EditMode; readonly #deferredDiagnostics: DeferredDiagnostics; readonly #sessions = new Map(); readonly #streamedArgs = new Map(); constructor( private readonly session: ToolSession, mode?: EditMode, ) { this.#editMode = mode; this.#deferredDiagnostics = new DeferredDiagnostics(session); } get mode(): EditMode { if (this.#editMode) return this.#editMode; const mode = resolveEditMode(this.session); // `edit.mode: none` unregisters this tool on the next built-in reconcile; until then keep a valid schema. return mode === "none" ? "replace" : mode; } get description(): string { return resolveEditToolDescription(this.mode, this.session.getActiveModel?.()); } get parameters(): TInput { switch (this.mode) { case "replace": return replaceEditSchema; case "patch": return patchEditSchema; case "apply_patch": return applyPatchSchema; case "hashline": return hashlineEditParamsSchema; case "sloppy": return sloppyEditSchema; } } get examples(): readonly ToolExample[] | undefined { if (this.mode === "patch") return PATCH_EXAMPLES; if (this.mode === "apply_patch") return APPLY_PATCH_EXAMPLES; return undefined; } get customFormat(): { syntax: "lark"; definition: string } | undefined { const definition = editGrammar(this.mode); return definition === null ? undefined : { syntax: "lark", definition }; } get customWireName(): string | undefined { return this.mode === "apply_patch" ? "apply_patch" : undefined; } /** * Strictest write decision ({@link strictestApproval}) over every path the payload writes: * section targets plus move/rename destinations. "write" while no target is known. */ readonly approval = (args: unknown): ToolApprovalDecision => { const { paths, fileOps } = this.#inspect(args); const targets = [...paths]; for (const op of fileOps) if (op.kind === "move" && op.to !== undefined) targets.push(op.to); if (targets.length === 0) return "write"; const router = InternalUrlRouter.instance(); return strictestApproval(targets.map(target => router.writeTier(target, undefined, this.session))); }; readonly formatApprovalDetails = (args: unknown): string[] => { const { paths, fileOps } = this.#inspect(args); const lines = paths.map(target => `File: ${truncateForPrompt(target)}`); for (const op of fileOps) { if (op.kind === "move" && op.to !== undefined) { lines.push(`Move: ${truncateForPrompt(op.path)} → ${truncateForPrompt(op.to)}`); } } return lines.length === 0 ? ["File: (unknown)"] : lines; }; /** * `always-ask-cached` keys: every file the payload writes — section targets * plus move/rename destinations, mirroring {@link approval}. */ readonly approvalCacheTargets = (args: unknown): readonly string[] | undefined => { const { paths, fileOps } = this.#inspect(args); const targets = [...paths]; for (const op of fileOps) if (op.kind === "move" && op.to !== undefined) targets.push(op.to); const keys: string[] = []; for (const target of targets) { const key = approvalTargetKey(this.session.cwd, target); if (key !== undefined) keys.push(key); } return keys.length > 0 ? keys : undefined; }; matcherDigest(args: unknown): string | undefined { const digest = this.#inspect(args) .entries.map(entry => entry.digest) .filter(Boolean) .join("\n"); return digest || undefined; } matcherPaths(args: unknown): readonly string[] | undefined { const inspection = this.#inspect(args); const paths = inspection.entries.length > 0 ? inspection.entries.map(entry => entry.path) : inspection.paths; return paths.length > 0 ? paths : undefined; } matcherEntries(args: unknown): readonly { path: string; digest: string }[] | undefined { const entries = this.#inspect(args).entries; return entries.length > 0 ? entries : undefined; } openArgStream(init: AgentToolArgStreamInit): AgentToolArgStream { this.#sessions.get(init.toolCallId)?.native.close(); this.#streamedArgs.delete(init.toolCallId); // A call that arrived through the custom-tool wire streams the payload // verbatim; JSON function calls stream JSON text. const rawInput = init.customWireName !== undefined; const open = this.#openSession(rawInput, undefined, (error, batch) => { if (error) { logger.debug("Native edit preview failed", { error: error.message, toolCallId: init.toolCallId }); return; } init.emit(batch); }); const editSession = open.native; this.#sessions.delete(init.toolCallId); this.#sessions.set(init.toolCallId, open); while (this.#sessions.size > 32) { const oldestId = this.#sessions.keys().next().value; if (oldestId === undefined) break; this.#sessions.get(oldestId)?.native.close(); this.#sessions.delete(oldestId); this.#streamedArgs.delete(oldestId); } return { push: delta => editSession.push(delta), end: args => { editSession.finish(); this.#streamedArgs.set(init.toolCallId, JSON.stringify(args)); }, cancel: () => { editSession.close(); if (this.#sessions.get(init.toolCallId) === open) this.#sessions.delete(init.toolCallId); this.#streamedArgs.delete(init.toolCallId); }, }; } async execute( toolCallId: string, params: EditParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { let open = this.#sessions.get(toolCallId); const argsJson = JSON.stringify(params); if (open && this.#streamedArgs.get(toolCallId) !== argsJson) { open.native.close(); this.#sessions.delete(toolCallId); open = undefined; } this.#streamedArgs.delete(toolCallId); if (open) { // The streamed session was opened before any signal existed; URL targets // resolved during apply honor this call's abort. open.resolveContext.signal = signal; } else { // No deltas were streamed (non-streaming provider, inline recovery, // Cursor batch frames), or a pre-execution hook revised the arguments: // the parsed args are the whole effective payload. open = this.#openSession(false, signal); open.native.setArgsJson(argsJson); open.native.finish(); } const editSession = open.native; const batch = getLspBatchRequest(context?.toolCall); let outcome; try { outcome = await editSession.apply( { lspBatchId: batch?.id, lspFlush: batch?.flush ?? false }, (_error, request) => this.#write(request, signal), ); if (outcome.isError && batch?.flush) { await flushLspWritethroughBatch(batch.id, this.session.cwd, signal); } } catch (error) { if (batch?.flush) await flushLspWritethroughBatch(batch.id, this.session.cwd, signal); throw error; } finally { editSession.close(); if (this.#sessions.get(toolCallId) === open) this.#sessions.delete(toolCallId); this.#streamedArgs.delete(toolCallId); } if (outcome.isError) { return { content: [{ type: "text", text: outcome.text }], details: { diff: "", errorKind: outcome.errorKind }, isError: true, }; } const details = aggregateDetails(outcome.files, this.mode); const result: AgentToolResult = { content: [{ type: "text", text: outcome.text }], ...(details ? { details } : {}), }; const record = createEditBlackboxRecorder(this.session, this.mode, params); const notes: string[] = []; for (const file of outcome.files) { if (!file.parseRegressed || file.oldText === undefined || file.newText === undefined) continue; const snapshot: AppliedEditSnapshot = { path: file.moveTo ?? file.path, prev: file.oldText, next: file.newText, }; try { await record?.(snapshot); } catch { // Blackbox recording is diagnostic only. } const display = path.relative(this.session.cwd, snapshot.path) || snapshot.path; let repaired: EditAutoRepairOutcome | undefined; try { repaired = await attemptEditAutoRepair({ session: this.session, snapshot, writethrough: createEditWritethrough(this.session), signal, }); if (repaired) getEditStore(this.session).invalidate(snapshot.path); } catch (error) { logger.warn("Edit auto-repair failed", { path: snapshot.path, error: error instanceof Error ? error.message : String(error), }); } notes.push( repaired ? `Note: ${display} stopped parsing after this edit; an automatic syntax repair (${repaired.model}) was applied on top:\n${repaired.diff}\nReview the repaired region; adjust it if the repair guessed wrong.` : `Warning: ${display} no longer parses after this edit. The change was applied; re-read the edited region and fix the syntax, or revert if unintended.`, ); } if (notes.length > 0) result.content.push({ type: "text", text: notes.join("\n\n") }); return result; } /** * TTSR asks `matcherPaths` and `matcherEntries` (and approval asks again) * for the same streamed args object on every delta, and the native inspect * re-parses the whole payload each time; the result is tagged onto the args * so repeat lookups for one object pay once. */ #inspect(args: unknown): EditInspection { const tagged = typeof args === "object" && args !== null ? (args as InspectedArgs) : undefined; const cached = tagged?.[kInspection]; if (cached?.mode === this.mode) return cached.inspection; let inspection: EditInspection; try { inspection = editInspect(this.mode, JSON.stringify(args ?? {})); } catch { inspection = { paths: [], entries: [], fileOps: [] }; } if (tagged) tagged[kInspection] = { mode: this.mode, inspection }; return inspection; } /** * Open a native session whose internal-URL targets resolve through the router * with one {@link ResolveContext} built for the session's lifetime. */ #openSession( rawInput: boolean, signal?: AbortSignal, onPreview?: (error: Error | null, batch: EditPreviewBatch) => void, ): OpenEditSession { const resolveContext = sessionResolveContext(this.session, { signal }); const native = new EditSession( getEditStore(this.session), this.#policy(rawInput, resolveContext), onPreview, (_error, url) => this.#resolveUrl(url, resolveContext), ); return { native, resolveContext }; } #policy(rawInput: boolean, context: ResolveContext): EditPolicy { const router = InternalUrlRouter.instance(); const specs = router.specs(); const urlAliasSchemes: string[] = []; for (const [scheme, spec] of specs) if (spec.singleSlashAlias) urlAliasSchemes.push(scheme); return { cwd: this.session.cwd, mode: this.mode, allowFuzzy: cfgEditFuzzyMatch.get(this.session.settings), fuzzyThreshold: cfgEditFuzzyThreshold.get(this.session.settings), enforceSeenLines: cfgEditEnforceSeenLines.get(this.session.settings), maxColumns: displayMaxColumns(this.session.settings), blockAutoGenerated: cfgEditBlockAutoGenerated.get(this.session.settings), planActive: this.session.getPlanModeState?.()?.enabled ?? false, urlSchemes: [...specs.keys()], urlAliasSchemes, planWritableRoots: router.sandboxRoots(context), homeDir: os.homedir(), rawInput, }; } /** * Host answer for one internal-URL edit target: its backing file, or a model-facing refusal. * Only file-written mutable schemes ({@link InternalUrlRouter.fileWritable}) are editable; * handler-owned writes (agent:// messages, proc:// stdin, ...) never patch their backing file. * A line/partial selector is refused ({@link InternalUrlRouter.peelWriteSelector}): edits * address whole files. */ async #resolveUrl(url: string, context: ResolveContext): Promise { const router = InternalUrlRouter.instance(); try { const target = router.peelWriteSelector(url, "edit"); const scheme = extractUriScheme(target); const spec = scheme === undefined ? undefined : router.spec(scheme); if (!spec) return { error: `Unknown internal URL: ${url}`, planWritable: false }; if (!router.fileWritable(target)) { const reason = spec.write?.via === "handler" ? "is written through `write`, not edited" : "is read-only"; return { error: `${url} ${reason}`, planWritable: false }; } const located = await router.locate(target, context, { create: true }); return { path: located ?? undefined, planWritable: spec.write?.scope === "sandbox" }; } catch (error) { return { error: error instanceof Error ? error.message : String(error), planWritable: false }; } } async #write(request: EditWriteRequest, signal?: AbortSignal): Promise { if (request.op === "delete") { await deleteFileWithFallback(request.path, Bun.file(request.path)); if (this.session.enableLsp ?? true) { await notifyWorkspaceWatchedFiles( this.session.cwd, [{ filePath: request.path, type: FileChangeType.Deleted }], signal, ); } this.session.bumpFileMutationVersion?.(request.path); const diagnostics = request.flushLsp && request.lspBatchId ? await flushLspWritethroughBatch(request.lspBatchId, this.session.cwd, signal) : undefined; return { written: "", diagnosticsJson: diagnostics ? JSON.stringify(diagnostics) : undefined, }; } if (request.content === undefined) { throw new ToolError(`Native edit ${request.op} request omitted content`, { path: request.path }); } if (request.op === "move") { if (!request.moveTo) { throw new ToolError("Native edit move request omitted destination", { path: request.path }); } const mode = await movedFileMode(request.path); await mkdirAllowingFallback(path.dirname(request.moveTo)); await writeFileWithFallback(request.moveTo, request.content); if (mode !== undefined) await chmodMovedFile(request.moveTo, mode); await deleteFileWithFallback(request.path, Bun.file(request.path)); if (this.session.enableLsp ?? true) { await notifyWorkspaceWatchedFiles( this.session.cwd, [ { filePath: request.path, type: FileChangeType.Deleted }, { filePath: request.moveTo, type: FileChangeType.Created }, ], signal, ); } this.session.bumpFileMutationVersion?.(request.path); this.session.bumpFileMutationVersion?.(request.moveTo); const diagnostics = request.flushLsp && request.lspBatchId ? await flushLspWritethroughBatch(request.lspBatchId, this.session.cwd, signal) : undefined; return { written: request.content, diagnosticsJson: diagnostics ? JSON.stringify(diagnostics) : undefined, }; } const bridge = await routeWriteThroughBridge( this.session, request.displayPath, request.path, request.content, signal, ); if (bridge) return { written: bridge.text }; // The pre-image is the only way to tell "the write never landed" from // "something else rewrote the file"; a stat cannot (coarse mtimes). let preWriteBytes: Uint8Array | undefined; if (request.op === "update") { try { preWriteBytes = await Bun.file(request.path).bytes(); } catch (error) { if (!isEnoent(error)) throw error; } } else if (request.op === "create") { await mkdirAllowingFallback(path.dirname(request.path)); } // `begin()` claims this mutation's version; only bump when the writethrough // never began a deferred fetch for the path (LSP off, unflushed batch). let began = false; const diagnostics = await createEditWritethrough(this.session)( request.path, request.content, signal, Bun.file(request.path), request.lspBatchId ? { id: request.lspBatchId, flush: request.flushLsp } : undefined, destination => { if (destination !== request.path) return undefined; began = true; return this.#deferredDiagnostics.begin(request.path); }, ); if (preWriteBytes !== undefined) { // Fail when a content-changing write left disk at the pre-image. Compare // post vs pre first (native memcmp, usually a length mismatch) so the // requested content is only encoded when disk really is unchanged. let postWriteBytes: Uint8Array | undefined; try { postWriteBytes = await Bun.file(request.path).bytes(); } catch (error) { if (!isEnoent(error)) throw error; } if ( postWriteBytes !== undefined && bytesEqual(postWriteBytes, preWriteBytes) && (Buffer.byteLength(request.content, "utf8") !== preWriteBytes.byteLength || !bytesEqual(Buffer.from(request.content, "utf8"), preWriteBytes)) ) { throw new ToolError( `edit appeared successful but file content did not change on disk: ${request.displayPath}`, { path: request.path }, ); } } if (!began) this.session.bumpFileMutationVersion?.(request.path); return { written: diagnostics.finalContent, diagnosticsJson: diagnostics.diagnostics ? JSON.stringify(diagnostics.diagnostics) : undefined, }; } }