Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage provider seam (resolve/stat/readText/streamText/writeText/editText with branded FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new dsh-file-context package owns the model-facing policy (read windowing, observed-state, write/edit freshness) as the concrete ctx.fileContext service. Authorization is now freshness-based rather than full/partial view: a windowed read records the file version and authorizes a later edit when the file is unchanged, removing the dead-end where reading lines 100-150 of a large file could not edit line 120. editText stays a provider primitive so version guard + literal match + atomic rewrite remain one critical section, and the stale check runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects fileContext, never reaching around to ctx.fs (the no-bypass contract).
6.0 KiB
Filesystem
The filesystem stack is split across four packages: a provider seam (dsh-fs, ctx.fs, text IO + guarded mutation), a local implementation (dsh-fs-local, local disk), a policy layer (dsh-file-context, ctx.fileContext, read windowing + write/edit freshness), and a consumer (dsh-tool-fs, the model-facing read/write/edit tools). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in core.md. A sandboxed, remote, virtual, or project-scoped backend can implement the same FileSystem service without changing the policy layer or the tool schemas.
Provider source: packages/fs/fs/src/types.ts and packages/fs/fs/src/index.ts. Policy source: packages/fs/file-context/src/types.ts.
Target identity and metadata (provider seam)
Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display displayPath, but must not parse targetKey (a branded opaque id) or assume it is a local absolute path.
interface FsTarget {
inputPath: string
targetKey: FsTargetKey
displayPath: string
}
The backend owns file-version tokens — the freshness token a write/edit guards against. The policy layer stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings.
type FsTargetKey = Branded<'FsTargetKey'>
type FsVersion = Branded<'FsVersion'>
stat returns metadata (never content), or undefined when the target is absent. type lets the policy layer reject directories/special files before reading, and size lets it choose readText vs streamText without probing by failure.
interface FsInfo {
version: FsVersion
type: 'file' | 'directory' | 'other'
size?: number
}
Write and edit guards (provider seam)
writeText takes an explicit write expectation rather than inferring intent. createIfAbsent creates a missing target and rejects an existing one with FS_NOT_OBSERVED; replaceIfVersion replaces only when the target exists at the observed version, else FS_STALE_VERSION.
type FsWriteExpectation =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
interface FsWriteOutcome {
operation: 'create' | 'update'
version: FsVersion
}
editText is a provider-level guarded mutation, not a read plus write composed in the policy layer. It verifies the expected version BEFORE literal matching (so a stale edit reports FS_STALE_VERSION, not a match failure against newer content), then applies the replacement and writes atomically — keeping matching, line-ending handling, stale checks, and atomic replacement inside one mutation critical section.
interface FsEditRequest {
oldString: string
newString: string
replaceAll: boolean
}
interface FsEditOutcome {
replacements: number
replaceAll: boolean
version: FsVersion
}
Execution context and read outcome (policy layer)
The policy layer needs just enough execution context to derive the observed-state owner. ToolExecution satisfies this shape, so dsh-tool-fs passes its execution object through without making dsh-file-context import the tool, agent, or session packages.
interface FileContextExec {
agent?: {
session?: object
}
}
A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing read tool renders carries the file's version at read time; there is no full/partial view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged.
interface FileReadRequest {
offset: number
limit: number
}
interface FileReadOutcome {
offset: number
limit: number
lines: FileTextLine[]
totalLines: number
truncatedByBytes?: true
version: FsVersion
}
Observed-file state (policy layer)
Observed state is a WeakMap<owner, Map<targetKey, { version }>> inside ctx.fileContext. An entry exists iff the owner has read that target through ctx.fileContext.read, so its presence is the read record — there is no separate hasRead flag and no view distinction. The owner is normally exec.agent.session, but the policy layer treats it as opaque and never reads its fields. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety).
Error taxonomy (provider seam)
Filesystem failures use stable FsErrorCode strings carried by FsError (HarnessError). The tool registry preserves { name, code } on error results, so retry, permission, and UI layers can branch without parsing text.
type FsErrorCode =
| 'FS_NOT_FOUND'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_STALE_VERSION'
| 'FS_NOT_OBSERVED'
| 'FS_AMBIGUOUS_EDIT'
| 'FS_EDIT_NOT_FOUND'
| 'FS_ABORTED'
FS_NOT_OBSERVED means no recorded read exists for this owner (or a createIfAbsent hit an existing file). FS_STALE_VERSION means the backend version no longer matches the observed one. Freshness authorization has no partial/full distinction, so there is no FS_PARTIAL_OBSERVATION.
The services
FileSystem (ctx.fs, abstract) owns the provider primitives: resolve, stat, readText, streamText, writeText, and editText. FileContext (ctx.fileContext, concrete) injects fs and owns the model-facing policy: read windows text and records observed state, write/edit derive the freshness expectation and refresh state. The generated wiring catalog shows the exact service signatures on events-and-services.md.