1dba122c创建于 1 天前历史提交

write

Create or overwrite a file, writable internal resource, archive entry, SQLite row, or merge-conflict resolution.

Source

  • Entry: packages/coding-agent/src/tools/write.ts
  • Model-facing prompt: packages/coding-agent/src/prompts/tools/write.md
  • Key collaborators:
    • packages/coding-agent/src/tools/archive-reader.ts — parse archive.ext:entry selectors.
    • packages/coding-agent/src/tools/sqlite-reader.ts — detect SQLite paths and perform row insert/update/delete.
    • packages/coding-agent/src/lsp/index.ts — format-on-write and diagnostics writethrough.
    • packages/coding-agent/src/tools/auto-generated-guard.ts — block overwriting generated files.
    • packages/coding-agent/src/tools/fs-cache-invalidation.ts — invalidate shared FS scan caches after writes.
    • packages/coding-agent/src/tools/plan-mode-guard.ts — resolve paths and enforce plan-mode write policy.

Inputs

Field Type Required Description
path string Yes Target path. Plain file path writes a filesystem file. Writable internal URLs are delegated to their handler. archive.ext:inner/path writes an archive entry for .tar, .tar.gz, .tgz, or .zip. db.sqlite:table inserts a row. db.sqlite:table:key updates or deletes a row. conflict://<id> resolves a recorded merge conflict.
content string Yes Full replacement file content, archive entry content, internal-resource content, conflict replacement, or SQLite row payload. SQLite non-delete writes must parse as a JSON5 object. Empty or whitespace-only content deletes a SQLite row when path includes a row key.

Worked examples:

path: "src/generated/config.json"
content: "{\n  \"enabled\": true\n}\n"
path: "fixtures/archive.zip:templates/email.txt"
content: "hello\n"
path: "data/app.sqlite:users:42"
content: "{name: 'Ada', active: true}"

Outputs

Single-shot result.

  • Success always returns a text block.
    • Plain file write: Successfully wrote <chars> bytes to <relative-path> (the count is cleanContent.length, not encoded byte length).
    • Internal URL write: Successfully wrote <chars> bytes to <url>.
    • Archive write: Successfully wrote <chars> bytes to <relative-archive-path>:<entry-path>.
    • SQLite write: one of Inserted row into <table>, Updated row '<key>' in <table>, No row updated ..., Deleted row ..., No row deleted ....
    • Conflict resolution: conflict-specific success text, with fresh hashline snapshot headers when applicable.
  • If hashline prefixes were copied from read output and stripped first, the first text block gets an extra note.
  • In hashline display mode, plain file writes (including ACP bridge writes) and conflict resolutions prepend a fresh ¶<relative-path>#TAG header so the next edit has a current snapshot tag without an extra read. Bulk conflict resolutions append a Snapshots: block listing one header per successfully written file.
  • Plain file writes may also return details.diagnostics plus details.meta.diagnostics when LSP diagnostics-on-write is enabled, and details.madeExecutable when a newly written shebang file is chmodded executable.
  • SQLite writes use toolResult(...).sourcePath(...), so details.meta.sourcePath points at the database file.
  • Archive and internal URL writes return empty details.

