@aron98/load-lock 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/demo/demo.sh +112 -0
  4. package/demo/sim-agent.mjs +149 -0
  5. package/dist/adapters/opencode/plugin.d.ts +18 -0
  6. package/dist/adapters/opencode/plugin.js +301 -0
  7. package/dist/cli/hook.d.ts +1 -0
  8. package/dist/cli/hook.js +141 -0
  9. package/dist/cli/install.d.ts +14 -0
  10. package/dist/cli/install.js +377 -0
  11. package/dist/cli/main.d.ts +2 -0
  12. package/dist/cli/main.js +461 -0
  13. package/dist/core/daemon-main.d.ts +2 -0
  14. package/dist/core/daemon-main.js +27 -0
  15. package/dist/core/daemon.d.ts +79 -0
  16. package/dist/core/daemon.js +526 -0
  17. package/dist/core/persist.d.ts +11 -0
  18. package/dist/core/persist.js +66 -0
  19. package/dist/core/state.d.ts +203 -0
  20. package/dist/core/state.js +909 -0
  21. package/dist/core/sysinfo.d.ts +2 -0
  22. package/dist/core/sysinfo.js +123 -0
  23. package/dist/mcp/main.d.ts +2 -0
  24. package/dist/mcp/main.js +4 -0
  25. package/dist/mcp/server.d.ts +27 -0
  26. package/dist/mcp/server.js +352 -0
  27. package/dist/policy/text.d.ts +30 -0
  28. package/dist/policy/text.js +58 -0
  29. package/dist/shared/client.d.ts +30 -0
  30. package/dist/shared/client.js +227 -0
  31. package/dist/shared/protocol.d.ts +333 -0
  32. package/dist/shared/protocol.js +22 -0
  33. package/dist/test/core.test.d.ts +1 -0
  34. package/dist/test/core.test.js +281 -0
  35. package/dist/test/daemon.test.d.ts +1 -0
  36. package/dist/test/daemon.test.js +384 -0
  37. package/dist/test/hook.test.d.ts +1 -0
  38. package/dist/test/hook.test.js +126 -0
  39. package/dist/test/mcp.test.d.ts +1 -0
  40. package/dist/test/mcp.test.js +139 -0
  41. package/dist/test/run-helper.test.d.ts +1 -0
  42. package/dist/test/run-helper.test.js +84 -0
  43. package/dist/test/testutil.d.ts +27 -0
  44. package/dist/test/testutil.js +111 -0
  45. package/docs/ADAPTERS.md +118 -0
  46. package/docs/AGENT_GUIDE.md +142 -0
  47. package/docs/ARCHITECTURE.md +195 -0
  48. package/docs/COMPATIBILITY.md +116 -0
  49. package/docs/OPERATIONS.md +126 -0
  50. package/package.json +102 -4
  51. package/skills/load-lock/SKILL.md +73 -0
