Skip to main content
Glama

fencepost

Leases with fencing tokens for agents that share one working tree. Windows-safe, no daemon, and the core imports nothing outside node:*.

Several coding agents editing the same repository need mutual exclusion. The usual answer is a lockfile holding a pid, and it fails in a way that is not fixable by better stale detection: after a takeover, the evicted holder keeps everything it needs to write and nothing is in a position to refuse it. fencepost gives every claim a strictly increasing number and refuses any operation carrying a number that is no longer current — so a holder that was declared dead, was written around, and then woke up cannot land a write.

Library

import { createStore, acquire, check, release } from 'fencepost';

const store = createStore('.fencepost');
const migrations = { kind: 'lock', target: 'db:migrations' } as const;

const lease = acquire(store, migrations, { ttlMs: 30_000, owner: 'claude-code' });
try {
  if (check(store, migrations, lease.token)) {
    // the only party whose write is authorized
  }
} finally {
  release(store, lease);
}

Every protocol step is an exclusive file creation. No rename, no replace, no compare-and-swap, no coordinator — which is also why it behaves on Windows, where renaming a directory another process holds a handle inside fails with EBUSY and stays failed under real contention.

Requires Node 22.6+, which runs .ts directly, so working from a checkout needs no build step and the core pulls in nothing outside node:*. What ships is compiled — see Installing for why that distinction was load-bearing here.

Related MCP server: agentclaim

Command line

The CLI imports nothing but node:*, so an agent that can only shell out gets the whole protocol.

fencepost claim  --lock db:migrations --ttl 30000   # prints a token; exit 3 if held
fencepost check  --lock db:migrations 17
fencepost run    --lock db:migrations -- npx prisma migrate deploy
fencepost status

run holds and renews for the child's entire lifetime and propagates its exit code. A token from claim is kept only until its ttl, because the CLI process exits and nothing renews it — that is the protocol working as designed rather than a gap: a lease is only as alive as the process keeping it.

Exit codes: 0 success, 1 error, 3 held or stale token.

MCP server

{ "mcpServers": { "fencepost": { "command": "fencepost-server", "args": ["--root", "/path/to/project"] } } }

Tools: claim, assert, renew, release, write, status.

Two things this earns over the library alone. write verifies the token, resolves the target, and verifies again immediately before writing, so the check-then-write window sits on the authority's side of the boundary instead of being hoped about. And there is no daemon: each agent spawns its own server process, and a real test proves they contend correctly through the filesystem rather than through a shared in-process registry.

The MCP SDK is an optional peer dependency: only fencepost/server reaches it, and the library and CLI import nothing outside node:*.

Installing

npm install fencepost

npm

npm view fencepost versions shows a 0.0.0-stage entry ahead of the real releases. That is a placeholder npm created when the first publish attempt went through npm stage; dist-tags.latest points at the real version and nothing installs it unless you ask for it by name. It is left alone deliberately: npm's rules for unpublishing a package younger than a few days can take the whole package with it, which is a bad trade for removing one line from a list.

Installs with zero transitive dependencies — the MCP SDK is an optional peer, so a consumer that never imports fencepost/server never sees express, hono, cors or zod. npm run verify:package proves the artifact rather than the source: it packs the tarball, installs it into a throwaway directory offline, imports it from .mjs, runs the installed CLI, and compiles a TypeScript consumer against the shipped .d.ts.

Getting it installable took two fixes that were invisible from inside the repository:

  • The entry point used to be src/index.ts. Node refuses to strip types under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so the package was unusable once installed while all 36 in-repo tests passed — they run against source, not against the artifact. npm run build now emits dist/ with declarations, using rewriteRelativeImportExtensions so the source can keep its explicit .ts specifiers.

  • The SDK was declared under optionalDependencies, which npm installs by default: the consumer's tree came back with 94 packages (express, hono, cors, jose, zod). Optional peer dependencies with peerDependenciesMeta.optional is what actually keeps them out.

Both are asserted by verify-package.mjs, so the class of mistake is closed rather than the two instances.

Measured cost