Flow

  1. WriteTool.execute() in packages/coding-agent/src/tools/write.ts strips pasted ¶PATH#HASH headers and LINE: hashline prefixes from content when the session is in hashline display mode.
  2. If path is an internal URL whose handler exposes write, the tool delegates directly to handler.write(...) and returns.
  3. conflict://... paths are handled next by the merge-conflict resolver. Scope reads such as conflict://<id>/ours are rejected as read-only; writable conflict URIs must omit the scope.
  4. It calls #resolveArchiveWritePath() next. That uses parseArchivePathCandidates() from packages/coding-agent/src/tools/archive-reader.ts, checks candidate archive files on disk, and falls back to the longest matching archive suffix even when the archive file does not exist yet.
  5. Archive writes call enforcePlanModeWrite(..., { op: exists ? "update" : "create" }), then #writeArchiveEntry().
    • The parent directory of the archive file is created with fs.mkdir(..., { recursive: true }).
    • .zip archives are read with fflate.unzipSync(), the target entry is replaced in an in-memory map, and the archive is rewritten with fflate.zipSync() + Bun.write().
    • .tar, .tar.gz, and .tgz archives are read with Bun.Archive, existing entries are copied into an object map, the target entry is replaced, and Bun.Archive.write() rewrites the archive.
    • invalidateFsScanAfterWrite() runs on the archive file path.
  6. If the path is not treated as an archive, execute() calls #resolveSqliteWritePath(). That uses parseSqlitePathCandidates() and isSqliteFile() from packages/coding-agent/src/tools/sqlite-reader.ts. Existing non-SQLite files suppress the SQLite path interpretation.
  7. SQLite writes call enforcePlanModeWrite(..., { op: "update" }), then #writeSqliteRow().
    • The database must already exist; missing DBs throw SQLite database '<path>' not found.
    • The tool opens new Database(..., { create: false, strict: true }) and sets PRAGMA busy_timeout = 3000.
    • Whitespace-only content with a row key deletes a row.
    • Non-empty content is parsed with Bun.JSON5.parse(), must be a JSON object, and is routed to insert/update helpers from packages/coding-agent/src/tools/sqlite-reader.ts.
    • invalidateFsScanAfterWrite() runs on the DB path and the connection is closed in finally.
  8. Otherwise the tool treats path as a plain filesystem file.
    • enforcePlanModeWrite(..., { op: "create" }) runs before path resolution.
    • Existing files are checked by assertEditableFile() to block overwriting detected generated files.
    • ACP bridge writeTextFile is tried first when available; otherwise the session’s writethrough callback writes content. With LSP enabled and lsp.formatOnWrite / lsp.diagnosticsOnWrite settings on, createLspWritethrough() may format content, sync it through LSP servers, save it, and collect diagnostics. Otherwise writethroughNoop() writes directly with Bun.write() or file.write().
    • maybeMarkExecutableForShebang() may chmod the file executable when content starts with #!.
    • invalidateFsScanAfterWrite() runs on the file path.
  9. The tool returns a text result and optional diagnostics / executable metadata.

Modes / Variants

Plain file path

  • Target is any path that does not resolve as an archive selector and does not resolve as an existing-or-new SQLite selector.
  • Existing files are overwritten.
  • write.ts does not call fs.mkdir() on this path; parent-directory creation is only implemented in the archive branch.

Example:

path: "tmp/output.txt"
content: "hello\n"

Archive entry write

  • Selector syntax: archive.ext:inner/path.
  • Supported archive suffixes come from parseArchivePathCandidates(): .tar, .tar.gz, .tgz, .zip.
  • The inner path is normalized to /, strips empty and . segments, rejects .., and rejects directory targets ending in /.
  • Rewrites the whole archive file after replacing one entry.
  • Creates the parent directory for the archive file if needed.

Example:

path: "build/assets.tar.gz:css/app.css"
content: "body { color: black; }\n"

SQLite table insert

  • Selector syntax: db.sqlite:table.
  • content must parse as a JSON5 object.
  • Empty object is allowed and becomes INSERT INTO <table> DEFAULT VALUES.
  • Query parameters are rejected for SQLite writes.

Example:

path: "data/app.db:users"
content: "{name: 'Ada', active: true}"

SQLite row update / delete

  • Selector syntax: db.sqlite:table:key.
  • Non-empty content updates the row.
  • Empty or whitespace-only content deletes the row.
  • Row lookup uses the single-column primary key if present; otherwise it falls back to rowid. Composite primary keys and WITHOUT ROWID tables are rejected for key-based writes.

Example update:

