import type { WriteToolDetails } from "@oh-my-pi/pi-tui/tools/write"; import { Database } from "bun:sqlite"; import { assertNotHeldOpen } from "@oh-my-pi/pi-utils/held-file"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, ToolApprovalDecision, } from "@oh-my-pi/pi-agent-core"; import { BINARY_SNIFF_BYTES, isEnoent, isProbablyBinaryHeader, isRecord, prompt, untilAborted, withFileLock, } from "@oh-my-pi/pi-utils"; import { type ArchiveMemberContent, archiveFormatFromPath, assertInMemorySize, DEFAULT_ARCHIVE_LIMITS, isWritableArchiveFormat, parseArchivePathCandidates, readArchiveEntries, writeArchive, } from "@oh-my-pi/pi-utils/ar"; import { detectLineEnding, normalizeToLF, restoreLineEndings } from "../edit/normalize"; import { extractUriScheme, InternalUrlRouter, LocalProtocolHandler, resolveLocalRoot, sessionResolveContext, sessionWriteContext, } from "../internal-urls"; import { createLspWritethrough, type WritethroughCallback, writethroughNoop } from "../lsp"; import { DeferredDiagnostics } from "../lsp/deferred-diagnostics"; import { getLspBatchRequest } from "../lsp/batch"; import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import writeDeviceOnlyDescription from "../prompts/tools/write-device-only.md" with { type: "text" }; import writeDeviceOnlyRefusal from "../prompts/tools/write-device-only-refusal.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { routeWriteThroughBridge, shouldRouteWriteThroughBridge } from "./acp-bridge"; import { formatApprovalArguments, truncateForPrompt } from "./approval"; import { approvalTargetKey } from "./approval-targets"; import { assertEditableFile } from "./auto-generated-guard"; import { isReadTruncationNotice } from "@oh-my-pi/pi-tui/tools/hashline-format"; import { hasColumnTruncationClause, isColumnTruncationNotice } from "@oh-my-pi/pi-tui/tools/output-meta"; import { recoverConflictUriPrefix } from "./conflict-detect"; import { unwrapHashlineHeaderPath } from "./hashline-header-path"; import { outputMeta } from "./output-meta"; import { formatPathRelativeToCwd, probeLiteralPathExists, specialFileKind } from "./path-utils"; import { splitPathAndSel } from "@oh-my-pi/pi-tui/tools/read"; import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "./plan-mode-guard"; import { decodeUtf8Text } from "./read-format"; import { routeReadThroughBridge } from "./read-summary"; import { shortenPath } from "@oh-my-pi/pi-tui/render/render-utils"; import { deleteRowByKey, deleteRowByRowId, insertRow, isSqliteFile, parseSqlitePathCandidates, resolveTableRowLookup, updateRowByKey, updateRowByRowId, } from "./sqlite-reader"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { toolResult } from "./tool-result"; import { maybeWriteSnapshotHeader, stripWriteContent } from "./write-content"; import { cfgLspDiagnosticsDeduplicate, cfgLspDiagnosticsOnWrite, cfgLspEnabled, cfgLspFormatOnWrite, cfgLspRequestTimeout, } from "../lsp/settings"; const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]"; const URI_LIKE_WRITE_PATH_RE = /^([a-z][a-z0-9+.-]*):\/{1,2}(.*)$/i; const MISSING_DELIMITER_RE = /^([a-z][a-z0-9+.-]*)\/+(.*)$/i; /** True when `typo` is exactly one insertion, deletion, substitution, or adjacent swap away from `word`. */ function isOneEditAway(typo: string, word: string): boolean { if (typo === word || Math.abs(typo.length - word.length) > 1) return false; let common = 0; while (common < typo.length && common < word.length && typo[common] === word[common]) common++; const a = typo.slice(common); const b = word.slice(common); if (a.slice(1) === b.slice(1) || a.slice(1) === b || a === b.slice(1)) return true; return a.length >= 2 && a[0] === b[1] && a[1] === b[0] && a.slice(2) === b.slice(2); } function assertWriteTargetAddressable(target: string, router: InternalUrlRouter): void { const trimmed = target.trim(); if (path.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return; // Tool-device transports get typo recovery: a bare `/x` or a near-miss // scheme is a mistyped dispatch, never an intended filesystem path. const deviceSchemes: string[] = []; for (const [scheme, spec] of router.specs()) { if (spec.write?.scope === "device") deviceSchemes.push(scheme); } const missingDelimiter = trimmed.match(MISSING_DELIMITER_RE); if (missingDelimiter && deviceSchemes.includes(missingDelimiter[1]!.toLowerCase())) { throw new ToolError( `Unknown URI-like write target '${trimmed}'. Did you mean '${missingDelimiter[1]!.toLowerCase()}://${missingDelimiter[2]}'? Prefix the path with './' to write it as a filesystem path.`, ); } const uriLike = trimmed.match(URI_LIKE_WRITE_PATH_RE); if (!uriLike) return; const scheme = uriLike[1]!.toLowerCase(); const canonicalScheme = router.spec(scheme) ? scheme : deviceSchemes.find(device => isOneEditAway(scheme, device)); const suggestion = canonicalScheme ? ` Did you mean '${canonicalScheme}://${uriLike[2]}'?` : deviceSchemes.length > 0 ? ` Tool devices use '${deviceSchemes[0]}://'.` : ""; throw new ToolError( `Unknown URI-like write target '${trimmed}'.${suggestion} Prefix the path with './' to write it as a filesystem path.`, ); } /** * Fail closed when a local write target looks like a mis-dispatched read. * * A read-only step that selects `write` instead of `read` passes the full read * expression (`src/foo.tsx:1-260:raw`) as the target. Because a literal colon * filename is legal on POSIX (issue #4618), that request otherwise resolves to * filesystem creation and reports success, leaving a stray zero-byte file the * model cannot recover from — the local analogue of the device-scheme near-miss guard * ({@link assertWriteTargetAddressable}, issue #6123). * * Fires only on the high-confidence combination the report identifies: the tail * parses as a read-tool selector, the literal target is missing, and no content * was supplied. Non-empty content is the escape hatch — it is never blocked, so * a deliberate write to a selector-shaped filename still succeeds. An existing * literal path or an ambiguous stat (`"unknown"`: EACCES, transient I/O) also * passes through so a real file is never shadowed by the guard. */ function readSelectorForEmptyWrite(target: string, content: string): string | undefined { if (content.length > 0) return undefined; return splitPathAndSel(target).sel; } function throwReadSelectorMisfire(target: string, sel: string): never { throw new ToolError( `write target '${target}' ends with a read-tool selector ':${sel}' and no such file exists — refusing to create a literal file by that name. ` + `If you meant to read it, use read({ path: "${target}" }). ` + `If you truly intend to create this file, pass its contents in \`content\` (a non-empty write is never blocked).`, ); } /** * Recognize a semicolon-joined list of read-tool selectors mis-dispatched as a * single write target — the multi-file read expression the scout emitted in * issue #6809 (`a.txt:1-2;b/c.txt:3-4`). Every `;`-segment must be non-empty and * carry its own read selector ({@link splitPathAndSel} peels a `:N-M`, `:raw`, * or `:conflicts` tail). No real call targets such a list: `read` accepts one * path, `write` writes one file. Unlike {@link readSelectorForEmptyWrite} this * fires regardless of `content` — the non-empty-content escape hatch exists for * a lone selector-shaped *filename*, never a `;`-list, and honoring it here * silently creates a nested directory tree (`a.txt:1-2;b/`) in the workspace. * The caller still probes the literal target first, so an existing POSIX file * by that exact name stays writable (same escape as the single-selector guard). */ function readSelectorListMisfire(target: string): number | undefined { if (!target.includes(";")) return undefined; const segments = target.split(";"); if (segments.length < 2) return undefined; for (const segment of segments) { const trimmed = segment.trim(); if (trimmed.length === 0 || splitPathAndSel(trimmed).sel === undefined) return undefined; } return segments.length; } function throwReadSelectorListMisfire(target: string, count: number): never { throw new ToolError( `write target '${target}' is a semicolon-joined list of ${count} read-tool selectors, not a filesystem path — refusing to create it. ` + `write creates a single file; issue one read() per path to read these ranges (e.g. read({ path: ":" })).`, ); } async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise { const listCount = readSelectorListMisfire(target); if (listCount !== undefined && (await probeLiteralPathExists(target, cwd)) === "missing") { throwReadSelectorListMisfire(target, listCount); } const sel = readSelectorForEmptyWrite(target, content); if (sel === undefined) return; if ((await probeLiteralPathExists(target, cwd)) !== "missing") return; throwReadSelectorMisfire(target, sel); } const writeSchema = type({ path: "string", "content?": "string", }); /** Write arguments; `content` may be omitted only where the target scheme's write policy allows it. */ export type WriteToolInput = typeof writeSchema.infer; /** * A `read` notice that its projection is incomplete: a truncation notice, or the column-cap * notice of a page whose long lines are shown cut (`[Some lines truncated to 768 chars. …]`). */ function isReadProjectionNotice(line: string): boolean { return isReadTruncationNotice(line) || isColumnTruncationNotice(line); } /** * Offset of the last non-blank line of LF-only `text` when that line is a `read` * truncation notice, else -1. Walks lines backward from the end instead of splitting. */ function readTruncationNoticeStart(text: string): number { let end = text.length; while (end > 0) { const start = text.lastIndexOf("\n", end - 1) + 1; const line = text.slice(start, end); if (line.trim().length > 0) return isReadProjectionNotice(line) ? start : -1; end = start - 1; } return -1; } /** `normalizeToLF(text).length` without the copy: each CRLF collapses to one LF; a lone CR stays one char. */ function lfNormalizedLength(text: string): number { let length = text.length; for (let at = text.indexOf("\r\n"); at !== -1; at = text.indexOf("\r\n", at + 2)) length--; return length; } /** * The last non-blank line of `content` without normalizing the payload, bounded in the raw * text by CR or LF (exactly the line normalization would produce); `undefined` when blank. */ function lastContentLine(content: string): string | undefined { const contentEnd = content.trimEnd().length; if (contentEnd === 0) return undefined; let start = contentEnd; while (start > 0) { const ch = content.charCodeAt(start - 1); if (ch === 0x0a || ch === 0x0d) break; start--; } let end = contentEnd; while (end < content.length) { const ch = content.charCodeAt(end); if (ch === 0x0a || ch === 0x0d) break; end++; } return content.slice(start, end); } /** `readTruncationNoticeStart(normalizeToLF(content)) !== -1` without normalizing the payload. */ function endsWithReadTruncationNotice(content: string): boolean { const line = lastContentLine(content); return line !== undefined && isReadProjectionNotice(line); } async function readCurrentWriteSource( session: ToolSession, requestedPath: string, absolutePath: string, ): Promise { const readDisk = async (): Promise => { try { return await Bun.file(absolutePath).text(); } catch (error) { if (isEnoent(error)) return undefined; throw error; } }; if (!(await shouldRouteWriteThroughBridge(session, requestedPath, absolutePath))) return readDisk(); const bridgeRead = routeReadThroughBridge(session, absolutePath); if (!bridgeRead) return readDisk(); try { return await bridgeRead; } catch { return readDisk(); } } /** * Byte span (UTF-16 length) of a read projection's shown payload — everything * up to but excluding its trailing `read` truncation notice and the blank * separator before it. Returns `undefined` when the content does not end in * such a notice. * * Excluding the notice matters at the byte-budget boundary: a single line * truncated just past the limit renders as a ~50 KB prefix plus a footer whose * combined length can exceed the original line, yet it still covers strictly * less source. Measuring the shown payload — not the rendered length — is what * the truncation marker, not character count, establishes as incomplete. */ function readProjectionPayloadLength(content: string): number | undefined { const text = normalizeToLF(content); let payloadEnd = readTruncationNoticeStart(text); if (payloadEnd === -1) return undefined; // Back over the blank lines separating the payload from the notice. `lastIndexOf` clamps a // negative start to 0, so the first line (ending at the LF at 0) needs the explicit guard. while (payloadEnd > 0) { const previousStart = payloadEnd > 1 ? text.lastIndexOf("\n", payloadEnd - 2) + 1 : 0; if (text.slice(previousStart, payloadEnd - 1).trim().length > 0) break; payloadEnd = previousStart; } // `payloadEnd` starts the first dropped line; the payload excludes the LF before it. return Math.max(0, payloadEnd - 1); } function assertNotShorterReadProjection( displayPath: string, rawContent: string, currentContent: string | undefined, writeContent: string = rawContent, ): void { const rawPayloadLength = readProjectionPayloadLength(rawContent); if (rawPayloadLength === undefined || currentContent === undefined) return; // A column-cap notice proves the page lossy at any length: a cut line ends in `…` in place of // its tail, and a line one column past the cap renders exactly as long as its source. So does // a truncation notice carrying the column clause, whose line-number prefixes pad the payload. const columnCut = hasColumnTruncationClause(lastContentLine(rawContent) ?? ""); if (!columnCut) { const payloadLength = writeContent === rawContent ? rawPayloadLength : lfNormalizedLength(writeContent); if (payloadLength >= lfNormalizedLength(currentContent)) return; } throw new ToolError( columnCut ? `Refusing to overwrite '${displayPath}' with an incomplete read projection: the content ends with an omp read column-cap notice, so its cut lines would replace the full ones. Re-read the cut lines in raw mode and write the complete file, or use edit for a partial change.` : `Refusing to overwrite '${displayPath}' with an incomplete read projection: the content ends with an omp read truncation notice and covers less than the current source, so it would discard unseen content. Re-read the omitted ranges and write the complete file, or use edit for a partial change.`, ); } async function assertNotTruncatedFileReadProjection( session: ToolSession, requestedPath: string, absolutePath: string, displayPath: string, rawContent: string, writeContent: string, ): Promise { if (!endsWithReadTruncationNotice(rawContent)) return; const currentContent = await readCurrentWriteSource(session, requestedPath, absolutePath); assertNotShorterReadProjection(displayPath, rawContent, currentContent, writeContent); } /** * Append a trailing note line to the first text block of a tool result. * Mutates `result` in place (the result object is owned by this call). */ function appendNoteToResult(result: AgentToolResult, note: string): void { const firstText = result.content.find( (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string", ); if (firstText) { firstText.text = firstText.text.length > 0 ? `${firstText.text}\n${note}` : note; } else { result.content.push({ type: "text", text: note }); } } function emitWriteProgress( onUpdate: AgentToolUpdateCallback | undefined, byteLength: number, displayPath: string, resolvedPath?: string, ): void { onUpdate?.({ content: [ { type: "text", text: `Writing ${byteLength} bytes to ${shortenPath(displayPath)}...`, }, ], details: resolvedPath ? { resolvedPath } : {}, }); } /** * If `content` begins with an interpreter shebang (not a Rust inner attribute), ensure the file is executable. * * Mirrors `chmod a+x` (adds user/group/other execute bits to existing mode). * Errors are swallowed: chmod failure (e.g. Windows ACL, read-only mount) * MUST NOT fail an otherwise successful write. Returns whether the mode * actually changed so the caller can surface a note — re-read after the chmod, * since Windows (and some mounts) accept it while keeping no execute bits. */ async function maybeMarkExecutableForShebang(absolutePath: string, content: string): Promise { if (!content.startsWith("#!") || content.startsWith("#![")) return false; try { const stat = await fs.stat(absolutePath); const currentMode = stat.mode & 0o7777; const newMode = currentMode | 0o111; if (newMode === currentMode) return false; await fs.chmod(absolutePath, newMode); return ((await fs.stat(absolutePath)).mode & 0o111) === 0o111; } catch { return false; } } /** Line-ending and BOM convention of a text file a write is about to replace. */ interface TextFileConvention { bom: boolean; crlf: boolean; } /** Bytes read per step while looking for a first line break past the binary sniff window. */ const LINE_BREAK_SCAN_BYTES = 64 * 1024; /** * Read the convention of the `size`-byte file at `absolutePath`: a leading UTF-8 BOM, and CRLF when * its first line break is one (the rule `edit` applies). The first {@link BINARY_SNIFF_BYTES} decide * whether it is text; a longer first line is scanned to its end, stopping when `signal` aborts. * `undefined` for a binary file, whose bytes say nothing about text line endings. */ async function readTextFileConvention( absolutePath: string, size: number, signal: AbortSignal | undefined, ): Promise { const file = Bun.file(absolutePath); let chunk = await file.slice(0, BINARY_SNIFF_BYTES).bytes(); if (isProbablyBinaryHeader(chunk)) return undefined; const bom = chunk[0] === 0xef && chunk[1] === 0xbb && chunk[2] === 0xbf; let offset = 0; let previous: number | undefined; for (;;) { const lf = chunk.indexOf(0x0a); if (lf !== -1) return { bom, crlf: (lf > 0 ? chunk[lf - 1] : previous) === 0x0d }; offset += chunk.length; if (chunk.length === 0 || offset >= size) return { bom, crlf: false }; signal?.throwIfAborted(); previous = chunk[chunk.length - 1]; chunk = await file.slice(offset, offset + LINE_BREAK_SCAN_BYTES).bytes(); } } /** * Give `content` the CRLF line endings and BOM of the file it replaces, as `edit` keeps them. * Models write LF text, so without this replacing a CRLF file rewrites every line and drops its BOM. * Each is independent: content with a carriage return keeps its line endings, content that starts * with a BOM keeps it, and empty content stays empty. `applied` says which were added. */ function applyTextFileConvention( content: string, convention: TextFileConvention | undefined, ): { text: string; applied: TextFileConvention } { const applied = { bom: false, crlf: false }; if (!convention) return { text: content, applied }; let text = content; if (convention.crlf && text.includes("\n") && !text.includes("\r")) { text = restoreLineEndings(text, "\r\n"); applied.crlf = true; } if (convention.bom && text.length > 0 && !text.startsWith("\uFEFF")) { text = `\uFEFF${text}`; applied.bom = true; } return { text, applied }; } /** * Name each convention `applied` added that `finalText` (what the LSP formatter left, or the content * as written) still has, so the model knows the file differs from the text it sent. A convention a * formatter removed again is not claimed. */ function textFileConventionNote(applied: TextFileConvention, finalText: string): string | undefined { const kept: string[] = []; if (applied.crlf && detectLineEnding(finalText) === "\r\n") kept.push("CRLF line endings"); if (applied.bom && finalText.startsWith("\uFEFF")) kept.push("UTF-8 BOM"); return kept.length === 0 ? undefined : `Note: kept the replaced file's ${kept.join(" and ")}.`; } // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ type WriteParams = WriteToolInput; interface ResolvedArchiveWritePath { absolutePath: string; archivePath: string; archiveSubPath: string; exists: boolean; } interface ResolvedSqliteWritePath { absolutePath: string; sqlitePath: string; table: string; key?: string; exists: boolean; } function isArchivePathNotFound(error: unknown): boolean { if (isEnoent(error)) return true; return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR"; } /** * On-disk form of `local://`, resolved the way the write itself resolves * it (session context, then process override, then main session) and shortened * to `~` so the refusal does not carry the home directory into transcripts; * undefined when `local://` has no root for this session. */ function localFileDiskPathHint(session: ToolSession): string | undefined { const options = LocalProtocolHandler.resolveOptions(sessionResolveContext(session)); return options ? shortenPath(path.join(resolveLocalRoot(options), "")) : undefined; } function normalizeArchiveWriteSubPath(rawPath: string): string { const normalized = rawPath.replace(/\\/g, "/"); if (normalized.length === 0) { throw new ToolError("Archive write path must target a file inside the archive"); } if (normalized.endsWith("/")) { throw new ToolError("Archive write path must target a file, not a directory"); } const parts = normalized.split("/"); const normalizedParts: string[] = []; for (const part of parts) { if (!part || part === ".") continue; if (part === "..") { throw new ToolError("Archive path cannot contain '..'"); } normalizedParts.push(part); } if (normalizedParts.length === 0) { throw new ToolError("Archive write path must target a file inside the archive"); } return normalizedParts.join("/"); } function parseSqliteWriteTarget(subPath: string, queryString: string): { table: string; key?: string } { if (queryString.trim().length > 0) { throw new ToolError("SQLite write paths do not support query parameters"); } const normalized = subPath.replace(/^:+/, "").trim(); if (!normalized) { throw new ToolError("SQLite write path must target a table"); } const separatorIndex = normalized.indexOf(":"); const table = separatorIndex === -1 ? normalized : normalized.slice(0, separatorIndex); const key = separatorIndex === -1 ? undefined : normalized.slice(separatorIndex + 1); if (!table) { throw new ToolError("SQLite write path must target a table"); } if (key !== undefined && key.length === 0) { throw new ToolError("SQLite row writes require a non-empty row key"); } return { table, key }; } /** * Write tool implementation. * * Creates or overwrites files with optional LSP formatting and diagnostics. */ export class WriteTool implements AgentTool { readonly name = "write"; readonly approval = (args: unknown): ToolApprovalDecision => { const { path: rawPath, content } = args as Partial; if (typeof rawPath !== "string") return "write"; // Unwrap a hashline `[path#TAG]` wrapper first (parity with execute) so a // wrapped `[scheme://h/x#ABCD]` gets the same tier as the bare URL. return InternalUrlRouter.instance().writeTier( unwrapHashlineHeaderPath(rawPath), typeof content === "string" ? content : undefined, this.session, ); }; readonly formatApprovalDetails = (args: unknown): string[] => { const params = args as Partial; const targetPath = typeof params.path === "string" ? params.path : "(missing)"; const content = typeof params.content === "string" ? params.content : ""; const unwrapped = unwrapHashlineHeaderPath(targetPath); if (extractUriScheme(unwrapped) === "xd") { try { const parsed: unknown = JSON.parse(content); return [`Path: ${truncateForPrompt(targetPath)}`, ...formatApprovalArguments(parsed, { showEmpty: true })]; } catch { // Non-JSON payload falls back to raw content } } return [`Path: ${truncateForPrompt(targetPath)}`, `Content:\n${truncateForPrompt(content)}`]; }; /** * `always-ask-cached` key: the path this write targets, unwrapped from a * hashline `[path#TAG]` header exactly like the approval tier above, so all * writes to one file share a single grant. */ readonly approvalCacheTargets = (args: unknown): readonly string[] | undefined => { const rawPath = (args as Partial | null | undefined)?.path; if (typeof rawPath !== "string" || rawPath.length === 0) return undefined; const key = approvalTargetKey(this.session.cwd, unwrapHashlineHeaderPath(rawPath)); return key === undefined ? undefined : [key]; }; readonly label = "Write"; get description(): string { const deviceOnly = this.session.deviceOnlyWrite === true && this.session.pendingFullWriteDescription !== true; return prompt.render(deviceOnly ? writeDeviceOnlyDescription : writeDescription); } readonly parameters = writeSchema; readonly strict = true; readonly concurrency = "exclusive"; readonly loadMode = "essential"; /** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */ matcherDigest(args: unknown): string | undefined { const content = (args as Partial).content; return typeof content === "string" ? content : undefined; } readonly #deferredDiagnostics: DeferredDiagnostics | undefined; constructor(private readonly session: ToolSession) { this.#deferredDiagnostics = session.queueDeferredDiagnostics ? new DeferredDiagnostics(session) : undefined; } /** Resolves the LSP writethrough from the current `lsp.*` settings so changes apply to the next write. */ #lspWritethrough(): { writethrough: WritethroughCallback; deferred: DeferredDiagnostics | undefined } { if (!(this.session.enableLsp ?? true) || !cfgLspEnabled.get(this.session.settings)) { return { writethrough: writethroughNoop, deferred: undefined }; } const { settings } = this.session; const enableDiagnostics = cfgLspDiagnosticsOnWrite.get(settings); const dedup = enableDiagnostics && cfgLspDiagnosticsDeduplicate.get(settings); const writethrough = createLspWritethrough(this.session.cwd, { enableFormat: cfgLspFormatOnWrite.get(settings), enableDiagnostics, transformDiagnostics: dedup ? (path, result) => getDiagnosticsLedger(this.session).reduce(path, result) : undefined, requestTimeoutSec: cfgLspRequestTimeout.get(settings), }); return { writethrough, deferred: enableDiagnostics ? this.#deferredDiagnostics : undefined }; } async #resolveArchiveWritePath(writePath: string, signal?: AbortSignal): Promise { const candidates = parseArchivePathCandidates(writePath).filter(candidate => candidate.archivePath !== writePath); if (candidates.length === 0) { return null; } const fallbackCandidate = candidates[candidates.length - 1]!; const fallback: ResolvedArchiveWritePath = { absolutePath: await resolvePlanPath(this.session, fallbackCandidate.archivePath, signal), archivePath: fallbackCandidate.archivePath, archiveSubPath: normalizeArchiveWriteSubPath(fallbackCandidate.subPath), exists: false, }; for (const candidate of candidates) { const absolutePath = await resolvePlanPath(this.session, candidate.archivePath, signal); try { const stat = await Bun.file(absolutePath).stat(); if (stat.isDirectory()) { continue; } return { absolutePath, archivePath: candidate.archivePath, archiveSubPath: normalizeArchiveWriteSubPath(candidate.subPath), exists: true, }; } catch (error) { if (!isArchivePathNotFound(error)) { throw error; } } } return fallback; } async #writeArchiveEntry( content: string, contentBytes: number, rawContent: string, resolvedArchivePath: ResolvedArchiveWritePath, signal?: AbortSignal, ): Promise> { const parentDir = path.dirname(resolvedArchivePath.absolutePath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } // Canonicalize the parent when the archive is not yet present. Existing // archive symlinks resolve to their target, so every spelling locks the // same resource before reading any members. const finalPath = await fs.realpath(resolvedArchivePath.absolutePath).catch(async error => { if (!isEnoent(error)) throw error; return path.join(await fs.realpath(parentDir), path.basename(resolvedArchivePath.absolutePath)); }); // A realpath swap can land on a name without an archive extension; a // whole-archive rewrite then defaults to an uncompressed tar. const inferredFormat = archiveFormatFromPath(finalPath); const format = inferredFormat ?? "tar"; if (!isWritableArchiveFormat(format)) { throw new ToolError(`Writing entries inside ${format} archives is not supported (read-only format).`); } return withFileLock( finalPath, async () => { signal?.throwIfAborted(); // A second writer may have created the archive while this one waited. let exists = resolvedArchivePath.exists; if (!exists) { try { await fs.stat(finalPath); exists = true; } catch (error) { if (!isEnoent(error)) throw error; } } const entries = new Map(); if (exists) { try { // A plain tar indexes lazily, so nothing upstream bounds its size; the // rewrite holds every member in memory, so reject before reading any. if (format === "tar") assertInMemorySize((await fs.stat(finalPath)).size, DEFAULT_ARCHIVE_LIMITS); const existing = await readArchiveEntries({ path: finalPath, format }); for (const [entryPath, data] of existing) { entries.set(entryPath, data); } } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } } const writeTarget = `${resolvedArchivePath.archivePath}:${resolvedArchivePath.archiveSubPath}`; const sel = readSelectorForEmptyWrite(writeTarget, content); if (sel !== undefined && !entries.has(resolvedArchivePath.archiveSubPath)) { throwReadSelectorMisfire(writeTarget, sel); } const existingTarget = entries.get(resolvedArchivePath.archiveSubPath); if (existingTarget !== undefined && endsWithReadTruncationNotice(rawContent)) { const existingBytes = existingTarget instanceof Blob ? new Uint8Array(await existingTarget.arrayBuffer()) : existingTarget; const existingText = typeof existingBytes === "string" ? existingBytes : decodeUtf8Text(existingBytes); assertNotShorterReadProjection(writeTarget, rawContent, existingText ?? undefined, content); } entries.set(resolvedArchivePath.archiveSubPath, content); // Publish atomically while holding the lock; a distinct temporary // path also avoids collisions with stale files left by prior runs. const tmpPath = `${finalPath}.tmp-${process.pid}-${Bun.randomUUIDv7()}`; try { await writeArchive(tmpPath, format, entries); signal?.throwIfAborted(); await fs.rename(tmpPath, finalPath); } catch (error) { await fs.rm(tmpPath, { force: true }).catch(() => {}); throw new ToolError(error instanceof Error ? error.message : String(error)); } const outputPath = `${formatPathRelativeToCwd(resolvedArchivePath.absolutePath, this.session.cwd)}:${ resolvedArchivePath.archiveSubPath }`; return { content: [{ type: "text", text: `Successfully wrote ${contentBytes} bytes to ${outputPath}` }], details: { resolvedPath: resolvedArchivePath.absolutePath }, }; }, { signal }, ); } async #resolveSqliteWritePath(writePath: string, signal?: AbortSignal): Promise { const candidates = parseSqlitePathCandidates(writePath).filter(candidate => candidate.sqlitePath !== writePath); if (candidates.length === 0) { return null; } const fallbackCandidate = candidates[candidates.length - 1]!; const fallbackTarget = parseSqliteWriteTarget(fallbackCandidate.subPath, fallbackCandidate.queryString); const fallback: ResolvedSqliteWritePath = { absolutePath: await resolvePlanPath(this.session, fallbackCandidate.sqlitePath, signal), sqlitePath: fallbackCandidate.sqlitePath, table: fallbackTarget.table, key: fallbackTarget.key, exists: false, }; let sawExistingNonSqlite = false; for (const candidate of candidates) { const target = parseSqliteWriteTarget(candidate.subPath, candidate.queryString); const absolutePath = await resolvePlanPath(this.session, candidate.sqlitePath, signal); try { const stat = await Bun.file(absolutePath).stat(); if (stat.isDirectory()) { continue; } const kind = specialFileKind(stat); if (kind) { throw new ToolError( `Cannot write '${candidate.sqlitePath}': it is a ${kind}, not a regular file or directory.`, ); } if (!(await isSqliteFile(absolutePath))) { sawExistingNonSqlite = true; continue; } return { absolutePath, sqlitePath: candidate.sqlitePath, table: target.table, key: target.key, exists: true, }; } catch (error) { if (!isArchivePathNotFound(error)) { throw error; } } } if (sawExistingNonSqlite) { return null; } return fallback; } async #writeSqliteRow( displayPath: string, content: string, resolvedSqlitePath: ResolvedSqliteWritePath, ): Promise> { let db: Database | null = null; try { if (!resolvedSqlitePath.exists) { throw new ToolError(`SQLite database '${displayPath}' not found`); } db = new Database(resolvedSqlitePath.absolutePath, { create: false, strict: true }); db.run("PRAGMA busy_timeout = 3000"); // FK enforcement is per-connection and off by default; enable it so edits // honor the schema's REFERENCES constraints and ON DELETE/UPDATE actions. db.run("PRAGMA foreign_keys = ON"); const trimmedContent = content.trim(); let resultText: string; if (trimmedContent.length === 0) { if (!resolvedSqlitePath.key) { throw new ToolError("SQLite deletes require a row key in the path"); } const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table); const deleted = lookup.kind === "pk" ? deleteRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key) : deleteRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key); resultText = deleted > 0 ? `Deleted row '${resolvedSqlitePath.key}' from ${resolvedSqlitePath.table}` : `No row deleted from ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`; } else { let parsedContent: unknown; try { parsedContent = Bun.JSON5.parse(content); } catch (error) { throw new ToolError( `SQLite write content must be valid JSON5: ${error instanceof Error ? error.message : String(error)}`, ); } if (!isRecord(parsedContent)) { throw new ToolError("SQLite write content must be a JSON object"); } if (resolvedSqlitePath.key) { const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table); const updated = lookup.kind === "pk" ? updateRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key, parsedContent) : updateRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key, parsedContent); resultText = updated > 0 ? `Updated row '${resolvedSqlitePath.key}' in ${resolvedSqlitePath.table}` : `No row updated in ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`; } else { insertRow(db, resolvedSqlitePath.table, parsedContent); resultText = `Inserted row into ${resolvedSqlitePath.table}`; } } return toolResult({ resolvedPath: resolvedSqlitePath.absolutePath }) .text(resultText) .sourcePath(resolvedSqlitePath.absolutePath) .done(); } catch (error) { if (isEnoent(error)) { throw new ToolError(`SQLite database '${displayPath}' not found`); } if (error instanceof ToolError) { throw error; } throw new ToolError(error instanceof Error ? error.message : String(error)); } finally { db?.close(); } } async execute( _toolCallId: string, { path: rawPath, content: rawContent }: WriteParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { // Strip a hashline `[path#TAG]` wrapper up front so every downstream // decision (scheme routing, internal-URL handler dispatch, plan-mode // guard, plan path resolution, ACP bridge routing) sees the same // filesystem target. Without this, a model that pastes a `read` // header as the `path` arg would slip past internal-URL detection // (which fails on a leading `[`) and the bridge router would send a // `[local://scratch.md#ABCD]` write to the editor instead of the // session-local sandbox. // Peel a read-tool selector (`:raw`, `:1-20`, …) so the write target matches // what `read` resolves for the same URL; line-range/malformed selectors throw. // A `:conflict://N` target is normalized to its URL; the note tells the model. const router = InternalUrlRouter.instance(); const recovered = recoverConflictUriPrefix(router.peelWriteSelector(unwrapHashlineHeaderPath(rawPath), "write")); const path = recovered.path; const target = router.writeTarget(path); const policy = target?.spec.write; if (rawContent === undefined && !(target && policy?.contentOptional?.(target.url))) { throw new ToolError(`content is required for ${path}.`); } const content = rawContent ?? ""; // Coordination writes (peer messages, background-work cancellation) and replies to the // user touch no files, so neither gate below applies to them. const touchesNoFiles = policy?.scope === "coordination" || policy?.scope === "reply" || (target !== undefined && policy?.cancels?.(target.url) === true); // A device-only session grants `write` purely as the device transport (see // createTools): device dispatches, file-free writes, and the session's // local:// sandbox proceed; every other target is rejected before any // handler, guard, conflict resolver, or bridge sees it. if ( this.session.deviceOnlyWrite === true && policy?.scope !== "device" && !touchesNoFiles && !(await targetsLocalSandbox(this.session, path, signal)) ) { // A device-only session that still has `yield` is the read-only subagent // shape: `write` is present only as the xd:// transport, yet the parent's // briefing names a file path. Without naming the channel that does work, // the rejection reads as a dead end (#14308). Keyed on `yield` being // active rather than on an `isReadOnlyAgent` flag, which never reaches // this session. When both the disk path and `yield` apply, the template // names them as one route (return the path or the content through // `yield`) instead of two competing deliverables. throw new ToolError( prompt.render(writeDeviceOnlyRefusal, { diskPath: localFileDiskPathHint(this.session), canYield: this.session.isToolActive?.("yield") === true, }), ); } return untilAborted(signal, async () => { // Text payloads get hashline display prefixes ([PATH#HASH] + LINE:) stripped if the model // copied them from read output. Verbatim payloads (messages, process stdin, setting // values, conflict directives) reach their handler exactly as the model wrote them. const verbatim = policy?.payload === "verbatim"; const { text: cleanContent, stripped } = verbatim ? { text: content, stripped: false } : stripWriteContent(this.session, content); // One UTF-8 scan per call, shared by the progress and result lines. const cleanBytes = Buffer.byteLength(cleanContent, "utf8"); assertWriteTargetAddressable(path, router); if (target) { if (policy?.via === "handler") { // Device payloads are dispatch arguments, not resource text, so only // non-device text writes are checked against a truncated read projection. if (!verbatim && target.spec.backing !== "device" && endsWithReadTruncationNotice(content)) { let currentContent: string | undefined; try { currentContent = (await router.resolve(path, sessionResolveContext(this.session, { signal }))) .content; } catch (error) { // A missing target has no source to discard, as for a new disk file (readCurrentWriteSource). if (!isEnoent(error)) throw error; } assertNotShorterReadProjection(path, content, currentContent, cleanContent); } // Handler-owned writes mutate state outside the sandbox unless they touch no // files (peer messages, replies, background-work cancellation) or dispatch a // device (which keeps each dispatched tool's own tier and policy). if (policy?.scope !== "device") { if (!touchesNoFiles) { await enforcePlanModeWrite(this.session, path, { op: "update", signal }); } emitWriteProgress(onUpdate, cleanBytes, path); } const handlerResult = await router.write( path, cleanContent, sessionWriteContext(this.session, { signal, toolCall: { id: _toolCallId, onUpdate, context }, }), ); // The flag tells the prewalk gate this write was not implementation work. const fileFreeDetails: WriteToolDetails = touchesNoFiles ? { touchesNoFiles: true } : {}; if (handlerResult) { const result: AgentToolResult = { content: handlerResult.content, details: { ...handlerResult.details, ...fileFreeDetails }, isError: handlerResult.isError, useless: handlerResult.useless, }; if (recovered.note) appendNoteToResult(result, recovered.note); return result; } let resultText = `Successfully wrote ${cleanBytes} bytes to ${path}`; if (stripped) { resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } return { content: [{ type: "text", text: resultText }], details: fileFreeDetails }; } // Read-only scheme: the router rejects the write with its uniform error. if (!policy) await router.write(path, cleanContent); // `via: "file"`: the pipeline below writes the located target (resolvePlanPath), // so write and read share one path. } const resolvedArchivePath = await this.#resolveArchiveWritePath(path, signal); if (resolvedArchivePath) { await enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, { op: resolvedArchivePath.exists ? "update" : "create", signal, }); emitWriteProgress( onUpdate, cleanBytes, `${formatPathRelativeToCwd(resolvedArchivePath.absolutePath, this.session.cwd)}:${ resolvedArchivePath.archiveSubPath }`, resolvedArchivePath.absolutePath, ); const archiveResult = await this.#writeArchiveEntry( cleanContent, cleanBytes, content, resolvedArchivePath, signal, ); if (stripped) { const firstText = archiveResult.content.find( (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string", ); if (firstText) { firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } } return archiveResult; } const resolvedSqlitePath = await this.#resolveSqliteWritePath(path, signal); if (resolvedSqlitePath) { await enforcePlanModeWrite(this.session, resolvedSqlitePath.sqlitePath, { op: "update", signal }); emitWriteProgress(onUpdate, cleanBytes, path, resolvedSqlitePath.absolutePath); const sqliteResult = await this.#writeSqliteRow(path, cleanContent, resolvedSqlitePath); if (stripped) { const firstText = sqliteResult.content.find( (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string", ); if (firstText) { firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } } return sqliteResult; } await assertNotReadSelectorMisfire(path, cleanContent, this.session.cwd); await enforcePlanModeWrite(this.session, path, { op: "create", signal }); const absolutePath = await resolvePlanPath(this.session, path, signal); // A located URL write keeps its URL identity in progress, results, and the hashline header. const displayPath = target ? path : formatPathRelativeToCwd(absolutePath, this.session.cwd); const batchRequest = getLspBatchRequest(context?.toolCall); const existing = await fs.stat(absolutePath).catch(() => undefined); if (target && existing?.isDirectory()) { throw new ToolError(`${target.url.protocol}// URL must resolve to a file: ${path}`); } const kind = existing && specialFileKind(existing); if (kind) { throw new ToolError(`Cannot write '${path}': it is a ${kind}, not a regular file or directory.`); } // Overwriting a live SQLite database or `-shm` destroys it (issue #15586). if (existing) { try { assertNotHeldOpen(absolutePath); } catch (error) { throw new ToolError(`Cannot write '${path}': ${(error as Error).message}`); } } // Check if file exists and is auto-generated before overwriting. if (existing) { await assertEditableFile(absolutePath, path, this.session.settings); } await assertNotTruncatedFileReadProjection( this.session, path, absolutePath, displayPath, content, cleanContent, ); // An ACP editor saves with its buffer's own line endings and encoding, so a write routed to // it goes as given. Elsewhere an unreadable file keeps today's behavior; the write reports why. const bridge = this.session.getClientBridge?.(); const editorOwnsFile = bridge?.capabilities.writeTextFile === true && bridge.writeTextFile !== undefined && (await shouldRouteWriteThroughBridge(this.session, path, absolutePath)); const convention = existing?.isFile() && !editorOwnsFile ? await readTextFileConvention(absolutePath, existing.size, signal).catch(() => undefined) : undefined; // `untilAborted` has already rejected the call if it was cancelled during the scan; don't write. signal?.throwIfAborted(); const { text: writeContent, applied } = applyTextFileConvention(cleanContent, convention); const writeBytes = writeContent === cleanContent ? cleanBytes : Buffer.byteLength(writeContent, "utf8"); emitWriteProgress(onUpdate, writeBytes, displayPath, absolutePath); // Try ACP bridge first for editor-visible filesystem paths. Internal // artifacts such as local:// plans are owned by OMP, not the editor. const bridgeWrite = await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal); if (bridgeWrite) { // `write` always replaces the whole file, so (unlike hashline's // hunk-scoped diff) there's no size cost to keying the header/ // executable-bit check on the verified post-write content — // use it so a drifted write (e.g. client format-on-save) still // hands back a tag that matches what's actually on disk. const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, bridgeWrite.text); const header = maybeWriteSnapshotHeader(this.session, absolutePath, bridgeWrite.text, displayPath); const writeLine = `Successfully wrote ${cleanBytes} bytes to ${displayPath}`; let resultText = header ? `${header}\n${writeLine}` : writeLine; if (stripped) { resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } if (madeExecutable) { resultText += `\n${EXECUTABLE_NOTICE}`; } return { content: [{ type: "text", text: resultText }], details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined }, }; } const { writethrough, deferred } = this.#lspWritethrough(); const diagnostics = await writethrough(absolutePath, writeContent, signal, undefined, batchRequest, dst => deferred?.begin(dst), ); if (!deferred || batchRequest?.flush === false) { this.session.bumpFileMutationVersion?.(absolutePath); } const finalContent = diagnostics.finalContent; const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, finalContent); const header = maybeWriteSnapshotHeader(this.session, absolutePath, finalContent, displayPath); const finalBytes = finalContent === writeContent ? writeBytes : Buffer.byteLength(finalContent, "utf8"); const writeLine = `Successfully wrote ${finalBytes} bytes to ${displayPath}`; let resultText = header ? `${header}\n${writeLine}` : writeLine; if (stripped) { resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } // A non-final batch entry returns before the batch's formatter runs: claim nothing yet. const conventionNote = batchRequest?.flush === false ? undefined : textFileConventionNote(applied, finalContent); if (conventionNote) { resultText += `\n${conventionNote}`; } if (madeExecutable) { resultText += `\n${EXECUTABLE_NOTICE}`; } if (!diagnostics.diagnostics) { return { content: [{ type: "text", text: resultText }], details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined }, }; } return { content: [{ type: "text", text: resultText }], details: { resolvedPath: absolutePath, diagnostics: diagnostics.diagnostics, madeExecutable: madeExecutable || undefined, meta: outputMeta() .diagnostics(diagnostics.diagnostics.summary, diagnostics.diagnostics.messages ?? []) .get(), }, }; }); } }