@aron98/load-lock 1.4.0 → 1.6.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 (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -9,7 +9,7 @@
9
9
  * - `service stop` waits bounded, escalates to SIGKILL, verifies the
10
10
  * socket is free, and reports honestly
11
11
  * - `service start` refusals name the conflicting pid
12
- * - `doctor` reports installed copies and warns on drift
12
+ * - `doctor` reports installed copies (incl. npx-cache, #57) and warns on drift
13
13
  *
14
14
  * Modeled after daemon.test.ts: real daemons over real sockets in temp
15
15
  * state dirs (LL_TEST_TMP / $TMPDIR/opencode — never the user's real dir).
@@ -453,3 +453,35 @@ test("doctor: reports installed copies, versions, daemon skew, and copy drift",
453
453
  rmSync(fakePkg, { recursive: true, force: true });
454
454
  }
455
455
  });
456
+ test("doctor: lists a planted npx-cache copy (issue #57)", async () => {
457
+ const d = await startDaemon();
458
+ const home = mkdtempSync(join(TMP_BASE, "ll-npxhome-"));
459
+ try {
460
+ // npx layout under the default npm cache: ~/.npm/_npx/<hash>/node_modules/@aron98/load-lock
461
+ const npxCopy = join(home, ".npm", "_npx", "0123abcd4567ef89", "node_modules", "@aron98", "load-lock");
462
+ mkdirSync(npxCopy, { recursive: true });
463
+ writeFileSync(join(npxCopy, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "0.4.2" }));
464
+ // A foreign npx entry is ignored.
465
+ const foreign = join(home, ".npm", "_npx", "ffff", "node_modules", "@aron98", "load-lock");
466
+ mkdirSync(foreign, { recursive: true });
467
+ writeFileSync(join(foreign, "package.json"), JSON.stringify({ name: "something-else", version: "1.0.0" }));
468
+ const env = { ...d.env, HOME: home };
469
+ // npm sets npm_config_cache for scripts it runs (`npm test`); the test
470
+ // must resolve the planted temp HOME's default cache, never the real one.
471
+ delete env.npm_config_cache;
472
+ delete env.NPM_CONFIG_CACHE;
473
+ const r = await runCliEnv(env, ["doctor", "--json"]);
474
+ assert.equal(r.code, 0, `stderr: ${r.stderr}`);
475
+ const report = JSON.parse(r.stdout);
476
+ const copies = report.copies;
477
+ const npx = copies.filter((c) => c.source === "npx-cache");
478
+ assert.equal(npx.length, 1, `exactly the planted npx copy: ${JSON.stringify(copies)}`);
479
+ assert.equal(npx[0].version, "0.4.2");
480
+ assert.equal(npx[0].path, npxCopy);
481
+ assert.ok(report.warnings.some((w) => w.includes("multiple load-lock versions") && w.includes("npx-cache=0.4.2")), `drift warning names the npx copy: ${JSON.stringify(report.warnings)}`);
482
+ }
483
+ finally {
484
+ await d.stop();
485
+ rmSync(home, { recursive: true, force: true });
486
+ }
487
+ });
package/docs/ADAPTERS.md CHANGED
@@ -52,8 +52,8 @@ adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
52
52
  ## Tier 2 — hooks + MCP
53
53
 