path: "data/app.sqlite:users:42"
content: "{email: 'ada@example.com'}"

Example delete:

path: "data/app.sqlite:users:42"
content: ""

Side Effects

  • Filesystem
    • Creates or overwrites plain files.
    • Rewrites entire archive files when writing an archive entry.
    • Creates parent directories for archive files only.
    • Mutates existing SQLite databases; never creates a new SQLite DB.
    • Resolves conflict markers in files for conflict://... writes.
    • May chmod a shebang file executable after a successful plain-file write.
  • Subprocesses / native bindings
    • Uses Bun SQLite bindings via bun:sqlite.
    • Uses Bun archive APIs and lazily imports fflate for ZIP reads/writes.
    • May talk to configured LSP servers through packages/coding-agent/src/lsp/index.ts.
  • Session state (transcript, memory, jobs, checkpoints, registries)
    • Invalidates shared filesystem scan cache entries through invalidateFsScanAfterWrite().
    • Enforces plan-mode write restrictions before mutating the target.
  • Background work / cancellation
    • Marks the tool nonAbortable = true and concurrency = "exclusive" in WriteTool.
    • LSP writethrough can schedule deferred diagnostics fetches after a timeout, but plain write.ts only consumes the immediate return value.

Limits & Caps

  • WriteTool itself exposes no byte cap beyond storing content in memory and, for archives, rebuilding the archive in memory.
  • Generated-file detection reads at most CHECK_BYTE_COUNT = 1024 bytes and HEADER_LINE_LIMIT = 40 header lines from an existing file in packages/coding-agent/src/tools/auto-generated-guard.ts.
  • SQLite writes set PRAGMA busy_timeout = 3000.
  • LSP writethrough uses a 5_000 ms operation timeout in runLspWritethrough() and may schedule a deferred diagnostics fetch with AbortSignal.timeout(25_000) in scheduleDeferredDiagnosticsFetch().
  • Shebang executable handling depends on host filesystem chmod support.

Errors

  • Invalid archive subpaths throw ToolError with messages such as:
    • Archive write path must target a file inside the archive
    • Archive write path must target a file, not a directory
    • Archive path cannot contain '..'
  • SQLite path parsing throws on unsupported forms:
    • SQLite write paths do not support query parameters
    • SQLite write path must target a table
    • SQLite row writes require a non-empty row key
  • Missing SQLite DBs surface as SQLite database '<path>' not found.
  • SQLite content errors are model-visible ToolErrors, including invalid JSON5, non-object payloads, unknown columns, non-scalar values, empty update objects, composite primary keys, and WITHOUT ROWID tables.
  • Existing plain files may be rejected by assertEditableFile() when they look generated.
  • Conflict scope writes such as conflict://<id>/ours are rejected as read-only; invalid conflict IDs or missing conflict history surface as ToolErrors from the conflict resolver.
  • Archive read/write failures and unexpected SQLite exceptions are wrapped in ToolError(error.message).
  • If no LSP server matches or LSP formatting/diagnostics times out, file writes still fall back to writing content; diagnostics may be omitted.

Notes

  • Archive path detection runs before SQLite detection. A path that matches an archive selector is never treated as SQLite.
  • SQLite detection declines when an existing file with a .sqlite / .db suffix is present but does not have SQLite magic bytes; then the path falls back to a plain file write.
  • ZIP entry content is encoded with new TextEncoder().encode(content) in #writeArchiveEntry(). Non-ZIP archive writes pass the string directly to Bun.Archive.write().
  • The prompt forbids two common anti-patterns: using write for routine edits that should use edit, and creating *.md / README files unless explicitly requested. It also forbids emojis unless requested.
  • Plain file and internal URL writes report cleanContent.length as “bytes”, which is UTF-16 code units in JS, not an on-disk byte measurement.
  • stripWriteContent() only removes hashline prefixes when the session’s file display mode has hashLines enabled; otherwise content is written unchanged.