@dreamtree-org/conductor
v0.3.0
Published
Collision control for parallel AI agent sessions sharing one working tree.
Maintainers
Readme
@dreamtree-org/conductor
Collision control for parallel Claude Code agents sharing one working tree.
When two Claude Code agents in two terminals edit the same repo, two failure modes follow: they clobber each other on the same file, or they edit dependency-linked files that each "succeeds" alone but breaks the build together. Conductor makes both visible. Each agent's working scope is recorded in a shared append-only ledger; a Claude Code PreToolUse hook gates every file mutation against it. Direct collisions are DENY'd outright. Dependency crossover — inferred from @dreamtree-org/graphify's knowledge graph — is escalated to the human through Claude Code's own permission prompt (ASK), because it's a judgment call.
Honest guarantee — read this first
Conductor gates tool-mediated file writes. The Bash escape hatch is wide open: sed -i, python - <<EOF, git checkout, mv all write files ungated. There is no way to gate them without parsing shell strings, which is the wrong fight. If an agent really wants to circumvent Conductor it can — the README says so in the first paragraph because honesty about that limit is the whole point. The provided guarantee is no tool-mediated collision through Write/Edit/MultiEdit/NotebookEdit, which is the common case and the source of most silent clobbering.
Install
# Inside the repo, with @dreamtree-org/conductor already in your dev deps:
npx conductor init
# Or if published: npm install --save-dev @dreamtree-org/conductor && npx conductor initinit writes:
.conductor/config.json— team-shared config (TTLs, strict paths, tier 2 toggle), tracked in git..conductor/hook.mjs— a ~20-line committed shim thatrequire.resolves the installed implementation and exits 0 silently if Conductor isn't installed. Nevernpx— measured:node -e 0is 40 ms, the hook bundle is ~8 ms,npx --offlineis 970 ms. A hook on every edit cannot pay 970 ms..conductor/.gitignore— ignores the ledger, agent files, footprint sidecars, locks; keepsconfig.json+hook.mjstracked.- Upserts conductor's hooks in
.claude/settings.json(SessionStart / PreToolUse / SessionEnd), preserving every foreign key — graphify's, itprocurement'sasync/asyncRewake, devtools' brokenhook-guardgroup.
Then restart Claude Code. Hooks load at SessionStart, so an init mid-session appears to do nothing until the next session begins.
Usage
# Take a scope up front — make your intent visible to other agents:
conductor claim "src/auth/**" --intent "refactoring session store"
# What's currently held?
conductor status
# Hand a claim back:
conductor release <claim-id>
conductor release --all # every auto-claim this session holds
conductor steal src/auth/session.ts --intent "handoff" # the deny-message primitive
# Tier 2 escalations (footprint crossovers, strictPaths asks):
conductor pending
conductor approve <escalation-id>
conductor deny <escalation-id> --reason "..."
# Repair: ledger growth, foreign PreToolUse collisions, fail-opens, stale locks:
conductor doctor
conductor doctor --fix # reap stale locks + stale-released claimsYou don't always need conductor claim. Writing a file nobody has claimed silently claims it for you (auto-claim). Auto-claims are shorter-TTL'd (15 min sliding vs 4 h explicit), released on SessionEnd, and visually distinguished in status. One incidental Write claims a file for the session — that's the design choice that makes invisible ownership visible, not a strict requirement to declare scopes up front.
How the gate decides
0. outside the repo root → ALLOW (ignored)
1. matches config.ignore → ALLOW (ignored)
2. active := live claims in this workspace
3. MY claim matches path → ALLOW (tier:'own') ← precedes every
collision check, or
an agent blocks itself
4. TIER 1 — another agent's scope.paths matches → DENY (tier:1) fail-CLOSED
5. TIER 2 — path ∈ another agent's footprint,
and no approved escalation covers it → ASK (tier:2) NEVER deny
6. matches config.strictPaths → ASK (tier:'strict')
7. genuinely unclaimed → ALLOW (tier:0) + auto-claimTwo rules generate the ordering. Uncertainty allows; certainty of collision blocks. A known tier-1 clash is fail-closed because it's a known collision. Tier-2 footprints are inference over a graph that may be stale (graphify's incremental updateCorpusGraph skips resolveCrossFileReferences, so cross-file edges can point at guessed ids) — so tier 2 is always ASK, never DENY. Loose relaxes "must I declare up front", never "may I stomp on someone" — auto-claim only happens after every collision check has passed. A lock-free read can be more permissive than reality; the auto-claim path closes that gap by re-reading under the append lock before writing.
Configuration reference
.conductor/config.json is tracked in git and shared across all agents. Every field has an env override so a single pane can deviate without editing the shared file. A malformed config falls back to defaults rather than wedging every agent in the repo.
Core settings
| Field | Type | Default | Description |
|---|---|---|---|
| strictPaths | string[] | [] | Paths that require a claim up front. Writing these without a claim triggers an ASK. |
| ignore | string[] | 13 globs (see below) | Paths the hook ignores entirely — never gated, never auto-claimed. If present in the file, replaces the defaults; omit to keep defaults. |
| ttlMinutes | number | 240 | TTL for explicit claims (conductor claim). 4 hours by default. |
| autoTtlMinutes | number | 15 | TTL for auto-claims (the implicit claim from first Write). 15 minutes sliding. |
| heartbeatIntervalSeconds | number | 120 | Interval at which heartbeats refresh active claims. Amortized onto the allow-own path. |
| inlineFootprintMax | number | 200 | Max footprint entries stored inline in the claim event. Larger footprints spill to sidecar files in .conductor/footprints/. |
| caseSensitivePaths | boolean | true | Path comparison mode for scope matching and footprint resolution. |
| graphifyOutDir | string | "graphify-out" | Where graphify keeps its graph, repo-relative. |
Default ignore patterns: .conductor/**, graphify-out/**, node_modules/**, .git/**, dist/**, build/**, coverage/**, .next/**, **/*.lock, package-lock.json, pnpm-lock.yaml, yarn.lock.
Tier 2 (graphify) settings
Under "tier2": { ... } in config.json:
| Field | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Whether tier-2 footprint ASK is active. Disable to run tier 1 only. |
| maxDepth | number | 2 | BFS depth in each direction (upstream + downstream). 2 is plenty; 3 turns most footprints into the whole repo. |
| limit | number | 200 | Node cap per direction in the BFS walk. |
| maxGlobFiles | number | 200 | If a claim's globs match more graph file-nodes than this, the footprint is marked truncated and falls back to tier 1 only. Without this cap, src/** produces a repo-sized footprint. |
| maxAgeHours | number | 24 | Graph older than this is used anyway but marked stale — the classifier shows a warning in the ASK prompt. |
Example config.json
{
"ttlMinutes": 120,
"autoTtlMinutes": 10,
"strictPaths": ["src/shared/**", "src/types.ts"],
"tier2": {
"enabled": true,
"maxDepth": 2,
"limit": 150
}
}Environment variables
All env overrides win over config.json. Pass them on the claude invocation line so hooks inherit them:
| Variable | Effect |
|---|---|
| CONDUCTOR_TTL_MINUTES | Overrides ttlMinutes |
| CONDUCTOR_AUTO_TTL_MINUTES | Overrides autoTtlMinutes |
| CONDUCTOR_TIER2 | Overrides tier2.enabled. Truthy: 1, true, on, yes. Falsy: 0, false, off, no. |
| CONDUCTOR_STRICT_PATHS | Comma-separated list, overrides strictPaths |
| CONDUCTOR_AGENT | Human-readable label for the agent (shown in conductor status). CONDUCTOR_AGENT=auth-refactor claude |
| CONDUCTOR | Set to off to disable the hook for this invocation |
| CONDUCTOR_HOOKS | Same as CONDUCTOR=off (legacy alias) |
Opt-outs
Three levels, in order of precedence:
- Per-invocation: prefix a
claudecommand withCONDUCTOR=off(orCONDUCTOR_HOOKS=off) - Per-clone:
touch .conductor/disabled— survives across sessions until removed - Per-repo: remove the
hookskey from.claude/settings.json— the hook shim will still be present but never invoked
A tool that can't be turned off gets ripped out. Every level is a deliberate escape hatch.
.conductor/ directory layout
| Path | Tracked in git? | Purpose |
|---|---|---|
| config.json | Yes | Team-shared configuration |
| hook.mjs | Yes | ~20-line shim that loads the installed hook implementation |
| .gitignore | Yes | Ignores runtime files, keeps config + shim tracked |
| ledger.jsonl | No | Append-only JSONL ledger of all claim/release/heartbeat/footprint/escalation events |
| .lock/ | No | Advisory append lock (directory-based, mkdir O_EXCL) |
| agents/*.json | No | Agent identity files, one per session |
| footprints/*.json | No | Footprint sidecar files for claims with large blast radii |
| conductor.log | No | Durable observability log |
| disabled | No | Marker file — if present, the hook is disabled (per-clone opt-out) |
The dead-lock-free guarantee
Every read is lock-free; only first-touch of a new file takes the lock, and only briefly. The lock is a directory (mkdir with O_EXCL semantics on every filesystem, zero deps).
TTL & heartbeat
SessionEnd does not fire on kill -9 (verified against claude-agent-sdk v0.3.177 — ExitReason covers only clear/resume/logout/...), so TTL + heartbeat is the primary liveness mechanism:
| Claim type | TTL | Created by | Released by |
|---|---|---|---|
| Explicit | 4 hours (configurable) | conductor claim | conductor release, SessionEnd (clean exit), or TTL expiry |
| Auto-claim | 15 minutes sliding (configurable) | First Write to an unclaimed file | SessionEnd (clean exit), or TTL expiry |
Heartbeats refresh the lastSeenMs on every claim this session holds, amortized onto the allow-own path — so an actively editing agent never ages out mid-session. Two writers racing the same unclaimed file serialize on the lock — one wins, the other re-classifies into tier-1 DENY.
A kill -9'd terminal leaves auto-claims alive for up to 15 min and explicit claims for up to 4 hours. Run conductor doctor --fix to reap them immediately. Stale locks (dead pid, impossibly old started_at) are reaped automatically at 30s; doctor --fix reaps them manually.
Troubleshooting
"Hook not firing"
- Is Conductor installed?
npm ls @dreamtree-org/conductor - Did you restart Claude Code after
conductor init? Hooks load at SessionStart. - Is the hook shim intact? Check
.conductor/hook.mjs— it should be a ~20-line file. - Is it disabled? Check for
CONDUCTOR=offin env, or.conductor/disabledmarker. - Run
conductor doctor— it reports foreign PreToolUse collisions (another hook might be intercepting first).
Stuck claims after crash
When an agent session is killed (kill -9, terminal closed, SSH disconnect), its claims survive until TTL expiry. Run:
conductor doctor --fix # reaps stale locks + stale-released claims
conductor status # see what's still liveUse --fix to immediately release claims from dead sessions (TTL-expired auto-claims and explicit claims) and remove stale lock directories.
Stale locks
Lock directories at .conductor/.lock/ contain pid and started_at files. If the owning process is dead (detected via kill(pid, 0) → ESRCH) or the lock is impossibly old, the lock is reaped automatically at 30s. doctor --fix reaps them manually on demand.
Ledger bloat
The ledger is append-only; every claim, release, heartbeat, footprint, and escalation adds a line. Over weeks of active use this can grow large. The fold reads the full ledger on every edit, so check the byte count:
wc -c .conductor/ledger.jsonlIf it's >10 MB, consider an archival cycle: move ledger.jsonl to ledger-archive-$(date).jsonl and start a fresh ledger. This is a manual step for now; see conductor reset (planned).
"graphify not installed" warnings
If graphify isn't installed (@dreamtree-org/graphify optional peer dep), every claim will have footprintStatus: 'no-graph' and tier 2 is a no-op. Direct collision detection (tier 1 DENY) still works. Install graphify to enable dependency-crossover ASK:
npm install --save-dev @dreamtree-org/graphify
# Then run graphify to produce graphify-out/graph.jsonProgrammatic API
Conductor can be imported directly — the pure functions are useful for building tooling around the ledger:
import {
// Classification
classify, compileScope,
// Ledger
foldLedger, activeClaims, readLedger, appendEvents, makeClaim, makeRelease,
// Lock
acquireLock, withLock,
// Config
loadConfig, defaultConfig, isDisabled,
// Workspace
resolveWorkspace, workspaceId,
// Identity
resolveAgentIdentity, readAgentIdentity,
} from '@dreamtree-org/conductor';
// Graph operations (separate subpath):
import {
loadGraphSnapshot, computeFootprint, forwardClosure, decideFootprintCarrier,
} from '@dreamtree-org/conductor/graph';
import type { ReadonlyGraph, GraphSnapshot, FootprintResult } from '@dreamtree-org/conductor/graph';Key categories:
| Category | Exports | Description |
|---|---|---|
| classify | classify, compileScope, isGlob | The three-tier overlap engine (pure, injectable) |
| ledger | foldLedger, activeClaims, readLedger, appendEvents, makeClaim, makeRelease, makeHeartbeat, makeFootprint, makeEscalation, makeDecision | Ledger fold, read, write, identity helpers |
| lock | acquireLock, reapStaleLock, withLock | Advisory append lock (directory-based, zero deps) |
| config | loadConfig, defaultConfig, isDisabled, ttlMs, autoTtlMs | Config load, merge with env, opt-out checks |
| graph | loadGraphSnapshot, computeFootprint, forwardClosure, decideFootprintCarrier | Graphify bridge + blast-radius BFS (import from @dreamtree-org/conductor/graph) |
| workspace | resolveWorkspace, workspaceId, currentBranch | Worktree root discovery + stable workspace hashing |
| identity | resolveAgentIdentity, readAgentIdentity, fallbackIdentity | Agent label resolution (4-tier priority) |
| hooks | runHook, isHookEvent | Hook harness entry point (import from @dreamtree-org/conductor/hooks) |
The full library exports ~60 symbols from the main entry. A proper API reference is planned for the VitePress docs site.
Performance
- Hook hot path: ~8 ms after node startup (~40 ms). Bundle is a single ~100 KB ESM file.
- Lock-free reads: every classification reads the ledger without taking a lock. Only the auto-claim append locks briefly.
- Lock mechanism:
mkdirwith O_EXCL semantics, zero dependencies, works on every filesystem. - Graphify stays off the hot path: the ~330 ms
await import('@dreamtree-org/graphify')cost is paid only in the detached footprint worker spawned at claim time, never during PreToolUse. The hook bundle is regression-tested in CI to never contain@dreamtree-org/graphifyorcommander. - No shared chunks: tsup bundles each entry independently. The few KB of duplication across
index/cli/hook/graphentries is cheaper than a second module resolution on every edit.
Phase status
- Phase 0 (collision control via Write/Edit/MultiEdit/NotebookEdit): shipped. Pure ledger + classifier + lock + auto-claim + TTL filtering +
init/claim/release/status/steal/doctorand thePreToolUse/SessionStart/SessionEndhooks. - Phase 1 (graphify-backed Tier 2 ASK + escalation workflow): shipped alongside as an optional peer dep. If graphify is installed (
graphify-out/graph.jsonon disk) conductor binds to it; footprints are computed at claim time, snapshotted into the claim event (or a sidecar overinlineFootprintMax), and used to ASK when an edit lands inside another agent's dependency blast radius. If graphify isn't installed, every claim isfootprintStatus: 'no-graph'and tier 2 is a no-op — DENY still fires on direct collisions so the core guarantee is unaffected.
Verification
npm run lint && npm run typecheck && npm test && npm run test:race && npm run build is the CI ladder. test:race runs the hammer fixture: 8 parallel sessions x 20 iterations each against one contested path; the lock-guarded re-classify must produce exactly one winner and DENY for everyone else, no corrupt ledger line, no orphan lock, skipped === 0. The tests/build-shape.test.ts regression enforces the latency budget: dist/hooks/run.js must not contain the strings commander or @dreamtree-org/graphify (the hook bundle stays a single ~100 KB file; the graphify barrel alone drags in @anthropic-ai/sdk, @modelcontextprotocol/sdk, mysql2, web-tree-sitter).
Related
- DreamtreeTech/graphify — the knowledge graph Conductor's tier 2 reads. Independent tools; both can be installed in the same repo.
- Claude Code's PreToolUse hook contract —
hook_event_name: 'PreToolUse', exit 0, JSON on stdout. Conductor's hook returnsnullon the allow path (silent; apermissionDecision: 'allow'skips the user's own permission prompt and can preempt another hook's deny — auto-approving every Write in the repo is a security regression nobody asked for).
License
MIT. See LICENSE.