54
54
  For harnesses with lifecycle hooks but no in-process API (Claude Code and
55
- Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary, or
56
- `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
55
+ Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
56
+ `load-lock mcp`, or `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
57
57
  to the CLI:
58
58
 
59
59
  - `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
@@ -96,6 +96,7 @@ normally touches:
96
96
  | `heartbeat {agentId}` | keep untracked leases alive |
97
97
  | `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
98
98
  | `offers_for_cwd {cwd}` | for hook scripts without session identity |
99
+ | `hook_once {harness, sessionId, event, occurrence?, ttlMs?, hold?}` | hook dedupe: `{first}` is true only for the first caller per key; keys expire (TTL, default 60s) or, with `hold`, live as long as the connection; in-memory only |
99
100
 
100
101
  ## Lifecycle contract (the part that must not be improvised)
101
102
 
@@ -6,8 +6,42 @@ wide capacity budget. Before resource-intensive work, acquire a permit. The
6
6
  whole thing is cooperative: you decide what is heavy; the budget only works
7
7
  if everyone participates.
8
8
 
9
- This guide is also available as `load-lock agent-guide` and as a skill in
10
- supported harnesses.
9
+ This guide is also available as the `agent_guide` MCP tool, as
10
+ `load-lock agent-guide`, and as a skill in supported harnesses.
11
+
12
+ ## 0. How to reach load-lock
13
+
14
+ Use the first of these that works; they all talk to the same daemon:
15
+
16
+ 1. **load-lock tools** in your harness (MCP tools, or `load_lock_*` in
17
+ OpenCode). The rest of this guide names the tools.
18
+ 2. **`load-lock` on PATH** (check with `load-lock version`).
19
+ 3. **Pinned npx**: `npx -y @aron98/load-lock@<version> <command>`, with the
20
+ version the load-lock skill pins (Node >= 20). Read `load-lock` below as
21
+ this whole prefix.
22
+
23
+ The first call starts the shared daemon; you never start it yourself.
24
+
25
+ **CLI only (skill-only install, no tools).** Prefer `load-lock run`: it
26
+ acquires, runs the command, and releases when it exits, success or failure:
27
+
28
+ ```sh
29
+ load-lock run --reason "full test suite" --op-key test/all -- npm test
30
+ ```
31
+
32
+ Exit code **68** means "not granted yet" (queued, with the request id in
33
+ the message): don't start the work. Keep doing light work; when you run out,
34
+ `load-lock wait --id <request-id>` sleeps until your turn, claims the offer
35
+ and prints a `permit_token` for `load-lock run --permit <permit_token> -- <cmd>`.
36
+ A `wait` that exits 68 again is a checkpoint: call it again. Your own
37
+ requests and offers: `load-lock pending`.
38
+
39
+ Your agent id is derived from your harness session, so all calls from one
40
+ session count as one agent. If `pending` doesn't show a request you made,
41
+ pin the id yourself with `LOAD_LOCK_AGENT=<unique-name>` (or `--agent`) on
42
+ every call. Without tools nothing wakes you while you sit idle and nothing
43
+ re-teaches the policy after context compaction; `load-lock setup` shows how
44
+ to add the harness integration that does.
11
45
 
12
46
  ## 1. Deciding heavy vs light
13
47
 
@@ -219,3 +253,15 @@ When you hold the permit, you own a share of the machine — bound your own
219
253
  work too: prefer e.g. `cargo test -j 2` or a bounded test-worker count over
220
254
  unbounded parallelism, and publish what you did via
221
255
  `configure(... guidance ...)` notes if it should become convention.
256
+
257
+ ## 7. Keeping load-lock itself current
258
+
259
+ If load-lock tells you its adapter copy is newer than the installed CLI
260
+ (`load-lock: this load-lock adapter copy (vX) is newer than the installed
261
+ CLI (vY). Run \`load-lock update\` ...`), run it **as an ordinary command**
262
+ (CLI: `load-lock update`; MCP/plugin: spawn the CLI or tell the user) —
263
+ never try to install anything behind the notice; it is notify-only.
264
+ `load-lock update --check` is the safe dry run: exit 0 = up to date,
265
+ 1 = update available (or this copy cannot self-apply it — repo checkouts
266
+ and opencode-managed copies are refused honestly), 70 = failure. A
267
+ successful apply restarts the service itself; you do not need to.
@@ -99,8 +99,13 @@ wrong-state error), and a failed start kills the process group immediately.
99
99
  Background execution is handled honestly: the helper returns after *the whole
100
100
  process group* finishes, never merely when a shell returns a task id.
101
101
 
