@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.
- package/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- 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/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -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 +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -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 +157 -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 +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -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.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/README.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
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://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
|
-
##
|
|
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
|
-
(
|
|
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,
|
|
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
|
|
128
|
-
shaping, event-cursor resume
|
|
129
|
-
`load-lock dashboard --once`
|
|
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:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/dist/cli/copies.d.ts
CHANGED
|
@@ -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[];
|
package/dist/cli/copies.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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)");
|
|
@@ -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
|
|
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();
|