@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
package/README.md CHANGED
@@ -1,6 +1,17 @@
1
- # load-lock
2
-
3
- [![CI](https://github.com/aron98/load-lock/actions/workflows/ci.yml/badge.svg)](https://github.com/aron98/load-lock/actions/workflows/ci.yml)
1
+ <h1 align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/banner.svg">
4
+ <img src="assets/banner.svg" width="640" alt="load-lock — machine-wide permits for parallel coding agents">
5
+ </picture>
6
+ </h1>
7
+
8
+ <p align="center">
9
+ <a href="https://github.com/aron98/load-lock/actions/workflows/ci.yml"><img src="https://github.com/aron98/load-lock/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
10
+ <a href="https://www.npmjs.com/package/@aron98/load-lock"><img src="https://img.shields.io/npm/v/@aron98/load-lock?color=%233fb950" alt="npm version"></a>
11
+ <a href="https://www.npmjs.com/package/@aron98/load-lock"><img src="https://img.shields.io/npm/dm/@aron98/load-lock?color=%233fb950" alt="npm downloads"></a>
12
+ <a href="https://github.com/aron98/load-lock/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license MIT"></a>
13
+ <img src="https://img.shields.io/badge/node-%E2%89%A520-green" alt="node >= 20">
14
+ </p>
4
15
 
5
16
  **A machine-wide heavy-work budget for parallel coding agents.**
6
17
 
@@ -21,6 +32,12 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
21
32
  release ✓ release ✓ rejoin → offer → claim ✓
22
33
  ```
23
34
 
35
+ Three agents, one budget — the queue drains FIFO while everyone keeps coding:
36
+
37
+ <p align="center">
38
+ <img src="assets/demo.gif" width="640" alt="load-lock demo: three agents acquire permits, the queue drains FIFO, watched live via load-lock dashboard">
39
+ </p>
40
+
24
41
  ## Features
25
42
 
26
43
  - **One budget for the whole machine** — every client of any harness shares
@@ -54,19 +71,45 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
54
71
  - **Resource-aware** — `inspect_machine` reports cgroup/container CPU and
55
72
  memory limits and current pressure; never assumes cores = capacity.
56
73
 
57
- ## Quickstart
74
+ ## Install
75
+
76
+ Pick a rung per harness. Each works on its own, a higher rung only adds
77
+ capability, and mixing them is safe: one daemon, one budget, one identity
78
+ per session, duplicate hooks dropped by the daemon.
79
+
80
+ | Rung | Install | Agents get |
81
+ | --- | --- | --- |
82
+ | 0 — **skill** | the `load-lock` skill ([`skills/load-lock`](skills/load-lock/SKILL.md), or via ACE) | the full permit lifecycle through the CLI (on PATH, else pinned `npx`); no idle wakeup, no policy injection |
83
+ | 1 — **MCP server** | a stdio server running `npx -y @aron98/load-lock@<version> mcp` (or the ACE `mcp` artifact) | tools, offers in every tool result, bounded `wait_for_turn` / `wait_for_completion` sleeps |
84
+ | 2 — **plugin** | Claude Code `load-lock-plugin`, OpenCode npm plugin `@aron98/load-lock` | everything: tools, policy that survives compaction, offer wakeups |
85
+ | — | `npm i -g` + `load-lock install --harness <h>` | the same as loose config; the route for harnesses without a plugin |
86
+
87
+ Claude Code plugin (MCP server + hooks, no global install needed; the
88
+ skill is separate):
89
+
90
+ ```text
91
+ /plugin marketplace add aron98/load-lock
92
+ /plugin install load-lock-plugin@load-lock
93
+ ```
94
+
95
+ Global CLI + installer (any supported harness):
58
96
 
59
97
  ```sh
60
98
  npm install -g @aron98/load-lock
61
- load-lock install --harness opencode # or claude-code | codex | gemini-cli |
99
+ load-lock install --harness opencode # or claude-code | codex | cursor | gemini-cli |
62
100
  # copilot-cli | cline | roo-code | goose |
63
101
  # omac (sandbox grants) | generic
102
+ load-lock setup # what is installed where, per harness
103
+ load-lock setup --yes # converge wired harnesses to their highest rung
64
104
  load-lock doctor
65
105
  ```
66
106
 
107
+ Rung details and ACE: docs/OPERATIONS.md → "The install ladder"; which
108
+ rung each harness reaches: docs/COMPATIBILITY.md → "Rung per harness".
109
+
67
110
  From then on, agents see the policy and tools automatically:
68
111
  OpenCode plugin (`load_lock_*` tools), Claude Code / Codex MCP tools
69
- (`mcp__load-lock__*`) plus session hooks. Any other MCP-capable harness can
112
+ (named per the harness's MCP namespacing) plus session hooks. Any other MCP-capable harness can
70
113
  use the generic stdio server; plain shells use the CLI:
71
114
 
72
115
  ```sh
@@ -114,7 +157,7 @@ skills/ packaged skill
114
157
  restart semantics, notification honesty, lifecycle tradeoffs.
115
158
  - [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
116
159
  matrix with implemented / contract-tested / proposed labels.
117
- - [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
160
+ - [docs/OPERATIONS.md](docs/OPERATIONS.md) — install ladder, `setup`, ACE, config,
118
161
  troubleshooting, recovery, daemon lifecycle, sandbox bridging.
119
162
  - [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
120
163
  - [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
@@ -122,17 +165,21 @@ skills/ packaged skill
122
165
 
123
166
  ## Status
124
167
 
125
- - Core daemon/protocol/CLI: implemented, 68 automated tests passing
168
+ - Core daemon/protocol/CLI: implemented, 253 automated tests passing
126
169
  (multi-process daemons, real processes, restarts, races, expiry,
127
- PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
128
- shaping, event-cursor resume across a daemon restart, reconnect, and a
129
- `load-lock dashboard --once` render smoke test).
170
+ PID-reuse, subprocess survival, installer round-trips, multi-copy
171
+ restarts; dashboard tests cover view-model shaping, event-cursor resume
172
+ across a daemon restart, reconnect, and a `load-lock dashboard --once`
173
+ render smoke test).
130
174
  - OpenCode adapter: implemented and **live-verified** (tools, policy
131
175
  injection, compaction-proof context hook, execution-boundary wakeup
132
176
  with tokens, skill).
133
- - Claude Code adapter: implemented, contract-tested against documented
134
- hook JSON; not yet live-verified (harness not installed on the
135
- verification machine).
177
+ - Claude Code adapter: loose install (`load-lock install --harness
178
+ claude-code`) implemented and **live-verified** in Docker; the
179
+ `load-lock-plugin` plugin is **live-verified** in a real Claude Code
180
+ session (#63: policy at startup and after `/compact`, plugin tool names,
181
+ `agent_guide`, Stop offer continuation, `asyncRewake` idle wakeup). See
182
+ COMPATIBILITY.md for what was not observed.
136
183
  - Codex CLI adapter: implemented and **live-verified** end-to-end in
137
184
  Docker (interactive TUI via pty: policy injection, MCP round-trip,
138
185
  FIFO queueing behind a competing holder, Stop-hook decision-block
@@ -24,6 +24,8 @@ import { POLICY_BRIEF } from "../../policy/text.js";
24
24
  import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
25
25
  import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
26
26
  import { killProcessGroup } from "../../shared/transport.js";
27
+ import { detectCliDrift } from "../../shared/drift.js";
28
+ import { packageVersion } from "../../shared/version.js";
27
29
  const client = new LLClient();
28
30
  const knownSessions = new Set();
29
31
  const jobs = new Map();
@@ -44,6 +46,18 @@ function toolContent(v) {
44
46
  export default Plugin.define({
45
47
  id: "load-lock",
46
48
  setup(ctx) {
49
+ // Drift notice (issue #38): when this adapter copy is NEWER than the
50
+ // `load-lock` CLI that PATH resolves to (opencode refreshed its cache
51
+ // copy but the global install was never updated), surface one clear
52
+ // notice naming the exact command — on stderr at init, and once in the
53
+ // injected context. Notify only: nothing is ever auto-installed. Note
54
+ // the honest limitation (docs/OPERATIONS.md): a harness session that
55
+ // was already running keeps the OLD adapter code in memory until the
56
+ // harness itself restarts — this fires from the NEW copy at its init.
57
+ const cliDrift = detectCliDrift(packageVersion());
58
+ if (cliDrift)
59
+ process.stderr.write(cliDrift.message + "\n");
60
+ const DRIFT_NOTE = cliDrift ? "\n\n" + cliDrift.message : "";
47
61
  // ------------------------------------------------------------- tools
48
62
  ctx.tool.transform((editor) => {
49
63
  editor.namespace({
@@ -314,7 +328,7 @@ export default Plugin.define({
314
328
  });
315
329
  // ------------------------------------------- policy on every model call
316
330
  void ctx.session.hook("context", async (event) => {
317
- event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) });
331
+ event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) + DRIFT_NOTE });
318
332
  });
319
333
  // -------------------------------------------------- idle wakeup via watch
320
334
  void (async () => {
@@ -100,6 +100,8 @@ function dashboardLines(v, width, f) {
100
100
  push(`note: ${v.daemonRestartNote}`, f.warn);
101
101
  if (v.versionSkewNote)
102
102
  push(`note: ${v.versionSkewNote}`, f.warn);
103
+ if (v.installDriftNote)
104
+ push(`note: ${v.installDriftNote}`, f.warn);
103
105
  if (v.machine) {
104
106
  const m = v.machine;
105
107
  const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -209,11 +211,14 @@ export default Plugin.define({
209
211
  id: "load-lock.tui",
210
212
  setup(ctx) {
211
213
  const [view, setView] = createSignal(null);
212
- const feed = new DashboardFeed(new LLClient());
214
+ // driftSurface "opencode": when this session's copy is older than the
215
+ // PATH CLI, the hint says to restart opencode (plugins never hot-reload).
216
+ const feed = new DashboardFeed(new LLClient(), { driftSurface: "opencode" });
213
217
  feed.onChange = () => setView(feed.view());
214
218
  feed.onState = () => setView(feed.view());
215
219
  void feed.start().catch(() => {
216
- /* daemon down or spawning; the feed keeps retrying and will go live */
220
+ /* daemon down or spawning; the reconnect loop keeps retrying and
221
+ will go live — start() only reports the FIRST connect failure */
217
222
  });
218
223
  // Local countdown tick: produces a fresh snapshot once per second so
219
224
  // ages/deadlines advance even between daemon events.
@@ -1,20 +1,12 @@
1
+ export { enclosingPackage, npxCacheRoot, opencodeCacheRoot } from "../shared/pkginfo.js";
1
2
  export interface InstalledCopy {
2
3
  /** Where this copy was found. */
3
- source: "cli" | "path" | "opencode-cache";
4
+ source: "cli" | "path" | "opencode-cache" | "npx-cache";
4
5
  /** Path of the copy's package root (its package.json's directory). */
5
6
  path: string;
6
7
  /** Version string, or null when the package.json could not be read. */
7
8
  version: string | null;
8
9
  }
9
- /**
10
- * Find the package.json of the @aron98/load-lock copy that contains the
11
- * given file, by climbing parent directories. Best-effort: returns null if
12
- * no enclosing package.json matches (or is readable).
13
- */
14
- export declare function enclosingPackage(startDir: string, maxDepth?: number): {
15
- path: string;
16
- version: string | null;
17
- } | null;
18
10
  /** The copy of load-lock this process runs from (its own package root). */
19
11
  export declare function thisCopy(): InstalledCopy | null;
20
12
  /**
@@ -24,5 +16,6 @@ export declare function thisCopy(): InstalledCopy | null;
24
16
  * so a symlink to the running copy is recognized and not double-listed
25
17
  * — a copy is only listed under "path" when it differs from "cli").
26
18
  * - "opencode-cache": copies under opencode's private npm cache.
19
+ * - "npx-cache": copies under npx's cache (`<npm cache>/_npx/<hash>/...`).
27
20
  */
28
21
  export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
@@ -3,48 +3,19 @@
3
3
  * of load-lock commonly coexist — the globally-installed CLI on PATH, and
4
4
  * the private copy opencode maintains under its npm cache (because
5
5
  * `install --harness opencode` registers the bare package name, opencode
6
- * installs and refreshes its own copy on its own schedule).
6
+ * installs and refreshes its own copy on its own schedule), and copies npx
7
+ * installs under its cache when skill / ACE / pinned MCP entries launch
8
+ * `npx -y @aron98/load-lock@<pin>` (issue #57).
7
9
  *
8
10
  * All scanning is best-effort: missing paths, unreadable files, and
9
11
  * unexpected layouts yield skipped entries or null versions — never a throw.
10
12
  */
11
13
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
12
14
  import { dirname, join } from "node:path";
13
- import { homedir } from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { isWindows } from "../shared/transport.js";
16
- /** Package name of this package (used to find enclosing package roots). */
17
- const PKG_NAME = "@aron98/load-lock";
18
- /**
19
- * Find the package.json of the @aron98/load-lock copy that contains the
20
- * given file, by climbing parent directories. Best-effort: returns null if
21
- * no enclosing package.json matches (or is readable).
22
- */
23
- export function enclosingPackage(startDir, maxDepth = 12) {
24
- let dir = startDir;
25
- for (let i = 0; i < maxDepth; i++) {
26
- const pkgPath = join(dir, "package.json");
27
- try {
28
- if (existsSync(pkgPath)) {
29
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
30
- if (pkg?.name === PKG_NAME) {
31
- return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
32
- }
33
- // A foreign package.json terminates the climb (e.g. a PATH bin
34
- // inside some other tool's package).
35
- return null;
36
- }
37
- }
38
- catch {
39
- return null;
40
- }
41
- const parent = dirname(dir);
42
- if (parent === dir)
43
- return null;
44
- dir = parent;
45
- }
46
- return null;
47
- }
17
+ import { enclosingPackage, npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
18
+ export { enclosingPackage, npxCacheRoot, opencodeCacheRoot } from "../shared/pkginfo.js";
48
19
  /** The copy of load-lock this process runs from (its own package root). */
49
20
  export function thisCopy() {
50
21
  // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
@@ -61,6 +32,7 @@ export function thisCopy() {
61
32
  * so a symlink to the running copy is recognized and not double-listed
62
33
  * — a copy is only listed under "path" when it differs from "cli").
63
34
  * - "opencode-cache": copies under opencode's private npm cache.
35
+ * - "npx-cache": copies under npx's cache (`<npm cache>/_npx/<hash>/...`).
64
36
  */
65
37
  export function findInstalledCopies(platform = process.platform, env = process.env) {
66
38
  const copies = [];
@@ -193,15 +165,31 @@ export function findInstalledCopies(platform = process.platform, env = process.e
193
165
  catch {
194
166
  /* cache scan is best-effort */
195
167
  }
168
+ // --- npx cache scan -------------------------------------------------------
169
+ // Layout: <npm cache>/_npx/<hash>/node_modules/@aron98/load-lock, one
170
+ // <hash> dir per distinct npx package spec (e.g. per pinned version).
171
+ try {
172
+ const npxRoot = npxCacheRoot(platform, env);
173
+ if (npxRoot && existsSync(npxRoot)) {
174
+ for (const hash of readdirSync(npxRoot)) {
175
+ try {
176
+ const cand = join(npxRoot, hash, "node_modules", "@aron98", "load-lock");
177
+ const pkgPath = join(cand, "package.json");
178
+ if (!existsSync(pkgPath))
179
+ continue;
180
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
181
+ if (pkg?.name !== PKG_NAME)
182
+ continue;
183
+ add({ source: "npx-cache", path: cand, version: typeof pkg.version === "string" ? pkg.version : null });
184
+ }
185
+ catch {
186
+ /* skip unreadable npx cache entry */
187
+ }
188
+ }
189
+ }
190
+ }
191
+ catch {
192
+ /* npx cache scan is best-effort */
193
+ }
196
194
  return copies;
197
195
  }
198
- /** Cache root the way opencode resolves it (its npm cache lives under it). */
199
- function opencodeCacheRoot(platform, env) {
200
- if (env.XDG_CACHE_HOME)
201
- return env.XDG_CACHE_HOME;
202
- if (platform === "win32")
203
- return env.LOCALAPPDATA ?? null;
204
- if (platform === "darwin")
205
- return join(env.HOME ?? homedir(), "Library", "Caches");
206
- return join(env.HOME ?? homedir(), ".cache");
207
- }
package/dist/cli/hook.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * against the official docs only (contract-tested, not live-verified).
24
24
  */
25
25
  import { LLClient } from "../shared/client.js";
26
- import { POLICY_BRIEF } from "../policy/text.js";
26
+ import { POLICY_BRIEF, SETUP_HINT } from "../policy/text.js";
27
27
  async function readStdin() {
28
28
  const chunks = [];
29
29
  for await (const c of process.stdin)
@@ -60,6 +60,23 @@ async function machineBrief() {
60
60
  return "load-lock daemon is not reachable right now; it will auto-start on your first load-lock tool call. Use the tools per the policy above when doing heavy work.";
61
61
  }
62
62
  }
63
+ /**
64
+ * Daemon-side dedupe (issue #54): duplicate hook wiring (loose hooks +
65
+ * plugin, two plugins, ...) fires each hook several times. Only the first
66
+ * caller per (harness, session, event, occurrence) acts. Fails open: no
67
+ * session id, an old daemon without hook_once, or any error => act as before.
68
+ */
69
+ async function firstHook(c, harness, event, sessionId, opts = {}) {
70
+ if (!sessionId)
71
+ return true;
72
+ try {
73
+ const r = (await c.request("hook_once", { harness, sessionId, event, ...opts }));
74
+ return r.first !== false;
75
+ }
76
+ catch {
77
+ return true;
78
+ }
79
+ }
63
80
  function claimInstruction(offer, seconds) {
64
81
  return (`load-lock: capacity for "${offer.pool}" is now reserved for you (request ${offer.requestId}, ` +
65
82
  `op "${offer.opKey}", reason: ${offer.reason}). Claim it NOW with the load-lock claim tool ` +
@@ -79,7 +96,23 @@ export async function hookCommand(rest) {
79
96
  case "claude-code/session-start":
80
97
  case "codex/session-start":
81
98
  case "codex/subagent-start": {
82
- process.stdout.write(POLICY_BRIEF + "\n\n" + (await machineBrief()) + "\n");
99
+ // Occurrence = SessionStart source (startup/resume/compact/...): a
100
+ // later compaction must re-inject; duplicates of one firing must not.
101
+ const c = new LLClient();
102
+ let first = true;
103
+ try {
104
+ await c.connect();
105
+ first = await firstHook(c, harness, event, input.session_id, { occurrence: input.source ?? input.agent_id });
106
+ }
107
+ catch {
108
+ /* daemon down: no dedupe possible; machineBrief explains */
109
+ }
110
+ finally {
111
+ c.close();
112
+ }
113
+ if (!first)
114
+ return;
115
+ process.stdout.write(POLICY_BRIEF + "\n\n" + SETUP_HINT + "\n\n" + (await machineBrief()) + "\n");
83
116
  return;
84
117
  }
85
118
  // ------------------------------------------------------------- wakeups
@@ -95,6 +128,9 @@ export async function hookCommand(rest) {
95
128
  const { offers } = (await c.request("offers_for_cwd", { cwd }));
96
129
  if (offers.length > 0) {
97
130
  const o = offers[0];
131
+ // Occurrence = the offer: each offer is delivered once per session.
132
+ if (!(await firstHook(c, harness, event, input.session_id, { occurrence: o.offerToken })))
133
+ return;
98
134
  const seconds = Math.max(0, Math.round(((o.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
99
135
  process.stdout.write(JSON.stringify({ decision: "block", reason: claimInstruction(o, seconds) }) + "\n");
100
136
  }
@@ -124,6 +160,8 @@ export async function hookCommand(rest) {
124
160
  const { offers } = (await c.request("offers_for_cwd", { cwd: cursorCwd }));
125
161
  if (offers.length > 0) {
126
162
  const o = offers[0];
163
+ if (!(await firstHook(c, harness, event, input.conversation_id ?? input.session_id, { occurrence: o.offerToken })))
164
+ return;
127
165
  const seconds = Math.max(0, Math.round(((o.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
128
166
  process.stdout.write(JSON.stringify({ followup_message: claimInstruction(o, seconds) }) + "\n");
129
167
  }
@@ -148,6 +186,12 @@ export async function hookCommand(rest) {
148
186
  catch {
149
187
  process.exit(0); // daemon down; policy hooks will cover it
150
188
  }
189
+ // One live watcher per session: the key is held on this connection
190
+ // and released by the daemon when the watcher exits.
191
+ if (!(await firstHook(c, harness, event, input.session_id, { hold: true }))) {
192
+ c.close();
193
+ process.exit(0);
194
+ }
151
195
  let woken = false;
152
196
  await new Promise((resolve) => {
153
197
  const done = setTimeout(() => {
@@ -160,8 +204,8 @@ export async function hookCommand(rest) {
160
204
  clearTimeout(done);
161
205
  const seconds = Math.max(0, Math.round(((ev.entry.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
162
206
  process.stderr.write(`load-lock: capacity for "${ev.entry.pool}" was just reserved for a request from this directory ` +
163
- `(op "${ev.entry.opKey}"). It expires in ~${seconds}s. Call your load-lock pending tool now and ` +
164
- `claim the offer with claim(offer_token), then start your heavy work immediately.`);
207
+ `(op "${ev.entry.opKey}"). It expires in ~${seconds}s. Call the load-lock pending tool now and ` +
208
+ `claim the offer with the load-lock claim tool (offer_token), then start your heavy work immediately.`);
165
209
  c.close();
166
210
  resolve();
167
211
  }
@@ -4,6 +4,8 @@ export interface InstallOptions {
4
4
  json?: boolean;
5
5
  global?: boolean;
6
6
  binDir?: string;
7
+ /** claude-code: install the loose wiring even when the plugin is enabled (#62). */
8
+ legacy?: boolean;
7
9
  }
8
10
  export interface UninstallOptions {
9
11
  harness: string;
@@ -13,6 +13,10 @@
13
13
  * - claude-code : user-scope MCP server in ~/.claude.json, SessionStart /
14
14
  * Stop hooks + background await-offer watcher in
15
15
  * ~/.claude/settings.json, CLAUDE.md section, skill.
16
+ * With the load-lock-plugin enabled at user scope it
17
+ * moves those loose entries out instead (--legacy keeps
18
+ * them); the skill is never written over one it didn't
19
+ * write (ACE).
16
20
  * - codex : ~/.codex/hooks.json (SessionStart/SubagentStart/Stop),
17
21
  * [mcp_servers.load-lock] in ~/.codex/config.toml,
18
22
  * ~/.codex/AGENTS.md pointer. Trust must be granted via /hooks.
@@ -59,6 +63,7 @@ import os from "node:os";
59
63
  import path from "node:path";
60
64
  import { POLICY_BRIEF } from "../policy/text.js";
61
65
  import { resolveStateDir } from "../shared/transport.js";
66
+ import { activePlugin, inventoryHarness, removeClaudeLoose, skillDirOwner } from "./setup.js";
62
67
  const HOME = os.homedir();
63
68
  const PKG_NAME = "@aron98/load-lock";
64
69
  export function pkgRoot(binDir) {
@@ -223,8 +228,34 @@ function hookEntry(P, event, extra) {
223
228
  ],
224
229
  };
225
230
  }
231
+ function claudeSkillDir() {
232
+ return path.join(HOME, ".claude", "skills", "load-lock");
233
+ }
234
+ function skillOwnerNote(owner) {
235
+ return owner === "ace" ? "managed by ACE (.ace.json), update or remove it through ACE" : "not written by load-lock install (a symlink)";
236
+ }
237
+ /** `load-lock-plugin@<marketplace>` from an `enabledPlugins["..."]` detail. */
238
+ function pluginKey(i) {
239
+ return /^enabledPlugins\["(.+)"\]$/.exec(i.detail)?.[1] ?? "load-lock-plugin@load-lock";
240
+ }
226
241
  function installClaudeCode(opts, P) {
242
+ // The plugin enabled at user scope replaces the loose MCP server, hooks
243
+ // and CLAUDE.md section everywhere (#62); project-scope enables don't.
244
+ const h = inventoryHarness("claude-code", { project: opts.project, home: HOME });
245
+ const plugin = h.integrations.find((i) => activePlugin(i) && i.owner === "harness" && i.scope === "user");
246
+ if (plugin && !opts.legacy) {
247
+ const actions = [
248
+ `plugin ${pluginKey(plugin)} is enabled in ${plugin.file}: it provides the MCP server and hooks, so the loose ones are not installed (--legacy installs them anyway)`,
249
+ ];
250
+ const removed = removeClaudeLoose(h);
251
+ actions.push(...(removed.length ? removed : ["no loose MCP server, hooks or CLAUDE.md section to remove"]));
252
+ actions.push(installClaudeSkill(P));
253
+ return actions;
254
+ }
227
255
  const actions = [];
256
+ if (plugin) {
257
+ actions.push(`--legacy: plugin ${pluginKey(plugin)} is enabled too; harmless, but two MCP servers are registered, keep one`);
258
+ }
228
259
  // 1. user-scope MCP server in ~/.claude.json
229
260
  const claudeJson = path.join(HOME, ".claude.json");
230
261
  const cj = readJson(claudeJson) ?? {};
@@ -256,20 +287,28 @@ function installClaudeCode(opts, P) {
256
287
  const marker = "<!-- load-lock:begin -->";
257
288
  if (mdSection(md, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
258
289
  `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
259
- `1. Call the MCP tool \`mcp__load-lock__acquire\` (reason + op_key) — it grants a permit or queues you.\n` +
260
- `2. While queued, keep doing light work. When an offer arrives (hook wakeup or \`mcp__load-lock__pending\`), claim it via \`mcp__load-lock__claim\` within the window (default 30s).\n` +
261
- `3. Only start heavy work after acquire/claim succeeds. Use \`mcp__load-lock__run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` for processes you already started.\n` +
262
- `4. Release (or cancel queued work) promptly. Inspect the machine with \`mcp__load-lock__inspect_machine\`; adjust capacity via \`mcp__load-lock__configure\` when warranted.\n\n` +
290
+ `1. Call the load-lock \`acquire\` tool (reason + op_key) — it grants a permit or queues you.\n` +
291
+ `2. While queued, keep doing light work. When an offer arrives (hook wakeup or the \`pending\` tool), claim it via the \`claim\` tool within the window (default 30s).\n` +
292
+ `3. Only start heavy work after acquire/claim succeeds. Use \`run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` for processes you already started.\n` +
293
+ `4. Release (or cancel queued work) promptly. Inspect the machine with \`inspect_machine\`; adjust capacity via \`configure\` when warranted.\n\n` +
294
+ `Exact tool names vary with the wiring (standalone MCP server vs. plugin); use the load-lock tools you have.\n\n` +
263
295
  `Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
264
296
  actions.push(`CLAUDE.md section in ${md}`);
265
297
  // 4. skill
266
- const skillDir = path.join(HOME, ".claude", "skills", "load-lock");
298
+ actions.push(installClaudeSkill(P));
299
+ return actions;
300
+ }
301
+ /** Copy the skill, never over one we didn't write, e.g. ACE's (#89). */
302
+ function installClaudeSkill(P) {
303
+ const skillDir = claudeSkillDir();
304
+ const owner = skillDirOwner(skillDir, skillDir);
305
+ if (owner !== "installer")
306
+ return `kept the skill in ${skillDir}: ${skillOwnerNote(owner)}`;
267
307
  fs.mkdirSync(skillDir, { recursive: true });
268
308
  fs.copyFileSync(P.skill, path.join(skillDir, "SKILL.md"));
269
- actions.push(`skill in ${skillDir}/SKILL.md`);
270
- return actions;
309
+ return `skill in ${skillDir}/SKILL.md`;
271
310
  }
272
- function uninstallClaudeCode() {
311
+ function uninstallClaudeCode(opts) {
273
312
  const actions = [];
274
313
  const P = pkgPaths(); // cli path for our-entry matching below
275
314
  const claudeJson = path.join(HOME, ".claude.json");
@@ -313,9 +352,24 @@ function uninstallClaudeCode() {
313
352
  const md = path.join(HOME, ".claude", "CLAUDE.md");
314
353
  removeMdSection(md, "<!-- load-lock:begin -->");
315
354
  actions.push(`removed CLAUDE.md section`);
316
- fs.rmSync(path.join(HOME, ".claude", "skills", "load-lock"), { recursive: true, force: true });
317
- pruneEmptyDirs(path.join(HOME, ".claude", "skills"), path.join(HOME, ".claude"));
318
- actions.push("removed skill");
355
+ const skillDir = claudeSkillDir();
356
+ const owner = skillDirOwner(skillDir, skillDir);
357
+ if (!fs.existsSync(skillDir)) {
358
+ // nothing to remove
359
+ }
360
+ else if (owner === "installer") {
361
+ fs.rmSync(skillDir, { recursive: true, force: true });
362
+ pruneEmptyDirs(path.join(HOME, ".claude", "skills"), path.join(HOME, ".claude"));
363
+ actions.push("removed skill");
364
+ }
365
+ else {
366
+ actions.push(`kept the skill in ${skillDir}: ${skillOwnerNote(owner)}`);
367
+ }
368
+ // Plugin state belongs to Claude Code: point at /plugin, never edit it.
369
+ const h = inventoryHarness("claude-code", { project: opts.project, home: HOME });
370
+ for (const i of h.integrations.filter((x) => x.kind === "plugin" && x.owner === "harness")) {
371
+ actions.push(`plugin ${pluginKey(i)} (${i.file}) is managed by Claude Code and stays; to remove it too, run /plugin uninstall ${pluginKey(i)}`);
372
+ }
319
373
  return actions;
320
374
  }
321
375
  // --------------------------------------------------------------------- codex
@@ -369,7 +423,7 @@ function installCodex(opts, P) {
369
423
  // 3. AGENTS.md pointer
370
424
  const agentsMd = path.join(codexHome, "AGENTS.md");
371
425
  if (mdSection(agentsMd, "<!-- load-lock:begin -->", `<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
372
- `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (mcp__load-lock__acquire / claim / release / run_with_permit / pending / status / inspect_machine / configure). ` +
426
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / run_with_permit / pending / status / inspect_machine / configure). ` +
373
427
  `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done. Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
374
428
  actions.push(`AGENTS.md section in ${agentsMd}`);
375
429
  actions.push("IMPORTANT: run `/hooks` inside Codex and grant trust to the new hooks (they are inert until trusted)");
@@ -1154,7 +1208,7 @@ function installOmac(opts, P) {
1154
1208
  "LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
1155
1209
  "LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
1156
1210
  actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
1157
- "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service stop && load-lock service start)");
1211
+ "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service restart)");
1158
1212
  actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
1159
1213
  "round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
1160
1214
  return actions;
@@ -1230,7 +1284,7 @@ export async function uninstallCommand(opts) {
1230
1284
  break;
1231
1285
  case "claude-code":
1232
1286
  case "claude":
1233
- actions = uninstallClaudeCode();
1287
+ actions = uninstallClaudeCode(opts);
1234
1288
  break;
1235
1289
  case "codex":
1236
1290
  actions = uninstallCodex();