102
- **Identity.** Per-agent identity comes from the harness (OpenCode session ID)
103
- or an explicit `agent_id` argument; every entry also records an agent label,
102
+ **Identity.** Per-agent identity is derived from the harness session
103
+ (issues #49/#50): an explicit `agent_id` / `--agent` / `LOAD_LOCK_AGENT`
104
+ wins, else a harness session id (OpenCode `sessionID`, Claude Code
105
+ `CLAUDE_CODE_SESSION_ID`, hook `session_id`), else the stable harness
106
+ ancestor process on Linux, so every wiring of one session (CLI, MCP
107
+ server, hooks) acts as one agent (details in
108
+ [COMPATIBILITY.md](COMPATIBILITY.md#session-identity-per-harness)); every entry also records an agent label,
104
109
  cwd, op key (for idempotent duplicate suppression), and reason. Idempotency:
105
110
  a duplicate `acquire` from the same agent for the same pending op key returns
106
111
  the existing request rather than a second one; multiple distinct ops per
@@ -118,10 +123,86 @@ same request ids (idempotent re-acquire keeps working), entries offered at
118
123
  crash time restart as queued and are re-offered with fresh tokens — so a
119
124
  restart no longer silently drops a queue position. The daemon auto-spawns on
120
125
  the next client connection, and a *self-ordered* restart (version handshake:
121
- a newer client than the daemon) respawns with the same argv after persisting
122
- state — see the update lifecycle in
126
+ a newer client than the daemon) persists state and respawns onto the newer
127
+ client's copy (#53; same argv when that copy can't be validated) — see the update lifecycle in
123
128
  [OPERATIONS.md](OPERATIONS.md#update-lifecycle).
124
129
 
130
+ **Persisted format compatibility (issue #51).** Several runtime copies may
131
+ share one state dir, so a downgrade is possible: a newer daemon dies and an
132
+ older client starts its older copy, which must then load files the newer one
133
+ wrote. `config.json` carries a top-level `formatVersion` (absent = 0, the
134
+ pre-versioning format); the journal stays a bare JSON array (what every
135
+ released daemon reads) with `formatVersion` stamped on each entry. The
136
+ promise, in both directions:
137
+
138
+ - Format changes are **additive**. A reader never rejects a file because its
139
+ `formatVersion` is higher than its own; it uses what it understands.
140
+ - Unknown fields are **never dropped destructively**: top-level and
141
+ `config`/pool/guidance fields of `config.json` survive a rewrite (e.g. a
142
+ `configure`), and unknown fields on a journal entry are written back while
143
+ that entry lives. Each writer stamps its *own* `formatVersion`, so readers
144
+ must treat fields from a newer format as possibly stale.
145
+ - Unknown journal entry kinds (a `state` this version doesn't know) and
146
+ malformed entries are **skipped**, never fatal; the count is reported in the
147
+ `daemon_recovered` event. A held entry with an unknown `phase` is kept as an
148
+ untracked permit with a fresh lease (capacity stays accounted; the lease
149
+ reclaims it if nobody heartbeats).
150
+ - An incompatible change would use a **new file name**, never reinterpret
151
+ `config.json` / `journal.json`.
152
+
153
+ ## Three layers: daemon, runtime copies, harness wiring
154
+
155
+ load-lock reaches a machine through many entry points (a skill, an MCP
156
+ registration, a harness plugin, ACE, `npm i -g` + `load-lock install`), and
157
+ users mix them across harnesses and over time. The design rule (issue #47):
158
+ **any combination, installed in any order, gives one working budget.** It
159
+ holds because load-lock is split into three layers with different
160
+ multiplicity:
161
+
162
+ | Layer | Per machine | What it is | Rule |
163
+ | --- | --- | --- | --- |
164
+ | **Daemon** | exactly 1 (one socket, one state dir) | the long-lived process that owns all state, started on demand by whichever client connects first (`src/shared/client.ts`) | the newest copy runs it |
165
+ | **Runtime copies** | many | installed copies of the npm package: global npm/bun, the npx cache, OpenCode's plugin cache, a git checkout; the Claude Code plugin's launcher picks one of them | copies interoperate in both directions; none "owns" the daemon |
166
+ | **Harness wiring** | ideally 1 per harness, possibly several | MCP registrations, hooks, policy text, skill copies, written by a plugin, ACE, `load-lock install` or by hand | duplicates are **harmless**, not merely discouraged |
167
+
168
+ **Duplicates are harmless.** Each overlap is defused where the singleton
169
+ lives, not by hoping the user cleans up:
170
+
171
+ - *Hooks*: two wirings of the same hook (loose + plugin, two plugins, ACE)
172
+ both fire, but the daemon's `hook_once` lets only the first act — one
173
+ policy injection, one Stop continuation, one idle watcher per session
174
+ (see "Duplicate hook wiring is harmless" below).
175
+ - *MCP servers*: two registrations in one session (e.g. ACE's `mcp.json` +
176
+ the plugin's bundled server) are two processes with the **same**
177
+ session-derived identity (see Identity above), so an `acquire` through
178
+ one shows up in `pending` through the other. Both talk to the one daemon.
179
+ - *Skill copies*: identical text; the skill only tells the agent how to
180
+ reach the daemon.
181
+
182
+ Housekeeping is still offered: `load-lock setup` lists every integration
183
+ per harness and, with `--yes`, converges each harness to one wiring at its
184
+ highest rung (see [OPERATIONS.md](OPERATIONS.md#load-lock-setup-what-is-installed-where)).
185
+ Correctness never depends on running it.
186
+
187
+ **Compatibility promise between copies.** Because several copies of
188
+ different versions talk to one daemon and one state dir:
189
+
190
+ - every client envelope carries its version and package root; a strictly
191
+ newer client makes the daemon persist state and respawn onto that
192
+ client's copy (#52/#53, see "Runtime copies and the restart hint"
193
+ below), with one attempt per version;
194
+ - the persisted files are versioned and additive in both directions, so
195
+ an older daemon started after a newer one died reads the newer files
196
+ safely (#51, "Persisted format compatibility" above);
197
+ - newer client features degrade against an older daemon instead of
198
+ failing: hooks without `hook_once` act as before, `wait_for_completion`
199
+ falls back to an events-based poll.
200
+
201
+ The **install ladder** built on top of this (skill → MCP server → harness
202
+ plugin, plus `npm i -g` + `load-lock install`) is described in
203
+ [OPERATIONS.md](OPERATIONS.md#the-install-ladder); which rung each harness
204
+ reaches is in [COMPATIBILITY.md](COMPATIBILITY.md#rung-per-harness).
205
+
125
206
  ## Notification honesty
126
207
 
127
208
  A critical constraint discovered by research: **no harness reliably pushes
@@ -168,7 +249,8 @@ mechanisms, and labels exactly which apply per harness:
168
249
  calls `ctx.session.synthetic(...)`, delivering the offer (with token)
169
250
  to an idle session at its next execution boundary — it never
170
251
  interrupts an in-flight shell command.
171
- - Claude Code (contract-tested): a `Stop` hook checks for cwd-matching
252
+ - Claude Code (live-verified in Docker with the loose install; the
253
+ plugin's hooks are not yet live-verified, #63): a `Stop` hook checks for cwd-matching
172
254
  offers and continues the session with a claim instruction; an async
173
255
  `await-offer` watcher with `asyncRewake: true` wakes idle sessions.
174
256
  - Codex CLI (live-verified in Docker): `Stop` hook with `decision: "block"`
@@ -191,8 +273,10 @@ and restored after context compaction:
191
273
  - **OpenCode**: plugin `context` hook injects the brief on every model call
192
274
  (compaction-proof, live-verified), plus a registered skill with the full
193
275
  guide.
194
- - **Claude Code**: `SessionStart` hook (matchers include `compact`) +
195
- `~/.claude/CLAUDE.md` section + skill.
276
+ - **Claude Code**: `SessionStart` hook (re-fires after `compact`), from
277
+ either the `load-lock-plugin` plugin or the loose install; the loose
278
+ install adds a `~/.claude/CLAUDE.md` section + skill, the plugin ships
279
+ neither (full guide via the `agent_guide` MCP tool).
196
280
  - **Codex CLI**: `SessionStart` hook (matcher `compact`) + `SubagentStart`
197
281
  hook + `~/.codex/AGENTS.md` section (hooks require the interactive
198
282
  hook-trust review once; live-verified in Docker).
@@ -203,6 +287,21 @@ Policy is short (one brief, no repeated reminders), reinforced by tool
203
287
  names/descriptions, and never interrupts ordinary work with approval
204
288
  prompts.
205
289
 
290
+ **Duplicate hook wiring is harmless (issue #54).** Loose hooks, a plugin
291
+ and ACE wiring may all be installed at once, so one harness event can fire
292
+ the same load-lock hook several times. The daemon is the singleton, so it
293
+ drops duplicates: each hook handler first calls `hook_once` with the key
294
+ `(harness, session_id, event, occurrence)`; only the first caller acts, the
295
+ rest exit 0 silently. The occurrence separates legitimate repeats:
296
+ SessionStart uses its `source` (a later `compact` re-injects the policy),
297
+ Stop hooks (Claude Code, Codex, Cursor) use the offer token being delivered
298
+ (each offer becomes at most one continuation per session). Keys expire on
299
+ their own (default 60s) and are never persisted; the `await-offer` watcher
300
+ instead *holds* its key for its connection's lifetime, so a session has at
301
+ most one live watcher and the key frees when the watcher exits. Hooks fail
302
+ open: no session id, a daemon without `hook_once` (`unknown method`), or
303
+ any error means the hook acts exactly as before.
304
+
206
305
  ## Failure modes covered
207
306
 
208
307
  | Failure | Behavior |
@@ -217,6 +316,22 @@ prompts.
217
316
  | 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 |
218
317
  | Duplicate claims/releases | Idempotent or precise `stale_token`/`offer_expired` errors, never double effects |
219
318
 
319
+ ### Runtime copies and the restart hint
320
+
321
+ `daemon.json` records the daemon's package root and version, and
322
+ `runtimes.json` the copies seen via the handshake (#52). When a strictly
323
+ newer client connects, the daemon respawns onto that client's copy (#53)
324
+ if the envelope's `clientRoot` names a `@aron98/load-lock` package whose
325
+ on-disk version equals the claimed `clientVersion` and which ships
326
+ `dist/core/daemon-main.js`; otherwise it reruns its own argv. The
327
+ `LOAD_LOCK_RESTARTED_FOR` marker keeps one restart per version either way.
328
+
329
+ Security: the hint arrives over the daemon's socket, which only the same
330
+ user can reach. Following it grants no privilege the client did not
331
+ already have — that client could run the same file itself. The checks
332
+ exist to avoid respawning onto garbage (wrong package, mismatched version,
333
+ missing entry), not as a trust boundary.
334
+
220
335
  ## Extension points
221
336
 
222
337
  The protocol already carries weights (a request may ask for >1 units of
@@ -46,6 +46,30 @@ Status labels mean exactly this:
46
46
  | **omac** (agent sandbox; verified with 0.11.0) | N/A — omac is a sandbox runtime, not a harness: the agent inside it uses whichever harness adapter applies (OpenCode, Claude Code, …) | via the inner harness | via the inner harness | via the inner harness | via the inner harness | Full for the sandbox grants: `load-lock uninstall --harness omac` removes exactly the entries install added (match-own-entries, empty sections pruned) | **Sandbox-profile grants installed + live-verified** (`install --harness omac`: `filesystem.allow_unix_dir` += state dir, `filesystem.read` += package tree when needed, `environment.allow_vars` += `LOAD_LOCK_*`) |
47
47
  | **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) |
48
48
 
49
+ ## Rung per harness
50
+
51
+ The install ladder (OPERATIONS.md, "The install ladder") has rungs 0 = skill
52
+ only, 1 = MCP server, 2 = harness plugin, plus the loose
53
+ `npm i -g` + `load-lock install --harness <h>` wiring. **Rung 0 works
54
+ everywhere** an agent can read the skill and run a shell: it only needs
55
+ the CLI (on PATH or through the skill's pinned `npx`), so it is not
56
+ repeated per row. `load-lock setup` reports the rung it finds per harness
57
+ as `plugin`, `loose`, `MCP only`, `skill only` or `not installed`.
58
+
59
+ | Harness | MCP server (1) | Plugin (2) | Loose `install` | Highest rung |
60
+ | --- | --- | --- | --- | --- |
61
+ | **OpenCode** | generic stdio registration works; the plugin's native `load_lock_*` tools are better | npm plugin `@aron98/load-lock` (also listed on ACE via `ace/opencode/`) — live-verified | `install --harness opencode` registers that same npm plugin | **plugin** |
62
+ | **Claude Code** | ACE `ace/mcp` or a manual stdio registration | `load-lock-plugin` from this repo's marketplace — contract-tested, passes `claude plugin validate --strict`; not yet live-verified as a plugin (pending #63); ACE's skills-dir install unverified (#63/#66) | `install --harness claude-code`: MCP + hooks + `CLAUDE.md` section + skill — live-verified in Docker | **plugin** (loose gives the same capability) |
63
+ | **Codex CLI** | `[mcp_servers]` entry in `~/.codex/config.toml` | none shipped | `install --harness codex`: MCP + hooks + `AGENTS.md` section — live-verified in Docker | **loose** |
64
+ | **Cursor** | project `.cursor/mcp.json` | none shipped | `install --harness cursor --project <dir>`: MCP + stop hook + rule — contract-tested | **loose** |
65
+ | **Gemini CLI, Copilot CLI, Cline, Roo Code, Goose** | the harness's MCP config | none shipped | `install --harness <h>`: MCP + instructions/rule file, no hooks — config-tested | **loose** (rung 1 plus a policy file; no idle wakeup) |
66
+ | **omac** | n/a (sandbox, not a harness) | — | `install --harness omac`: sandbox-profile grants — live-verified | the inner harness's rung |
67
+ | **Aider** | no MCP support | none | none | **0** (CLI only) |
68
+
69
+ "Highest rung" is what `load-lock setup --yes` converges a harness to once
70
+ it has some load-lock wiring (OPERATIONS.md, "Converging"). Mixing rungs in
71
+ one harness is safe (ARCHITECTURE.md, "Three layers").
72
+
49
73
  ## How each integration tier works
50
74
 
51
75
  ### Tier 1 — OpenCode (native plugin, live-verified)
@@ -73,6 +97,23 @@ tool round-trip (acquire → queued → wakeup → claim → release), execution
73
97
  boundary delivery without interrupting a running `sleep`, skill visible,
74
98
  and graceful handling of a deliberately missed offer (paused → cancel).
75
99
 
100
+ #### ACE listing (opencode, issue #79)
101
+
102
+ ACE ([TNG/ace](https://github.com/TNG/ace)) cannot detect the root
103
+ `package.json` as an OpenCode plugin, because its entry point is the
104
+ gitignored `dist/`. The OpenCode listing therefore comes from the dedicated
105
+ folder `ace/opencode/`. It is a thin pointer: `index.js` re-exports
106
+ `@aron98/load-lock`, and `package.json` (`load-lock-opencode`, private) pins
107
+ that dependency to the release version through `scripts/check-pins.mjs`.
108
+ ACE finds the folder through its npm-package heuristic (a `main` that exists
109
+ in the git tree, plus `opencode` in the name or keywords). ACE marks every
110
+ such detection as **"verify before registering"**, so that note is expected.
111
+ `src/test/ace-opencode.test.ts` mirrors the heuristic (TNG/ace@b703410) and
112
+ asserts that this repo yields exactly one OpenCode plugin, at `ace/opencode`.
113
+ Users install the published package as usual (a `plugins` entry
114
+ `@aron98/load-lock` in `opencode.json`), not the folder itself. The
115
+ end-to-end ACE install is not yet live-verified (#66).
116
+
76
117
  #### Multiple copies of load-lock (opencode)
77
118
 
78
119
  `install --harness opencode` registers the **bare package name** in
@@ -89,15 +130,37 @@ Which copy is authoritative for what:
89
130
  (it refreshes the cache entry); you cannot reliably update it by hand.
90
131
  - **CLI commands** (`load-lock status`, `service`, `doctor`, …) — the copy on
91
132
  PATH (typically a global install via npm/bun). Update it with your usual
92
- global install command.
133
+ global install command, or `load-lock update` (since #38), which detects
134
+ the package manager from the copy's location and refuses to touch copies
135
+ it does not manage: it will not "update" the opencode cache copy (opencode
136
+ owns it) nor a git checkout (your git workflow owns it), and it restarts
137
+ the service after a successful apply.
93
138
  - **The daemon's code** — whichever copy spawned it (usually the CLI copy,
94
139
  since auto-spawn and `service start` run from the CLI client code). The
95
140
  version handshake makes a daemon self-restart onto updated code when a
96
- newer client contacts it — but the respawn reuses the daemon's original
97
- argv, so it only picks up an update when its spawning copy was updated
98
- *in place* (the normal global-upgrade case). A daemon spawned from a
99
- different copy than the one you updated keeps running the old code until
100
- `load-lock service stop` + any command re-spawns it from the new one.
141
+ newer client contacts it. Since #53 the respawn moves onto the newer
142
+ client's own copy (validated from its `clientRoot` hint), so a daemon
143
+ spawned from a different copy than the one you updated follows the
144
+ newer client. Clients older than #52 send no `clientRoot`; for them the
145
+ respawn reuses the daemon's original argv and only picks up an in-place
146
+ update of its spawning copy — `load-lock service restart` (since #38)
147
+ re-spawns it from the current CLI copy.
148
+ - **npx-launched copies** (since #57) — skill / ACE / pinned MCP entries run
149
+ `npx -y @aron98/load-lock@<pin>`, which installs a copy under the npm
150
+ cache at `~/.npm/_npx/<hash>/node_modules/@aron98/load-lock/` (or under
151
+ `$npm_config_cache/_npx`; `%LocalAppData%\npm-cache\_npx` on Windows).
152
+ `doctor` lists these as `npx-cache`. They are owned by the pin that
153
+ launches them: `load-lock update` refuses to touch them and points at
154
+ bumping the pinned version (or the ACE artifact) instead.
155
+
156
+ The mirror drift — a *newer* adapter copy (opencode refreshed its cache)
157
+ with an *older* CLI on PATH — surfaces at adapter init (issue #38): the
158
+ plugin/MCP server prints one clear notice naming `load-lock update` and
159
+ embeds it in the injected context/instructions. Notify only, never
160
+ auto-install. See
161
+ [OPERATIONS.md](OPERATIONS.md#adapter-init-drift-notice) for the
162
+ in-memory-staleness limitation (a running session keeps the old adapter
163
+ code until the harness restarts).
101
164
 
102
165
  `load-lock doctor` lists every copy it can find (PATH-resolved CLI, this
103
166
  running copy, opencode cache copies) with versions and warns when they
@@ -121,6 +184,51 @@ observed live on this machine before the feature existed.
121
184
  idle Claude. The token is deliberately NOT in that message (stderr may be
122
185
  shown); the agent claims via `pending`/`claim`.
123
186
 
187
+ **Tool names depend on the wiring.** A loose install (`load-lock install`)
188
+ registers the MCP server as `load-lock`, so Claude Code exposes
189
+ `mcp__load-lock__<tool>`. A plugin-bundled server is namespaced by plugin
190
+ and server key, `mcp__plugin_<plugin>_<server>__<tool>` (per the Claude Code
191
+ MCP docs), e.g. `mcp__plugin_load-lock-plugin_load-lock__claim` for the
192
+ `load-lock-plugin` Claude Code plugin. Every text
193
+ load-lock injects (policy brief, hook claim/wakeup messages, CLAUDE.md /
194
+ AGENTS.md sections, skill) therefore names tools neutrally ("the load-lock
195
+ `claim` tool") instead of hard-coding a prefix.
196
+
197
+ **Claude Code plugin** (`/plugin install load-lock-plugin@load-lock`, see
198
+ OPERATIONS "Claude Code plugin"): the same MCP server and hooks, bundled
199
+ and run through the dependency-free launcher; no skill, the detailed guide
200
+ comes from the `agent_guide` tool. Its manifest, hooks file and
201
+ marketplace entry are contract-tested (`src/test/plugin.test.ts`) and pass
202
+ `claude plugin validate --strict` (Claude Code 2.1.312), and the hook
203
+ commands are tested the way the plugin invokes them. Hooks use shell form
204
+ (one `command` string, `${CLAUDE_PLUGIN_ROOT}` quoted; #63).
205
+
206
+ **Plugin live verification** (#63; Claude Code 2.1.312, 2026-10-29, Linux
207
+ host): a real session with a throwaway `CLAUDE_CONFIG_DIR` and `HOME`,
208
+ load-lock isolated by `LOAD_LOCK_STATE_DIR` / `LOAD_LOCK_SOCKET`, the
209
+ plugin installed from this repo's marketplace with `claude plugin
210
+ marketplace add <checkout>` + `claude plugin install
211
+ load-lock-plugin@load-lock`, and the checkout's CLI first on `PATH`.
212
+ Headless `claude -p` and an interactive pty session. Status: **plugin
213
+ live-verified** for the checks marked observed:
214
+
215
+ | Check | Result |
216
+ | --- | --- |
217
+ | Shell-form hooks run, launcher takes the PATH branch | Observed (`load-lock launcher: path 1.5.0` on every hook) |
218
+ | SessionStart policy at startup | Observed (model quoted the isolated daemon's `load-lock now:` line) |
219
+ | SessionStart policy after `/compact` | Observed (`SessionStart:compact` hook ran; model quoted the fresh `1/1` line where the startup brief said `0/1`) |
220
+ | MCP tools named `mcp__plugin_load-lock-plugin_load-lock__<tool>` | Observed (all 19 tools listed under that prefix; server `plugin:load-lock-plugin:load-lock` connected) |
221
+ | `agent_guide` tool | Observed (called; returned the guide) |
222
+ | Stop offer continuation | Observed (headless: Stop hook returned `decision: block` with the offer token; model claimed and released) |
223
+ | `await-offer` idle wakeup (`async` + `asyncRewake`) | Observed (interactive: session idle ~18s, offer arrived, the watcher's exit 2 woke the session with a task notification; model ran `pending` → `claim` → `release`) |
224
+ | Daemon started by the plugin | Observed (fresh state dir; the daemon came up during session start with `CLAUDE_PLUGIN_ROOT` in its environment and `daemon.json` naming the PATH copy). Which plugin component spawned it first (hook or MCP server) was not determined. |
225
+ | Session-derived identity | Observed for the plugin MCP server (requests carried agent `claude-code:<session id>` matching the session's transcript). That hooks read the same `session_id` from stdin was not separately inspected. |
226
+ | Loose install + plugin together | Not observed (not run; covered by the #54 dedupe tests only) |
227
+ | Launcher npx / known-copy branches inside a live session | Not observed (only the PATH branch ran) |
228
+
229
+ The TUI labels the asyncRewake wake as "Stop hook blocking error from
230
+ command SessionStart:startup"; the message text is the watcher's.
231
+
124
232
  Contract tests cover all hook stdin/stdout shapes against the documented
125
233
  harness contracts. Claude Code is additionally **live-verified** (in Docker,
126
234
  via `docker/run-claude-verify.sh` against the real `claude` CLI): SessionStart
@@ -253,7 +361,7 @@ without a dedicated installer.
253
361
  Idle policy: these harnesses get no push notifications, so the PRIMARY
254
362
  idle mechanism is the MCP `wait_for_turn` tool — a bounded, honest sleep
255
363
  until it is the caller's turn. Queued agents call
256
- `mcp__load-lock__wait_for_turn` (optionally with `timeout_ms`, default
364
+ the load-lock `wait_for_turn` tool (optionally with `timeout_ms`, default
257
365
  600000, capped by the machine's `maxTurnWaitMs` config) and get a
258
366
  structured result on every path: `granted` (the offer was auto-claimed —
259
367
  `permit_token` included), `still_queued` (timeout, with queue position
@@ -319,7 +427,7 @@ UI** — where no UI surface exists, we say so rather than claim parity.
319
427
  | Harness | In-harness dashboard | Also available |
320
428
  | --- | --- | --- |
321
429
  | **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by clicking the sidebar widget; deliberately **not** bound to any key — issue #28: the old `gl` sequence swallowed plain `g` typing, and the candidate `ctrl+g` is the host's `session.first` default) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, in-window presentation, plain `g` reaches the composer, `ctrl+g` not intercepted by load-lock). | `load-lock dashboard` (alias `top`) |
322
- | **Claude Code** | None in-app — Claude Code has no plugin UI surface. The model can report status via the `load_lock_status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
430
+ | **Claude Code** | None in-app — load-lock ships no in-app view for Claude Code: `load-lock-plugin` bundles the MCP server + hooks only, and the loose install adds no UI either. The model can report status via the load-lock `status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
323
431
  | **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
324
432
  | **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
325
433
  | **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
@@ -334,14 +442,49 @@ reconnect, view-model shaping, frame rendering) is covered by
334
442
 
335
443
  ## Session identity per harness
336
444
 
445
+ The daemon routes offers, `pending` and op-key duplicate suppression by agent
446
+ id, so each harness session needs exactly one id, whichever wiring (CLI, MCP
447
+ server, hook) made the call. The default identity of the CLI (issue #49) and
448
+ of the MCP server (issue #50) comes from the shared resolver in
449
+ `src/shared/identity.ts`, first match wins:
450
+
451
+ 1. explicit override: `--agent` (CLI), `agent_id` (MCP tools), or
452
+ `LOAD_LOCK_AGENT`;
453
+ 2. a harness session env var exported to the processes the harness spawns:
454
+ `CLAUDE_CODE_SESSION_ID` → `claude-code:<session_id>` (the same value hooks
455
+ receive as `session_id`; verified on Claude Code 2.1.x), and
456
+ `OPENCODE_SESSION_ID` → the bare `sessionID` (exported by OpenCode's bash
457
+ tool; verified on opencode 2.1.1). The bare id is exactly the native
458
+ plugin's agent id, so CLI calls from an OpenCode shell share the plugin's
459
+ identity;
460
+ 3. the stable harness ancestor process (Linux): walk up from the CLI's parent
461
+ past shells and launcher wrappers (`sh`/`bash`/`zsh`/…, `env`, `timeout`,
462
+ `npm`/`npx`, nested `load-lock`) to the first other ancestor, identified as
463
+ `proc:<pid>:<start time>` (pid-reuse safe via `/proc/<pid>/stat`), labelled
464
+ `<comm>:<pid>`. This covers harnesses without a verified env var (Codex,
465
+ Cursor, Gemini CLI, …), since one session = one long-lived harness process;
466
+ 4. otherwise (no harness ancestor, or no `/proc` — macOS/Windows without a
467
+ session env var) the legacy default: `cli:$USER@$HOSTNAME` for the CLI, a
468
+ random per-process `agt_…` id for the MCP server.
469
+
470
+ So two MCP registrations in one session (plugin-bundled + standalone) share
471
+ one id: an `acquire` through one shows in `pending` through the other, and
472
+ both heartbeat the same id (idempotent). The CLI and the MCP server of one
473
+ session share an id too when both see the same session env var or the same
474
+ harness ancestor; whether a harness exports its session env var to MCP
475
+ server processes is not verified for every harness.
476
+
477
+ Labels stay human-readable: session-derived ids get a short holder label
478
+ (`claude-code:ee690007`, `codex:12345`); explicit ids label themselves.
479
+
337
480
  | Harness | Identity | Notes |
338
481
  | --- | --- | --- |
339
- | OpenCode | plugin context `sessionID` (also MCP `_meta` when used) | automatic |
340
- | Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` | policy instructs agents to pass it |
341
- | Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
342
- | Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); MCP tools take explicit `agent_id` | same |
482
+ | OpenCode | plugin context `sessionID` (also MCP `_meta` when used); CLI via `OPENCODE_SESSION_ID` (same id) | automatic |
483
+ | Claude Code | hooks get `session_id`; CLI and MCP server via `CLAUDE_CODE_SESSION_ID`, else the harness ancestor; MCP tools also take explicit `agent_id` | automatic; explicit `agent_id` still wins |
484
+ | Codex | hooks get `session_id`; CLI and MCP server via harness ancestor process; MCP tools also take explicit `agent_id` | same |
485
+ | Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); CLI and MCP server via harness ancestor process; MCP tools also take explicit `agent_id` | same |
343
486
  | omac (sandbox) | inner harness identity + `LOAD_LOCK_LABEL_PREFIX=omac` | dashboard shows holders as `omac:<label>` |
344
- | Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
487
+ | Tier 3 | explicit `agent_id` argument; CLI and MCP server via harness ancestor process | MCP falls back to a per-server-process id only when no session is found |
345
488
 
346
489
  ## Platform matrix
347
490