Filesystem (fs.mount)
In-process versioned filesystem for AI agents — POSIX-style writeFile/readFile/bash().exec() backed by atomic git commits.
coregit.fs.mount() returns a MountedFS handle that exposes the repo as a filesystem. Reads are lazy through git objects; writes batch into one or more atomic commits. A bash() sub-helper runs shell commands server-side over the same overlay, so the agent's writes show up to cat, grep, npm, anything.
import { CoregitClient } from "@coregit/sdk";
const cg = new CoregitClient({ apiKey: process.env.COREGIT_API_KEY! });
const fs = await cg.fs.mount({
repos: [{ slug: "my-repo" }],
mode: "rw",
});
await fs.writeFile("/my-repo/src/README.md", "hello world");
const r = await fs.bash().exec("cat /my-repo/src/README.md");
console.log(r.stdout);
await fs.unmount();Up to 10 repos can be mounted in one handle. Paths are namespaced by slug — /my-repo/... for the example above. Single-repo mounts also accept the slug-less form (fs.writeFile("/README.md", ...)).
Throws on error unlike the rest of the SDK.
MountedFSis the one place we use the POSIX-feelawait fs.writeFile(...)API and surface failures as exceptions (MountErrorand subclasses). All other SDK methods return{ data, error }.
Open a Mount
const fs = await cg.fs.mount({
repos: [{ slug: "my-repo", branch: "agents/alice" }],
mode: "rw",
branch: "main", // default for repos that don't override
author: { name: "alice", email: "alice@example.com" },
commitMode: "auto",
});| Option | Required | Description |
|---|---|---|
repos | Yes | Array of { slug, namespace?, branch? }. 1–10 entries. |
mode | No | "rw" (default) or "ro". In "ro" mode every mutating method throws MountReadOnlyError. |
branch | No | Default branch applied to repos that don't set their own. Defaults to "main". |
author | No | { name, email } used for auto-mode commits. Defaults to coregit-sdk <sdk@coregit.dev>. |
commitMode | No | See Commit modes below. Default "auto". |
cache | No | Local LRU blob cache (default 64 MiB, on). See Cache. |
session | No | Bind the mount to a Zero-Wait session. See Sessions. |
execStrategy | No | "remote" (default), "local", or "auto". Where bash().exec() runs. See Exec strategy. |
mount() is purely client-side construction — no HTTP — so it's cheap to call repeatedly.
Commit Modes
commitMode controls when buffered writes turn into git commits.
| Mode | Behaviour | Best for |
|---|---|---|
"auto" (default) | Every writeFile/mkdir/rm/mv triggers an immediate POST /commits for that single change. Bash sees the new HEAD on the next call. | Quick experimentation, one-off agent steps. Mental model: "everything is persisted instantly." |
"manual" | Writes accumulate in an in-memory queue keyed by repo. commit({message}) flushes the queue. bash() does not flush — the shell sees only what was already committed. | Composing a large, named commit from many small writes. |
"on-exec" | Like manual, but bash().exec() flushes pending writes into the same exec call (via pre_apply_changes) so the shell sees them, then commits the whole batch. | Agent loops where writes and shell commands interleave and you want one clean commit per loop iteration. |
// Manual mode — accumulate, then flush
const fs = await cg.fs.mount({
repos: [{ slug: "my-repo" }],
mode: "rw",
commitMode: "manual",
});
await fs.writeFile("/my-repo/a.md", "...");
await fs.writeFile("/my-repo/b.md", "...");
await fs.writeFile("/my-repo/c.md", "...");
const shas = await fs.commit({ message: "draft a/b/c" });
// → { "my-repo": "a1b2c3..." }Reads
All read methods go directly to the public /tree and /blob endpoints — buffered writes are not visible to reads in manual/on-exec modes until you commit() or run bash().
readFile(path, options?)
const text = await fs.readFile("/my-repo/README.md");
const bytes = await fs.readFile("/my-repo/img.png", { encoding: "binary" });UTF-8 string by default. Pass { encoding: "binary" } to get a Uint8Array.
Hits the local cache first (if enabled) and short-circuits via If-None-Match on a 304 — repeat reads of the same blob avoid both the network RTT and the body transfer.
readFiles(paths, options?)
const blobs = await fs.readFiles([
"/my-repo/README.md",
"/my-repo/src/a.ts",
"/my-repo/src/b.ts",
]);
// → Map<string, string | Uint8Array>
for (const [path, content] of blobs) console.log(path, content);Collapses N readFile round-trips into one POST /v1/repos/:slug/blobs/batch (up to 100 paths per repo). Cache hits are served locally; only the misses are batched. Throws MountPathError("ENOENT") if any path doesn't exist.
Multi-repo mounts route automatically — paths are grouped by repo, and one batch request goes per repo.
readFileRange(path, start, end)
const slice = await fs.readFileRange("/my-repo/big.parquet", 0, 1023);
// → Uint8Array of 1024 bytesRead a byte range without loading the whole blob client-side. Bypasses the 50 MB blob cap (the Range header lifts it). Inclusive byte offsets, HTTP semantics. Useful for tailing logs or reading specific records out of large data files.
createReadStream(path, options?)
const stream = await fs.createReadStream("/my-repo/asset.bin");
// → ReadableStream<Uint8Array>
for await (const chunk of stream) {
// process incrementally
}Web ReadableStream over the /raw endpoint — no JSON envelope, no base64 inflation. Optional { start, end } for a range slice. Use this for big files where you don't want them all in memory client-side.
watch(options?)
for await (const ev of fs.watch()) {
console.log("commit:", ev.sha);
}Async iterator over branch-tip changes. Auto-reconnects on the server's 25 s rotation, auto-invalidates the local cache for the watched branch on each event. Single-repo mounts only.
See the Watch endpoint for the underlying SSE protocol.
prefetch(globs, options?)
const { entries, bytes, capped } = await fs.prefetch([
"src/**/*.ts",
"package.json",
]);Streams blobs matching the globs into the local cache via POST /prefetch (NDJSON). Subsequent readFile/readFiles for any prefetched path are zero-RTT. Caps: 16 MB total, 1 MB per blob, 32 globs (server-enforced). No-op when caching is disabled.
See the Prefetch endpoint for body shape and limits.
readdir(path, options?)
const entries = await fs.readdir("/my-repo/src");
// → [{ name, path, type: "file" | "folder", sha, mode }, ...]
const flat = await fs.readdir("/my-repo", { recursive: true });stat(path) / exists(path)
const st = await fs.stat("/my-repo/README.md");
// → { type: "file", size: 0, sha: "9c3a1f...", mode: "100644" }
if (await fs.exists("/my-repo/missing.md")) { /* ... */ }stat derives via the parent's readdir; size on file entries is currently 0 (server-side blob size isn't surfaced by the tree endpoint yet — readFile().length is the canonical source).
Writes
| Method | Behaviour |
|---|---|
writeFile(path, content) | Create or overwrite a file. content may be string (utf-8) or Uint8Array (base64-encoded on the wire). |
mkdir(path) | Equivalent to writeFile(path + "/.gitkeep", ""). Git doesn't track empty directories. |
rm(path) | Delete a file. Errors if the path doesn't exist. |
mv(oldPath, newPath) | Rename within a repo (single atomic operation) or across repos (read+write+delete fallback — not atomic across repos). |
In commitMode: "auto" each call triggers one POST /commits immediately. In manual/on-exec they accumulate in pending until you commit() or bash().
Bash
const r = await fs.bash().exec("ls -la /my-repo && cat /my-repo/README.md");
// r.stdout, r.stderr, r.exitCode, r.commits, r.changedFiles, r.executionTimeMsMulti-repo mounts hit POST /v1/workspace/exec; single-repo mounts hit POST /v1/repos/:slug/exec. In manual/on-exec modes the pending queue is sent as pre_apply_changes so the shell sees buffered writes; in on-exec the same call also commits everything.
| Option | Description |
|---|---|
cwd | Working directory inside the workspace. Default "/". |
env | Extra environment variables. |
commit | Override the mode-default commit behaviour. |
commitMessage | Message for the auto-generated commit. |
author | { name, email } for the commit author. Defaults to the mount's author. |
BashExecResult shape:
{
stdout: string;
stderr: string;
exitCode: number;
changedFiles: Record<string, { path: string; action: string }[]>;
commits: Record<string, string>; // repoKey → new commit SHA
reposMounted: string[];
executionTimeMs: number;
}Commit (manual flush)
const shas = await fs.commit({
message: "feat: add three drafts",
repos: ["my-repo"], // optional, defaults to all repos with pending changes
});
// → { "my-repo": "a1b2c3..." }Throws MountError if you call it in commitMode: "auto" (every write already commits). Throws MountConflictError on a CAS conflict against the branch — usually means another writer raced and you need to re-pull and retry.
Unmount
await fs.unmount();Pure client-side cleanup. In manual/on-exec modes, outstanding pending changes are discarded with a warning — call commit() first if you want them persisted.
Multi-Repo
const fs = await cg.fs.mount({
repos: [
{ slug: "frontend" },
{ slug: "backend" },
{ slug: "shared", branch: "main" },
],
mode: "rw",
});
await fs.writeFile("/frontend/src/App.tsx", "...");
await fs.writeFile("/backend/src/server.ts", "...");
const r = await fs.bash().exec(`
cd /frontend && grep -r 'API_URL' .
cd /backend && grep -r 'CORS_ORIGIN' .
`);Paths must start with /<slug>/. Cross-repo mv is supported but not atomic — it does a read on the source repo, write on the destination, then a delete on the source.
Cache
By default MountedFS keeps a 64 MiB byte-budget LRU keyed by <repo>:<branch>:<path> for blobs you read. Cached entries also store the git SHA so the next readFile of the same path can short-circuit via If-None-Match and accept a 304 from the server. Invalidation happens automatically on writeFile / rm / mv / commit / bash().exec().
const fs = await cg.fs.mount({
repos: [{ slug: "my-repo" }],
mode: "rw",
cache: { maxBytes: 128 * 1024 * 1024 }, // 128 MiB instead of 64
});
console.log(fs.cacheStats());
// → { entries: 47, bytes: 8421376, maxBytes: 134217728 }Disable with cache: { enabled: false }. cacheStats() returns null when disabled.
Sessions
Pin the mount to a Zero-Wait session so per-request DB/cache auth lookups are skipped. Three patterns:
// 1. mount() opens and unmount() closes — the simple case
await cg.fs.mount({
repos: [{ slug: "my-repo" }],
session: { autoOpen: true, ttlSeconds: 3600 },
});
// 2. re-use an already-open session id (caller manages lifetime)
await cg.fs.mount({
repos: [{ slug: "my-repo" }],
session: { id: existingSessionId },
});
// 3. open via SessionClient yourself
const { data } = await cg.session.open({ ttl_seconds: 7200, idle_extend: true });
// then pass data.session_id into mount()HttpClient injects X-Session-Id on every subsequent SDK request — including the underlying /blob, /blobs/batch, and /exec calls.
Exec Strategy
bash().exec() defaults to execStrategy: "remote" — the command runs server-side over the in-memory CoW overlay. Set "local" (Node-only) to materialise the repo into a tempdir, run child_process.spawn, and commit the diff:
await cg.fs.mount({
repos: [{ slug: "my-repo" }],
execStrategy: "local",
});"auto" picks "local" when process.versions.node is available, otherwise falls back to "remote". Local mode is experimental: it sees only committed state (buffered writes from commitMode: "manual" aren't replayed onto the tempdir), skips binary blobs and symlinks, and is single-repo only.
Errors
| Class | .code | .errno | Thrown when |
|---|---|---|---|
MountError | "EIO" | -5 | Generic — wraps any HTTP failure, network error, or precondition violation. |
MountReadOnlyError | "EROFS" | -30 | A mutating call (writeFile, rm, mv, mkdir, commit) on a mode: "ro" mount. |
MountPathError | "ENOENT" (default) | -2 | Path doesn't start with /, doesn't match any mounted repo, or refers to a missing file (404 from server). |
MountConflictError | "ECONFLICT" | -1024 | CAS conflict when committing — branch HEAD moved underneath you. |
All MountError subclasses carry POSIX-style .code and Node-libuv .errno so callers can branch like node:fs:
import { MountConflictError, MountPathError } from "@coregit/sdk";
try {
await fs.readFile("/my-repo/missing.md");
} catch (err) {
if (err instanceof MountPathError && err.code === "ENOENT") {
// path doesn't exist — handle as "not found"
} else {
throw err;
}
}
try {
await fs.commit({ message: "..." });
} catch (err) {
if (err instanceof MountConflictError) {
// pull the new HEAD and retry
} else {
throw err;
}
}Authorization failures (401 / 403) become MountError with .code === "EACCES". Custom 409s on non-commit endpoints become MountConflictError.
Why this exists
Coregit's other endpoints (/blob, /tree, /commits, /workspace/exec) are the building blocks. fs.mount() is the agent-facing facade that mirrors how an agent already thinks about working with a project: open a workspace, read/write files, run npm install, then commit. No clones, no temporary directories, no manual git plumbing — but every change is a real git commit with a real SHA, on a branch you can review or merge.