dsh-block-to-file
Model-facing block-to-file (b2f) runtime pipeline plugin.
Fenced code blocks whose info string contains file= are committed through the
selected transaction backend before any tool call of the same assistant message
executes. The default backend uses a plugin-owned Git object store; a host can
mount its own context repository backend to own version reads and publication. The workspace at $DSH_B2F_ROOT does not need a .git directory;
only the Git executable is required for the default backend. The plugin is not a tool: it observes
assistant/message, validates every block, compares the target blobs with the
agent's snapshot, and submits all files as one transaction. The default Git
backend publishes with git update-ref CAS.
Protocol
```python file=src/app.py
def main():
print("hello")
```
Attributes: file (required), mode=write|create|update|append|delete (default
write), plus one edit mode (below); diff=full|limited|stats|none (default
limited), encoding=utf-8, newline=preserve|lf|crlf.
An absolute file= path is accepted when it resolves inside the transaction
root and is rewritten to its relative form; a path that escapes the root is
rejected with PATH_ABSOLUTE.
append is computed from the observed blob and is idempotent: when that blob
already ends with the block content, b2f reports [b2f] append skipped.
All blocks in one assistant message are one transaction. If any target blob is stale, nothing commits and feedback includes each stale file's latest complete content, blob OID, repository revision, and intervening b2f commits. A stale response becomes the agent's new observation for an immediate retry.
The bare object store lives at <root>.b2f-git. Git index construction and
workspace-projection temp files live in <root>.b2f-tmp (or $DSH_B2F_TMP).
Both locations are outside the workspace.
Partial edits
A partial edit is a front-end only: b2f resolves (observed blob, patch) → full content and the result travels the same path as any full-content proposal
(stale comparison, precondition checks, ref CAS, projection, diff feedback).
Resolution is pure and runs against the exact bytes of the blob the model
observed — never the worktree and never through git apply, so no clean/smudge
filter, core.autocrlf, or apply.whitespace setting can alter content on the
way in.
editFormat selects one dialect. Only that dialect is described in the
system prompt and only its mode is accepted, so the model never has to choose
between patch formats; the other is rejected with EDIT_MODE_DISABLED.
editFormat: replace — anchors are content:
```python file=src/client.py mode=edit
<<<<<<< SEARCH
timeout = 1
=======
timeout = 3
retries = 5
>>>>>>> REPLACE
```
Each SEARCH is matched against the observed content and must match exactly once; spans must not overlap. An empty REPLACE deletes. Insert by keeping the anchor lines in both sections. Matching is order-independent, so a failure is always attributable to one edit.
editFormat: git_diff — anchors are line numbers plus context:
```python file=src/client.py mode=diff
@@ -40,3 +40,4 @@
def connect():
- timeout = 1
+ timeout = 3
+ retries = 5
return client
```
Emit hunks only — no diff --git, index, ---, or +++ lines. Stated line
counts are ignored and recomputed from the body (as git apply --recount does),
and the @@ start line is a hint: the hunk is located by searching outward
for its context, nearest match winning, up to maxEditDrift lines. This
tolerates the line drift that plain git apply cannot recover from.
Both dialects allow several edits per block and preserve the file's existing line
endings, so newline= is rejected on an edit block. When anchors do not resolve,
nothing commits and feedback returns the current content to re-anchor on.
Content echoed in [b2f] feedback is labelled path=, not file=, so a model
that copies an echo back verbatim writes nothing. Under git_diff those echoes
are line-numbered to match the read tool's format.
Configuration
b2f:
root: "$WS" # expands $WS / $DSH_B2F_ROOT; DSH_B2F_ROOT env wins
editFormat: git_diff # git_diff | replace | none
maxEditDrift: 200 # lines a mode=diff hunk may drift from its @@ line
maxFileSize: 1048576
maxTotalSize: 2097152
maxFilesPerMessage: 16
diffLineLimit: 200
canonicalRef: refs/heads/agent-canonical
maxCasRetries: 8
tempFileKeep: 16
Set editFormat: none to disable partial edits entirely.
Each settled transaction is emitted as b2f/transaction with the full report.
Per-block editFormat, editsProposed, editsApplied, and fuzz are carried
on every result, so first-apply success rate, retry counts, and drift tolerance
can be compared across dialects without this plugin aggregating anything.
root must be an absolute workspace path, but it does not need to be a Git
worktree. On first use b2f snapshots the workspace into its private bare store
and creates canonicalRef from that baseline; after that the canonical ref is
the only publication source of truth. Existing or nested Git worktrees contribute
their tracked files without exposing their .git object stores. Unrelated
concurrent b2f commits are retained when a candidate is rebuilt on the latest
canonical head.
Transaction backends
The reusable editing core is available separately from the DSH adapter:
import { parseFileBlocks, validateFileBlocks, resolveFileProposal,
validateResolvedFileProposals, GitBackend } from 'dsh-block-to-file/core'
import type { B2FBackend } from 'dsh-block-to-file/core'
This entry point does not load DSH or Cordis. A host supplies immutable observed content to the pure proposal resolver and validates the resolved batch before calling its backend. The package's main entry remains the compatible DSH plugin; its service binds Agent/Session lifecycle and scopes to this core. Writing files does not itself publish a domain event or advance a host's workflow.
maxFileSize and maxTotalSize limit the final UTF-8 replacement contents, in
addition to the incoming block bodies. Append, partial edits and newline
conversion cannot bypass them. The optional fields on B2FCommitConfig default
to 1 MiB and 2 MiB; existing backend implementations remain compatible. The DSH
adapter checks these limits before calling any managed backend, and the default
Git backend also enforces them for direct callers. Configuring larger limits is
supported: blob reads use their actual size and projection streams to disk.
The model-facing file-block syntax and edit dialects are the same for both modes.
With no backend on a resolved scope, b2f uses GitBackend and its private bare
store. With a backend, b2f resolves edits from that backend's immutable bytes and
submits the full replacements to that authority. It does not create a private
Git store, update a second ref, or run legacy publishers for that scope. A
backend error never falls back to standalone Git.
A host registers a stable backend object through the existing root resolver:
Integrations must first require ctx.b2f.backendProtocolVersion === 1 (also
exported as B2F_BACKEND_PROTOCOL_VERSION). Older services may support root
resolvers while ignoring scope.backend; resolver availability alone does not
establish backend support. Reject managed writes explicitly when this capability
is missing instead of falling back to a private Git authority.
import type { B2FBackend } from 'dsh-block-to-file'
const backend: B2FBackend = contextRepositoryBackend
const scope = {
root: contextCheckout,
scope: 'context-repo',
authorization: 'mounted-workspace' as const,
backend,
}
const dispose = ctx.b2f.registerRootResolver((_agent, _session, paths) =>
paths === undefined || paths.every(isContextPath) ? scope : undefined,
)
ctx.effect(() => dispose)
The host supplies contextRepositoryBackend, contextCheckout, and its path
ownership predicate. The backend may use the checkout's existing Git repository
or a different versioned store. Use one backend object per authority; don't
construct a fresh object on each resolver call. Changing root, scope, or backend
invalidates the agent's old snapshot and observations. A message spanning
multiple backends is rejected before any backend prepares or commits.
Backend contract
B2FBackend has four methods, each synchronous or asynchronous:
| Method | Responsibility |
|---|---|
captureSnapshot(context) |
Prepare a readable workspace and return its immutable starting revision. |
head(context) |
Return the current revision without mutating the workspace. |
readFile(context, revision, path) |
Read exact bytes and an opaque file version at that revision; return { content: null, fileVersion: 'absent' } for non-existence. |
commit(request) |
Atomically check all expected file versions and submit the whole batch; materialize the resulting view before reporting success. |
b2f validates block syntax, paths, configured limits and edit dialect, reads the
observed content, and resolves append/SEARCH-REPLACE/diff into full replacement
bytes. It reports failed existence conditions and unresolved edits without
calling commit; if their observations became stale, it returns fresh versions
instead. The backend receives changes with path, mode, content (null means
delete), expectedVersion, observedRevision, and b2f's diff/edit result. It must
recheck every expected version at publication, preserve unrelated concurrent
changes, and apply its domain validation and authorization. The earlier reads
are not an atomic concurrency barrier.
Requests include trusted root, scope, agentId, viewRevision, and a stable
Session/message transactionId. Managed backends can use that ID for durable
retry deduplication. The default Git backend retains its existing content-based
retry behavior. config.canonicalRef and config.tempFileKeep configure the
standalone Git backend; they do not define a managed backend's authority.
The backend returns the existing B2FReport union. Successful reports carry its
own revision/commit identifiers and the prepared changes' result values;
versions need not be Git OIDs. On a stale batch, return current content and
versions in staleFiles, with no partial write. On domain rejection, return a
failed report. If publication succeeded but materialization failed, return
projection-failed with the committed revision and own the forward recovery;
do not throw and imply that publication never happened. Same-message tools stay
blocked until an ok report, and subsequent model sampling awaits outstanding
b2f settlements even when the message contained no tools.
A read integration can record an exact managed observation with
ctx.b2f.recordObservation(agent.id, observation, scope) after preparing the
scope. The backend identity and scope must match the active snapshot. Provider
filesystem versions are not automatically interchangeable with backend versions.
Use await ctx.b2f.prepareSnapshot(agent, session, scope, config) for asynchronous
backends; captureSnapshot remains the synchronous compatibility entry point.
Managed snapshot preparation must succeed before model sampling. Direct callers
use commitToScope(..., scope, transactionId) for managed transactions; the
existing synchronous commit(...) API remains available for standalone Git.
registerPublisher is a separate, legacy post-commit bridge for standalone
Git transactions. Its failure can leave a local commit behind. Use a backend
when the external system must decide whether the file transaction commits.
Standalone workspace recovery
The Git backend serializes snapshot preparation, commit comparison, ref updates and filesystem projection across local b2f processes sharing one root. Its internal lock ref records a process owner; a dead owner's lock is reclaimed by CAS. Contention is bounded to 30 seconds and fails explicitly when still busy. This lock is local to one machine and does not coordinate arbitrary editors or shell writers. Projection rechecks local contents after staging, but ordinary tools should not write the same paths concurrently with a b2f transaction.
refs/b2f/projected records the last complete filesystem projection separately
from canonical. A restart can finish a transaction interrupted after publication,
including a partially projected multi-file batch. A new Agent does not replay
the entire bootstrap-to-head delta over user work: local edits, including a
deliberate restore to older bytes, are preserved. A proposal touching local drift
returns worktree-dirty; unrelated local edits are retained. Existing stores
without a projection cursor preserve their current workspace on upgrade rather
than guessing whether old-looking bytes are unfinished projection or user work.
Canonical publication is atomic. Filesystem replacement is atomic per file;
same-message tools wait for the complete batch. A projection failure after
publication reports its committed revision and keeps those tools blocked.
Temporary-file cleanup preserves unrelated files and other live processes'
staging files, including when roots share DSH_B2F_TMP.
Environment
| Variable | Purpose |
|---|---|
DSH_B2F_ROOT |
workspace root; file= paths are relative to it |
DSH_B2F_PLUGINS_DIR |
public plugin-artifact root (default $DSH_B2F_ROOT/plugins) |
DSH_B2F_PRIVATE_DIR |
private plugin state (default $DSH_B2F_ROOT/.b2f/plugins) |
DSH_B2F_TMP |
atomic-write temp dir (default <root>.b2f-tmp, outside the working tree) |
Testing
pnpm install
pnpm check
Usage
Install from npm and add the package to the DSH profile:
npm install dsh-block-to-file
dsh plugin --profile web add dsh-block-to-file
During local development, link this checkout instead (pnpm install && pnpm build, then dsh plugin --profile web add "$PWD").
Mount the plugin in the Host composition, where its b2f service can be shared
by every Agent session:
- id: block-to-file
name: 'dsh-block-to-file'
config:
root: $WS
Do not mount this service provider as a loose row in an Agent preset. A preset
that owns b2f must isolate the b2f service and place every consumer in that
same isolate realm.
b2f replaces the model-facing str_replace_editor write path. Remove or
disable the official editor in YOUR composition (preset / overlay) — this
package intentionally does not patch or remove any official plugin.
For generic per-agent checkouts or sandboxes, install a path-aware root
resolver at activation time and retain its Fiber-scoped disposer. Return
undefined for paths the resolver does not own so older registrations or the
default Session workspace can handle them:
const dispose = ctx.b2f.registerRootResolver(
(agent, session, paths) => paths?.every(isCheckoutPath)
? {
root: checkoutRootFor(agent, session),
scope: 'checkout',
authorization: 'mounted-workspace',
}
: undefined,
)
ctx.effect(() => dispose)
For standalone Git, a consumer may also register an async post-commit
publisher. Same-message tools await the newest publisher that claims the
transaction; a rejection becomes publication-failed and blocks those tools.
A successful receipt is rendered separately from the local workspace commit:
const disposePublisher = ctx.b2f.registerPublisher(async request => {
if (request.scope !== 'checkout') return undefined
const result = await publishCanonical(request)
return { scope: 'example', revision: result.revision, noOp: result.noOp }
})
ctx.effect(() => disposePublisher)
Every path is resolved independently. If one message spans more than one root
or named scope, the whole transaction fails with MIXED_ROOT_SCOPE. Resolvers
may prepare a scope asynchronously. The newest resolver returning a claim wins.
Roots that need asynchronous preparation are skipped by the pre-step snapshot
and captured on demand at commit time, so an async resolver never fails an
agent step. When ctx.sandboxPolicy is mounted, b2f consumes that same
per-Session policy: read-only rejects every mutation, workspace-write
accepts the Session root and trusted mounted-workspace claims, and
danger-full-access retains the configured b2f boundary. The default resolver
uses session.header.cwd, falling back to the static config.root / $WS /
$DSH_B2F_ROOT value. b2f pins an agent's canonical snapshot when its
repository view is first prepared and advances it only after commit or stale
feedback. When ctx.fs is mounted, a successful b2f settlement resolves and
stats each result through that provider and emits fs/observed before
same-message tools run. Provider-native FsVersion values are deliberately not
reused as Git blob observations; a read-capable plugin with exact b2f version
information may instead call ctx.b2f.recordObservation(agentId, {...}).
# in your preset or overlay cordis.yml
- id: str-replace-editor
disabled: true
tool-bash remains the single model-facing tool for operating on files.
No comments yet. Be the first to write one.