Skip to main content
Glama
README.md
# 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

```ts
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](#installing) for why that distinction was load-bearing here.

## Command line

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

```sh
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

```json
{ "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](test/mcp.test.ts) 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

```sh
npm install fencepost
```

[![npm](https://img.shields.io/npm/v/fencepost)](https://www.npmjs.com/package/fencepost)

`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`](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"](docs/design-01-lease-and-fence.md).

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/`](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

```sh
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.