189 lines
13 KiB
Markdown
189 lines
13 KiB
Markdown
# 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/utils/zip.ts` — the unified ZIP/tar wrapper: parse `archive.ext:entry` selectors and rewrite the archive whole.
|
||
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite paths and perform row insert/update/delete.
|
||
- `packages/coding-agent/src/tools/conflict-detect.ts` — parse `conflict://` URIs and splice recorded merge-conflict regions.
|
||
- `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; `conflict://*` bulk-resolves every registered 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:
|
||
|
||
```text
|
||
path: "src/generated/config.json"
|
||
content: "{\n \"enabled\": true\n}\n"
|
||
```
|
||
|
||
```text
|
||
path: "fixtures/archive.zip:templates/email.txt"
|
||
content: "hello\n"
|
||
```
|
||
|
||
```text
|
||
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 writes set `details.resolvedPath` to the archive's absolute path; 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/utils/zip.ts`, checks candidate archive files on disk (longest match first), and falls back to the shortest candidate archive path 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 `unzip()`, the target entry is replaced in an in-memory map, and the archive is reframed with `zip()` + `Bun.write()` (both from `packages/coding-agent/src/utils/zip.ts`, over `node:zlib`).
|
||
- `.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; explicit parent-directory creation only exists in the archive branch, but `Bun.write()` itself creates missing parent directories for plain file writes.
|
||
|
||
Example:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
path: "data/app.sqlite:users:42"
|
||
content: "{email: 'ada@example.com'}"
|
||
```
|
||
|
||
Example delete:
|
||
|
||
```text
|
||
path: "data/app.sqlite:users:42"
|
||
content: ""
|
||
```
|
||
|
||
## Side Effects
|
||
- Filesystem
|
||
- Creates or overwrites plain files.
|
||
- Rewrites entire archive files when writing an archive entry.
|
||
- Explicitly creates parent directories (via `fs.mkdir`) for archive files only; plain file writes get parent directories from `Bun.write()`.
|
||
- 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` for tar; ZIP read/write is framed in `packages/coding-agent/src/utils/zip.ts` over the `node:zlib` DEFLATE codec.
|
||
- 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 `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 `ToolError`s, 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 `ToolError`s 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.
|