@aron98/load-lock 1.5.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 (66) hide show
  1. package/README.md +61 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +157 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +76 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. 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
@@ -1,7 +1,7 @@
1
- export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
1
+ export { enclosingPackage, npxCacheRoot, opencodeCacheRoot } from "../shared/pkginfo.js";
2
2
  export interface InstalledCopy {
3
3
  /** Where this copy was found. */
4
- source: "cli" | "path" | "opencode-cache";
4
+ source: "cli" | "path" | "opencode-cache" | "npx-cache";
5
5
  /** Path of the copy's package root (its package.json's directory). */
6
6
  path: string;
7
7
  /** Version string, or null when the package.json could not be read. */
@@ -16,5 +16,6 @@ export declare function thisCopy(): InstalledCopy | null;
16
16
  * so a symlink to the running copy is recognized and not double-listed
17
17
  * — a copy is only listed under "path" when it differs from "cli").
18
18
  * - "opencode-cache": copies under opencode's private npm cache.
19
+ * - "npx-cache": copies under npx's cache (`<npm cache>/_npx/<hash>/...`).
19
20
  */
20
21
  export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
@@ -3,7 +3,9 @@
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.
@@ -12,8 +14,8 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "n
12
14
  import { dirname, join } from "node:path";
13
15
  import { fileURLToPath } from "node:url";
14
16
  import { isWindows } from "../shared/transport.js";