@@ -0,0 +1,142 @@
1
+ # load-lock agent guide
2
+
3
+ load-lock coordinates heavy work across all coding agents on this machine.
4
+ Every session — any harness, any repo, any worktree — shares one machine-
5
+ wide capacity budget. Before resource-intensive work, acquire a permit. The
6
+ whole thing is cooperative: you decide what is heavy; the budget only works
7
+ if everyone participates.
8
+
9
+ This guide is also available as `load-lock agent-guide` and as a skill in
10
+ supported harnesses.
11
+
12
+ ## 1. Deciding heavy vs light
13
+
14
+ Judge by **expected** CPU/memory/IO cost, not by name:
15
+
16
+ - **Heavy** (acquire first): builds, test suites, `cargo build`/`cargo
17
+ test`, `npm test`, container builds, migration runs, anything you expect
18
+ to saturate cores or memory for more than a few seconds.
19
+ - **Light** (no permit): file edits, greps, reads, git status/diff, short
20
+ shell snippets, install/status probes, anything bounded and cheap.
21
+
22
+ If in doubt about the machine's headroom, call `inspect_machine` — it
23
+ reports effective cores and memory *including container/cgroup limits*
24
+ (the machine may have 8 logical cores but a 2-core cgroup quota — never
25
+ assume "N cores means N jobs") plus current load and memory pressure. Use
26
+ `get_config`/`configure` when the budget genuinely needs adjustment.
27
+
28
+ ## 2. Core flow
29
+
30
+ ```
31
+ acquire(reason, op_key)
32
+ ├─ granted → you hold a permit → do the heavy work now
33
+ ├─ queued → keep doing LIGHT work; offers reach you at your next
34
+ │ load-lock tool call, or via a wakeup message
35
+ └─ ...
36
+ offer arrives (token, expiry) → claim(token) BEFORE the window closes
37
+ claim → held → start the heavy work
38
+ best: run_with_permit(permit_token, command) # daemon tracks the process
39
+ else: attach_pid(permit_token, pid) # you started it yourself
40
+ else: mark_started(permit_token) # lease-based, weaker
41
+ work finishes → release(permit_token)
42
+ no longer needed while queued → cancel(request_id)
43
+ offer arrived but you won't make it → decline(offer_token)
44
+ ```
45
+
46
+ Rules, in order of importance:
47
+
48
+ 1. **Acquire immediately before** the heavy work — not at the start of a
49
+ long task.
50
+ 2. **While queued, continue useful light work** — read code, write plans,
51
+ make edits. Do not sleep or spin; offers are delivered to you.
52
+ 3. **Claim before starting.** Heavy work starts only after `acquire`
53
+ returned granted (or `claim` succeeded). A notification alone never
54
+ authorizes work.
55
+ 4. **Hold until the process truly finishes** — including background
56
+ execution. If the shell returned but the job still runs, you still hold
57
+ the permit (that is the point).
58
+ 5. **Release promptly** — success or failure. Release is idempotent; calling
59
+ it twice is safe, calling it after a crash-recovery release is safe.
60
+ 6. **Cancel** queued requests you no longer need, so someone else can have
61
+ the capacity.
62
+
63
+ Notes on the details:
64
+
65
+ - `op_key` makes duplicate acquires idempotent: if your pending request for
66
+ the same op already exists, `acquire` returns it instead of a second one.
67
+ Use a stable key per distinct operation (e.g. `build/auth-module`), and
68
+ different keys for different operations.
69
+ - Offers have a **claim window** (default 30s). If it lapses, the request
70
+ becomes **paused** — capacity moves on and your request waits until you
71
+ explicitly `rejoin(request_id)`. If you know you can't start in time,
72
+ `decline` instead of letting it expire.
73
+
74
+ ## 3. Running heavy work (lifecycle options)
75
+
76
+ How you start the work determines how reliably the permit cleans up after
77
+ you — pick the best available:
78
+
79
+ 1. **`run_with_permit(permit_token, command)`** — the helper spawns your
80
+ command in its own process group, atomically associates it with the
81
+ permit, and auto-releases when the process group *actually finishes*
82
+ (including background children). Check `job_status(job_id)` for
83
+ completion/exit code/tail of output; `job_stop(job_id)` to kill it.
84
+ Do **not** manually release a permit you handed to `run_with_permit`.
85
+ 2. **`attach_pid(permit_token, pid)`** — you spawned the process yourself;
86
+ the daemon now tracks that pid (and its process group) and releases when
87
+ it is confirmed dead. Works for `... &` background jobs whose parent
88
+ shell exits.
89
+ 3. **`mark_started(permit_token)`** — no observable process (e.g. you call
90
+ an external service). The permit is held under a lease (default 15 min)
91
+ that is kept alive by heartbeats from your harness integration. Honest
92
+ limitation: if your agent process dies silently, the lease eventually
93
+ frees the capacity, but during that window nobody can guarantee the
94
+ unseen work has stopped. Prefer 1 or 2 whenever possible.
95
+
96
+ If you `acquire` and get **granted** but then fail to start the work within
97
+ the start deadline (default 120s), the permit is automatically reclaimed —
98
+ nobody is stranded by a crashed claimant, and neither are you.
99
+
100
+ The equivalent CLI forms exist for shell-driven flows:
101
+
102
+ ```sh
103
+ load-lock acquire --reason "cargo test" --op-key "test/core" # prints JSON
104
+ load-lock run --permit <permit-token> -- cargo test --quiet # tracks + releases
105
+ # or acquire+run in one go:
106
+ load-lock run --reason "nightly build" -- npm run build
107
+ load-lock wait --id <request-id> --timeout 120000 # bounded wait → auto-claim
108
+ ```
109
+
110
+ ## 4. Reading state
111
+
112
+ - `status(pool?)` — snapshot of the whole machine: holders (with phase,
113
+ reason, age), outstanding offers, queue depth, paused requests.
114
+ - `pending()` — *your* requests and offers (with claim tokens).
115
+ - `inspect_machine()` — cores/memory incl. cgroup limits, pressure.
116
+ - `get_config()` / `configure(expected_version, changes, reason)` —
117
+ versioned CAS budget changes; every change is recorded with author and
118
+ rationale. On `version_conflict`, re-read and retry with the fresh
119
+ version.
120
+ - Events have durable ids; after reconnects or restarts, consult
121
+ `events(since)` rather than assuming.
122
+
123
+ ## 5. Cleanup and failure etiquette
124
+
125
+ | Situation | Do this |
126
+ | --- | --- |
127
+ | Work finished (success or failure) | `release` |
128
+ | Queued request no longer needed | `cancel` |
129
+ | Claim window will lapse before you can start | `decline` the offer |
130
+ | Offer already expired (`offer_expired`) | `rejoin` when actually ready |
131
+ | Your harness session is about to end with a live tracked job | Nothing — `run_with_permit`/`attach_pid` jobs release themselves when done |
132
+ | You suspect a stuck holder | `status` → `recover list` diagnostics, and discuss with the operator before `force-release` |
133
+
134
+ Never force-release someone else's permit without checking `recover list`
135
+ first — a long hold with a live tracked process is working as intended.
136
+
137
+ ## 6. Suggested internal parallelism
138
+
139
+ When you hold the permit, you own a share of the machine — bound your own
140
+ work too: prefer e.g. `cargo test -j 2` or a bounded test-worker count over
141
+ unbounded parallelism, and publish what you did via
142
+ `configure(... guidance ...)` notes if it should become convention.
@@ -0,0 +1,195 @@
1
+ # load-lock architecture
2
+
3
+ load-lock is a cross-harness coordination plugin for parallel coding agents. It
4
+ provides a machine-wide counting semaphore: before doing resource-intensive
5
+ work (builds, test suites, long compiles), an agent acquires a permit; while
6
+ queued it keeps doing light work; when capacity frees up it receives a FIFO
7
+ offer, claims it, runs the heavy work, and releases. Coordination is
8
+ cooperative and advisory — nothing wraps or rewrites shell commands, and agents
9
+ decide for themselves what counts as "heavy".
10
+
11
+ ## Why a small dedicated service (vs extending something existing)
12
+
13
+ We evaluated the existing landscape before building:
14
+
15
+ - **WorkOS Airlock** (the closest name match) is a commercial, cloud-hosted
16
+ *authorization* gateway for agents: it approves or denies tool calls
17
+ (agent identity, intent, egress policies) via WorkOS Pipes. It answers "is
18
+ this agent allowed to do this?", not "does this machine have spare capacity
19
+ right now?". It has no machine-local state, no queue, no offers, and no
20
+ process-lifecycle tracking. Smaller GitHub projects named "airlock"
21
+ (an agent type-checker gate, an MCP egress allow-list) are similarly
22
+ unrelated.
23
+ - **mic-lock** (npm) provides local sticky locks for shared test resources,
24
+ but it is a single lock with no counting semaphore, no FIFO queue, no
25
+ offer/claim protocol, no harness integration, and no automatic cleanup.
26
+ - **flock-wrapper scripts** (advisory lock around a command) cannot work
27
+ across harness tool calls: the acquire step is a short-lived process that
28
+ must exit before the agent does its real work, releasing the lock with it.
29
+ A `flock` held by a process that has already exited protects nothing. (We
30
+ do use an flock-style spawn lock internally — for daemon bootstrapping —
31
+ but never for permits.)
32
+
33
+ No existing project combines a counting semaphore, FIFO offers with claim
34
+ tokens, per-agent identity, automatic permit cleanup tied to real process
35
+ lifetimes, and harness instruction/notification adapters. The coordination
36
+ state is small and its lifetime is the machine, not a repo — so a small
37
+ dedicated daemon plus thin per-harness adapters is the right shape. Extending
38
+ a larger system (e.g. a build tool) would couple the budget to one harness
39
+ and inherit unrelated constraints.
40
+
41
+ ## Core design
42
+
43
+ **One daemon, one budget.** A single `load-lock` daemon process per machine
44
+ owns all state. Every client — any harness, any repo, any worktree — talks to
45
+ the same Unix-domain socket, so all sessions share one capacity budget
46
+ regardless of working directory. The default configuration is one pool
47
+ `heavy` with capacity 1; pools, capacity, and timings are changed at runtime
48
+ through `configure` (versioned compare-and-swap, persisted with author +
49
+ reason). The state directory lives outside any checkout
50
+ (`$XDG_STATE_HOME/load-lock`, default `~/.local/state/load-lock`, overridable
51
+ via `LOAD_LOCK_STATE_DIR`).
52
+
53
+ **Single-threaded atomicity.** The daemon is one Node process; every state
54
+ mutation happens on one event loop turn. There is no interleaving between
55
+ "check capacity" and "grant" — acquisition is atomic by construction, and
56
+ there is no window in which a release can be missed between a failed check
57
+ and queue registration. `acquire` either grants or registers in the queue in
58
+ one operation; there is no separate failed-acquire/subscribe sequence that
59
+ could miss a wakeup.
60
+
61
+ **FIFO offers with claim tokens.** When capacity frees, the daemon reserves
62
+ it and creates an offer for the head of the queue: an opaque claim token with
63
+ an expiry (default 30s). The offer is delivered to the agent (see
64
+ notifications below); heavy work starts only after a successful `claim`,
65
+ which atomically converts the offer into a permit. Outstanding offers consume
66
+ capacity, so an unclaimed offer cannot admit a second agent over budget. If
67
+ the offer expires, capacity passes to the next waiter and the expired request
68
+ becomes **paused** — it will not be re-offered until the agent explicitly
69
+ `rejoin`s. This prevents endless recycling of offers to absent agents.
70
+
71
+ **Permit lifecycle (automatic cleanup).** A permit moves through phases:
72
+
73
+ 1. **Claimed, awaiting execution** — has a configurable start deadline
74
+ (default 120s). If execution never starts, the permit is reclaimed.
75
+ 2. **Running, tracked** — associated with a real OS process via
76
+ `attach_pid`/`run_with_permit` (pid + process-group id + `/proc` start
77
+ time). The permit lives as long as the associated job: parent shells may
78
+ exit, background children keep the group alive, and the daemon
79
+ auto-releases only on confirmed termination. It is never auto-expired
80
+ merely because time passed. PID reuse is defended by comparing
81
+ `/proc/<pid>/stat` start times.
82
+ 3. **Running, untracked** — `mark_started` for jobs the daemon cannot
83
+ observe. Held under a lease (default 15 min) kept alive by heartbeats
84
+ (the MCP server and OpenCode plugin heartbeat automatically every 30s).
85
+ This is an explicit tradeoff: a lease cannot simultaneously guarantee
86
+ prompt reclamation and never overlap an unobserved surviving process.
87
+ We reclaim when the owner stops heartbeating, and we document the
88
+ residual risk instead of claiming a guarantee we can't make.
89
+
90
+ Explicit `release` always remains available and is idempotent (a duplicate or
91
+ late release is a no-op, not an error). `admin release` / `recover
92
+ force-release` exists for humans with holder/job diagnostics.
93
+
94
+ **Association is atomic.** `run_with_permit` spawns the job (its own process
95
+ group) and only then associates pid+pgid with the permit in a single daemon
96
+ operation; a reclamation racing the start loses cleanly (stale token /
97
+ wrong-state error), and a failed start kills the process group immediately.
98
+ Background execution is handled honestly: the helper returns after *the whole
99
+ process group* finishes, never merely when a shell returns a task id.
100
+
101
+ **Identity.** Per-agent identity comes from the harness (OpenCode session ID)
102
+ or an explicit `agent_id` argument; every entry also records an agent label,
103
+ cwd, op key (for idempotent duplicate suppression), and reason. Idempotency:
104
+ a duplicate `acquire` from the same agent for the same pending op key returns
105
+ the existing request rather than a second one; multiple distinct ops per
106
+ agent are supported. All tokens (permits, offers) are opaque; releasing or
107
+ claiming with someone else's token fails.
108
+
109
+ **Restart behavior.** Config is persisted (atomic write + rename). Held
110
+ permits are journaled: on daemon restart, tracked permits are reconciled
111
+ against real process liveness (dead ⇒ released, alive ⇒ kept, same tokens
112
+ still valid), claimed permits keep their original start deadline, untracked
113
+ permits get a fresh lease window, and a release arriving late for an entry
114
+ that was reconciled is idempotent. Queues and offers are *not* journaled:
115
+ they are ephemeral by design — queued agents are still running, notice the
116
+ missed offer via their next tool call, and re-acquire; the daemon
117
+ auto-spawns on the next client connection, so an agent never needs to know
118
+ the daemon restarted. This is documented, deliberate behavior, not a bug.
119
+
120
+ ## Notification honesty
121
+
122
+ A critical constraint discovered by research: **no harness reliably pushes
123
+ MCP-server-initiated notifications into model context.** MCP transports
124
+ having a notification mechanism says nothing about whether the harness wakes
125
+ an idle agent or injects into context. load-lock therefore uses four
126
+ mechanisms, and labels exactly which apply per harness:
127
+
128
+ 1. **Tool-boundary delivery (always available).** Every load-lock tool
129
+ result and every `status`/`pending` call carries the caller's current
130
+ offers (with claim tokens) — an agent performing light work discovers its
131
+ offer at its next tool call, without polling.
132
+ 2. **Bounded blocking wait.** `acquire(wait_ms)` blocks briefly (default
133
+ 5s) inside the tool call and auto-claims if capacity frees during the
134
+ wait. The CLI `wait` command and MCP `wait` pattern provide longer
135
+ bounded waits. No model-authored busy-polling required.
136
+ 3. **Idle wakeup where the harness genuinely supports it.**
137
+ - OpenCode (live-verified): the native plugin watches daemon events and
138
+ calls `ctx.session.synthetic(...)`, delivering the offer (with token)
139
+ to an idle session at its next execution boundary — it never
140
+ interrupts an in-flight shell command.
141
+ - Claude Code (contract-tested): a `Stop` hook checks for cwd-matching
142
+ offers and continues the session with a claim instruction; an async
143
+ `await-offer` watcher with `asyncRewake: true` wakes idle sessions.
144
+ - Codex CLI (contract-tested): `Stop` hook with `decision: "block"`
145
+ continuation. Note: Codex async background hooks do **not** start a new
146
+ turn — documented as a limitation.
147
+ 4. **Durable event identities.** All events have monotonic IDs; agents can
148
+ pull owner-filtered events since a cursor and deduplicate after reconnect
149
+ or daemon restart. A notification NEVER grants permission — only
150
+ `claim(offer_token)` does; expired offers are rejected and stale
151
+ notifications authorize nothing.
152
+
153
+ ## Instruction delivery (policy teaching)
154
+
155
+ The behavioral policy (acquire before heavy work, keep doing light work
156
+ while queued, claim before starting, hold until the process truly finishes,
157
+ release promptly, cancel unneeded work, use inspect/configure when the
158
+ budget needs adjustment) is delivered per harness using its real mechanism,
159
+ and restored after context compaction:
160
+
161
+ - **OpenCode**: plugin `context` hook injects the brief on every model call
162
+ (compaction-proof, live-verified), plus a registered skill with the full
163
+ guide.
164
+ - **Claude Code**: `SessionStart` hook (matchers include `compact`) +
165
+ `~/.claude/CLAUDE.md` section + skill.
166
+ - **Codex CLI**: `SessionStart` hook (matcher `compact`) + `SubagentStart`
167
+ hook + `~/.codex/AGENTS.md` section (hooks require `/hooks` trust review).
168
+ - Other harnesses: MCP server `instructions` + a global instructions file
169
+ snippet where the harness supports one (see COMPATIBILITY.md).
170
+
171
+ Policy is short (one brief, no repeated reminders), reinforced by tool
172
+ names/descriptions, and never interrupts ordinary work with approval
173
+ prompts.
174
+
175
+ ## Failure modes covered
176
+
177
+ | Failure | Behavior |
178
+ | --- | --- |
179
+ | Agent disconnects mid-heavy-work | Permit retained; tracked job keeps capacity until the real process finishes; untracked job's lease survives until heartbeats stop |
180
+ | Agent process crashes | Tracked permit auto-releases on confirmed process death; untracked reclaims after lease expiry |
181
+ | Background job outlives the tool call | pgid tracking retains the permit until the whole group exits |
182
+ | Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queues/offers ephemeral by design |
183
+ | Stale/forged token | Opaque tokens; wrong-owner claim/release rejected; expired offers rejected with a precise error |
184
+ | Forgotten release | start deadline (claimed), process death (tracked), lease expiry (untracked), plus human `recover force-release` |
185
+ | PID reuse | `/proc` start-time comparison |
186
+ | Config change while offers outstanding | Capacity increase admits queued agents immediately; capacity decrease lets holders finish and delays new admissions; outstanding offers keep their reservations |
187
+ | Duplicate claims/releases | Idempotent or precise `stale_token`/`offer_expired` errors, never double effects |
188
+
189
+ ## Extension points
190
+
191
+ The protocol already carries weights (a request may ask for >1 units of
192
+ capacity; admission is FIFO with head-of-line blocking) and multiple pools;
193
+ the machine currently uses one pool by default but accepts arbitrary pool
194
+ names through the same code paths. Adding a second budget (e.g. `network`)
195
+ is configuration, not code.
@@ -0,0 +1,116 @@
1
+ # Harness compatibility matrix
2
+
3
+ load-lock separates **core capability** (daemon + protocol + CLI, fully
4
+ harness-agnostic) from **per-harness integration** (tools, policy
5
+ instruction injection, compaction restore, idle wakeup, lifecycle help).
6
+ Status labels mean exactly this:
7
+
8
+ - **Implemented + live-verified** — deployed on this machine and exercised
9
+ end-to-end against the real harness.
10
+ - **Implemented + contract-tested** — adapter shipped; behavior tested
11
+ against the harness's documented hook/tool JSON contracts
12
+ (`src/test/hook.test.ts`, `src/test/mcp.test.ts`), but the harness itself
13
+ was not installed during verification.
14
+ - **Proposed / fallback** — research-verified harness capabilities only; no
15
+ adapter shipped beyond the generic MCP path.
16
+
17
+ ## Core (all harnesses)
18
+
19
+ | Capability | Status |
20
+ | --- | --- |
21
+ | Machine-wide pool, atomic acquire, FIFO offers, claim tokens, expiry → paused → rejoin | Implemented + tested (`core.test` 14, `daemon.test` 8) |
22
+ | Idempotent acquire/release, duplicate suppression, weighted head-block | Implemented + tested |
23
+ | Automatic cleanup: start deadline, tracked pgid process death, untracked lease, heartbeats, PID-reuse defense | Implemented + tested with real processes |
24
+ | Daemon restart: journal reconciliation, idempotent late release, ephemeral queues | Implemented + tested |
25
+ | Config via tools, CAS versions, persisted history with author+reason | Implemented + tested |
26
+ | Resource inspection incl. cgroup v2/v1 CPU+memory limits and pressure | Implemented + tested |
27
+ | CLI: acquire/claim/release/run/wait/status/configure/doctor/recover/install | Implemented + tested + demoed |
28
+
29
+ ## Per-harness integration
30
+
31
+ | Harness | MCP tools | Policy injection | Compaction restore | Idle wakeup | Async push into context | Cleanup fidelity | Overall |
32
+ | --- | --- | --- | --- | --- | --- | --- | --- |
33
+ | **OpenCode** (v2) | Native plugin, `load_lock_*` (better than MCP) | Context hook every model call | Yes (hook re-fires per model call) | Yes — `session.synthetic` at execution boundary (live-verified; never interrupts in-flight shell) | Yes (live-verified) | Full: `run_with_permit` tracked jobs + session heartbeats | **Implemented + live-verified** |
34
+ | **Claude Code** | MCP `mcp__load-lock__*` (user scope) | `SessionStart` hook plain-stdout context | Yes (SessionStart matcher `compact`) | Yes — `Stop` hook decision-block + async `await-offer` (`asyncRewake`) | Yes, via asyncRewake (contract-tested) | Full via MCP `run_with_permit` + server heartbeats | **Implemented + contract-tested** |
35
+ | **Codex CLI** | MCP `mcp__load-lock__*` (config.toml) | `SessionStart` + `SubagentStart` additionalContext; `~/.codex/AGENTS.md` | Yes (SessionStart matcher `compact`) | Yes — `Stop` hook `decision:"block"` continuation | Partial: async background hooks do NOT start a new turn (documented limitation); stop-hook covers turn boundaries | Full via MCP `run_with_permit` + server heartbeats | **Implemented + contract-tested** (hooks need `/hooks` trust review) |
36
+ | **Gemini CLI** | Yes (`settings.json` mcpServers) | `GEMINI.md` global (config `context.fileName`) | Via global GEMINI.md (always loaded) | No hook mechanism exists | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
37
+ | **GitHub Copilot CLI** | Yes (`copilot mcp add`) | AGENTS.md/GEMINI.md discovery + custom instructions | Via global instructions file | Hooks reported in ecosystem content but **not verified in official docs** — treat as unavailable | Unverified | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
38
+ | **Cursor** | Yes (`.cursor/mcp.json` + global) | `.cursor/rules/*.md` Always rules | Rules re-apply (Always) | `stop` hook exists but **beta** with a less-documented JSON contract — not shipped | Beta/unverified | Via MCP `run_with_permit` only | Proposed (MCP fallback; hook tier possible later) |
39
+ | **Cline** | Yes (`cline_mcp_settings.json`) | `.clinerules/` + `~/.cline/rules` | Rules always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
40
+ | **Roo Code** | Yes (Roo MCP settings) | `.roo/` rule dirs | Rules always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
41
+ | **Goose** | Yes (`~/.config/goose/config.yaml`) | `.goosehints` (global + project) | Hints always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
42
+ | **Aider** | **No MCP support** | CONVENTIONS.md / `.aider.conf.yml` | Read each run | N/A | N/A | CLI-only: same-shell `load-lock acquire ... && cmd; load-lock release ...` (manual) | Proposed (manual CLI tier) |
43
+
44
+ ## How each integration tier works
45
+
46
+ ### Tier 1 — OpenCode (native plugin, live-verified)
47
+
48
+ Global plugin (`load-lock install --harness opencode`) registers:
49
+ - `load_lock_*` tools with session-bound agent identity and cwd,
50
+ - the policy brief injected into system context on **every** model call
51
+ (survives compaction by construction),
52
+ - a `load-lock` skill with the full agent guide,
53
+ - daemon event watcher → `ctx.session.synthetic` offer messages with the
54
+ real claim token, delivered at execution boundaries,
55
+ - per-session heartbeats.
56
+
57
+ Verified live on opencode v2.0.29: plugin load, policy visible to the model,
58
+ tool round-trip (acquire → queued → wakeup → claim → release), execution-
59
+ boundary delivery without interrupting a running `sleep`, skill visible,
60
+ and graceful handling of a deliberately missed offer (paused → cancel).
61
+
62
+ ### Tier 2 — Claude Code / Codex CLI (hooks + MCP, contract-tested)
63
+
64
+ `load-lock install --harness claude-code|codex` configures the MCP server
65
+ (user scope) plus hooks:
66
+
67
+ - **SessionStart** → policy brief + machine summary as developer context
68
+ (matchers include `compact` so it re-fires after auto-compaction).
69
+ - **SubagentStart** (Codex) → same for spawned subagents.
70
+ - **Stop** → if the daemon has an offer whose entry cwd matches the session
71
+ cwd, the hook continues the session with a claim instruction (decision
72
+ block). Honors `stop_hook_active` to avoid loops; silent otherwise.
73
+ - **await-offer** (Claude Code, `async: true, asyncRewake: true`) → watches
74
+ daemon events; on a matching offer exits 2 with a message that wakes an
75
+ idle Claude. The token is deliberately NOT in that message (stderr may be
76
+ shown); the agent claims via `pending`/`claim`.
77
+
78
+ Contract tests cover all hook stdin/stdout shapes against the documented
79
+ harness contracts. Live verification pending a machine with the harness
80
+ installed. Codex requires the user to trust the hooks once via `/hooks`
81
+ (the installer prints this).
82
+
83
+ ### Tier 3 — MCP-only fallback (Gemini CLI, Copilot CLI, Cursor, Cline, Roo Code, Goose)
84
+
85
+ The MCP server itself carries the policy in its `instructions`, every tool
86
+ result surfaces pending offers (tool-boundary delivery), and
87
+ `inspect_machine`/`get_config`/`configure` work unchanged. Where the harness
88
+ supports global instruction files (GEMINI.md / AGENTS.md / rules / hints),
89
+ `load-lock install --harness generic` prints a snippet to add. Missing:
90
+ idle wakeup and automatic context re-injection — queued agents discover
91
+ offers at their next tool call or via a bounded `wait`. This is the honest
92
+ fallback; we do not pretend these harnesses got push notifications.
93
+
94
+ ### Tier 4 — Aider (manual CLI)
95
+
96
+ No MCP/hooks; nothing to install. Agents (or humans) use the CLI directly in
97
+ the shell: `load-lock acquire ... && load-lock run -- <cmd>` handles
98
+ acquire→run→release in one command line.
99
+
100
+ ## Session identity per harness
101
+
102
+ | Harness | Identity | Notes |
103
+ | --- | --- | --- |
104
+ | OpenCode | plugin context `sessionID` (also MCP `_meta` when used) | automatic |
105
+ | Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` | policy instructs agents to pass it |
106
+ | Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
107
+ | Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
108
+
109
+ ## Verified vs proposed — summary counts
110
+
111
+ - Live-verified: OpenCode (full adapter, including wakeup).
112
+ - Contract-tested: Claude Code, Codex CLI hook/MCP contracts.
113
+ - Core daemon/protocol/CLI: 32 automated tests + scripted 3-worktree demo
114
+ (three independent agent processes, real git worktrees, one pool).
115
+ - Proposed (researched capabilities, generic adapter only): Gemini CLI,
116
+ Copilot CLI, Cursor, Cline, Roo Code, Goose, Aider.
@@ -0,0 +1,126 @@
1
+ # Operating load-lock
2
+
3
+ ## Install
4
+
5
+ Requirements: Node 20+, Linux (macOS works with the limitations noted below;
6
+ Windows would need a named-pipe adapter — not implemented).
7
+
8
+ ```sh
9
+ npm install -g @aron98/load-lock # or: npm install -g ./aron98-load-lock-<ver>.tgz
10
+ load-lock install --harness opencode
11
+ load-lock install --harness claude-code
12
+ load-lock install --harness codex
13
+ load-lock install --harness generic # prints MCP snippets for other harnesses
14
+ ```
15
+
16
+ What each installer does (all merges are non-destructive; uninstall removes
17
+ exactly the entries it added):
18
+
19
+ - **opencode** — writes a plugin stub to
20
+ `~/.config/opencode/plugins/load-lock/` (npm-installed against the global
21
+ package), registers it in `~/.config/opencode/opencode.json`.
22
+ - **claude-code** — adds `mcpServers.load-lock` to `~/.claude.json`; hooks
23
+ (`SessionStart`, `Stop`, async `await-offer`) to `~/.claude/settings.json`;
24
+ a marked section to `~/.claude/CLAUDE.md`; copies the skill to
25
+ `~/.claude/skills/load-lock/`.
26
+ - **codex** — writes `~/.codex/hooks.json` (`SessionStart`, `SubagentStart`,
27
+ `Stop`), appends `[mcp_servers.load-lock]` to `~/.codex/config.toml` if
28
+ absent, adds a marked section to `~/.codex/AGENTS.md`. **Then run `/hooks`
29
+ inside Codex once to trust the hooks.**
30
+ - **generic** — prints MCP stdio config snippets (Gemini CLI, Cursor,
31
+ Cline/Roo, Goose) plus a suggested instructions-file section.
32
+
33
+ `load-lock uninstall --harness <harness>` reverses any of these.
34
+
35
+ ## The daemon
36
+
37
+ - State dir: `~/.local/state/load-lock` (override `LOAD_LOCK_STATE_DIR`).
38
+ - Socket: `<state dir>/daemon.sock` (override `LOAD_LOCK_SOCKET`).
39
+ - **Auto-spawns** on first client connection; no service unit needed.
40
+ - Files: `config.json` (settings + history), `journal.json` (held permits),
41
+ `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
42
+
43
+ ```sh
44
+ load-lock service status # hello + pid
45
+ load-lock service start # ensure running (idempotent)
46
+ load-lock service stop # SIGTERM; next tool call respawns
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ All configuration is agent-editable through tools (no file editing for
52
+ ordinary use) — MCP `configure` / plugin `load_lock_configure` /
53
+ `load-lock configure`. Updates are compare-and-swap on a version number
54
+ (`expected_version` prevents silent overwrites), validated, persisted with
55
+ author + reason, and appended to a visible history (last 50 changes).
56
+
57
+ ```sh
58
+ load-lock configure --capacity 2 --reason "host has 16 cores" --author ops
59
+ load-lock configure --offer-timeout-ms 60000 --reason "model turns can be slow" --author agent
60
+ load-lock get-config
61
+ ```
62
+
63
+ Defaults: one pool `heavy` capacity 1 · offer window 30s · blocking wait 5s ·
64
+ start deadline 120s · untracked lease 15min. Valid ranges:
65
+ offer window 1s–10min, wait 0–10min, start deadline 1s–1h, lease 10s–24h.
66
+
67
+ Semantics of changes:
68
+
69
+ - **Capacity increase** — queued requests are admitted immediately (FIFO
70
+ offers); outstanding offers keep their reservations.
71
+ - **Capacity decrease** — current holders finish untouched; new admissions
72
+ are delayed until usage fits.
73
+
74
+ `inspect_machine` reports logical cores, cgroup v2/v1 CPU quota and memory
75
+ limits (containers respected — we never assume 2 cores means 2 jobs),
76
+ available memory, and current load/memory pressure with an advisory
77
+ suggestion. Agents are expected to consult it before proposing capacity.
78
+
79
+ ## Diagnostics & recovery
80
+
81
+ ```sh
82
+ load-lock doctor # state dir, socket, daemon reachability, effective cores, pressure
83
+ load-lock status # human view: usage, holders w/ phase+pid, offers, queue, paused
84
+ load-lock pending # your own offers/queue entries (JSON)
85
+ load-lock events --follow # durable event log (grants, offers, releases, reclaims...)
86
+ load-lock recover list # holder diagnostics: tracked? alive? age, long-hold flags
87
+ load-lock recover force-release --id <request-id> # admin release (prints holder/job info)
88
+ ```
89
+
90
+ Recovery policy summary:
91
+
92
+ - Held permits with a tracked live process are **never** revoked for being
93
+ old — long holds are flagged in diagnostics, nothing more.
94
+ - A tracked permit auto-releases when its process (or process group) is
95
+ confirmed dead — including background children that outlived the shell.
96
+ - A claimed-but-never-started permit is reclaimed after the start deadline.
97
+ - An untracked (`mark_started`) permit lives under a heartbeat lease; if the
98
+ owning agent (MCP server / plugin / CLI heartbeat) disappears, it is
99
+ reclaimed after the lease. Residual risk of overlap with an unobserved
100
+ surviving process is documented — use `attach_pid`/`run_with_permit`
101
+ whenever possible for real guarantees.
102
+ - After a daemon restart, `journal.json` reconciles held permits against
103
+ actual process liveness (dead ⇒ released, alive ⇒ kept with same tokens).
104
+ Queued/offered requests are not journaled — agents re-acquire; this is
105
+ intentional and safe.
106
+
107
+ ## Troubleshooting
108
+
109
+ | Symptom | Check / fix |
110
+ | --- | --- |
111
+ | Tools hang | `load-lock doctor`. Daemon down? It auto-spawns; if the socket file is stale it is detected and replaced automatically. |
112
+ | `offer_expired` on claim | Claim window elapsed (default 30s) — the request is paused; `rejoin` it and claim promptly. Consider a longer `--offer-timeout-ms`. |
113
+ | Agent got "undefined" wakeup / no offer | Offers match the cwd recorded at acquire time; verify with `load-lock pending`. Claude Code/Codex wakeups require cwd match between hook session and acquire call. |
114
+ | Capacity seems stuck | `status` + `recover list`: look for untracked leases (`[untracked, leased]`), long holds, or dead holders awaiting sweep (runs every 5s). Use `force-release` with the printed request id. |
115
+ | Codex hooks never fire | Run `/hooks` in Codex and trust the load-lock entries; check `[features] hooks` is not disabled. |
116
+ | Claude Code doesn't wake when idle | The async `await-offer` hook must be in `~/.claude/settings.json` with `async: true, asyncRewake: true` — verify with `load-lock doctor` / inspect the file. |
117
+ | Config change lost | Configuration is CAS-versioned: fetch `get_config`, retry with the fresh `version` on `version_conflict`. |
118
+ | After OS reboot | Nothing to do: state dir persists config; daemon respawns on first use; journal reconciliation releases permits whose processes died. |
119
+
120
+ ## macOS / Windows notes
121
+
122
+ - macOS: no cgroup limits; process-liveness checks fall back from
123
+ `/proc/<pid>/stat` start times to `kill(pid, 0)` (weaker PID-reuse
124
+ defense — document and prefer `run_with_permit`). Everything else works.
125
+ - Windows: Unix-domain sockets are not used; a named-pipe transport adapter
126
+ would be required (planned, not implemented).