@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
@@ -7,6 +7,49 @@ Windows has a named-pipe transport adapter — implemented and unit-tested on
7
7
  Linux via platform injection, not yet verified on a Windows machine; see the
8
8
  Windows section at the bottom).
9
9
 
10
+ ### The install ladder
11
+
12
+ load-lock can be installed at several levels ("rungs"). Each rung works on
13
+ its own, and a higher rung only adds capability. Installing more than one,
14
+ in any order, is safe: there is still one daemon and one budget, one
15
+ identity per session, and duplicate hooks are dropped by the daemon (see
16
+ [ARCHITECTURE.md](ARCHITECTURE.md#three-layers-daemon-runtime-copies-harness-wiring)).
17
+
18
+ | Rung | What you install | Runtime it runs | What the agent gets |
19
+ | --- | --- | --- | --- |
20
+ | 0 | **Skill only** (`skills/load-lock/SKILL.md`) | the load-lock MCP tools if listed, else `load-lock` on PATH, else `npx -y @aron98/load-lock@<version>` (pinned in the skill) | the full permit lifecycle through the CLI (`run`, `wait`, `acquire` / `claim` / `release`, `cancel`); the first call starts the daemon. No idle wakeup, no policy injection, nothing after compaction |
21
+ | 1 | **MCP server** (`npx -y @aron98/load-lock@<version> mcp`) | the pinned npx copy | the tools; your offers in every load-lock tool result; `wait_for_turn` / `wait_for_completion` bounded sleeps; the policy in the MCP `instructions` where the harness shows them; the `agent_guide` tool |
22
+ | 2 | **Harness plugin**: Claude Code `load-lock-plugin`, OpenCode npm plugin `@aron98/load-lock` | Claude Code: the plugin launcher (PATH CLI if new enough, else a known copy, else pinned npx); OpenCode: its own plugin cache copy | everything the harness supports: tools, policy at session start that survives compaction, offer wakeups at turn ends and while idle |
23
+ | — | **`npm i -g` + `load-lock install --harness <h>`** | the global CLI | the same wiring as loose config files. The only route for harnesses without a plugin, and the escape hatch where plugins aren't allowed |
24
+
25
+ Each rung points at the next one once: the skill, the MCP `instructions`
26
+ and the session-start brief end with a one-line pointer to
27
+ [`load-lock setup`](#load-lock-setup-what-is-installed-where), which lists
28
+ what is installed where and converges it. The rung each harness can reach
29
+ is in [COMPATIBILITY.md](COMPATIBILITY.md#rung-per-harness).
30
+
31
+ How to install each rung:
32
+
33
+ - **Skill (0).** Through ACE (the `load-lock` skill artifact, see
34
+ [Installing via ACE](#installing-via-ace)), or copy `skills/load-lock/`
35
+ (shipped in the npm package and the repo) into your harness's skills
36
+ directory. `load-lock install --harness claude-code` also copies it to
37
+ `~/.claude/skills/load-lock/`.
38
+ - **MCP server (1).** Through ACE (the `ace/mcp` artifact), or register a
39
+ stdio server named `load-lock` with command `npx` and args
40
+ `["-y", "@aron98/load-lock@<version>", "mcp"]`, exactly what
41
+ `ace/mcp/mcp.json` declares. `load-lock mcp` is the same server as the
42
+ `load-lock-mcp` binary. With a global install,
43
+ `load-lock install --harness generic` prints a snippet that runs that
44
+ copy instead of npx.
45
+ - **Plugin (2).** Claude Code: see [Claude Code plugin](#claude-code-plugin).
46
+ OpenCode: add `@aron98/load-lock` to the `plugins` array of
47
+ `~/.config/opencode/opencode.json` (OpenCode downloads and updates it),
48
+ or let `load-lock install --harness opencode` register it.
49
+ - **Loose wiring.** The installers below.
50
+
51
+ ### `npm i -g` + `load-lock install`
52
+
10
53
  ```sh
11
54
  npm install -g @aron98/load-lock # or: npm install -g ./aron98-load-lock-<ver>.tgz
12
55
  load-lock install --harness opencode
@@ -32,13 +75,23 @@ round-trips in `src/test/install.test.ts` for all three (an earlier matcher
32
75
  never matched its own entries — duplicate installs and hooks that survived
33
76
  uninstall — see issue #19); not live-verified inside the real CLIs.
34
77
 
35
- - **opencode** — writes a plugin stub to
36
- `~/.config/opencode/plugins/load-lock/` (npm-installed against the global
37
- package), registers it in `~/.config/opencode/opencode.json`.
78
+ - **opencode** — registers the published package `@aron98/load-lock` in
79
+ the `plugins` array of `~/.config/opencode/opencode.json` (OpenCode
80
+ installs and updates it itself) and removes the legacy plugin stub
81
+ `~/.config/opencode/plugins/load-lock/` if an older install left one.
38
82
  - **claude-code** — adds `mcpServers.load-lock` to `~/.claude.json`; hooks
39
83
  (`SessionStart`, `Stop`, async `await-offer`) to `~/.claude/settings.json`;
40
84
  a marked section to `~/.claude/CLAUDE.md`; copies the skill to
41
- `~/.claude/skills/load-lock/`.
85
+ `~/.claude/skills/load-lock/`. A skill dir it didn't write (an ACE
86
+ install with an `.ace.json` stamp, or a symlink) is left alone by both
87
+ `install` and `uninstall`, which say so (#89). With the
88
+ [Claude Code plugin](#claude-code-plugin) enabled in
89
+ `~/.claude/settings.json` (`enabledPlugins["load-lock-plugin@…"]: true`),
90
+ `install` instead removes the loose MCP server, hooks and `CLAUDE.md`
91
+ section, reporting each step, and only installs the skill (the plugin
92
+ ships none); `--legacy` installs the loose wiring anyway. `uninstall`
93
+ removes loose entries only and names the `/plugin uninstall …` command
94
+ for the plugin (#62).
42
95
  - **codex** — writes `~/.codex/hooks.json` (`SessionStart`, `SubagentStart`,
43
96
  `Stop`), appends `[mcp_servers.load-lock]` to `~/.codex/config.toml` if
44
97
  absent, adds a marked section to `~/.codex/AGENTS.md`. **Then run `/hooks`
@@ -83,6 +136,135 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
83
136
 
84
137
  `load-lock uninstall --harness <harness>` reverses any of these.
85
138
 
139
+ ### `load-lock setup`: what is installed where
140
+
141
+ `load-lock setup [--project <dir>] [--json]` lists every load-lock
142
+ integration it finds, for every harness `install` supports (the project
143
+ defaults to the cwd). It is **read-only**: it never writes a file.
144
+
145
+ For each harness it reports:
146
+
147
+ - **what** it found: plugins (Claude Code `enabledPlugins` entries named
148
+ `load-lock-plugin@…`, OpenCode `plugin` / `plugins` entries), MCP
149
+ registrations (user and project scope, including ACE's `.mcp.json`
150
+ `servers` patch), loose hooks, policy sections / rule files, skill
151
+ copies, and omac sandbox grants;
152
+ - **who** wrote each entry: `installer` (the exact shape `load-lock
153
+ install` writes), `harness` (the harness's own plugin state), `ace`
154
+ (an `.ace.json` stamp sits next to it), or `other` (manual, npx, …);
155
+ - **which copy** an entry runs, and whether that copy is gone (stale);
156
+ - the **rung**: `plugin`, `loose` (`load-lock install` wiring), `MCP only`,
157
+ `skill only`, or `not installed`;
158
+ - **findings**: loose hooks next to an active plugin, the MCP server
159
+ registered more than once (a Claude Code plugin's bundled server counts),
160
+ hooks from more than one copy, duplicate hook entries, several skill
161
+ copies, stale entries, disabled plugins.
162
+
163
+ It ends with the runtime copies: those on disk (as in `doctor`), the copy
164
+ the daemon runs (`daemon.json`), and the copies the daemon has seen.
165
+
166
+ An ACE-installed Claude plugin (unzipped into `.claude/skills/<name>/`) is
167
+ listed but not counted as an active plugin: whether Claude Code loads it
168
+ there is not verified yet (#63).
169
+
170
+ #### Converging: `load-lock setup --yes`
171
+
172
+ After the inventory, `setup` prints a **plan** that brings every harness
173
+ that already has some load-lock wiring to the recommended layout: the
174
+ highest rung available, exactly one of each. Without `--yes` it is a dry
175
+ run. `--yes` applies it (`--json` adds `plan` and `applied`). Harnesses with
176
+ nothing installed are left alone; the plan names them once.
177
+
178
+ The plan only uses moves the installers can reverse:
179
+
180
+ | Situation | Step |
181
+ |---|---|
182
+ | Claude Code plugin enabled **and** loose `install` wiring | `remove-loose`: drop the loose MCP server, hooks and `CLAUDE.md` section (the plugin provides them). The skill stays: the plugin ships none. Undo: `load-lock install --harness claude-code --legacy`. |
183
+ | Installer wiring incomplete, pointing at a missing copy, or hooks from several copies / duplicated | `install`: remove the installer-shaped hooks of every copy (and a stale codex `[mcp_servers.load-lock]` block), then `load-lock install --harness <h>` from the running copy |
184
+ | Only an MCP registration or only the skill | `install` (moves up to the installer's rung) |
185
+ | Anything not written by `load-lock install` (plugin state, ACE entries, manual / npx entries, extra skill copies) | `manual`: printed with what to remove or run (`/plugin uninstall …`), never applied |
186
+
187
+ Guards, each turning an `install` into a `manual` step:
188
+
189
+ - a foreign entry sits in the slot `install` would overwrite (e.g. a
190
+ hand-written `mcpServers.load-lock`);
191
+ - the Claude Code skill directory is managed by ACE (`.ace.json`);
192
+ - the running copy lives in the npx or opencode cache, which npm may prune
193
+ (install globally first);
194
+ - `cursor` / `roo-code` write into the project, so they are only refreshed
195
+ when the project already has their installer wiring.
196
+
197
+ `load-lock uninstall --harness <h>` still removes everything load-lock
198
+ added, after a converge as before it. The MCP instructions and the
199
+ session-start brief end with a one-line pointer to `load-lock setup`.
200
+
201
+ ### Claude Code plugin
202
+
203
+ Instead of `install --harness claude-code`, Claude Code can load load-lock
204
+ as a plugin (issue #61). The repo root is a marketplace
205
+ (`.claude-plugin/marketplace.json`) listing one plugin, `load-lock-plugin`,
206
+ in `plugins/claude-code/`:
207
+
208
+ ```text
209
+ /plugin marketplace add aron98/load-lock
210
+ /plugin install load-lock-plugin@load-lock
211
+ ```
212
+
213
+ - **MCP server** `load-lock`, declared inline in `plugin.json` and started
214
+ through the launcher (`--bin load-lock-mcp`). Tools are named
215
+ `mcp__plugin_load-lock-plugin_load-lock__<tool>`.
216
+ - **Hooks** in `hooks/hooks.json`, the same three as the loose install:
217
+ `SessionStart` (policy + machine summary), `Stop` (offer continuation)
218
+ and the `async` + `asyncRewake` `await-offer` watcher. Each runs in shell
219
+ form, one command string `node "${CLAUDE_PLUGIN_ROOT}/scripts/load-lock.mjs"
220
+ hook claude-code <event>` with the root quoted (#63). Shell form is the
221
+ long-documented hook shape; exec form (`command` + `args`) has no
222
+ documented minimum Claude Code version. The string has no shell
223
+ metacharacters, so ACE's `plugin_hooks_command_safe` check passes.
224
+ - **No skill and no CLAUDE.md section.** The policy arrives through the
225
+ SessionStart hook (re-fired after compaction), and the full guide through
226
+ the `agent_guide` MCP tool. Install the `load-lock` skill separately if
227
+ wanted. The plugin is not named `load-lock`, so it doesn't clash with the
228
+ skill on ACE (#64).
229
+ - **Runtime**: the plugin ships no `dist/`. See [Plugin launcher](#plugin-launcher)
230
+ for how hooks and the MCP server find one.
231
+ - `plugin.json` `version` is pinned to `package.json` by `check-pins`. A
232
+ pinned version keeps users on it until the next release changes it.
233
+
234
+ Combining the plugin with a loose `install --harness claude-code` is safe
235
+ (one session identity, duplicate hooks dropped by the daemon, #54), but
236
+ registers two MCP servers; keep one. Once the plugin is enabled, re-running
237
+ `load-lock install --harness claude-code` (or `load-lock setup --yes`) moves
238
+ the loose MCP server, hooks and `CLAUDE.md` section out (`--legacy` keeps
239
+ them), and `load-lock doctor` reports the mode (`claudeCodeMode`: `plugin`,
240
+ `loose`, `both` or `none`) and warns on `both`.
241
+
242
+ ### Installing via ACE
243
+
244
+ [ACE](https://github.com/TNG/ace) imports artifacts from this repository.
245
+ ACE does not edit harness config itself: its `install_artifact` tool hands
246
+ the agent a download plus instructions, and the agent unpacks and wires it
247
+ (findings in issue #64). This repo provides one artifact per folder:
248
+
249
+ | Folder | ACE kind | What installing it gives you |
250
+ | --- | --- | --- |
251
+ | `skills/load-lock/` | skill `load-lock` | rung 0; unpacked into the harness's skills directory (e.g. `.claude/skills/load-lock/`) |
252
+ | `ace/mcp/` | MCP server `load-lock` (local, stdio) | rung 1; ACE returns a patch with `npx -y @aron98/load-lock@<version> mcp` for the agent to merge into the MCP config (e.g. `.mcp.json`) |
253
+ | `plugins/claude-code/` | Claude Code plugin `load-lock-plugin` | rung 2; ACE unpacks Claude plugins as skills-directory plugins (`.claude/skills/load-lock-plugin/`), not through `/plugin install` |
254
+ | `ace/opencode/` | OpenCode plugin (pointer) | rung 2; install the published `@aron98/load-lock` into `opencode.json` as above, not the folder (see [COMPATIBILITY.md](COMPATIBILITY.md#ace-listing-opencode-issue-79)) |
255
+
256
+ Install the MCP server **or** the Claude Code plugin, not both: the plugin
257
+ already bundles the server (two registrations are harmless, just
258
+ redundant). The plugin is not named `load-lock`, so it doesn't unpack into
259
+ the skill's directory. ACE updates what it installed (it writes an
260
+ `.ace.json` stamp next to each bundle); `load-lock setup` reports those
261
+ entries as `ace` and never rewrites them.
262
+
263
+ Not yet verified (#63, #66): whether Claude Code loads a plugin from a
264
+ skills directory (and resolves `${CLAUDE_PLUGIN_ROOT}` there), and the ACE
265
+ install end to end on a clean machine. Until then `load-lock setup` lists an
266
+ ACE-installed Claude plugin but does not count it as an active plugin.
267
+
86
268
  ## The daemon
87
269
 
88
270
  - State dir: `~/.local/state/load-lock` (Windows: `%LOCALAPPDATA%\load-lock`;
@@ -96,12 +278,23 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
96
278
  - Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
97
279
  holder labels; see Sandboxes).
98
280
  - Files: `config.json` (settings + history), `journal.json` (held permits +
99
- queued requests), `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
281
+ queued requests), `daemon.json` (pid, plus since #52 the daemon's
282
+ `packageRoot` and `version`), `runtimes.json` (known runtime copies, see
283
+ below), `spawn.lock` (bootstrap race guard).
284
+ - **Known runtime copies** (since #52): every client envelope carries its
285
+ package root (`clientRoot`); the daemon validates it (a package.json
286
+ naming `@aron98/load-lock`; the version is read from disk, not trusted
287
+ from the client) and records `{path, version, lastSeen}` in
288
+ `runtimes.json`, deduped by real path, pruning copies whose path is gone.
289
+ Small entry points (skill, plugin launchers) can read `daemon.json` /
290
+ `runtimes.json` to find an existing runtime without npx; `doctor` lists
291
+ them as `daemonRuntime` and `knownRuntimes`.
100
292
 
101
293
  ```sh
102
294
  load-lock service status # hello + pid + daemon version
103
295
  load-lock service start # ensure running (idempotent); refusals name the conflicting pid
104
296
  load-lock service stop # SIGTERM → bounded wait → SIGKILL; verifies the socket is free
297
+ load-lock service restart # stop then start; both phases reported; failures name the phase
105
298
  ```
106
299
 
107
300
  `service stop` is honest, not fire-and-forget: it signals the daemon
@@ -110,6 +303,64 @@ socket still answers, and only reports success once the endpoint is
110
303
  verifiably free (stale `daemon.json`/socket files are cleaned up either way).
111
304
  `service start` refuses loudly when something else owns the endpoint —
112
305
  naming the conflicting pid — instead of a bare "did not come up within 10s".
306
+ `service restart` (since #38) composes the same two hardened phases and
307
+ reports both lines; a failure names the phase and reason
308
+ (`restart failed in the STOP phase: ...` / `... in the START phase ...`).
309
+ Stopping when nothing runs is not an error: the start phase simply spawns a
310
+ fresh daemon.
311
+
312
+ ## Plugin launcher
313
+
314
+ Plugin and ACE bundles are git folders without `dist/`, so their hooks and
315
+ MCP server go through a committed, dependency-free launcher,
316
+ `plugins/claude-code/scripts/load-lock.mjs` (issue #60; plain ESM, Node
317
+ built-ins only, no build step):
318
+
319
+ ```sh
320
+ node plugins/claude-code/scripts/load-lock.mjs hook claude-code stop # any `load-lock` CLI args
321
+ node plugins/claude-code/scripts/load-lock.mjs --bin load-lock-mcp # the MCP server
322
+ ```
323
+
324
+ It runs the first runtime it finds:
325
+
326
+ 1. `load-lock` on PATH, when its version is at least the launcher's pin;
327
+ 2. else the newest known copy at least the pin from the state dir
328
+ (`runtimes.json`, `daemon.json`'s `packageRoot`; see #52 above);
329
+ 3. else `npx --yes --package=@aron98/load-lock@<pin> -- <bin>`.
330
+
331
+ Branches 1 and 2 run the copy's compiled entry (`dist/cli/main.js` /
332
+ `dist/mcp/main.js`) with the launcher's own `node`, so no shell and no
333
+ `.cmd` shim is involved. For npx it prefers the `npx-cli.js` shipped next to
334
+ `node`, falling back to `npx` (POSIX) or `npx.cmd` through a shell
335
+ (Windows). The pin is kept equal to `package.json` by `check-pins`.
336
+
337
+ Hook mode (`hook ...`) never breaks or stalls a session:
338
+
339
+ - npx runs with `--offline` (npm cache only), so a cold cache fails in
340
+ well under a second instead of downloading. The MCP server's npx start
341
+ is online and warms the cache for later hooks.
342
+ - The run is bounded by `LOAD_LOCK_LAUNCHER_TIMEOUT_MS` (default 15000;
343
+ the process group is killed). Not applied to the long-lived
344
+ `await-offer` watcher.
345
+ - Any failure exits 0; exit 2 passes through (the `asyncRewake` wakeup).
346
+ A failed SessionStart that printed nothing prints one install hint
347
+ instead of the policy.
348
+
349
+ The MCP server mode passes the runtime's exit code through and reports a
350
+ missing runtime on stderr. `LOAD_LOCK_LAUNCHER_DEBUG=1` prints the chosen
351
+ branch to stderr.
352
+
353
+ **Measured latency (PATH branch).** On a Linux box (Node 22, 32 cores, warm
354
+ file cache, daemon running), median over 20 runs:
355
+
356
+ | Hook | Direct `node dist/cli/main.js` | Through the launcher |
357
+ |---|---|---|
358
+ | `hook claude-code stop` | 31 ms | 52 ms |
359
+ | `hook claude-code session-start` | 32 ms | 52 ms |
360
+
361
+ So the launcher adds about 20 ms (one extra Node start plus PATH/package.json
362
+ lookups). A warm npx (`--offline`) run adds about 0.2 s; a cold one fails in
363
+ about 0.2 s.
113
364
 
114
365
  ## Update lifecycle
115
366
 
@@ -121,9 +372,13 @@ code. The **version handshake** closes that gap (v1.3.0+):
121
372
  carry `daemonVersion` (the package version of the code the daemon runs).
122
373
  - When a **newer** client contacts an older daemon, the daemon finishes
123
374
  answering, then **self-restarts**: it persists state + journal, drains all
124
- connections, respawns itself with the same argv (`process.execPath` +
125
- original arguments, so it runs whatever is now on disk at the same path),
126
- and exits. The respawn inherits a `LOAD_LOCK_RESTARTED_FOR` marker so
375
+ connections, respawns, and exits. Since #53 the respawn runs the **newer
376
+ client's copy** when the envelope's `clientRoot` checks out (a
377
+ `@aron98/load-lock` package.json whose on-disk version equals the
378
+ claimed `clientVersion`, plus a `dist/core/daemon-main.js`), so a daemon
379
+ spawned by an old global CLI moves onto e.g. a newer npx copy. Without a
380
+ valid hint it falls back to the same argv (`process.execPath` + original
381
+ arguments, so it runs whatever is now on disk at the same path). The respawn inherits a `LOAD_LOCK_RESTARTED_FOR` marker so
127
382
  repeated contact with the same version cannot cause a restart loop — one
128
383
  respawn attempt per version.
129
384
  - Clients that were connected see a disconnect and simply reconnect to the
@@ -139,17 +394,117 @@ code. The **version handshake** closes that gap (v1.3.0+):
139
394
  pending self-restart; `doctor` actively waits a few seconds to confirm the
140
395
  daemon came back on the new version.
141
396
 
142
- Limits, stated honestly: the respawn re-runs the **same argv** — it picks up
143
- an updated copy only if the daemon's launching copy was updated *in place*
144
- (the normal `npm i -g` case). A daemon spawned from a different copy (e.g.
145
- opencode's private cache copy) keeps running that copy's code after a
146
- restart; see the multi-copy notes in
397
+ ### `load-lock update` (since #38)
398
+
399
+ The update command checks npm for a newer version, classifies where *this*
400
+ copy lives **before touching anything**, and applies the update via the
401
+ detected package manager — or refuses honestly rather than guessing:
402
+
403
+ | this copy is... | `load-lock update` does |
404
+ | ------------------------------------- | ------------------------------------------------------------------ |
405
+ | bun global (`~/.bun/install/global/…`) | `bun i -g @aron98/load-lock@latest` |
406
+ | npm global (under `npm root -g`) | `npm i -g @aron98/load-lock@latest` |
407
+ | opencode's npm-cache copy | refuses — opencode manages this copy and refreshes it on its own schedule |
408
+ | npx cache copy (`~/.npm/_npx/<hash>/…`, since #57) | refuses — it is whatever version the launching `npx -y @aron98/load-lock@<pin>` pins; bump that pin (MCP/skill config or ACE artifact) |
409
+ | a git checkout (nearest `.git`) | refuses — update it via your git workflow (`git pull` etc.) |
410
+ | anything else | refuses — "cannot tell how this copy was installed" |
411
+
412
+ After a successful apply it runs `service restart` explicitly, so the
413
+ daemon moves onto the new code immediately (the version handshake remains
414
+ the safety net, not the primary means). The package manager's output is
415
+ shown live. `--check` is a dry run that touches nothing and uses
416
+ script-friendly exit codes:
417
+
418
+ - exit `0` — up to date (or this copy is ahead of the registry)
419
+ - exit `1` — an update is available (including the honest refusals above:
420
+ the update *exists*, this copy just cannot self-apply it)
421
+ - exit `70` — failure (registry unreachable, package manager failed, or a
422
+ refusal in apply mode)
423
+
424
+ Reports are prose for humans and `--json` structured objects for agents
425
+ (`state`: `up-to-date` | `update-available` | `refused` | `updated`).
426
+
427
+ ### Adapter-init drift notice
428
+
429
+ The mirror problem is a *newer adapter copy* with an *older CLI on PATH*
430
+ (typically: opencode refreshed its private cache copy, the global install
431
+ was never updated). At init, the OpenCode plugin and the MCP server compare
432
+ their own version with the PATH-resolved CLI copy's `package.json` and
433
+ surface **one clear notice** naming the exact command (`load-lock update`),
434
+ on stderr and in the injected context / MCP `instructions`. Notify only —
435
+ **nothing is ever auto-installed**. Honest limitation: a harness session
436
+ that was already running keeps the *old* adapter code in memory until the
437
+ harness itself restarts, so the notice fires from the new copy at its init
438
+ — sessions started before a refresh see it on their next restart.
439
+ The same check covers **npx-vs-PATH skew** (#57): an MCP server launched
440
+ via a pinned `npx -y @aron98/load-lock@<pin>` runs from the npx cache, and
441
+ when that copy is newer than the PATH CLI the notice names it as the npx
442
+ copy. (The reverse — a pin older than the PATH CLI — is not flagged here;
443
+ the daemon's version handshake already keeps the daemon on the newer code.)
444
+
445
+ Limits, stated honestly: the respawn moves onto the newer client's copy
446
+ only when that client sends a valid `clientRoot` (#52/#53). Clients older
447
+ than #52 don't, so the respawn re-runs the **same argv** and picks up an
448
+ updated copy only if the daemon's launching copy was updated *in place*
449
+ (the normal `npm i -g` case); `load-lock service restart` moves it onto
450
+ the current CLI copy. See the multi-copy notes in
147
451
  [COMPATIBILITY.md](COMPATIBILITY.md#multiple-copies-of-load-lock-opencode).
148
452
  Pre-1.3.0 daemons do not answer the handshake and never self-restart —
149
- `service stop` followed by any load-lock command moves them onto current
150
- code. The restart behavior itself is live-verified on Linux by the test
151
- suite (`src/test/update.test.ts`); the in-place-update path beyond
152
- same-argv respawn is inspected, not live-tested.
453
+ `service restart` (or `service stop` followed by any load-lock command)
454
+ moves them onto current code. The restart behavior itself is live-verified
455
+ on Linux by the test suite (`src/test/update.test.ts`); the in-place-update
456
+ path beyond same-argv respawn is inspected, not live-tested. The `update`
457
+ command's stop→start compositing, its refusals, `--check` exit codes, and
458
+ the adapter-init drift notice are live-verified on Linux by
459
+ `src/test/update-command.test.ts` — with a **stubbed npm on PATH and a
460
+ planted copy**; no test ever contacts the real npm registry or installs
461
+ anything, so the genuine `npm i -g` round trip is contract-tested, not
462
+ live-tested.
463
+
464
+ ### Plugin in-memory staleness (issue #39)
465
+
466
+ The daemon heals itself via the handshake, but the **opencode plugin does
467
+ not**: opencode loads plugin code **once per process at startup** and never
468
+ hot-reloads it. After `npm i -g @aron98/load-lock@latest` (or opencode
469
+ refreshing its plugin cache for *new* sessions), an already-running opencode
470
+ session keeps executing the **old copy in memory** — even though the panel
471
+ and dashboards reconnect to the daemon just fine. A stale in-memory plugin
472
+ is not a reconnect failure; restarting opencode is the only way to pick up
473
+ the new plugin code.
474
+
475
+ To make that visible instead of mysterious (v1.4.1+):
476
+
477
+ - The dashboards (TUI panel and `load-lock dashboard`) periodically compare
478
+ the running copy's version with the PATH-resolved `load-lock` install.
479
+ When the PATH CLI is newer, the panel shows
480
+ `note: plugin updated — restart opencode to pick up vX (this session
481
+ still runs vY; opencode does not hot-reload plugins)`; the CLI surface
482
+ says the running copy is stale instead.
483
+ - When the daemon itself restarts onto a **different version** (update
484
+ self-restart, or a manual restart of a newer install), the reconnect note
485
+ names it: `daemon restarted onto v1.5.0 (was v1.4.0; pid N); event
486
+ history re-synced`.
487
+ - If a harness pruned the running copy's plugin-cache directory mid-session
488
+ (making the on-disk daemon entry vanish), client auto-spawn falls back to
489
+ the PATH-installed CLI's daemon instead of failing with ENOENT forever.
490
+ - Recovery story: the daemon side is automatic (handshake self-restart);
491
+ the plugin side needs exactly one action — **restart opencode**. Until
492
+ then everything keeps working; only the plugin's code (and its notes)
493
+ is one version behind.
494
+
495
+ The reverse direction — a *newer* adapter copy with an *older* CLI on PATH —
496
+ is #38's [adapter-init drift notice](#adapter-init-drift-notice), above.
497
+
498
+ Verification split (honest): live-verified on Linux by
499
+ `src/test/reconnect.test.ts` against real daemons — the daemon-kill
500
+ auto-respawn reconnect (the H1 repro), the failed-initial-connect recovery,
501
+ and the vanished daemon entry not crashing the host (real ENOENT spawn
502
+ through the compiled client). Contract-tested — mock client / planted
503
+ fixture PATH layouts, no version is ever really upgraded in a test — the
504
+ "restarted onto vX (was vY)" note wording, install-drift detection and its
505
+ per-surface hints, and the `resolveDaemonEntry` fallback table. The genuine
506
+ `npm i -g` + opencode-cache-refresh round trip is inspected, not
507
+ live-tested.
153
508
 
154
509
  ## Configuration
155
510
 
@@ -240,6 +595,14 @@ companion to the main plugin, which is unchanged). Inside the opencode TUI:
240
595
  palette — is open, so Esc keeps cancelling the dialog instead of
241
596
  closing the panel underneath it.
242
597
 
598
+ The panel surfaces two kinds of operational notes inline (issue #39): a
599
+ `daemon restarted (onto vX)` line after the daemon restarts underneath the
600
+ session (event history is re-synced automatically), and — because opencode
601
+ never hot-reloads plugins — a `plugin updated — restart opencode to pick
602
+ up vX` hint whenever the PATH `load-lock` install is newer than the copy
603
+ this session is running. See "Plugin in-memory staleness" under
604
+ [Update lifecycle](#update-lifecycle).
605
+
243
606
  The open command deliberately ships with **no global key binding** (issue
244
607
  #28). Never bind unmodified single letters or letter sequences (the old
245
608
  `gl`) at global scope: while the keymap engine waits for the next key of a
@@ -697,9 +1060,10 @@ unless you want the grants in your real profile):
697
1060
  # GOTCHA 2: the machine's real daemon was still v1.1.0 — a daemon
698
1061
  # started before v1.2.0 silently strips the unknown labelPrefix field
699
1062
  # (zod), so prefixed labels disappear without any error. Upgrade and
700
- # restart: load-lock service stop && load-lock service start. This
701
- # verification deliberately
702
- # used its own daemon on a temp state dir.
1063
+ # restart: load-lock service stop && load-lock service start (at
1064
+ # verification time there was no `service restart` subcommand; it exists
1065
+ # since issue #38 and is the shorter spelling today). This verification
1066
+ # deliberately used its own daemon on a temp state dir.
703
1067
  LOAD_LOCK_STATE_DIR=/tmp/ll-32-test node dist/cli/main.js service start
704
1068
 
705
1069
  # 1. install the grants (merge-only; prints exactly what it merged)
@@ -763,7 +1127,7 @@ The two gotchas that shaped the installer:
763
1127
  2. **Label prefixes need a current daemon on the host.** A daemon
764
1128
  started before v1.2.0 strips the unknown `labelPrefix` field silently
765
1129
  (zod); the dashboard shows the bare label with no error anywhere.
766
- `load-lock service stop && load-lock service start` after upgrading.
1130
+ `load-lock service restart` after upgrading.
767
1131
 
768
1132
  What was NOT verified: omac's macOS seatbelt path (same profile schema,
769
1133
  per omac's docs, but the verification machine is Linux) and omac's agent
@@ -942,3 +1306,25 @@ Caveats to watch for (unverified expectations):
942
1306
  `LOAD_LOCK_SOCKET` at a different pipe name if that is a problem.
943
1307
  - `inspect_machine` cgroup detection reads `/sys/fs/cgroup` and will
944
1308
  degrade gracefully (`cgroupVersion: "none"`), reporting raw host numbers.
1309
+
1310
+ ## Releasing (maintainers)
1311
+
1312
+ Publishing is automated: publishing a GitHub Release runs
1313
+ `.github/workflows/publish.yml` (npm Trusted Publishing; read its comments
1314
+ before changing the flow). The release commit itself is manual:
1315
+
1316
+ 1. Bump `version` in `package.json` (and `package-lock.json`), e.g.
1317
+ `npm version 1.6.0 --no-git-tag-version`.
1318
+ 2. Sync every pinned `@aron98/load-lock@<version>` to it:
1319
+ `npm run check-pins -- --write` (alias for
1320
+ `node scripts/check-pins.mjs --write`), then `npm run check-pins` must
1321
+ print `pins ok`. Commit the bump and the pin updates together.
1322
+ 3. Merge, then publish a GitHub Release tagged `v<version>`.
1323
+
1324
+ Pinned files (the skill's npx fallback, ACE `ace/mcp/mcp.json` and
1325
+ `ace/opencode/package.json`, the Claude Code plugin's `plugin.json` and
1326
+ launcher) are declared in the
1327
+ `PINNED` list at the top of `scripts/check-pins.mjs`; add an entry whenever
1328
+ a new artifact pins a version. CI (`pins` job in `ci.yml`) fails on any
1329
+ pin that differs from `package.json`, on a declared file that is missing,
1330
+ and on a declared file that no longer contains a pin.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -88,7 +88,8 @@
88
88
  "scripts": {
89
89
  "build": "tsc -p tsconfig.json",
90
90
  "test": "tsc -p tsconfig.json && node --test dist/test/*.test.js",
91
- "typecheck": "tsc -p tsconfig.json --noEmit"
91
+ "typecheck": "tsc -p tsconfig.json --noEmit",
92
+ "check-pins": "node scripts/check-pins.mjs"
92
93
  },
93
94
  "engines": {
94
95
  "node": ">=20"
@@ -1,3 +1,17 @@
1
+ ---
2
+ name: load-lock
3
+ description: >-
4
+ Teach parallel AI coding agents sharing one machine to take turns on heavy
5
+ work such as full builds and test suites. Agents acquire a machine-wide
6
+ permit before resource-intensive local work, keep doing light work while
7
+ queued FIFO, sleep honestly with bounded waits instead of polling, and
8
+ release the permit when done. Use when several agents on one machine risk
9
+ overloading it by starting heavy work in the same minute. If load-lock is
10
+ not installed, this skill runs it via `npx -y @aron98/load-lock@1.6.0`
11
+ (downloads the pinned npm package), and load-lock keeps its shared state in
12
+ ~/.local/state/load-lock (a small per-user daemon started on first use).
13
+ ---
14
+
1
15
  # load-lock: machine-wide heavy-work coordination
2
16
 
3
17
  All coding agents on this machine (any harness, any repo/worktree) share one
@@ -5,6 +19,54 @@ small budget of heavy-work permits. Before resource-intensive local work, a
5
19
  polite agent acquires a permit; everyone else keeps doing light work until an
6
20
  offer arrives.
7
21
 
22
+ ## How to call load-lock
23
+
24
+ Use the first of these that works; they all talk to the same daemon:
25
+
26
+ 1. **load-lock MCP tools** (`acquire`, `wait_for_turn`, `run_with_permit`,
27
+ ...), if your harness lists them. The rest of this skill names them.
28
+ 2. **`load-lock` on PATH**: check with `load-lock version`.
29
+ 3. **Pinned npx**: `npx -y @aron98/load-lock@1.6.0 <command>` (Node >= 20).
30
+ It downloads that exact npm package once into the npx cache. Below,
31
+ read `load-lock` as this whole prefix. `npm install -g @aron98/load-lock`
32
+ makes it permanent.
33
+
34
+ The first call starts the daemon on its own (shared state in
35
+ `~/.local/state/load-lock`, or `$XDG_STATE_HOME/load-lock`); you never start
36
+ it yourself.
37
+
38
+ ### CLI-only use (no MCP tools)
39
+
40
+ `load-lock help` lists every command. The safest flow is `load-lock run`,
41
+ which acquires a permit, runs the command, and releases it when the command
42
+ exits (success or failure):
43
+
44
+ ```sh
45
+ load-lock run --reason "full test suite" --op-key test/all -- npm test
46
+ ```
47
+
48
+ - Exit code **68** with `queued at position N (request <id>)` means you are
49
+ not allowed to start yet. Keep doing light work. Out of light work? Sleep
50
+ honestly with `load-lock wait --id <request-id> --timeout 600000`: it
51
+ wakes you on your turn, claims the offer, and prints a `permit_token`.
52
+ Then `load-lock run --permit <permit_token> -- npm test`. A `wait` that
53
+ exits 68 again (still queued) is a checkpoint: call it again.
54
+ - No longer need a queued request: `load-lock cancel --id <request-id>`.
55
+ Your own offers, queue entries and paused requests: `load-lock pending`.
56
+
57
+ **Identity.** The daemon routes offers and `pending` per agent. The CLI
58
+ derives your agent id from your harness session (Claude Code and OpenCode
59
+ session env vars; on Linux, otherwise your harness process), so every call
60
+ from one session counts as one agent, and other sessions are other agents.
61
+ If `pending` does not show a request you made, pin the id yourself: set
62
+ `LOAD_LOCK_AGENT=<unique-name>` (or pass `--agent <unique-name>`) on every
63
+ call.
64
+
65
+ Skill-only use has no idle wakeup (nothing tells you an offer arrived while
66
+ you sit idle; use `load-lock wait`) and no policy reminder after context
67
+ compaction. To get both, install the harness integration once:
68
+ `load-lock setup` (or `load-lock install --harness <name>`).
69
+
8
70
  ## When to use it
9
71
 
10
72
  **Heavy** (acquire first): full builds, `cargo build`/`npm run build`, whole