15
- import { enclosingPackage, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
16
- export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
17
+ import { enclosingPackage, npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
18
+ export { enclosingPackage, npxCacheRoot, opencodeCacheRoot } from "../shared/pkginfo.js";
17
19
  /** The copy of load-lock this process runs from (its own package root). */
18
20
  export function thisCopy() {
19
21
  // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
@@ -30,6 +32,7 @@ export function thisCopy() {
30
32
  * so a symlink to the running copy is recognized and not double-listed
31
33
  * — a copy is only listed under "path" when it differs from "cli").
32
34
  * - "opencode-cache": copies under opencode's private npm cache.
35
+ * - "npx-cache": copies under npx's cache (`<npm cache>/_npx/<hash>/...`).
33
36
  */
34
37
  export function findInstalledCopies(platform = process.platform, env = process.env) {
35
38
  const copies = [];
@@ -162,5 +165,31 @@ export function findInstalledCopies(platform = process.platform, env = process.e
162
165
  catch {
163
166
  /* cache scan is best-effort */
164
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
+ }
165
194
  return copies;
166
195
  }
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)");
@@ -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();
package/dist/cli/main.js CHANGED
@@ -14,6 +14,7 @@ import { ProtocolError } from "../shared/protocol.js";
14
14
  import { waitForTurn } from "../shared/wait-turn.js";
15
15
  import { waitForTrackedJob } from "../shared/wait-completion.js";
16
16
  import { stateDir, socketPath } from "../core/persist.js";
17
+ import { loadRuntimes, readDaemonInfo } from "../shared/runtimes.js";
17
18
  import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
18
19
  import { packageVersion, compareVersions } from "../shared/version.js";
19
20
  import { findInstalledCopies } from "./copies.js";
@@ -22,6 +23,8 @@ import { hookCommand } from "./hook.js";
22
23
  import { topCommand } from "./top.js";
23
24
  import { awaitDaemonUpgraded, serviceRestart, serviceStart, serviceStop, ServiceError } from "./service.js";
24
25
  import { updateCommand } from "./update.js";
26
+ import { applyPlan, claudeCodeMode, formatInventory, formatPlan, inventory, inventoryHarness, planConverge, selfCopy } from "./setup.js";
27
+ import { sessionIdentity } from "../shared/identity.js";
25
28
  // ---------------------------------------------------------------- arg parsing
26
29
  const argv = process.argv.slice(2);
27
30
  let flags = {};
@@ -36,7 +39,7 @@ for (let i = 0; i < argv.length; i++) {
36
39
  const eq = a.indexOf("=");
37
40
  if (eq !== -1)
38
41
  flags[a.slice(2, eq)] = a.slice(eq + 1);
39
- else if (["json", "follow", "force", "admin", "quiet", "check", "version"].includes(a.slice(2)))
42
+ else if (["json", "follow", "force", "admin", "quiet", "check", "version", "yes"].includes(a.slice(2)))
40
43
  flags[a.slice(2)] = true;
41
44
  else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
42
45
  flags[a.slice(2)] = argv[++i];
@@ -63,9 +66,24 @@ function out(v, human) {
63
66
  else
64
67
  process.stdout.write((human ? human() : JSON.stringify(v, null, 2)) + "\n");
65
68
  }
69
+ // Session-derived default identity (issue #49): one id per harness session,
70
+ // so CLI-driven agents on one machine no longer share `cli:$USER@$HOSTNAME`
71
+ // (that remains the fallback when no session can be found).
72
+ let identity;
73
+ function sessionId() {
74
+ return (identity ??= sessionIdentity({
75
+ fallback: "cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"),
76
+ }));
77
+ }
66
78
  function defaultAgent() {
67
- return (process.env.LOAD_LOCK_AGENT ||
68
- "cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
79
+ return sessionId().id;
80
+ }
81
+ /** Readable holder label for a session-derived id (explicit ids label themselves). */
82
+ function defaultLabel() {
83
+ if (S("agent"))
84
+ return undefined;
85
+ const s = sessionId();
86
+ return s.label !== s.id ? s.label : undefined;
69
87
  }
70
88
  const HELP_GROUPS = [
71
89
  {
@@ -112,9 +130,11 @@ const HELP_GROUPS = [
112
130
  {
113
131
  title: "setup & docs",
114
132
  items: [
115
- { names: ["install"], desc: "install the load-lock adapter for a harness: --harness <name>" },
133
+ { names: ["install"], desc: "install the load-lock adapter for a harness: --harness <name> (claude-code: with the plugin enabled, moves the loose wiring out; --legacy keeps it)" },
116
134
  { names: ["uninstall"], desc: "remove what install added: --harness <name>" },
135
+ { names: ["setup"], desc: "inventory every load-lock integration across all harnesses and plan the recommended layout (dry run); --yes applies it: [--project <dir>] [--json]" },
117
136
  { names: ["hook"], desc: "harness hook handlers (called by integrations, not humans)" },
137
+ { names: ["mcp"], desc: "run the MCP stdio server (same as the load-lock-mcp binary; started by harnesses, not humans)" },
118
138
  { names: ["agent-guide"], desc: "print the full agent guide" },
119
139
  { names: ["help"], desc: "this usage; also bare load-lock, --help, -h; help <command> for one line" },
120
140
  ],
@@ -269,7 +289,7 @@ async function main() {
269
289
  const r = await withClient((c) => c.request("acquire", {
270
290
  pool: S("pool", "heavy"),
271
291
  agentId: S("agent", defaultAgent()),
272
- agentLabel: S("label") || undefined,
292
+ agentLabel: S("label") || defaultLabel(),
273
293
  labelPrefix: S("label-prefix") || undefined,
274
294
  cwd: S("cwd", process.cwd()),
275
295
  opKey: S("op-key") || fail("acquire: --op-key is required"),
@@ -492,12 +512,25 @@ async function main() {
492
512
  // when their versions drift apart.
493
513
  const copies = findInstalledCopies();
494
514
  report.copies = copies;
515
+ // Runtime copies the daemon has seen via the handshake (issue #52),
516
+ // and the copy the daemon itself runs from.
517
+ const dinfo = readDaemonInfo(dir);
518
+ if (dinfo?.packageRoot)
519
+ report.daemonRuntime = { path: dinfo.packageRoot, version: dinfo.version ?? null };
520
+ report.knownRuntimes = loadRuntimes(dir);
495
521
  const versions = new Set(copies.map((c) => c.version).filter((v) => v !== null));
496
522
  if (versions.size > 1) {
497
523
  warnings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}): ` +
498
524
  copies.map((c) => `${c.source}=${c.version ?? "?"} (${c.path})`).join("; ") +
499
525
  ` — see docs/COMPATIBILITY.md "Multiple copies of load-lock" for which copy is authoritative for what`);
500
526
  }
527
+ // Claude Code wiring mode (#62): plugin, loose install, both or none.
528
+ const ccMode = claudeCodeMode(inventoryHarness("claude-code", { project: process.cwd() }));
529
+ report.claudeCodeMode = ccMode;
530
+ if (ccMode === "both") {
531
+ warnings.push("Claude Code: the plugin and the loose `load-lock install` wiring are both active (harmless, but two MCP servers); " +
532
+ "`load-lock install --harness claude-code` or `load-lock setup --yes` moves the loose entries out");
533
+ }
501
534
  report.warnings = warnings;
502
535
  out(report, () => JSON.stringify(report, null, 2));
503
536
  return;
@@ -534,15 +567,39 @@ async function main() {
534
567
  json: B("json"),
535
568
  global: true,
536
569
  binDir: import.meta.dirname ? join(import.meta.dirname, "..") : undefined,
570
+ legacy: B("legacy"),
537
571
  });
538
572
  return;
539
573
  case "uninstall":
540
574
  await uninstallCommand({ harness: S("harness"), project: S("project", process.cwd()) });
541
575
  return;
576
+ case "setup": {
577
+ const project = S("project", process.cwd());
578
+ const inv = inventory({ project });
579
+ const plan = planConverge(inv, selfCopy());
580
+ if (!B("yes")) {
581
+ out({ inventory: inv, plan }, () => formatInventory(inv) + "\n" + formatPlan(plan));
582
+ return;
583
+ }
584
+ const binDir = import.meta.dirname ? join(import.meta.dirname, "..") : undefined;
585
+ const applied = await applyPlan(inv, plan, { project, binDir });
586
+ const after = inventory({ project });
587
+ out({ inventory: after, plan, applied }, () => formatInventory(after) + "\n" + formatPlan(plan, applied));
588
+ return;
589
+ }
542
590
  // ------------------------------------------------------------------ hooks
543
591
  case "hook":
544
592
  await hookCommand(rest);
545
593
  return;
594
+ // ------------------------------------------------------------ mcp server
595
+ // Same server as the `load-lock-mcp` bin, reachable through the default
596
+ // `load-lock` bin so `npx -y @aron98/load-lock@<pin> mcp` works (ACE, #65).
597
+ // Imported lazily: other commands never load the MCP SDK.
598
+ case "mcp": {
599
+ const { runStdioMcp } = await import("../mcp/server.js");
600
+ await runStdioMcp();
601
+ return;
602
+ }
546
603
  case "agent-guide": {
547
604
  const p = join(import.meta.dirname ?? ".", "..", "..", "docs", "AGENT_GUIDE.md");
548
605
  process.stdout.write(readFileSync(existsSync(p) ? p : join(import.meta.dirname ?? ".", "..", "..", "docs", "AGENT_GUIDE.md"), "utf8"));