@aron98/load-lock 1.5.0 → 1.6.1
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.
- package/README.md +62 -14
- package/dist/cli/copies.d.ts +3 -2
- package/dist/cli/copies.js +32 -3
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +67 -13
- package/dist/cli/main.js +62 -5
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +5 -1
- package/dist/cli/update.js +16 -3
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/mcp/server.d.ts +4 -3
- package/dist/mcp/server.js +29 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +5 -3
- package/dist/shared/drift.d.ts +4 -2
- package/dist/shared/drift.js +24 -6
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/pkginfo.d.ts +9 -0
- package/dist/shared/pkginfo.js +16 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +206 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +93 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.js +71 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +36 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +142 -13
- package/docs/OPERATIONS.md +292 -14
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/README.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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://badgen.net/npm/dm/@aron98/load-lock?color=3fb950" alt="npm downloads"></a>
|
|
12
|
+
<a href="https://skills.sh/aron98/load-lock"><img src="https://skills.sh/b/aron98/load-lock" alt="skills.sh"></a>
|
|
13
|
+
<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>
|
|
14
|
+
<img src="https://img.shields.io/badge/node-%E2%89%A520-green" alt="node >= 20">
|
|
15
|
+
</p>
|
|
4
16
|
|
|
5
17
|
**A machine-wide heavy-work budget for parallel coding agents.**
|
|
6
18
|
|
|
@@ -21,6 +33,12 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
|
|
|
21
33
|
release ✓ release ✓ rejoin → offer → claim ✓
|
|
22
34
|
```
|
|
23
35
|
|
|
36
|
+
Three agents, one budget — the queue drains FIFO while everyone keeps coding:
|
|
37
|
+
|
|
38
|
+
<p align="center">
|
|
39
|
+
<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">
|
|
40
|
+
</p>
|
|
41
|
+
|
|
24
42
|
## Features
|
|
25
43
|
|
|
26
44
|
- **One budget for the whole machine** — every client of any harness shares
|
|
@@ -54,19 +72,45 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
|
|
|
54
72
|
- **Resource-aware** — `inspect_machine` reports cgroup/container CPU and
|
|
55
73
|
memory limits and current pressure; never assumes cores = capacity.
|
|
56
74
|
|
|
57
|
-
##
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
Pick a rung per harness. Each works on its own, a higher rung only adds
|
|
78
|
+
capability, and mixing them is safe: one daemon, one budget, one identity
|
|
79
|
+
per session, duplicate hooks dropped by the daemon.
|
|
80
|
+
|
|
81
|
+
| Rung | Install | Agents get |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| 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 |
|
|
84
|
+
| 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 |
|
|
85
|
+
| 2 — **plugin** | Claude Code `load-lock-plugin`, OpenCode npm plugin `@aron98/load-lock` | everything: tools, policy that survives compaction, offer wakeups |
|
|
86
|
+
| — | `npm i -g` + `load-lock install --harness <h>` | the same as loose config; the route for harnesses without a plugin |
|
|
87
|
+
|
|
88
|
+
Claude Code plugin (MCP server + hooks, no global install needed; the
|
|
89
|
+
skill is separate):
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
/plugin marketplace add aron98/load-lock
|
|
93
|
+
/plugin install load-lock-plugin@load-lock
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Global CLI + installer (any supported harness):
|
|
58
97
|
|
|
59
98
|
```sh
|
|
60
99
|
npm install -g @aron98/load-lock
|
|
61
|
-
load-lock install --harness opencode # or claude-code | codex | gemini-cli |
|
|
100
|
+
load-lock install --harness opencode # or claude-code | codex | cursor | gemini-cli |
|
|
62
101
|
# copilot-cli | cline | roo-code | goose |
|
|
63
102
|
# omac (sandbox grants) | generic
|
|
103
|
+
load-lock setup # what is installed where, per harness
|
|
104
|
+
load-lock setup --yes # converge wired harnesses to their highest rung
|
|
64
105
|
load-lock doctor
|
|
65
106
|
```
|
|
66
107
|
|
|
108
|
+
Rung details and ACE: docs/OPERATIONS.md → "The install ladder"; which
|
|
109
|
+
rung each harness reaches: docs/COMPATIBILITY.md → "Rung per harness".
|
|
110
|
+
|
|
67
111
|
From then on, agents see the policy and tools automatically:
|
|
68
112
|
OpenCode plugin (`load_lock_*` tools), Claude Code / Codex MCP tools
|
|
69
|
-
(
|
|
113
|
+
(named per the harness's MCP namespacing) plus session hooks. Any other MCP-capable harness can
|
|
70
114
|
use the generic stdio server; plain shells use the CLI:
|
|
71
115
|
|
|
72
116
|
```sh
|
|
@@ -114,7 +158,7 @@ skills/ packaged skill
|
|
|
114
158
|
restart semantics, notification honesty, lifecycle tradeoffs.
|
|
115
159
|
- [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
|
|
116
160
|
matrix with implemented / contract-tested / proposed labels.
|
|
117
|
-
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
|
|
161
|
+
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install ladder, `setup`, ACE, config,
|
|
118
162
|
troubleshooting, recovery, daemon lifecycle, sandbox bridging.
|
|
119
163
|
- [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
|
|
120
164
|
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
|
|
@@ -122,17 +166,21 @@ skills/ packaged skill
|
|
|
122
166
|
|
|
123
167
|
## Status
|
|
124
168
|
|
|
125
|
-
- Core daemon/protocol/CLI: implemented,
|
|
169
|
+
- Core daemon/protocol/CLI: implemented, 253 automated tests passing
|
|
126
170
|
(multi-process daemons, real processes, restarts, races, expiry,
|
|
127
|
-
PID-reuse, subprocess survival
|
|
128
|
-
shaping, event-cursor resume
|
|
129
|
-
`load-lock dashboard --once`
|
|
171
|
+
PID-reuse, subprocess survival, installer round-trips, multi-copy
|
|
172
|
+
restarts; dashboard tests cover view-model shaping, event-cursor resume
|
|
173
|
+
across a daemon restart, reconnect, and a `load-lock dashboard --once`
|
|
174
|
+
render smoke test).
|
|
130
175
|
- OpenCode adapter: implemented and **live-verified** (tools, policy
|
|
131
176
|
injection, compaction-proof context hook, execution-boundary wakeup
|
|
132
177
|
with tokens, skill).
|
|
133
|
-
- Claude Code adapter:
|
|
134
|
-
|
|
135
|
-
|
|
178
|
+
- Claude Code adapter: loose install (`load-lock install --harness
|
|
179
|
+
claude-code`) implemented and **live-verified** in Docker; the
|
|
180
|
+
`load-lock-plugin` plugin is **live-verified** in a real Claude Code
|
|
181
|
+
session (#63: policy at startup and after `/compact`, plugin tool names,
|
|
182
|
+
`agent_guide`, Stop offer continuation, `asyncRewake` idle wakeup). See
|
|
183
|
+
COMPATIBILITY.md for what was not observed.
|
|
136
184
|
- Codex CLI adapter: implemented and **live-verified** end-to-end in
|
|
137
185
|
Docker (interactive TUI via pty: policy injection, MCP round-trip,
|
|
138
186
|
FIFO queueing behind a competing holder, Stop-hook decision-block
|
package/dist/cli/copies.d.ts
CHANGED
|
@@ -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[];
|
package/dist/cli/copies.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/cli/install.d.ts
CHANGED
package/dist/cli/install.js
CHANGED
|
@@ -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
|
|
260
|
-
`2. While queued, keep doing light work. When an offer arrives (hook wakeup or \`
|
|
261
|
-
`3. Only start heavy work after acquire/claim succeeds. Use \`
|
|
262
|
-
`4. Release (or cancel queued work) promptly. Inspect the machine with \`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 (
|
|
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 (
|
|
68
|
-
|
|
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") ||
|
|
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"));
|