bench/RESULTS.md is generated by npm run bench on one Windows box, and it does not flatter this library: a fenced acquire + release costs roughly an order of magnitude more than proper-lockfile's lock + unlock, and one hot resource serializes to a few dozen operations per second. The exact figures are left to that file rather than repeated here — p50 on this machine drifts by tens of percent between runs, and a number typed into prose is a number that silently goes stale.

The conclusion is stable even where the digits are not: this is a tool for claiming a migration or a port, not for guarding a per-file write path.

Durability is not one switch. A claim's flush is load-bearing — lose it and the next grantor can be handed the same generation number, giving two authorized holders — while the tombstone a release writes is deliberately unflushed, worth 1.8x on the round trip, because losing it only delays the next contender. See "Which fsyncs are load-bearing".

The benchmark also runs the scenario both libraries are actually distinguished by, as two real OS processes. A holder wedges its event loop past its own expiry, a second process takes over, and the wedged holder resumes:

  • proper-lockfile — the rival got in, the original holder's onCompromised never fired, and its release() deleted the lockfile that belonged to the rival.

  • fencepost — the successor got a higher generation, and the wedged holder's gate call returned false and its release became a no-op.

That is not a knock on proper-lockfile, whose own README lists this exact cause; it is the difference between being stopped at your next refresh and being stopped at the write.

What is not guaranteed

Read docs/ before relying on any of it. In short:

  • Writes that bypass the gate are outside the guarantee. An agent editing with its own tool is not stopped by a fence it never consulted.

  • A hard-killed holder blocks the resource until its ttl expires. Nothing inspects the dead process — no heartbeat, no exit hook, no pid check — because liveness detection is the thing this design refuses to trust. ttl is a tuning knob, not a formality.

  • Hard links are two identities. realpath does not unify them while stat reports one (dev, ino) pair, so the true identity is available and unused. Closing it needs multi-key acquisition, which rewrites every signature in lease.ts; it is deferred on budget, not on knowledge, and a test pins the current behaviour so the docs must change along with the fix.

  • Network shares are untested, and a clock that steps forwards can expire a lease early — the fence is what makes that survivable, not the clock logic.

Layout

path

what it holds

src/atomic.ts

filesystem primitives, restricted to operations Windows promises

src/record.ts

claim wire format, and the live/expiry rule

src/identity.ts

resource identity — the equality test the whole lock rests on

src/lease.ts

the protocol: acquire, renew, release, and the fence gate

src/cli.ts

command line entry point, no dependencies

src/server.ts

MCP server, mediated writes, path confinement

test/

contention, hard kill, clock steps, corruption, CLI exit codes, MCP round trip

test/verify-package.mjs

installs the built tarball elsewhere and uses it as a stranger would

bench/

latency, contention throughput, the two-process wedged-holder comparison, generated results

docs/

three design notes: the guarantees, the identity evidence, the server

Development

npm install
npm test              # 39 tests, ~18s, runs against source (no build needed)
npm run typecheck
npm run build         # emits dist/ (JS + .d.ts) from tsconfig.build.json
npm run verify:package # packs the tarball, installs it offline, uses it as a stranger
npm run bench         # regenerates bench/RESULTS.md, takes ~1min

prepublishOnly runs the build and the package verification, so the artifact that gets published is the one just proven installable. Once dist/ exists, npm test also picks up the compiled-server case, and skips it when it does not.

License

MIT.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Coherence guard for shared files: when two agents write the same file, the stale writer is denied instead of silently overwriting, then reacquires and retries. Single-host, TLA+-verified.
    6
    11
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables multiple AI coding agents to safely collaborate in the same git working tree by managing file ownership, merging writes, and preventing snapshot races.
    10 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides a hardened filesystem interface that lets a local model read and write files only within user-specified directories, with symlink protection and atomic operations.
    18 npm
    2
    MIT
  • A
    license
    C
    quality
    C
    maintenance
    Enables safe coordination of Herdr coding agents with lease-scoped reviewer and writer lifecycles, semantic waits for agent states, and read-only topology inspection, while prohibiting arbitrary shell execution and unscoped lifecycle operations.
    44
    MIT