Files
oh-my-pi/docs/tools/write.md
T
can1357 31f3fbda61 feat(write): added snapshot header to write tool output in hashline mode
- Prepended `¶path#TAG` hashline header to plain file, ACP-bridge, and conflict resolution write results.
- Bulk conflict resolutions emit a trailing `Snapshots:` block with one header per written file.
- Suppressed when hashline display mode is disabled or for archive/SQLite/internal-URL targets.
- Added tests covering header presence, patcher usability, and disabled-mode suppression.
2026-05-29 06:42:46 +02:00

11 KiB
Raw Blame History

write

Create or overwrite a file, archive entry, or SQLite row.

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. 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.
content string Yes Full replacement file content, archive entry content, 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 <bytes> bytes to <relative-path>.
    • Archive write: Successfully wrote <bytes> 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 ....
  • 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.
  • SQLite writes use toolResult(...).sourcePath(...), so details.meta.sourcePath points at the database file.
  • Archive 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. It calls #resolveArchiveWritePath() first. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
    • 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().
    • invalidateFsScanAfterWrite() runs on the file path.
  7. The tool returns a text result and optional diagnostics 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.
  • 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().

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.
  • 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 writes report byte count using cleanContent.length, 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.