Cooperative FIFO reader/writer file locks for Claude Code,
implemented entirely as user-level hooks. When several Claude Code sessions/agents
run against the same working tree, a Read waits while another session is mid-Write,
and an Edit/Write/MultiEdit/NotebookEdit waits while another session is mid-Read
or Write — so concurrent agents don't clobber each other's file operations.
- Reader/writer semantics — many concurrent readers, exclusive writers.
- FIFO fairness — no writer starvation, no reader starvation.
- Multi-file locks — lock several files atomically (all-or-nothing).
- Self-healing — leases expire, stale locks are garbage-collected; a crashed session never wedges the lock.
- Git-safe — never touches anything under
.git/(Git keeps its own locking), and lock artifacts use a distinct.agentlocksuffix so they never collide with real dependency lockfiles (poetry.lock,Cargo.lock, …). - Fail-open — it's an advisory cooperative lock, not a security boundary; any bug in the lock code lets the operation through rather than blocking the agent.
No daemon, no database, no dependencies beyond Python 3.10+. State lives in tiny sidecar JSON files next to the locked files.
Tested and working well under both Claude Code
and Codex. The lock engine (file_lock_core.py)
is agent-agnostic — it reads a tool payload (JSON, with the target file path and the
pre/post event) on stdin and manages the .agentlock sidecars — so it runs under any agent
that can invoke a command on its pre-tool and post-tool lifecycle. Only the thin glue is
host-specific: the event names and the hookSpecificOutput permission schema are Claude
Code's. The bundled install.sh wires Claude Code; under Codex, point its
equivalent pre/post-tool hook at the very same read_file_lock.py / write_file_lock.py
scripts.
git clone https://github.com/Tiwas/claude-code-file-locks.git
cd claude-code-file-locks
./install.shinstall.sh is idempotent. It:
- copies the three hook scripts into
~/.claude/hooks/, - merges the hook wiring into
~/.claude/settings.json(preserving any existing settings and hooks; re-running never duplicates entries), - appends
*.agentlockand*.agentlock.mutexto your global git excludes file.
Then start a new Claude Code session (hooks load at session start).
Uninstall with ./uninstall.sh.
Paste this to Claude Code or Codex and it will clone + install everything itself:
Install cooperative file locking from https://github.com/Tiwas/claude-code-file-locks :
1. Clone it into a scratch dir (or `git pull` if you already have it).
2. Read its README.md.
3. If you are Claude Code, run `./install.sh`. If you are Codex, follow the README's
"Compatibility" note and wire hooks/read_file_lock.py + hooks/write_file_lock.py
into Codex's pre-tool and post-tool hooks (acquire on pre, release on post).
4. Run `./tests/smoke_test.sh` and confirm it prints "ALL SMOKE TESTS PASSED".
5. Then tell me to restart this session so the hooks load.
Do not commit any .agentlock files; the installer adds them to the global gitignore.
Prefer a single self-contained prompt that writes every file from scratch (no clone)? See
docs/AGENT_PROMPT.md.
./tests/smoke_test.shEach locked file X gets two sidecars:
| File | Purpose |
|---|---|
X.agentlock |
JSON lock state: {version, target, active[], queue[]} |
X.agentlock.mutex |
a short-lived mkdir mutex, held only while X.agentlock is rewritten |
The hooks fire on Claude Code's tool lifecycle:
PreToolUse→ acquire. The hook process blocks (waits in FIFO order) until the lock is granted, then returnsallow. That blocking is the lock — the tool cannot run until the hook returns.PostToolUse→ release.
A Read requests a read lease; Edit/Write/MultiEdit/NotebookEdit request a
write lease.
- Write is granted only when there are no active leases and the request is at the head of the FIFO queue (exclusive).
- Read is granted when no writer is active and no writer sits ahead of it in the queue (so multiple readers share, but a queued writer is not starved).
FIFO order is a monotonic ticket (time.time_ns). Multi-file requests grab every
per-file mutex in sorted path order (deadlock-free) and are granted only when all
requested files can be granted at once.
- Every lease carries an
expires_at. Expired entries are dropped whenever the state is read, so a crashed/killed session is cleaned up automatically (default leases: 120 s for reads, 300 s for writes — far longer than a normal tool call, short enough to recover quickly). - The
.agentlockfile is deleted once it has no active or queued holders. - A
.mutexleft behind by a hard crash is force-removed after 30 s.
The lock state is written to a temp file, fsync'd, then os.replace'd into place, so a
reader never sees a half-written lock file.
All optional, via environment variables:
| Variable | Default | Meaning |
|---|---|---|
CLAUDE_FILE_READ_LOCK_SECONDS |
120 |
read-lease TTL (seconds) |
CLAUDE_FILE_WRITE_LOCK_SECONDS |
300 |
write-lease TTL (seconds) |
CLAUDE_FILE_LOCK_WAIT_SECONDS |
540 |
max time to wait for a lock before denying |
CLAUDE_FILE_LOCK_POLL_SECONDS |
0.1 |
poll interval while waiting |
Keep the hook timeout in settings.json (default 600) ≥ CLAUDE_FILE_LOCK_WAIT_SECONDS,
or Claude Code will kill the hook before it can finish waiting.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/read_file_lock.py\"", "timeout": 600 }] },
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/write_file_lock.py\"", "timeout": 600 }] }
],
"PostToolUse": [
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/read_file_lock.py\"", "timeout": 600 }] },
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/write_file_lock.py\"", "timeout": 600 }] }
]
}
}(The installer uses the absolute hook directory rather than $HOME so it works
regardless of how the hook command is evaluated.)
- Advisory & cooperative. Every participant must run these hooks. It does not stop a non-Claude process — or a session without the hooks — from touching files.
- Lock scope = one tool call. The lease is held between
PreToolUseandPostToolUse, i.e. for the duration of a singleRead/Edit/Write. It does not make a multi-step read → think → edit sequence atomic across agents; another agent can write between your read and your edit. (Hooks are separate processes per tool call, so a lease can't span the agent's reasoning.) - Fail-open by design. If the lock code raises, the operation is allowed (with a stderr note). A locking bug must never brick file access.
- Why a distinct
PreToolUseschema? The decision is emitted as{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow|deny", ...}}. A top-level{"permissionDecision": …}is silently ignored by Claude Code, which would makedeny-on-timeout a no-op.
- Python 3.10+ (uses
X | Nonesyntax at runtime). - A POSIX-ish filesystem (uses
mkdir/os.replace/fsyncfor atomicity). - Git ≥ 2.x for the global-excludes step (optional).
MIT — see LICENSE.