Mount
flockfs mount ~/FlockfsLive # runs in the foreground; Ctrl-C to stop
flockfs mount research ~/ResearchLive # a specific drive (id, slug or slug-id)
flockfs unmount ~/FlockfsLive # from another terminal (the mount process then exits)flockfs mount DRIVE DIR is the same as flockfs --drive DRIVE mount DIR; with one argument it is
the folder, and the drive is --drive / FLOCKFS_DRIVE, else the agent token's drive, else the
server's default (giving two different drives is an error). The drive is resolved to its id
when the mount starts, so renaming it meanwhile changes nothing.
Global options apply as usual (--url, --token/--token-file, --actor, --drive;
FLOCKFS_SANDBOX targets a sandbox). Flags: --read-only, --port N (default: any free port), --no-mount
(serve only and print the mount command).
DIR must be empty (it is created if missing). Every program sees the workspace there:
ls, editors, grep -r, coding agents. Nothing is copied to disk. Reads come from the
server; writes go to the server about 300 ms after the last write to a file.
Compared with flockfs sync: sync keeps a real copy (works offline, any tool, full local speed);
mount keeps none (nothing to reconcile, always current, needs the server).
How it works
flockfs mount runs a user-space NFSv3 server on 127.0.0.1 (the
nfsserve crate) and mounts it with the operating
system's own NFS client, so no kernel extension or macFUSE is needed (the FUSE-T / XetHub
approach). macOS lets a user mount NFS onto a folder they own without sudo.
macOS command (printed by --no-mount):
/sbin/mount_nfs -o vers=3,tcp,port=P,mountport=P,rsize=1048576,wsize=1048576,acregmin=0,acregmax=0,acdirmin=1,acdirmax=1,soft,sync,nolocks,locallocks,intr,nonegnamecache,deadtimeout=30,nobrowse[,ro] 127.0.0.1:/ DIRacregmin=0,acregmax=0: the kernel asks for a file's attributes before using cached pages (answered from memory), so it never works from content older than what flockfs last told it, and remote changes to files are visible immediately. This costs some speed on read-heavy scans (see below).acdirmin=1,acdirmax=1: folder attributes are cached for a second. Without it (noac) every path lookup asked again for each folder on the way, so eachstat/openofpkg/node_modules/x/y.jscost several round trips before reaching the local disk (a plainstatmeasured 6.6 ms under load, 0.27 ms with this). A listing shows a file created elsewhere up to a second later; looking it up by name finds it at once (nonegnamecache).sync: everywrite(2)is an RPC, so a refused write (lock, read-only) fails the call that made it rather than a laterclose.locallocks:flock/fcntllocks work, but only on this machine.soft,intr,deadtimeout=30: if theflockfs mountprocess dies, calls fail instead of hanging, and the kernel drops the mount after 30 s.
Linux (mounting needs root and an NFS client such as nfs-common, so flockfs mount prints the
command instead of running it unless it runs as root):
flockfs mount /mnt/flockfs --port 11111 & # prints the command below and keeps serving
sudo mount -t nfs -o vers=3,tcp,port=11111,mountport=11111,rsize=1048576,wsize=1048576,acregmin=0,acregmax=0,acdirmin=1,acdirmax=1,soft,sync,mountproto=tcp,nolock,lookupcache=positive 127.0.0.1:/ /mnt/flockfsA kernel mount smoke test on Ubuntu (Linux 6.8) verified text reads and writes, appends, atomic saves preserving file identity, binary roundtrips, remote file visibility, directory creation, moves, deletion and unmounting. Automatic process exit after an external unmount was not verified because the test server ran in a separate container mount namespace.
Mapping
| File system | flockfs |
|---|---|
| fileid | derived from the flockfs file id (root = 1): stable across renames and moves (files created through the mount get a session-local id until the next mount) |
lookup, readdir, getattr | the server listing, held in memory. The long-poll change feed refreshes it on every event (30 s safety refresh), so changes made elsewhere appear within tens of milliseconds |
| size, mtime | size of the current revision; mtime is the server's time of that revision (modified_ms in listings: when the file's content, mode or path last changed). The same in every mount, so cargo/make see an unchanged file as unchanged after a remount; a new revision gets a new mtime, so editors and the kernel notice. Files written through this mount show the time of the local write until the next mount |
read | whole file fetched once per revision (GET /files/{id}), ranges served from memory; reads past offset 0 within a second reuse the same snapshot, so a file is never read torn between revisions |
write, setattr(size) | per-file buffer of offset writes / truncation; committed ~300 ms after the last write, on unmount, and on Ctrl-C/SIGTERM |
| commit | pure appends → POST /files/{id}/append (atomic; concurrent appenders all land). Otherwise PUT /files/{id} with the base revision and the base content (base), which the server three-way merges for text; a stale binary is a conflict |
create | a new file is uploaded at its first commit (POST /entries, parents created; created with an executable mode, it is uploaded executable) |
| mode | 0755 for executable files (and folders), 0644 otherwise; chmod sets or clears the file's executable bit on the server (PATCH /files/{id}). Other mode bits are not stored |
mkdir / remove / rmdir / rename | create folder / DELETE / DELETE / PATCH (move; links are rewritten by the server) |
| symlinks | only ignored folders are symlinks (see below); creating one is not supported (ENOTSUP) |
Concurrent edits
NFS writes are offsets into what the kernel last saw, which may be older than the server's
latest content (someone is typing in the browser). The mount remembers what it last served or
accepted for each file (its view). A program that rewrites the file (truncate + write, or
write-temp + rename) has its change from that view replayed onto the newest content with the
server's merge (line level, then character level), and the result is sent with the newest
revision as base. Appends whose offset is the end of an older version are moved to the real
end. In testing, a simulated browser typing 20 characters/s into line 1 while the mount
appended lines and rewrote lines 2 and 3 lost nothing on either side. If the two changes
touch the same characters, the mount's version is saved next to the file as
name (conflict <unix time>).ext and the file shows the server's version. A stale binary
write is handled the same way.
Atomic saves keep file identity
- Write temp + rename over (VS Code atomic save,
os.replace, many tools): the temp file's content becomes a new revision of the existing file (same id, history, links, locks). If the temp file was already uploaded it is deleted. - Rename away + new file (vim
backupcopy=no, JetBrains___jb_old___): a file renamed onto a scratch name stays bound to its flockfs file for 3 s; a new file created (or renamed) at the old path within that time is the old file. If nothing takes its place, the rename is carried out on the server after 3 s. - In-place saves (
>, vim's default here) are ordinary writes.
Local-only names
The sync daemon's litter list (.DS_Store, ._* AppleDouble, .Spotlight-V100, .Trashes,
swap/backup files such as *.swp, *~, 4913, .#*, JetBrains temp files, .git, .flockfs)
plus .nfs* (the NFS client's "silly rename" of a deleted-but-open file) are accepted and kept
in memory only: they work for the programs that create them and are never uploaded. They
disappear when the folder is unmounted, including anything under .git.
Ignored paths stay on this machine
The drive's .gitignore and .flockfsignore files (root and nested, gitignore syntax, read
from the drive and re-read whenever one changes) decide what the mount keeps local. Nothing
created at an ignored path reaches the server.
Ignored folders are symlinks to real local folders. mkdir target (or node_modules, at
any depth) where the folder is ignored creates a real folder on this machine and shows it in
the mount as a symlink to it:
web/node_modules -> $TMPDIR/flockfs-scratch/<drive id>/live/web/node_modulesThe kernel follows the symlink itself, so everything below it (a cargo build, npm ci,
thousands of small files) runs on the local disk at native speed; the mount only answers the
lookups of the folders on the way (one round trip each, cached for a second). The scratch
folder mirrors the drive's layout, so packages that find each other by walking up from their
real path still do. Details:
- Persistent local caches. Mounts use
<base>/<drive id>/live, so ignored build folders survive remounts. A concurrent mount uses a private scratch folder if that slot is locked.<base>isFLOCKFS_SCRATCH_DIR, else$TMPDIR/flockfs-scratch. Plainflockfs runuses a private scratch folder. - Removal is instant.
rm -rf node_modules(andnpm ci, which starts with it) removes the symlink; its folder is renamed into<base>/.trashat once and deleted in the background, so the nextmkdirstarts empty. - Renames. A folder renamed onto an ignored path becomes local: cargo creates
target/as.tmpXXXX/+ rename, so the.tmpXXXXfolder briefly exists in the drive and is deleted from it by the rename, its files moved into the local folder. An ignored folder can be renamed to another ignored path (its local folder moves too), not to a tracked one (EXDEV). Moving a tracked folder moves the ignored folders inside it along. - Ignore files changing. When a path stops being ignored its symlink disappears (the local
folder is kept and comes back if the rule does); when a path becomes ignored, entries already
in the drive there are hidden and a new
mkdirmakes a local folder. Rules apply to names as they are created, from the ignore files in the drive (an edit is in effect once saved, ~0.3 s). - Cleanup. When a mount starts it removes, in the background, the trash, private folders of
mounts that are gone, and stable folders of its drive that were unused for 14 days. Folders in use are never
touched. To reclaim space by hand:
rm -rf $TMPDIR/flockfs-scratchwhile nothing is mounted.
Ignored files outside an ignored folder (.env, *.sqlite) are listed, readable, writable
and chmod-able like any other, are never uploaded, and are kept by the mount process (large
ones in a temporary file) until it ends. Entries already in the drive at ignored paths are not
shown. A scratch file renamed to a non-ignored path is uploaded then; a file that becomes ignored
after it was uploaded stays in the drive but is not shown any more.
Why symlinks: the alternative, serving ignored folders from the mount process (what this did
before), sends every open/write/unlink of a build through NFS RPCs to a user-space server.
npm ci of 14 packages (3,085 files; ~1 s on a local disk) did not finish in 10 minutes and
could not be interrupted (processes stuck in uninterruptible NFS waits), removing what it left
took 220 s, cargo's incremental cache failed on its lock file (ENOTSUP) and big builds hit
ESTALE. A symlink costs one lookup per path and keeps no build state in the server.
Locks
- Someone else's whole-file lock: the first write (or
O_TRUNC) fails immediately withEACCES("Permission denied"); nothing is buffered. The mount log names the holder, the reason and the expiry. - A section lock is only known when the commit is refused (423): the buffered version is
kept (and readable in the folder), further writes to that file fail with
EACCES, the log names the holder, and the commit is retried every 5 s, landing once the lock is released. If the folder is unmounted first, the unsaved content is written to$TMPDIR/flockfs-mount-unsaved-<time>/<path>and the path is printed.
Read-only
A read-only credential (role: viewer, e.g. a read-only agent token) or --read-only mounts
with ro; every change fails with EROFS ("Read-only file system").
Stopping
Ctrl-C, SIGTERM or flockfs unmount DIR (which tries umount, then diskutil unmount force,
then umount -f). The mount process unmounts first, while it is still serving, so the kernel
flushes its last writes; then it commits every buffered write and exits. Plain umount DIR
works too: the process notices within a second.
Measurements
MacBook (Apple silicon), macOS 26, release builds, local server and PostgreSQL:
| mount | ~20 ms |
stat | 0.13 ms |
cat of a 1 KB file | 0.4 ms |
ls of 10 folders (300 files) | 3.5 ms |
grep -r over 300 files (1 KB each) | 430 ms cold, 180 ms warm (with actimeo=1 instead of noac: 16 ms warm, but see noac above) |
append (>>) syscall | ~4 ms; on the server after ~400 ms (300 ms idle + tick) |
| overwrite → on the server | ~400 ms |
| API write → visible in the folder | ~10 ms |
cp of a 3 MB binary | 37 ms; on the server after ~0.5 s |
mkdir / rm / mv | 6 ms / 28 ms / 65–100 ms (the server's link rewriting dominates mv) |
Ignored folders (October 2026, debug builds, a machine at load average 25–110 from parallel builds, so absolute numbers are noisy; before = the per-mount in-memory scratch):
| before | after | |
|---|---|---|
npm ci in packages/bash-tool (14 packages, 3,085 files; 0.9–1.1 s natively) | > 10 min, unkillable (killed with -9) | 9–12 s; a second npm ci 32–35 s |
rm -rf node_modules (2,192 entries) | 220 s | 0.05–0.08 s |
3,000 small files below node_modules/ (test) | — | 0.74 s |
cargo build of a 20-module crate, target/ in the mount | fails (ENOTSUP on the incremental lock); with CARGO_INCREMENTAL=0 9.4 s, and a target folder lands in the drive | 1.3 s |
second cargo build in a fresh mount of the same drive | recompiles (mtimes = mount start): 0.7–1.2 s, Compiling; with target/ in the mount it is gone anyway | 0.15–0.18 s, fresh |
Limitations
- Needs the server: no offline mode. With the server gone, calls fail after the soft timeout and unsaved buffers are kept in memory until the process exits (then dumped as above).
- Writes are durable on the server ~300 ms after the last write, not when
write(2)orclosereturn (NFSv3 has noclose).fsyncdoes not wait for the commit. - Text files are fetched whole per revision, binary files are base64 over JSON: large files (up to the 25 MiB limit) are slow to read the first time and to commit.
- No symlinks (other than ignored folders), no hard links, no permissions other than the
executable bit, no extended attributes (macOS stores xattrs in
._*files, which stay local). Times set bytouch -tare ignored. Paths through an ignored folder still pay one NFS lookup per folder on the way (npm cimeasured 9–35 s vs ~1 s natively on a heavily loaded machine); a build that needs full speed can use the real folder (cd "$(readlink target)"orCARGO_TARGET_DIR). - File handles do not survive a restart of
flockfs mount(the kernel reports stale handles; remount). - Local-only files (including
.git/) live in memory and vanish on unmount, as do ignored files outside ignored folders; ignored folders persist per drive (see above). - Linux mounting is documented but not tested; Windows is not supported.
flockfs run
flockfs run [--drive D] [--path FOLDER] [--read-only] [--agent-token-name NAME [--scope FOLDER]]
[--sync | --mount] [--dir DIR] [--keep] [-q] -- COMMAND [ARGS…]
flockfs run -- claude
flockfs run --drive research --path inbox -- python agent.py
flockfs run --read-only -- grep -r TODO .Runs COMMAND in a live workspace (the idea of Turso AgentFS's agentfs run):
-
Workspace. The drive is mounted as above (same NFS server, in the
flockfs runprocess) at a new/tmp/flockfs-run-XXXXXXXXfolder (0700), or at--dir DIR(must be empty for a mount).--read-only(or a viewer credential) mountsro. -
Fallback. If the mount cannot be made (Linux without root,
mount_nfsfailing,FLOCKFS_RUN_NO_MOUNT=1),flockfs runsays so and uses a synced folder instead: one fullflockfs sync --oncepass, then the sync daemon in its own process group for live updates both ways; at the end the daemon is stopped with SIGTERM and a last pass sends what is left. The folder then contains.flockfs/(checkpoint,run-sync.log). With--read-onlythe synced folder is a read-only snapshot taken at start (files and folderschmod a-w, no live updates, nothing sent).--syncalways uses this mode;--mountfails instead of falling back. The mount is the default because it needs no copy and starts in milliseconds; the fallback keepsflockfs runworking everywhere the sync daemon does. -
Command. It starts in the folder (or
--path FOLDERinside it, created if missing and writable) with stdin/stdout/stderr passed through and this environment:Variable Value FLOCKFS_URLthe server FLOCKFS_DRIVEthe drive's permanent id FLOCKFS_TOKEN_FILEa 0600 file in $TMPDIRholding the run's token, deleted afterwards (FLOCKFS_TOKENis removed from the environment; the CLI readsFLOCKFS_TOKEN_FILE)FLOCKFS_WORKSPACEthe mount (or synced) folder FLOCKFS_SESSIONa new UUID; every flockfs Clientwith it set (the CLI, andflockfs run's own mount or sync) sends it asX-Flockfs-Session, so the run's changes can be groupedA short header names the drive and folder on stderr unless
-q. -
Signals. SIGINT, SIGTERM, SIGHUP and SIGQUIT are caught by
flockfs runand passed to COMMAND; a Ctrl-C typed at the terminal already reaches COMMAND (same process group), so it is not sent twice. A signal never skips cleanup. -
End. When COMMAND exits: unmount (the kernel flushes its last writes), commit every buffered write (up to 30 s; what cannot be saved, e.g. refused by a lock or permissions, is copied to
$TMPDIR/flockfs-run-unsaved-<time>/and reported), remove the folder unless--keep(a--dirfolder is only removed ifflockfs runcreated it and it is empty; a folder that is still mounted is never emptied), delete the token file, and exit with COMMAND's exit code, or 128 + N if it was killed by signal N. Errors offlockfs runitself exit 1.
Least privilege: --agent-token-name NAME [--scope FOLDER]. For drive owners: before
COMMAND starts, flockfs run creates an agent token labelled NAME (POST /api/tokens, days: 1)
with write (read with --read-only), limited to --scope FOLDER (default: --path; with
neither, the whole drive). Both the workspace and COMMAND use that token, so COMMAND sees only
what the token may read, cannot change anything outside the scope (through the folder or the
API), and its changes are recorded as agent:<token id> and shown as NAME. When COMMAND exits
the token is revoked (DELETE /api/tokens/{id}); if that fails it is reported, and the token
expires within a day anyway. Through a mount, creating a file where the token may not write
succeeds locally but is refused when committed (the copy is saved as described in step 5).
Non-owners get an error.