import path from "path" import fs from "fs/promises" import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS, RooCodeEventName } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath, createDirectoriesForFile } from "../../utils/fs" import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" interface WriteToFileParams { path: string content: string } /** * Per-task partial-streaming state tracked by WriteToFileTool. */ interface TaskPartialStreamState { /** Last path seen during streaming; undefined until the first delta. */ lastSeenPartialPath: string | undefined /** True once this stream was found to be editing a different path than the retained session. */ differentPathStream: boolean /** True once a streaming delta hit a fatal filesystem error. */ streamFailed: boolean /** The original filesystem error of the failed streaming delta, reported once * by onParameterParseFailure() when the final block fails to parse (so * execute() never runs and would never report it). */ streamError: Error | undefined /** The task that owns this state; target for abort-listener deregistration. */ task: Task /** TaskAborted listener that tears this state down; registered once per task. */ abortCleanup: () => void } export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const /** * Per-task partial-streaming state, keyed by task id (taskId + instanceId). * * All per-task fields live in one object per task so that resetTaskPartialState() / * resetPartialState() cannot clear a subset of them and leak the rest (abort * listener, failure mark, path-stabilization entry) for an abandoned stream. * * This deliberately diverges from the sibling streaming tools (ApplyDiffTool, * EditFileTool, SearchReplaceTool, EditTool), which rely on BaseTool's singleton * lastSeenPartialPath / resetPartialState and keep no failure state. The divergence is * intentional, for two reasons: * * 1. Only this tool's handlePartial performs failure-prone streaming work * (diffViewProvider.open/update, which can throw EACCES/EROFS); the siblings only * send a task.ask preview. Without per-task failure tracking, every later delta for * a failed path would re-attempt the failing operation and re-spawn a partial tool * message. * * 2. The tool instance is a module-level singleton shared by every task, including * tasks from different ClineProvider instances (e.g. sidebar and tab-panel * providers, which activate independently). A single provider streams at most one * task at a time — TaskScheduler gates task.run() at maxConcurrency=1 and * delegation disposes the parent before the child starts — so per-task keying is * reachable specifically across providers, where two providers can stream * write_to_file concurrently through this same singleton. * * Lifting this per-task keying into BaseTool for all streaming tools is a follow-up * (separate PR); it is deliberately not done here. */ private taskPartialStreamState = new Map() private getPartialStreamFailureKey(task: Task): string { return `${task.taskId}.${task.instanceId}` } /** * Get this task's partial stream state, creating it on first use and registering the * TaskAborted teardown listener exactly once per task. */ private getTaskPartialStreamState(task: Task): TaskPartialStreamState { const key = this.getPartialStreamFailureKey(task) const existing = this.taskPartialStreamState.get(key) if (existing) { return existing } const state: TaskPartialStreamState = { lastSeenPartialPath: undefined, differentPathStream: false, streamFailed: false, streamError: undefined, task, abortCleanup: () => this.resetTaskPartialState(task), } this.taskPartialStreamState.set(key, state) task.once(RooCodeEventName.TaskAborted, state.abortCleanup) return state } private hasPathStabilizedForTask(state: TaskPartialStreamState, partialPath: string | undefined): boolean { const pathHasStabilized = state.lastSeenPartialPath !== undefined && state.lastSeenPartialPath === partialPath state.lastSeenPartialPath = partialPath return pathHasStabilized && !!partialPath } /** * Clear a task's partial-stream state from a disposal path that does not abort first. * Task.dispose() removes every listener, so a task disposed directly (for example * ClineProvider.cleanupFailedHistoryTask()) never fires the TaskAborted cleanup and * this singleton would keep the disposed task and its diff-view provider. */ public clearTaskState(task: Task): void { this.resetTaskPartialState(task) } private resetTaskPartialState(task: Task): void { const key = this.getPartialStreamFailureKey(task) const state = this.taskPartialStreamState.get(key) if (!state) { return } state.task.off(RooCodeEventName.TaskAborted, state.abortCleanup) this.taskPartialStreamState.delete(key) } /** * Whether this task's partial stream is still the live one. handlePartial() awaits provider * state, a filesystem probe, task.ask() and the diff view's open() before it streams content; * a cancellation during any of those awaits runs the TaskAborted teardown (or a direct * clearTaskState), which deletes this entry. Continuing would re-ask and re-open a diff view * for a task the user already cancelled, resurrecting the state the teardown released. * Identity, not presence: an entry re-created under the same key belongs to a new stream, * and this delta must not write into it. */ private isPartialStreamStillLive(task: Task, state: TaskPartialStreamState): boolean { return this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) === state } private async resetDiffViewAfterWrite(task: Task): Promise { await task.diffViewProvider.reset().catch((resetError) => { console.error("Error resetting write_to_file diff view:", resetError) }) } /** * Release the diff view of a write that was never approved, then let the caller reset it. * * reset() clears the provider's state but leaves the diff document dirty with the * streamed content; a user save would then persist a write the task never completed * (denied or failed before approval). Must run BEFORE resetDiffViewAfterWrite(), * since reset() clears the state the discard relies on. No-op when no diff view * is open. Failures are logged and reported through the return value: the caller * must not treat the teardown as complete when this returns false, because the * document may still hold the unapproved content, but the remaining cleanup (reset, * per-task state teardown) still runs so the task is not left half-torn-down. * * This is deliberately not revertChanges(): that method SAVES. For a new file it writes * the streamed partial output into the placeholder before deleting it, and for an * existing file it writes the restored original back to a file this stream never * changed. discardUnapprovedStream() empties or restores the buffer in memory and * closes the tab only once it is clean, so nothing the user did not approve reaches * disk and the modified side of the vscode.diff tab - a TabInputTextDiff, which * discardFileTab() cannot see - is what actually gets discarded. */ private async discardUnapprovedStreamBeforeReset(task: Task): Promise { try { await task.diffViewProvider.discardUnapprovedStream() return true } catch (discardError) { console.error("Error discarding the unapproved write_to_file diff view:", discardError) return false } } /** * Cleanup for an approved write whose saveChanges() failed before its commit point: * approval is not persistence - nothing landed, so the placeholder open() created is * still the empty file on disk and still owned. Remove it before reset() forgets the * ownership, or the empty file is stranded unowned: a later attempt would treat it as * the pre-existing target and a later denial's rollback would no longer delete it. * The dirty editor keeps the approved content for a manual save or retry. A removal * that fails (EPERM, EACCES) must not be swallowed: ownership is preserved (the * provider keeps placeholderPath when the unlink rejects) and the caller must skip * reset() - clearing the session would strand the placeholder unowned with no retry * path - and report the hazard instead. Returns whether the placeholder was removed. */ private async discardOwnedPlaceholderBeforeReset(task: Task): Promise { try { await task.diffViewProvider.discardOwnedPlaceholder() return true } catch (placeholderError) { console.error("Error removing the uncommitted write_to_file placeholder:", placeholderError) return false } } /** * Surface a placeholder this edit still owns but could not remove. Like the rollback * report, the hazard has to be visible in the chat: the empty file is still on disk, * still owned, and the diff session was left open for a retry instead of being reset. * A failing say must not abort the teardown, so its failure is logged only. */ private async reportPlaceholderStranded(task: Task): Promise { await task .say( "error", "write_to_file: the placeholder created for this new file could not be removed after the failed write, so this edit still owns it and the diff session was left open for a retry instead of being reset.", ) .catch((sayError) => { console.error("Error reporting write_to_file stranded placeholder failure:", sayError) }) } private async finalizePartialToolAskAfterFailure(task: Task, text?: string): Promise { await task.finalizePartialToolAsk(text).catch((finalizeError) => { console.error("Error finalizing write_to_file partial tool ask:", finalizeError) }) } /** * Surface a failed rollback to the user. The hazard has to be visible in the chat, * not only in the console: the editor may still hold content the task never * approved and a save of it would land an unauthorized write. A failing say must * not abort the teardown - a retained streaming error still has to reach the user * through handleError - so its failure is logged only, matching the other cleanup * helpers in this file. */ private async reportRevertFailure(task: Task): Promise { await task .say( "error", "write_to_file: the diff editor could not be restored after the failed tool call, so it may still show unapproved content. Do not save that editor.", ) .catch((sayError) => { console.error("Error reporting write_to_file rollback failure:", sayError) }) } /** * Cleanup for a failed partial stream: restore the diff document, close the view, * and - when the restore itself failed - tell the user the editor may still hold * unapproved content, the same way the parse-failure teardown does. Without the * report, a failed rollback here is invisible: the stream error that triggered the * cleanup is a different failure and is reported elsewhere. */ private async cleanupFailedPartialStream(task: Task): Promise { const reverted = await this.discardUnapprovedStreamBeforeReset(task) // A failed rollback that still owns the placeholder must keep the session: // reset() would forget that ownership and strand the empty file unowned, so it // is skipped and the hazard reported instead - the same discipline execute() // uses on its error path. const strandedPlaceholder = !reverted && task.diffViewProvider.ownsPlaceholder if (!strandedPlaceholder) { await this.resetDiffViewAfterWrite(task) } if (!reverted) { await this.reportRevertFailure(task) } if (strandedPlaceholder) { await this.reportPlaceholderStranded(task) } } /** * Teardown boundary for the handle() parse-failure path, where execute() never * runs and therefore its finally (resetTaskPartialState) never runs either. * * Tears down the per-task stream state: otherwise the abort listener leaks for * the task's lifetime, and when a streaming delta had failed, the streamFailed * guard would suppress the diff preview of every later write_to_file in this * task. Restores the diff document: streaming may have opened it with * unapproved partial content, and execute()'s error cleanup (revert + reset) * never fires on this path, so a user save could persist the content without * the teardown here. When a streaming delta already hit a fatal filesystem * error, that error is what the user can act on, so report it with the same * "writing file" context execute()'s catch uses, and suppress the incidental * parse error. */ override async onParameterParseFailure(task: Task, callbacks: ToolCallbacks, parseError: Error): Promise { const state = this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) if (!state) { return false } if (state.differentPathStream) { // This stream never owned the diff session: it belongs to another path's retained, // approved edit. Discarding or resetting the view here would throw that edit away, // so only release this stream's own bookkeeping and report any streaming failure. this.resetTaskPartialState(task) if (!state.streamError) { return false } void parseError await callbacks.handleError("writing file", state.streamError) return true } // Streaming may have opened the diff view with unapproved partial content. // execute() never runs on this path, so its error cleanup (revert + reset) // never fires: restore the document here so a user save cannot persist // content the write never completed (the same invariant the denial and // streaming-failure paths maintain). Both helpers no-op when no view is open. // The revert runs BEFORE the per-task state is torn down: when it fails, the // document can still hold that unapproved content, and the recovery state has // to exist while the outcome is decided and reported. const reverted = await this.discardUnapprovedStreamBeforeReset(task) this.resetTaskPartialState(task) // The same ownership guard as execute(): a rollback that could not remove the // placeholder leaves it owned, and reset() would strand it unowned. const strandedPlaceholder = !reverted && task.diffViewProvider.ownsPlaceholder if (!strandedPlaceholder) { await this.resetDiffViewAfterWrite(task) } if (!reverted) { // Do not report a completed teardown: the editor may still show content this // task never approved, and saving it would land a write the user never // authorized. The user has to be able to tell that from the UI. await this.reportRevertFailure(task) } if (strandedPlaceholder) { await this.reportPlaceholderStranded(task) } if (!state.streamError) { return false } void parseError await callbacks.handleError("writing file", state.streamError) return true } override resetPartialState(): void { super.resetPartialState() for (const state of this.taskPartialStreamState.values()) { state.task.off(RooCodeEventName.TaskAborted, state.abortCleanup) } this.taskPartialStreamState.clear() } async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { const { pushToolResult, handleError, askApproval } = callbacks const relPath = params.path let newContent = params.content if (!relPath) { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "path")) // Returning here skips the cleanup below: release THIS task's stream state (and // only this task's) so the abort listener and any streamFailed guard do not outlive // the call, and tear down the diff view an earlier streaming delta may have opened - // otherwise its unapproved content sits in the editor until the next write. this.resetTaskPartialState(task) await this.cleanupFailedPartialStream(task) return } if (newContent === undefined) { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "content")) // Returning here skips the cleanup below: release THIS task's stream state (and // only this task's) so the abort listener and any streamFailed guard do not outlive // the call, and tear down the diff view an earlier streaming delta may have opened. this.resetTaskPartialState(task) await this.cleanupFailedPartialStream(task) return } // A retained session belongs to ITS path: when a rollback could not complete, // the provider keeps the dirty buffer and relPath so the user's content survives // a retry. Writing a DIFFERENT path through that session would update and save // the old document while asking approval for the new one, so the write is // rejected before any cleanup path can reset the retained session. if (task.diffViewProvider.isEditingDifferentPath(relPath)) { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") const retainedPath = task.diffViewProvider.editingRelPath ?? "another file" // The retained session is only released by a retry of ITS path (which reuses the // session and reaches reset()) - saving or closing the editor does not clear // isEditing/relPath in this process, so that remedy would loop. const retainedErrorMessage = `Cannot write '${relPath}': an unfinished write to '${retainedPath}' must be resolved first. ` + `Retry the write to '${retainedPath}' to reuse and finish that session, then retry this write.` await task.say("error", retainedErrorMessage) pushToolResult(formatResponse.toolError(retainedErrorMessage)) // This return skips the try/finally below, so the per-task stream state a delta may // have created for the rejected path must be released here: otherwise its TaskAborted // listener leaks and a streamFailed flag would suppress every later write_to_file delta. this.resetTaskPartialState(task) return } const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) // Returning here skips the cleanup below: release THIS task's stream state (and // only this task's) so the abort listener and any streamFailed guard do not outlive // the call. The denial can arrive after a streaming delta opened the denied path and // streamed unapproved content into it, so the diff view is torn down here too - the // write never happens, and a later save of that editor would land it anyway. this.resetTaskPartialState(task) await this.cleanupFailedPartialStream(task) return } const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false // The point of no return for this write. saveChanges() persists the document and then keeps // working (closing the diff views, tab bookkeeping, diagnostics), and trackFileContext() and // pushToolWriteResult() run after it, so a rejection past this point must not roll back a // write the user already approved: the rollback would restore the previous content, or delete // a file that was created and saved. Set only once the durable write has landed. let writeCommitted = false let fileExists: boolean const absolutePath = path.resolve(task.cwd, relPath) if (task.diffViewProvider.editType !== undefined) { fileExists = task.diffViewProvider.editType === "modify" } else { fileExists = await fileExistsAtPath(absolutePath) task.diffViewProvider.editType = fileExists ? "modify" : "create" } if (newContent.startsWith("```")) { newContent = newContent.split("\n").slice(1).join("\n") } if (newContent.endsWith("```")) { newContent = newContent.split("\n").slice(0, -1).join("\n") } if (!task.api.getModel().id.includes("claude")) { newContent = unescapeHtmlEntities(newContent) } const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath), content: newContent, isOutsideWorkspace, isProtected: isWriteProtected, } // Tracks whether the user approved the write, so the error path only reverts the // diff document when the content was never approved (an approved edit is kept in // the editor so the user can save it manually after a late failure). let writeApproved = false try { // Create parent directories for new files inside the try block so filesystem // errors (EROFS, EACCES, etc.) route through handleError with proper cleanup // and consecutive-mistake counting, rather than escaping unhandled. if (!fileExists) { await createDirectoriesForFile(absolutePath) } task.consecutiveMistakeCount = 0 const provider = task.providerRef.deref() const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" if (fileExists) { const absolutePath = path.resolve(task.cwd, relPath) task.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") } else { task.diffViewProvider.originalContent = "" } let unified = fileExists ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: computeDiffStats(unified) || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { // The prevent-focus branch set editType/originalContent on the provider // before asking. Clear them on denial (no diff document was opened in // this branch, so reset() is sufficient; the non-prevent-focus denial // branch resets through revertChanges()), so a later write re-checks // the file system instead of reusing the stale editType. await this.resetDiffViewAfterWrite(task) return } writeApproved = true await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) // saveDirectly() performs the write itself and reports a failing write, so returning means // the content is on disk: from here the rollback has to stand down. writeCommitted = true } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, true).catch(() => {}) await task.diffViewProvider.open(relPath) } await task.diffViewProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, true, ) task.diffViewProvider.scrollToFirstDiff() let unified = fileExists ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: computeDiffStats(unified) || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { await task.diffViewProvider.revertChanges() return } writeApproved = true await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, () => { // Signalled by saveChanges() at the document save, not when it returns: the editor // bookkeeping and diagnostics that follow can still reject, and by then the file has // already landed. writeCommitted = true }) } if (relPath) { await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) pushToolResult(message) await this.resetDiffViewAfterWrite(task) task.processQueuedMessages() return } catch (error) { // Finalize any open partial tool message so the UI spinner doesn't get stuck. // The partial ask fired during streaming (handlePartial) or early in execute sets // partial: true on the webview message; without this, the spinner persists even // after the error bubble appears. await this.finalizePartialToolAskAfterFailure(task) // The diff cleanup runs in a finally around handleError: the production // handleError awaits Task.say(), which rejects when the task is aborted, and a // rejected handleError must not skip restoring the unapproved streamed content. try { await handleError("writing file", error as Error) } finally { // Before approval the diff document holds unapproved streamed content: // restore it so a user save cannot persist it. After approval the content // is the user's accepted edit -- keep it in the editor (dirty) so they can // save it manually. let reverted = true let strandedPlaceholder = false // Two independent stand-downs: the user approved this content, or the write is already // durable. Either one makes a rollback undo something the user wanted rather than // cleaning up this call's debris. if (!writeApproved && !writeCommitted) { reverted = await this.discardUnapprovedStreamBeforeReset(task) // A denial whose rollback could not remove the placeholder (refused restore, // EPERM unlink) leaves it owned: reset() would forget that ownership and // strand the empty file unowned, so it is skipped and the hazard reported - // the same discipline as the pre-commit save-failure path below. strandedPlaceholder = !reverted && task.diffViewProvider.ownsPlaceholder } else if (writeApproved && !writeCommitted) { // saveChanges() failed before its commit point: the write never persisted, // so the owned empty placeholder must not be stranded unowned by reset(). strandedPlaceholder = !(await this.discardOwnedPlaceholderBeforeReset(task)) } if (!strandedPlaceholder) { await this.resetDiffViewAfterWrite(task) } if (!reverted) { // The restore failed, so the editor still holds content this task never approved. // The error the user sees on this path is the write failure, not this one. await this.reportRevertFailure(task) } if (strandedPlaceholder) { // The placeholder is still on disk and still owned; reset() was skipped so // the session keeps that ownership for a retry. Make the hazard visible. await this.reportPlaceholderStranded(task) } } return } finally { this.resetTaskPartialState(task) } } override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { const relPath: string | undefined = block.params.path const newContent: string | undefined = block.params.content const partialStreamFailureKey = this.getPartialStreamFailureKey(task) // A prior streaming delta for this task already hit a fatal filesystem error. // Skip further streaming work so we don't create a new partial tool message on every // subsequent delta. execute() will report the error once when the block completes. if (this.taskPartialStreamState.get(partialStreamFailureKey)?.streamFailed) { return } // Get (or create) this task's state; registers the TaskAborted teardown listener // once, so abandoned streams are torn down even if execute() never runs. const partialStreamState = this.getTaskPartialStreamState(task) // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilizedForTask(partialStreamState, relPath) || newContent === undefined) { return } // Everything below runs after getTaskPartialStreamState() registered this task's entry and its // TaskAborted listener, so it belongs inside a boundary that releases them. The awaits here look // like reads and are not: provider.getState() is an await into the extension host and the // filesystem probe can reject. If either does, this delta reaches neither the streaming block // nor execute()'s teardown, and BaseTool.handle() only reports the error - which would leave // the singleton holding a task that nothing else ever releases. // Declared outside the try: the streaming catch below reads it, and a try block's // own scope is not visible to code that runs after the try. let partialMessage: string | undefined try { const provider = task.providerRef.deref() const state = await provider?.getState() // Cancelled while provider state was in flight: the teardown already released this // task's stream state, so this delta must not go on to ask or open a diff view. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) if (isPreventFocusDisruptionEnabled) { // The preview is suppressed for this stream: release the entry registered above so the // abort listener and any failure mark do not outlive a delta that never shows a diff // view and never reaches execute()'s teardown. super.resetPartialState() this.resetTaskPartialState(task) return } // relPath is guaranteed non-null after hasPathStabilized let fileExists: boolean const absolutePath = path.resolve(task.cwd, relPath!) if (task.diffViewProvider.editType !== undefined) { fileExists = task.diffViewProvider.editType === "modify" } else { fileExists = await fileExistsAtPath(absolutePath) task.diffViewProvider.editType = fileExists ? "modify" : "create" } // Cancelled during the filesystem probe: stop before the partial ask and the diff view. // (The probe above is the only await in this branch.) if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath!), content: newContent || "", isOutsideWorkspace, isProtected: isWriteProtected, } // A retained session belongs to its own path: streaming a different path's deltas // must not open a partial ask (the final rejection in execute() does not finalize it, // leaving a spinner) and must not touch the diff view. Remember the stream so a // later parse failure does not discard the retained, approved session. if (task.diffViewProvider.isEditingDifferentPath(relPath!)) { partialStreamState.differentPathStream = true return } partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) // Cancelled while the partial ask was in flight: the teardown released this task's // state, so this delta must not touch the diff view. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } } catch (error) { // Unexpected failure in the pre-streaming window: nothing was streamed and nothing was shown, // so this is not the retained-streaming-failure case the inner catch below keeps state for. // Release this delta's entry and its listener before the error reaches BaseTool.handle(), // then rethrow so the delta is still reported. Identity-guarded: if a cancellation already // released the entry and a newer stream re-created it under the same key, that entry stays. if (this.isPartialStreamStillLive(task, partialStreamState)) { this.resetTaskPartialState(task) } throw error } if (newContent) { try { if (!task.diffViewProvider.isEditing) { await task.diffViewProvider.open(relPath!) } // Cancelled while open() was in flight: do not stream partial content into a // cancelled task's view. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } await task.diffViewProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, false, ) } catch (error) { // The task may have been aborted while open() or update() was in flight. The teardown // already released this entry and the disposal path already owns the diff cleanup, so // marking, finalizing and restoring here would be a second cleanup of the same stream: // a second partial-tool row and a second restore racing the first. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } // Opening or updating the diff view can throw on filesystem errors // (EACCES/EROFS on read-only paths). Finalize the partial tool message // so the UI spinner doesn't get stuck and reset the diff view. Do NOT // rethrow: the same filesystem operation is retried in execute() once the // block completes, and that authoritative non-partial path reports the // error to the user. Surfacing it here too would show the same error twice. // Swallowing it here is safe because the agent loop advances naturally when // the non-partial block arrives (it does not depend on this throw). console.error(`Error streaming write_to_file diff view:`, error) // Mark the stream as failed so later deltas don't re-attempt and spawn a new // partial tool message each time. Retain the original error: if the final // block later fails to parse, execute() never runs and only // onParameterParseFailure() can report this failure to the user. partialStreamState.streamFailed = true partialStreamState.streamError = error instanceof Error ? error : new Error(String(error)) await this.finalizePartialToolAskAfterFailure(task, partialMessage) // Finalizing is an await: a cancellation during it has already run the teardown and the // disposal cleanup, so the restore below must not run a second time over the same view. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } // The write was never approved: restore the document so a user save cannot // persist the failed streamed content (reset() alone leaves it dirty), and // surface the hazard if that restore itself failed. The stream error is // reported by the authoritative non-partial path in execute(); the rollback // hazard is a different failure and nothing else in this path says so. await this.cleanupFailedPartialStream(task) } } } } export const writeToFileTool = new WriteToFileTool()