@aron98/load-lock 1.1.0 → 1.2.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 +12 -1
- package/dist/adapters/opencode/tui.d.ts +3 -2
- package/dist/adapters/opencode/tui.js +25 -8
- package/dist/cli/main.js +3 -1
- package/dist/core/state.js +11 -2
- package/dist/mcp/server.js +2 -0
- package/dist/shared/client.d.ts +13 -0
- package/dist/shared/client.js +48 -0
- package/dist/shared/protocol.d.ts +8 -0
- package/dist/test/cli.test.js +8 -1
- package/dist/test/core.test.js +27 -0
- package/dist/test/mcp.test.js +2 -1
- package/dist/test/sandbox.test.d.ts +1 -0
- package/dist/test/sandbox.test.js +155 -0
- package/docs/ADAPTERS.md +1 -1
- package/docs/COMPATIBILITY.md +3 -3
- package/docs/OPERATIONS.md +383 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,17 @@ opens it in-window, every session screen shows a clickable at-a-glance
|
|
|
81
81
|
widget in the right sidebar, and the home screen has a footer indicator —
|
|
82
82
|
same data, themed. See docs/OPERATIONS.md → "Dashboard".
|
|
83
83
|
|
|
84
|
+
## Sandboxed agents
|
|
85
|
+
|
|
86
|
+
Agents running inside a sandbox (bubblewrap/omac, Docker, macOS seatbelt,
|
|
87
|
+
WSL2) share the host's budget instead of getting their own: bridge the
|
|
88
|
+
daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
|
|
89
|
+
client never spawns a useless sandbox-local daemon, and optionally tag
|
|
90
|
+
holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
|
|
91
|
+
Ready-to-run per-sandbox recipes — live-verified for bubblewrap,
|
|
92
|
+
contract-inspected for the rest, plus the cross-PID-namespace lease
|
|
93
|
+
lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
|
|
94
|
+
|
|
84
95
|
## Layout
|
|
85
96
|
|
|
86
97
|
```
|
|
@@ -103,7 +114,7 @@ skills/ packaged skill
|
|
|
103
114
|
- [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
|
|
104
115
|
matrix with implemented / contract-tested / proposed labels.
|
|
105
116
|
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
|
|
106
|
-
troubleshooting, recovery, daemon lifecycle.
|
|
117
|
+
troubleshooting, recovery, daemon lifecycle, sandbox bridging.
|
|
107
118
|
- [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
|
|
108
119
|
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
|
|
109
120
|
harness, using the published library surface.
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* - a clickable sidebar.footer widget with the same summary (session
|
|
16
16
|
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
17
17
|
* disjoint surfaces, so this is not a double-render
|
|
18
|
-
* - /load-lock (palette + slash command
|
|
19
|
-
*
|
|
18
|
+
* - /load-lock (palette + slash command) opens the panel IN-WINDOW via
|
|
19
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
20
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
20
21
|
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
21
22
|
* force-release is an admin action gated behind an explicit select +
|
|
22
23
|
* confirm dialog
|
|
@@ -16,8 +16,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/soli
|
|
|
16
16
|
* - a clickable sidebar.footer widget with the same summary (session
|
|
17
17
|
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
18
18
|
* disjoint surfaces, so this is not a double-render
|
|
19
|
-
* - /load-lock (palette + slash command
|
|
20
|
-
*
|
|
19
|
+
* - /load-lock (palette + slash command) opens the panel IN-WINDOW via
|
|
20
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
21
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
21
22
|
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
22
23
|
* force-release is an admin action gated behind an explicit select +
|
|
23
24
|
* confirm dialog
|
|
@@ -181,8 +182,8 @@ function MiniIndicator(v, ctx) {
|
|
|
181
182
|
* Right-sidebar widget: the same glance summary plus a "click to open"
|
|
182
183
|
* affordance. Click support is contract-level (opentui enables mouse and
|
|
183
184
|
* bubbles mouse-down events to slot renderables); terminals that don't
|
|
184
|
-
* deliver mouse events can still open the dashboard
|
|
185
|
-
*
|
|
185
|
+
* deliver mouse events can still open the dashboard via the /load-lock
|
|
186
|
+
* slash command (or the command palette) named on the hint line.
|
|
186
187
|
*/
|
|
187
188
|
function SidebarWidget(v, ctx) {
|
|
188
189
|
const f = themeFg(ctx.theme);
|
|
@@ -196,7 +197,7 @@ function SidebarWidget(v, ctx) {
|
|
|
196
197
|
return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
|
|
197
198
|
}
|
|
198
199
|
const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
|
|
199
|
-
const hint = shortcut ? `${shortcut} / click opens dashboard` : "click opens dashboard";
|
|
200
|
+
const hint = shortcut ? `${shortcut} / click opens dashboard` : "/load-lock or click opens dashboard";
|
|
200
201
|
return (_jsxs("box", { onMouseDown: openDashboard, children: [_jsx("text", { fg: healthColor(g.health, f), children: `load-lock ${g.bits.join("·")}` }), _jsx("text", { fg: f.dim, children: hint })] }));
|
|
201
202
|
}
|
|
202
203
|
// ---------------------------------------------------------------- plugin
|
|
@@ -268,9 +269,21 @@ export default Plugin.define({
|
|
|
268
269
|
id: "load-lock.dashboard.open",
|
|
269
270
|
title: "load-lock: open the lock-status dashboard",
|
|
270
271
|
group: "load-lock",
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
bind
|
|
272
|
+
// NO global key binding, on purpose (issue #28):
|
|
273
|
+
//
|
|
274
|
+
// 1. Never bind unmodified single letters or letter sequences
|
|
275
|
+
// (the old "gl") at global scope: while the keymap engine waits
|
|
276
|
+
// for the next key of a sequence it swallows the prefix, so
|
|
277
|
+
// plain "g" never reached the composer. Modifier chords don't
|
|
278
|
+
// have this problem.
|
|
279
|
+
// 2. The obvious chord, ctrl+g, is already the host's documented
|
|
280
|
+
// default binding for session.first ("Navigate to first
|
|
281
|
+
// message", bound to ctrl+g,home,alt+home in opencode >=
|
|
282
|
+
// 2.0.32); our layer is mode "global" + priority 5 and would
|
|
283
|
+
// shadow it. Rather than shadow the host or pick another
|
|
284
|
+
// unmodified key, the command ships unbound: the sidebar
|
|
285
|
+
// widget (click), the command palette, and the /load-lock
|
|
286
|
+
// slash command remain as entry points.
|
|
274
287
|
palette: true,
|
|
275
288
|
slash: { name: "load-lock", aliases: ["lock", "ll"] },
|
|
276
289
|
run: () => {
|
|
@@ -291,6 +304,10 @@ export default Plugin.define({
|
|
|
291
304
|
id: "load-lock.dashboard.resync",
|
|
292
305
|
title: "load-lock: resync the dashboard",
|
|
293
306
|
group: "load-lock",
|
|
307
|
+
// Unmodified letters are fine here (unlike the global-scope rule
|
|
308
|
+
// above): `enabled` gates these to moments when OUR panel is the
|
|
309
|
+
// fullscreen current one and the composer is suspended, so there is
|
|
310
|
+
// no typing to swallow.
|
|
294
311
|
bind: "r",
|
|
295
312
|
enabled: panelOpen,
|
|
296
313
|
run: () => {
|
package/dist/cli/main.js
CHANGED
|
@@ -65,7 +65,7 @@ const HELP_GROUPS = [
|
|
|
65
65
|
{
|
|
66
66
|
title: "permit operations",
|
|
67
67
|
items: [
|
|
68
|
-
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--wait-ms] [--no-queue]" },
|
|
68
|
+
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--label] [--label-prefix] [--wait-ms] [--no-queue]" },
|
|
69
69
|
{ names: ["claim"], desc: "claim an offered permit before its window expires: --offer <token>" },
|
|
70
70
|
{ names: ["release"], desc: "release a held permit: --permit <token> [--outcome success|failure]" },
|
|
71
71
|
{ names: ["cancel"], desc: "cancel a queued/offered/paused request: --id <request-id> [--admin]" },
|
|
@@ -253,6 +253,7 @@ async function main() {
|
|
|
253
253
|
pool: S("pool", "heavy"),
|
|
254
254
|
agentId: S("agent", defaultAgent()),
|
|
255
255
|
agentLabel: S("label") || undefined,
|
|
256
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
256
257
|
cwd: S("cwd", process.cwd()),
|
|
257
258
|
opKey: S("op-key") || fail("acquire: --op-key is required"),
|
|
258
259
|
reason: S("reason") || fail("acquire: --reason is required"),
|
|
@@ -461,6 +462,7 @@ async function runWithPermit(jobArgv) {
|
|
|
461
462
|
pool: S("pool", "heavy"),
|
|
462
463
|
agentId: S("agent", defaultAgent()),
|
|
463
464
|
agentLabel: S("label") || "load-lock run",
|
|
465
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
464
466
|
cwd: process.cwd(),
|
|
465
467
|
opKey: S("op-key") || `run/${command}/${Date.now()}`,
|
|
466
468
|
reason: S("reason") || command,
|
package/dist/core/state.js
CHANGED
|
@@ -239,6 +239,15 @@ export class Machine {
|
|
|
239
239
|
const weight = p.weight ?? 1;
|
|
240
240
|
if (!Number.isInteger(weight) || weight < 1)
|
|
241
241
|
throw new ProtocolError("invalid", "weight must be a positive integer");
|
|
242
|
+
// Holder-label prefix (sandbox tagging): composed here so every adapter
|
|
243
|
+
// gets identical behavior; falls back to agentId when no label is given.
|
|
244
|
+
let agentLabel = p.agentLabel;
|
|
245
|
+
if (p.labelPrefix !== undefined) {
|
|
246
|
+
const prefix = p.labelPrefix.trim();
|
|
247
|
+
if (!prefix)
|
|
248
|
+
throw new ProtocolError("invalid", "labelPrefix must be a non-empty string");
|
|
249
|
+
agentLabel = `${prefix}:${p.agentLabel ?? p.agentId}`;
|
|
250
|
+
}
|
|
242
251
|
// Idempotency: a live request for the same (agent, op) returns its state
|
|
243
252
|
// (including its offer/permit token for the owner).
|
|
244
253
|
const existing = this.liveEntryByOpKey(p.agentId, p.opKey);
|
|
@@ -259,7 +268,7 @@ export class Machine {
|
|
|
259
268
|
id: rid(),
|
|
260
269
|
pool,
|
|
261
270
|
agentId: p.agentId,
|
|
262
|
-
agentLabel
|
|
271
|
+
agentLabel,
|
|
263
272
|
cwd: p.cwd,
|
|
264
273
|
opKey: p.opKey,
|
|
265
274
|
reason: p.reason,
|
|
@@ -281,7 +290,7 @@ export class Machine {
|
|
|
281
290
|
id: rid(),
|
|
282
291
|
pool,
|
|
283
292
|
agentId: p.agentId,
|
|
284
|
-
agentLabel
|
|
293
|
+
agentLabel,
|
|
285
294
|
cwd: p.cwd,
|
|
286
295
|
opKey: p.opKey,
|
|
287
296
|
reason: p.reason,
|
package/dist/mcp/server.js
CHANGED
|
@@ -42,6 +42,7 @@ export function buildServer(opts) {
|
|
|
42
42
|
reason: z.string().describe("Why you need the machine (shown to other agents, e.g. 'cargo test --all in worktree feat-x')"),
|
|
43
43
|
op_key: z.string().describe("Stable key for the pending operation (idempotency; e.g. 'wt-feat-x/cargo-test')"),
|
|
44
44
|
agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
|
|
45
|
+
label_prefix: z.string().optional().describe("Prefix prepended to your holder label so sandboxed clients are identifiable, e.g. 'omac' -> 'omac:<label>' (or set env LOAD_LOCK_LABEL_PREFIX)"),
|
|
45
46
|
wait_ms: z.number().int().optional().describe("Bounded blocking wait for capacity, clamped by config (default 0)"),
|
|
46
47
|
subscribe_if_busy: z.boolean().optional().describe("false = fail fast instead of queueing (default true)"),
|
|
47
48
|
pool: z.string().optional().describe("Resource pool (default 'heavy')"),
|
|
@@ -50,6 +51,7 @@ export function buildServer(opts) {
|
|
|
50
51
|
const res = await client.request("acquire", {
|
|
51
52
|
pool: input.pool,
|
|
52
53
|
agentId: agentIdOf(input),
|
|
54
|
+
labelPrefix: input.label_prefix,
|
|
53
55
|
cwd: process.cwd(),
|
|
54
56
|
opKey: input.op_key,
|
|
55
57
|
reason: input.reason,
|
package/dist/shared/client.d.ts
CHANGED
|
@@ -34,6 +34,19 @@ export declare class LLClient {
|
|
|
34
34
|
onDisconnect(cb: () => void): () => void;
|
|
35
35
|
close(): void;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
39
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
40
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
41
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare function noSpawnRequested(env?: NodeJS.ProcessEnv): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
46
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
47
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
48
|
+
*/
|
|
49
|
+
export declare function envLabelPrefix(env?: NodeJS.ProcessEnv): string | undefined;
|
|
37
50
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
38
51
|
export declare function ensureDaemon(stateDir: string, sockPath: string): Promise<void>;
|
|
39
52
|
/** One-shot helper: connect (auto-spawning), run fn, close. */
|
package/dist/shared/client.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* NTFS) and starts a detached daemon; losers wait for the endpoint to
|
|
13
13
|
* appear. The freshly spawned daemon itself re-checks the endpoint before
|
|
14
14
|
* binding, so a double-spawn is harmless (the loser exits).
|
|
15
|
+
*
|
|
16
|
+
* Client-only mode (LOAD_LOCK_NO_SPAWN): sandboxed clients must be thin
|
|
17
|
+
* clients of the HOST daemon. A sandbox-local daemon is useless (it cannot
|
|
18
|
+
* see host processes or host-wide load) and hides the failure — so when the
|
|
19
|
+
* env var is set (any non-empty value except "0"), no daemon is ever
|
|
20
|
+
* auto-spawned here or in ensureDaemon(); an unreachable endpoint fails
|
|
21
|
+
* loudly with a pointer to the Sandboxes section of docs/OPERATIONS.md.
|
|
15
22
|
*/
|
|
16
23
|
import { connect } from "node:net";
|
|
17
24
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
@@ -48,6 +55,9 @@ export class LLClient {
|
|
|
48
55
|
catch {
|
|
49
56
|
/* fall through to spawn */
|
|
50
57
|
}
|
|
58
|
+
if (noSpawnRequested()) {
|
|
59
|
+
throw new ProtocolError("unavailable", noSpawnError(this.socketPath()));
|
|
60
|
+
}
|
|
51
61
|
if (!this.opts.spawnDaemon) {
|
|
52
62
|
throw new ProtocolError("unavailable", `load-lock daemon is not running on ${this.socketPath()}`);
|
|
53
63
|
}
|
|
@@ -134,6 +144,13 @@ export class LLClient {
|
|
|
134
144
|
}
|
|
135
145
|
/** Issue one RPC. Reconnects once if the connection was lost. */
|
|
136
146
|
async request(method, params = {}, timeoutMs = 20_000) {
|
|
147
|
+
if (method === "acquire" && params.labelPrefix === undefined) {
|
|
148
|
+
// Sandbox wrapper tagging (LOAD_LOCK_LABEL_PREFIX): applied by the
|
|
149
|
+
// shared client so every adapter (CLI, MCP, plugin, hooks) gets it.
|
|
150
|
+
const prefix = envLabelPrefix();
|
|
151
|
+
if (prefix)
|
|
152
|
+
params = { ...params, labelPrefix: prefix };
|
|
153
|
+
}
|
|
137
154
|
if (!this.sock || this.sock.destroyed)
|
|
138
155
|
await this.connect();
|
|
139
156
|
const id = this.nextId++;
|
|
@@ -179,6 +196,33 @@ export class LLClient {
|
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
// ------------------------------------------------------------------- spawning
|
|
199
|
+
/**
|
|
200
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
201
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
202
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
203
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
204
|
+
*/
|
|
205
|
+
export function noSpawnRequested(env = process.env) {
|
|
206
|
+
const v = env.LOAD_LOCK_NO_SPAWN;
|
|
207
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
211
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
212
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
213
|
+
*/
|
|
214
|
+
export function envLabelPrefix(env = process.env) {
|
|
215
|
+
const v = env.LOAD_LOCK_LABEL_PREFIX?.trim();
|
|
216
|
+
return v || undefined;
|
|
217
|
+
}
|
|
218
|
+
function noSpawnError(sockPath) {
|
|
219
|
+
return (`load-lock daemon is not reachable at ${sockPath} and auto-spawn is disabled (LOAD_LOCK_NO_SPAWN is set): ` +
|
|
220
|
+
`this client is client-only and started no daemon. Run the daemon on the host machine ` +
|
|
221
|
+
`(e.g. load-lock service start) and bridge its socket into this sandbox, ` +
|
|
222
|
+
`then point LOAD_LOCK_SOCKET at the bridged path — see the "Sandboxes" section of ` +
|
|
223
|
+
`docs/OPERATIONS.md for per-sandbox recipes (bubblewrap/omac, Docker, seatbelt, WSL2). ` +
|
|
224
|
+
`To allow spawning a daemon here instead, unset LOAD_LOCK_NO_SPAWN.`);
|
|
225
|
+
}
|
|
182
226
|
function daemonMainPath() {
|
|
183
227
|
if (process.env.LOAD_LOCK_DAEMON_BIN)
|
|
184
228
|
return process.env.LOAD_LOCK_DAEMON_BIN;
|
|
@@ -187,6 +231,10 @@ function daemonMainPath() {
|
|
|
187
231
|
}
|
|
188
232
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
189
233
|
export async function ensureDaemon(stateDir, sockPath) {
|
|
234
|
+
if (noSpawnRequested()) {
|
|
235
|
+
// Client-only mode: never spawn, even for direct callers.
|
|
236
|
+
throw new ProtocolError("unavailable", noSpawnError(sockPath));
|
|
237
|
+
}
|
|
190
238
|
if (await probe(sockPath))
|
|
191
239
|
return;
|
|
192
240
|
const lockDir = join(stateDir, "spawn.lock");
|
|
@@ -226,6 +226,14 @@ export interface AcquireParams {
|
|
|
226
226
|
pool?: string;
|
|
227
227
|
agentId: string;
|
|
228
228
|
agentLabel?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Optional prefix prepended to the holder label (composed daemon-side) so
|
|
231
|
+
* sandboxed/thin clients are identifiable in the dashboard, e.g.
|
|
232
|
+
* labelPrefix "omac" + agentLabel "issue-42 build" -> "omac:issue-42
|
|
233
|
+
* build". Falls back to agentId when no agentLabel is given. Adapters may
|
|
234
|
+
* source it from LOAD_LOCK_LABEL_PREFIX (the shared client injects it).
|
|
235
|
+
*/
|
|
236
|
+
labelPrefix?: string;
|
|
229
237
|
cwd?: string;
|
|
230
238
|
opKey: string;
|
|
231
239
|
reason: string;
|
package/dist/test/cli.test.js
CHANGED
|
@@ -94,6 +94,13 @@ test("top stays a working compat alias for dashboard", async () => {
|
|
|
94
94
|
assert.equal(alias.code, 0);
|
|
95
95
|
assert.equal(primary.code, 0);
|
|
96
96
|
// "synced N ago" and daemon uptime tick between runs; normalize before comparing.
|
|
97
|
-
|
|
97
|
+
// The MACHINE block is live host telemetry — load averages, available memory
|
|
98
|
+
// and the used-percentage in the pressure note move (and round across
|
|
99
|
+
// boundaries) between the two back-to-back runs — so collapse it wholesale;
|
|
100
|
+
// the alias check is that everything *else* renders identically.
|
|
101
|
+
const norm = (s) => s
|
|
102
|
+
.replace(/synced \S+ ago/g, "synced N ago")
|
|
103
|
+
.replace(/uptime \S+/g, "uptime N")
|
|
104
|
+
.replace(/^MACHINE.*\n\s*pressure:.*\n/m, "MACHINE <live telemetry>\n");
|
|
98
105
|
assert.equal(norm(alias.stdout), norm(primary.stdout), "identical rendering");
|
|
99
106
|
});
|
package/dist/test/core.test.js
CHANGED
|
@@ -279,3 +279,30 @@ test("diagnose reports holder info and flags long holds without revoking", () =>
|
|
|
279
279
|
assert.equal(stateOf(m, a.requestId), "held", "long hold alone never revokes a running job");
|
|
280
280
|
assert.ok(diags[0].diagnostics.note.includes("pid 333"));
|
|
281
281
|
});
|
|
282
|
+
// ------------------------------------------------------- labelPrefix (issue #29)
|
|
283
|
+
test("labelPrefix composes the holder label; agentId fallback when unlabeled", () => {
|
|
284
|
+
const { m } = mk();
|
|
285
|
+
const a = grant(m, A, "op1", { agentLabel: "issue-42 build", labelPrefix: "omac" });
|
|
286
|
+
assert.equal(a.agentLabel, "omac:issue-42 build");
|
|
287
|
+
// queued entry composes too, falling back to agentId without a label
|
|
288
|
+
const b = m.acquire({ agentId: B, opKey: "op1", reason: "b", labelPrefix: "omac" });
|
|
289
|
+
assert.equal(b.state, "queued");
|
|
290
|
+
assert.equal(b.entry.agentLabel, "omac:" + B);
|
|
291
|
+
// status (dashboard data source) shows the composed labels
|
|
292
|
+
const st = m.status();
|
|
293
|
+
assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
|
|
294
|
+
assert.equal(st.pools[0].queue[0].agentLabel, "omac:" + B);
|
|
295
|
+
// events and journal carry the composed label (restart reconciliation)
|
|
296
|
+
assert.equal(m.eventsSince(0)[1].entry.agentLabel, "omac:issue-42 build");
|
|
297
|
+
assert.equal(m.journalEntries()[0].agentLabel, "omac:issue-42 build");
|
|
298
|
+
// no prefix: unchanged behavior
|
|
299
|
+
const c = m.acquire({ agentId: C, opKey: "op1", reason: "c", agentLabel: "plain" });
|
|
300
|
+
assert.equal(c.entry.agentLabel, "plain");
|
|
301
|
+
});
|
|
302
|
+
test("labelPrefix is trimmed and must be non-empty when present", () => {
|
|
303
|
+
const { m } = mk();
|
|
304
|
+
const a = grant(m, A, "op1", { agentLabel: "build", labelPrefix: " omac " });
|
|
305
|
+
assert.equal(a.agentLabel, "omac:build");
|
|
306
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op2", reason: "r", labelPrefix: " " }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
307
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op3", reason: "r", labelPrefix: "" }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
308
|
+
});
|
package/dist/test/mcp.test.js
CHANGED
|
@@ -32,10 +32,11 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
|
|
|
32
32
|
for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
|
|
33
33
|
assert.ok(names.includes(expected), `tool ${expected} registered`);
|
|
34
34
|
}
|
|
35
|
-
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
35
|
+
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA", label_prefix: "omac" });
|
|
36
36
|
assert.equal(acq.state, "granted");
|
|
37
37
|
assert.ok(acq.permit_token, "acquire result carries permit_token");
|
|
38
38
|
assert.ok(acq.next_action, "structured next_action present");
|
|
39
|
+
assert.equal(acq.entry.agentLabel, "omac:mcpAgentA", "label_prefix composes the holder label");
|
|
39
40
|
// idempotent acquire with the same op_key
|
|
40
41
|
const acq2 = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
41
42
|
assert.equal(acq2.request_id, acq.request_id, "same op_key -> same request");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #29: sandboxed thin clients.
|
|
3
|
+
*
|
|
4
|
+
* - Client-only mode (LOAD_LOCK_NO_SPAWN): with the env set and the socket
|
|
5
|
+
* unreachable, the client must NEVER auto-spawn a daemon and must fail
|
|
6
|
+
* loudly, pointing at the Sandboxes section of docs/OPERATIONS.md.
|
|
7
|
+
* - Holder-label prefixing (labelPrefix / LOAD_LOCK_LABEL_PREFIX) so
|
|
8
|
+
* sandboxed clients are identifiable in the dashboard.
|
|
9
|
+
*/
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { LLClient, ensureDaemon } from "../shared/client.js";
|
|
16
|
+
import { ProtocolError } from "../shared/protocol.js";
|
|
17
|
+
import { startDaemon, delay, probe, cliMainPath } from "./testutil.js";
|
|
18
|
+
const ACQ = (agentId, opKey, extra = {}) => ({
|
|
19
|
+
agentId,
|
|
20
|
+
opKey,
|
|
21
|
+
reason: "sandbox test",
|
|
22
|
+
...extra,
|
|
23
|
+
});
|
|
24
|
+
/** Fresh empty state dir + socket path; the dir is NOT created. */
|
|
25
|
+
function freshDir(prefix) {
|
|
26
|
+
const dir = mkdtempSync(join(process.env.LL_TEST_TMP ?? "/tmp", prefix));
|
|
27
|
+
rmSync(dir, { recursive: true, force: true }); // we want it absent
|
|
28
|
+
const sock = join(dir, "daemon.sock");
|
|
29
|
+
return { dir, sock, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
30
|
+
}
|
|
31
|
+
/** Stop a daemon spawned via auto-spawn (pid recorded in daemon.json). */
|
|
32
|
+
async function killSpawnedDaemon(dir) {
|
|
33
|
+
const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
34
|
+
try {
|
|
35
|
+
process.kill(info.pid, "SIGTERM");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* already gone */
|
|
39
|
+
}
|
|
40
|
+
await delay(300);
|
|
41
|
+
}
|
|
42
|
+
test("LOAD_LOCK_NO_SPAWN: no daemon spawned, loud error pointing at the docs", async () => {
|
|
43
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn-");
|
|
44
|
+
process.env.LOAD_LOCK_NO_SPAWN = "1";
|
|
45
|
+
try {
|
|
46
|
+
const c = new LLClient({ socket: sock, stateDir: dir });
|
|
47
|
+
await assert.rejects(() => c.connect(), (e) => {
|
|
48
|
+
assert.ok(e instanceof ProtocolError);
|
|
49
|
+
assert.equal(e.code, "unavailable");
|
|
50
|
+
assert.ok(e.message.includes("LOAD_LOCK_NO_SPAWN"), "error names the env var");
|
|
51
|
+
assert.ok(e.message.includes("docs/OPERATIONS.md"), "error points at the docs");
|
|
52
|
+
assert.ok(e.message.includes("Sandboxes"), "error points at the Sandboxes section");
|
|
53
|
+
assert.ok(e.message.includes(sock), "error names the unreachable socket");
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
// No daemon process was spawned: the state dir was never even created
|
|
57
|
+
// (ensureDaemon creates it only when spawning) and nothing is listening.
|
|
58
|
+
assert.equal(existsSync(dir), false, "no state dir created");
|
|
59
|
+
assert.equal(await probe(sock), false, "nothing listening on the socket");
|
|
60
|
+
await delay(1_000); // would-be spawner waits up to 10s; give it a beat
|
|
61
|
+
assert.equal(existsSync(dir), false, "still no state dir after a wait");
|
|
62
|
+
assert.equal(await probe(sock), false, "still no daemon after a wait");
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
66
|
+
cleanup();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
test("LOAD_LOCK_NO_SPAWN: ensureDaemon refuses directly too", async () => {
|
|
70
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn2-");
|
|
71
|
+
process.env.LOAD_LOCK_NO_SPAWN = "true";
|
|
72
|
+
try {
|
|
73
|
+
await assert.rejects(() => ensureDaemon(dir, sock), (e) => e instanceof ProtocolError && e.message.includes("docs/OPERATIONS.md"));
|
|
74
|
+
assert.equal(existsSync(dir), false, "ensureDaemon created nothing");
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
78
|
+
cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
test("LOAD_LOCK_NO_SPAWN=0 disables client-only mode (daemon auto-spawns)", async () => {
|
|
82
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn0-");
|
|
83
|
+
process.env.LOAD_LOCK_NO_SPAWN = "0";
|
|
84
|
+
try {
|
|
85
|
+
const c = new LLClient({ socket: sock, stateDir: dir });
|
|
86
|
+
await c.connect();
|
|
87
|
+
const hello = (await c.request("hello", {}));
|
|
88
|
+
assert.ok(hello.pid, "daemon spawned and reachable");
|
|
89
|
+
c.close();
|
|
90
|
+
await killSpawnedDaemon(dir);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
94
|
+
cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
test("CLI under LOAD_LOCK_NO_SPAWN fails loudly with a docs pointer", async () => {
|
|
98
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawncli-");
|
|
99
|
+
const res = await new Promise((resolve) => {
|
|
100
|
+
const child = spawn(process.execPath, [cliMainPath, "status"], {
|
|
101
|
+
env: {
|
|
102
|
+
...process.env,
|
|
103
|
+
LOAD_LOCK_NO_SPAWN: "1",
|
|
104
|
+
LOAD_LOCK_STATE_DIR: dir,
|
|
105
|
+
LOAD_LOCK_SOCKET: sock,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
let stderr = "";
|
|
109
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
110
|
+
child.once("exit", (code) => resolve({ code, stderr }));
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
assert.equal(res.code, 70, "unavailable -> exit 70");
|
|
114
|
+
assert.ok(res.stderr.includes("docs/OPERATIONS.md"), "stderr points at the docs");
|
|
115
|
+
assert.ok(res.stderr.includes("Sandboxes"), "stderr points at the Sandboxes section");
|
|
116
|
+
assert.equal(existsSync(dir), false, "CLI spawned no daemon");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
test("daemon-side labelPrefix end-to-end: holder shows prefixed label", async () => {
|
|
123
|
+
const d = await startDaemon();
|
|
124
|
+
try {
|
|
125
|
+
const c = d.client();
|
|
126
|
+
const acq = (await c.request("acquire", ACQ("agentSbx", "sbx-1", { agentLabel: "issue-42 build", labelPrefix: "omac" })));
|
|
127
|
+
assert.equal(acq.state, "granted");
|
|
128
|
+
assert.equal(acq.entry.agentLabel, "omac:issue-42 build");
|
|
129
|
+
const st = (await c.request("status", {}));
|
|
130
|
+
assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await d.stop();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test("LOAD_LOCK_LABEL_PREFIX env is injected by the shared client; explicit param wins", async () => {
|
|
137
|
+
const d = await startDaemon();
|
|
138
|
+
process.env.LOAD_LOCK_LABEL_PREFIX = "bwrap";
|
|
139
|
+
try {
|
|
140
|
+
const c = d.client();
|
|
141
|
+
// injected from env when the acquire does not pass labelPrefix itself
|
|
142
|
+
const a = (await c.request("acquire", ACQ("agentEnv", "env-1", { agentLabel: "cargo test" })));
|
|
143
|
+
assert.equal(a.entry.agentLabel, "bwrap:cargo test");
|
|
144
|
+
// an explicit labelPrefix overrides the env-injected one
|
|
145
|
+
const b = (await c.request("acquire", ACQ("agentEnv", "env-2", { agentLabel: "x", labelPrefix: "omac" })));
|
|
146
|
+
assert.equal(b.entry.agentLabel, "omac:x");
|
|
147
|
+
// unprefixable clients (no label) still show the prefix via agentId fallback
|
|
148
|
+
const e2 = (await c.request("acquire", ACQ("agentEnv2", "env-3", { pool: "heavy", weight: 1 })));
|
|
149
|
+
assert.ok(String(e2.entry.agentLabel).startsWith("bwrap:"), `fallback label was ${e2.entry.agentLabel}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
delete process.env.LOAD_LOCK_LABEL_PREFIX;
|
|
153
|
+
await d.stop();
|
|
154
|
+
}
|
|
155
|
+
});
|
package/docs/ADAPTERS.md
CHANGED
|
@@ -84,7 +84,7 @@ normally touches:
|
|
|
84
84
|
|
|
85
85
|
| Method | Purpose |
|
|
86
86
|
| --- | --- |
|
|
87
|
-
| `acquire {pool?, agentId, agentLabel?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey) |
|
|
87
|
+
| `acquire {pool?, agentId, agentLabel?, labelPrefix?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey); labelPrefix composes `prefix:label` holder labels for sandboxed clients (env `LOAD_LOCK_LABEL_PREFIX` is injected by the shared client) |
|
|
88
88
|
| `claim {offerToken}` | offer → permit (before starting heavy work) |
|
|
89
89
|
| `release {permitToken, outcome?}` | idempotent |
|
|
90
90
|
| `cancel {requestId}` / `rejoin {requestId}` / `decline {offerToken}` | queue hygiene |
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -62,8 +62,8 @@ Global plugin (`load-lock install --harness opencode`) registers:
|
|
|
62
62
|
`load-lock.dashboard` session panel in-window (issue #24: side panel,
|
|
63
63
|
not full-screen) with pools, holder cards, offer countdowns, queue,
|
|
64
64
|
paused, event feed, `inspect_machine`; a clickable sidebar widget
|
|
65
|
-
(`load-lock 0/3`, health-colored; `
|
|
66
|
-
a home-footer `lock 1/1·Q2` indicator; and an `x` force-release flow
|
|
65
|
+
(`load-lock 0/3`, health-colored; `/load-lock` as the mouse-free
|
|
66
|
+
fallback) and a home-footer `lock 1/1·Q2` indicator; and an `x` force-release flow
|
|
67
67
|
gated behind an explicit select + confirm (the panel itself is read-only
|
|
68
68
|
and never acquires a permit).
|
|
69
69
|
|
|
@@ -251,7 +251,7 @@ UI** — where no UI surface exists, we say so rather than claim parity.
|
|
|
251
251
|
|
|
252
252
|
| Harness | In-harness dashboard | Also available |
|
|
253
253
|
| --- | --- | --- |
|
|
254
|
-
| **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by the `gl`
|
|
254
|
+
| **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by clicking the sidebar widget; deliberately **not** bound to any key — issue #28: the old `gl` sequence swallowed plain `g` typing, and the candidate `ctrl+g` is the host's `session.first` default) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, in-window presentation, plain `g` reaches the composer, `ctrl+g` not intercepted by load-lock). | `load-lock dashboard` (alias `top`) |
|
|
255
255
|
| **Claude Code** | None in-app — Claude Code has no plugin UI surface. The model can report status via the `load_lock_status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
|
|
256
256
|
| **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
|
|
257
257
|
| **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -89,7 +89,12 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
|
|
|
89
89
|
override `LOAD_LOCK_STATE_DIR`).
|
|
90
90
|
- Socket: `<state dir>/daemon.sock` (Windows: named pipe `\\.\pipe\load-lock`;
|
|
91
91
|
override `LOAD_LOCK_SOCKET`).
|
|
92
|
-
- **Auto-spawns** on first client connection; no service unit needed.
|
|
92
|
+
- **Auto-spawns** on first client connection; no service unit needed. Set
|
|
93
|
+
`LOAD_LOCK_NO_SPAWN` to any non-empty value except `0` to forbid
|
|
94
|
+
spawning (client-only mode for sandboxes — see
|
|
95
|
+
[Sandboxes](#sandboxes)).
|
|
96
|
+
- Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
|
|
97
|
+
holder labels; see Sandboxes).
|
|
93
98
|
- Files: `config.json` (settings + history), `journal.json` (held permits),
|
|
94
99
|
`daemon.json` (pid), `spawn.lock` (bootstrap race guard).
|
|
95
100
|
|
|
@@ -169,16 +174,16 @@ alternate screen, so it works anywhere the CLI works.
|
|
|
169
174
|
The OpenCode installer also ships the package's `./tui` plugin export (a
|
|
170
175
|
companion to the main plugin, which is unchanged). Inside the opencode TUI:
|
|
171
176
|
|
|
172
|
-
- `/load-lock` (or the command palette
|
|
177
|
+
- `/load-lock` (or the command palette) opens the
|
|
173
178
|
**load-lock.dashboard** session panel **in-window** as a side panel —
|
|
174
179
|
same data as `load-lock dashboard`, themed, live-updating, with the rest
|
|
175
180
|
of the TUI layout still visible beside it.
|
|
176
181
|
- Every session screen shows an at-a-glance widget at the bottom of the
|
|
177
182
|
right sidebar: `load-lock 0/3` colored by pool health (green ok, yellow
|
|
178
183
|
full/queued, red daemon down) plus a hint line. Clicking it opens the
|
|
179
|
-
dashboard; `
|
|
180
|
-
events. The home screen keeps its `lock 1/1·Q2` footer indicator —
|
|
181
|
-
sidebar widget only renders on session screens, so the two never
|
|
184
|
+
dashboard; `/load-lock` is the fallback for terminals that don't deliver
|
|
185
|
+
mouse events. The home screen keeps its `lock 1/1·Q2` footer indicator —
|
|
186
|
+
the sidebar widget only renders on session screens, so the two never
|
|
182
187
|
double-render.
|
|
183
188
|
- `r` resyncs; `x` opens force-release, which requires an explicit
|
|
184
189
|
select + confirm — the panel itself is read-only.
|
|
@@ -187,15 +192,31 @@ companion to the main plugin, which is unchanged). Inside the opencode TUI:
|
|
|
187
192
|
palette — is open, so Esc keeps cancelling the dialog instead of
|
|
188
193
|
closing the panel underneath it.
|
|
189
194
|
|
|
195
|
+
The open command deliberately ships with **no global key binding** (issue
|
|
196
|
+
#28). Never bind unmodified single letters or letter sequences (the old
|
|
197
|
+
`gl`) at global scope: while the keymap engine waits for the next key of a
|
|
198
|
+
sequence it swallows the prefix, so plain `g` never reached the composer.
|
|
199
|
+
The obvious modifier chord, `ctrl+g`, is already the host's documented
|
|
200
|
+
default for `session.first` ("Navigate to first message") in opencode
|
|
201
|
+
>= 2.0.32, so binding it would shadow a host feature; per the issue's
|
|
202
|
+
fallback the command is unbound instead — the sidebar click, the command
|
|
203
|
+
palette, and `/load-lock` are the entry points. (The panel-scoped `r` and
|
|
204
|
+
`x` bindings are fine: they are `enabled` only while the dashboard panel
|
|
205
|
+
itself is fullscreen, when the composer is suspended and no typing can be
|
|
206
|
+
swallowed.)
|
|
207
|
+
|
|
190
208
|
Verified live against opencode 2.0.32 (real pty session): the sidebar
|
|
191
209
|
widget renders with live daemon data, a mouse press on the widget opens
|
|
192
|
-
the dashboard in-window with the chat still visible beside it, `
|
|
193
|
-
|
|
210
|
+
the dashboard in-window with the chat still visible beside it, plain `g`
|
|
211
|
+
types into the composer with no keymap interception, `ctrl+g` is not
|
|
212
|
+
intercepted by load-lock (the host's documented `session.first` default
|
|
213
|
+
is left untouched), and the `/load-lock` command appears in the slash
|
|
214
|
+
suggestions and opens the panel.
|
|
194
215
|
Earlier live verification (2.0.29): panel render, palette/slash entry
|
|
195
216
|
points, and the confirm-gated force-release flow. Click handling itself
|
|
196
217
|
is contract-level from opentui (mouse reporting enabled by default,
|
|
197
218
|
mouse-down events bubble to slot renderables) — the pty test above
|
|
198
|
-
exercises the full path; the `
|
|
219
|
+
exercises the full path; the `/load-lock` command works regardless.
|
|
199
220
|
Plugins configured in `opencode.json` that expose `./tui` are loaded
|
|
200
221
|
automatically by the CLI, so the published package needs no extra config.
|
|
201
222
|
For local development against a checkout, opencode also auto-discovers
|
|
@@ -317,6 +338,10 @@ Limitations observed (all inherent to containers, not load-lock bugs):
|
|
|
317
338
|
respawns with defaults (fine for ephemeral CI containers; volume-mount
|
|
318
339
|
`~/.local/state/load-lock` if you want persistence).
|
|
319
340
|
|
|
341
|
+
The setup above gives the container its *own* daemon. To make a container
|
|
342
|
+
a thin client of the **host's** daemon instead (bridged socket +
|
|
343
|
+
`LOAD_LOCK_NO_SPAWN`), see the [Sandboxes](#sandboxes) section below.
|
|
344
|
+
|
|
320
345
|
Reproduce from a checkout (needs `dist/` built; tarball from `npm pack`):
|
|
321
346
|
|
|
322
347
|
```sh
|
|
@@ -381,6 +406,356 @@ Caveats to watch for (unverified expectations):
|
|
|
381
406
|
- Keep the state dir on the Linux filesystem (`~/.local/state/load-lock`),
|
|
382
407
|
not on `/mnt/c` (drvfs/9p is slow and lock semantics differ).
|
|
383
408
|
|
|
409
|
+
## Sandboxes
|
|
410
|
+
|
|
411
|
+
Sandboxed agents (bubblewrap/omac, Docker, macOS seatbelt, WSL2) face one
|
|
412
|
+
core problem: the budget belongs on the *host*, but a sandbox that cannot
|
|
413
|
+
see the host's daemon will happily auto-spawn its own useless local daemon
|
|
414
|
+
and fragment the budget. The recipes below all use the same two mechanisms:
|
|
415
|
+
|
|
416
|
+
1. **Bridge the host daemon's socket into the sandbox.** The daemon listens
|
|
417
|
+
on an AF_UNIX *path* socket. Path sockets are scoped by the **mount**
|
|
418
|
+
namespace, not the network namespace — so a bind mount of the state dir
|
|
419
|
+
survives `--unshare-net` (bubblewrap), and only needs the right
|
|
420
|
+
filesystem mapping/allow rules elsewhere. Point the sandboxed client at
|
|
421
|
+
the bridged path with `LOAD_LOCK_SOCKET`.
|
|
422
|
+
2. **Run the client in client-only mode** so it can never auto-spawn a
|
|
423
|
+
daemon inside the sandbox (see below).
|
|
424
|
+
|
|
425
|
+
A third mechanism matters once the client is inside a PID namespace:
|
|
426
|
+
**lease-mode (untracked) permits**, because the host daemon cannot
|
|
427
|
+
`kill(pid, 0)` a sandbox pid it cannot see (lifecycle below).
|
|
428
|
+
|
|
429
|
+
### Client-only mode: `LOAD_LOCK_NO_SPAWN`
|
|
430
|
+
|
|
431
|
+
Set `LOAD_LOCK_NO_SPAWN=1` (any non-empty value except `0`) and the shared
|
|
432
|
+
client will never spawn a daemon, no matter which adapter asks:
|
|
433
|
+
`ensureDaemon` refuses before creating anything, and `connect` fails loudly
|
|
434
|
+
instead of falling back to a spawn. The error is actionable and points
|
|
435
|
+
here:
|
|
436
|
+
|
|
437
|
+
```
|
|
438
|
+
load-lock: load-lock daemon is not reachable at /run/load-lock/daemon.sock
|
|
439
|
+
and auto-spawn is disabled (LOAD_LOCK_NO_SPAWN is set): this client is
|
|
440
|
+
client-only and started no daemon. Run the daemon on the host machine
|
|
441
|
+
(e.g. load-lock service start) and bridge its socket into this sandbox,
|
|
442
|
+
then point LOAD_LOCK_SOCKET at the bridged path — see the "Sandboxes"
|
|
443
|
+
section of docs/OPERATIONS.md for per-sandbox recipes (bubblewrap/omac,
|
|
444
|
+
Docker, seatbelt, WSL2). To allow spawning a daemon here instead, unset
|
|
445
|
+
LOAD_LOCK_NO_SPAWN. (code: unavailable)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The CLI exits with code 70 for this failure (unavailable transport). Use
|
|
449
|
+
this mode inside every sandbox: it turns "mysteriously isolated budget"
|
|
450
|
+
into a loud, documented failure, and it is what the tests in
|
|
451
|
+
`src/test/sandbox.test.ts` enforce (no state dir is ever created, and the
|
|
452
|
+
error names the docs).
|
|
453
|
+
|
|
454
|
+
### Label prefixes: identifying sandboxed holders
|
|
455
|
+
|
|
456
|
+
Sandboxed clients can prefix their holder labels so the dashboard shows
|
|
457
|
+
*where* a permit came from, e.g. `omac:issue-42 build`. Three equivalent
|
|
458
|
+
entry points, all composing `prefix:label` (or `prefix:<agentId>` when no
|
|
459
|
+
label was given) daemon-side:
|
|
460
|
+
|
|
461
|
+
- `LOAD_LOCK_LABEL_PREFIX` env var — the shared client injects it into
|
|
462
|
+
every `acquire` request (including CLI, MCP, plugin and hook paths)
|
|
463
|
+
unless a request already carries an explicit prefix. This is the natural
|
|
464
|
+
place for a sandbox wrapper (omac profile, Docker `-e`, bwrap
|
|
465
|
+
`--setenv`) to tag everything an agent does inside it.
|
|
466
|
+
- CLI: `--label-prefix` on `acquire` and `run`.
|
|
467
|
+
- MCP: the `label_prefix` input on the `acquire` tool; the RPC field is
|
|
468
|
+
`labelPrefix` (see `docs/ADAPTERS.md`).
|
|
469
|
+
|
|
470
|
+
A present-but-empty prefix is rejected (`labelPrefix must be a non-empty
|
|
471
|
+
string`); prefixes are trimmed.
|
|
472
|
+
|
|
473
|
+
### Cross-PID-namespace permits: the lease-mode lifecycle
|
|
474
|
+
|
|
475
|
+
With `--unshare-pid` (bubblewrap) or default Docker containers, the host
|
|
476
|
+
daemon cannot see the sandboxed client's pid — verified live below: inside
|
|
477
|
+
the sandbox `kill -0 <host-daemon-pid>` fails with "No such process" while
|
|
478
|
+
the daemon is plainly running. That makes **tracked** permits
|
|
479
|
+
(`start_job` / `attach_pid`, liveness via `kill(pid, 0)` +
|
|
480
|
+
`/proc/<pid>/stat`) meaningless across PID namespaces. Worse, calling
|
|
481
|
+
`attach_pid` from *inside* the sandbox passes a pid that is invisible or
|
|
482
|
+
reused on the host; the daemon's liveness check fails immediately and the
|
|
483
|
+
permit is auto-released. Only use tracked mode when the host sees the same
|
|
484
|
+
pid you do (host-run jobs, or containers started with `--pid=host`).
|
|
485
|
+
|
|
486
|
+
**Lease mode** is the cross-namespace lifecycle:
|
|
487
|
+
|
|
488
|
+
1. `acquire` → permit granted (holder label visible in the dashboard).
|
|
489
|
+
2. `mark_started` → permit moves to `running_untracked` with a lease
|
|
490
|
+
deadline of `now + untrackedLeaseMs` (default 15 minutes,
|
|
491
|
+
configurable — see Configuration). No host-side pid is recorded.
|
|
492
|
+
3. **Heartbeats renew the lease.** Heartbeat sources: an MCP server
|
|
493
|
+
process heartbeats every 30s for its own agent (`agent_id` /
|
|
494
|
+
`defaultAgentId`); the OpenCode plugin heartbeats every 30s per known
|
|
495
|
+
session. The CLI has **no** automatic heartbeat — for a CLI-held
|
|
496
|
+
untracked permit the lease is a hard ceiling, so finish within
|
|
497
|
+
`untrackedLeaseMs` or release promptly.
|
|
498
|
+
4. `release` → capacity freed immediately, whatever remains on the lease.
|
|
499
|
+
5. **Lease expiry:** if no heartbeat arrives before the deadline, the
|
|
500
|
+
daemon reclaims the permit and admits queued waiters, journaling
|
|
501
|
+
`untracked lease expired: no heartbeat from the owning agent`. After a
|
|
502
|
+
daemon restart, `restore` grants surviving untracked permits a *fresh*
|
|
503
|
+
lease window from restore time.
|
|
504
|
+
|
|
505
|
+
The same trap catches `load-lock run` / `run_with_permit`: those helpers
|
|
506
|
+
use *tracked* mode (`start_job` with the spawned job's pid). Run from
|
|
507
|
+
inside a PID-namespace sandbox, the pid is invisible to the host daemon,
|
|
508
|
+
whose liveness sweep then auto-releases the permit while the job is still
|
|
509
|
+
running. Inside sandboxes the recipe is therefore always:
|
|
510
|
+
`acquire` → `mark_started` (+ heartbeats where the harness provides them)
|
|
511
|
+
→ explicit `release` — never `run`, `attach-pid`, or `start-job` from the
|
|
512
|
+
sandboxed side. Save `run` for host-side invocations where the daemon sees
|
|
513
|
+
the job's pid.
|
|
514
|
+
|
|
515
|
+
Tradeoff (deliberate, and the `recover list` output says so): an untracked
|
|
516
|
+
permit whose owner is alive but silent is reclaimed after the lease, so
|
|
517
|
+
overlap with an unobserved survivor is possible — e.g. two builds may run
|
|
518
|
+
concurrently for up to `untrackedLeaseMs` after a sandbox stopped
|
|
519
|
+
heartbeating. That is the price of not being able to see pids across
|
|
520
|
+
namespaces; keep heartbeating (or release promptly) and size
|
|
521
|
+
`untrackedLeaseMs` accordingly.
|
|
522
|
+
|
|
523
|
+
### bubblewrap — live-verified
|
|
524
|
+
|
|
525
|
+
Verified 2026-10-20 with bubblewrap 0.6.1 on Linux 6.8.0-138-generic,
|
|
526
|
+
Node v22.23.2, load-lock 1.1.0 working tree (issue-29 branch). The daemon
|
|
527
|
+
ran on the *host* in an isolated state dir; every client call below ran
|
|
528
|
+
inside `bwrap --unshare-net --unshare-pid` and reached the **host** daemon.
|
|
529
|
+
|
|
530
|
+
1. Start the daemon on the host, in a state dir you will bind into the
|
|
531
|
+
sandbox (here a temp dir; for real use `~/.local/state/load-lock`):
|
|
532
|
+
|
|
533
|
+
```sh
|
|
534
|
+
LOAD_LOCK_STATE_DIR=/tmp/ll-bwrap-29 node dist/cli/main.js service start
|
|
535
|
+
# load-lock: daemon running on pid 2259769 (default socket)
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
The socket appears at `/tmp/ll-bwrap-29/daemon.sock` with mode
|
|
539
|
+
`srwxrwxr-x` (775): connecting needs *write* on the socket inode, so a
|
|
540
|
+
sandbox running as the same user (or group) can talk to it.
|
|
541
|
+
|
|
542
|
+
2. Run the CLI inside the sandbox, binding the state dir and pointing
|
|
543
|
+
`LOAD_LOCK_SOCKET` at it. Bind the **state dir**, not the socket file —
|
|
544
|
+
the socket inode is replaced when the daemon restarts, so a file-only
|
|
545
|
+
bind goes stale, while the dir bind follows the new socket
|
|
546
|
+
automatically. Binding to an existing path keeps this simple (see
|
|
547
|
+
caveats):
|
|
548
|
+
|
|
549
|
+
```sh
|
|
550
|
+
bwrap --dev-bind / / --unshare-net --unshare-pid --proc /proc \
|
|
551
|
+
--bind /tmp/ll-bwrap-29 /tmp/ll-bwrap-29 \
|
|
552
|
+
--setenv LOAD_LOCK_SOCKET /tmp/ll-bwrap-29/daemon.sock \
|
|
553
|
+
--setenv LOAD_LOCK_NO_SPAWN 1 \
|
|
554
|
+
--setenv LOAD_LOCK_LABEL_PREFIX bwrap \
|
|
555
|
+
node dist/cli/main.js status --json
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Observed: `status --json` answered by the *host* daemon — same pid as
|
|
559
|
+
the host's `service status` (`2259769`, protocol v1, heavy pool
|
|
560
|
+
capacity 1, `untrackedLeaseMs` 900000). `--unshare-net` does not
|
|
561
|
+
interfere: the path socket is reached through the mount namespace.
|
|
562
|
+
|
|
563
|
+
3. PID-namespace proof: inside the same sandbox,
|
|
564
|
+
`kill -0 2259769` → `No such process` — the host daemon pid is
|
|
565
|
+
invisible to the sandbox, confirming the client crossed namespaces via
|
|
566
|
+
the filesystem, and that lease mode (not tracked mode) is required for
|
|
567
|
+
long holds (below).
|
|
568
|
+
|
|
569
|
+
4. Full permit round trip from inside the sandbox:
|
|
570
|
+
|
|
571
|
+
```sh
|
|
572
|
+
# acquire (env LOAD_LOCK_LABEL_PREFIX=bwrap was set by bwrap):
|
|
573
|
+
node dist/cli/main.js acquire --op-key bwrap-check \
|
|
574
|
+
--reason "issue-29 live verification build" --label "issue-29 build"
|
|
575
|
+
# → permit prm_… ; entry.agentLabel = "bwrap:issue-29 build"
|
|
576
|
+
|
|
577
|
+
node dist/cli/main.js mark-started --permit prm_…
|
|
578
|
+
# → running_untracked (lease 900s)
|
|
579
|
+
|
|
580
|
+
node dist/cli/main.js release --permit prm_…
|
|
581
|
+
# → host pool "heavy": 0/1
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Host-side `status` while the permit was held:
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
pool "heavy": 1/1 in use
|
|
588
|
+
held: bwrap:issue-29 build — issue-29 live verification build (5s) [untracked, leased]
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
and `recover list` reported `[running_untracked] bwrap:issue-29 build`
|
|
592
|
+
with `heartbeat lease 895s remaining — reclaimed when the owning agent
|
|
593
|
+
stops heartbeating (overlap with an unobserved survivor is possible;
|
|
594
|
+
see docs)`. Five durable events on the host journal: `daemon_started`,
|
|
595
|
+
`daemon_recovered`, `granted bwrap:issue-29 build`, `started`,
|
|
596
|
+
`released`.
|
|
597
|
+
|
|
598
|
+
Caveats observed (verified):
|
|
599
|
+
|
|
600
|
+
- `bwrap` creates missing bind destinations by mkdir-ing them, so
|
|
601
|
+
`--bind <state-dir> /run/load-lock` failed here with
|
|
602
|
+
`bwrap: Can't mkdir /run/load-lock: Permission denied` (`/run` is
|
|
603
|
+
root-owned). Either bind at an existing path (as above), pre-create the
|
|
604
|
+
destination, or pick a parent you can write to.
|
|
605
|
+
- Client-only mode matters: with `LOAD_LOCK_NO_SPAWN=1` unset, a sandbox
|
|
606
|
+
whose bind/env was misconfigured would silently spawn its own daemon
|
|
607
|
+
inside the sandbox and split the budget. The tests in
|
|
608
|
+
`src/test/sandbox.test.ts` pin the loud failure instead.
|
|
609
|
+
|
|
610
|
+
### omac — contract-inspected
|
|
611
|
+
|
|
612
|
+
**omac is not installed on the verification machine**, so nothing in this
|
|
613
|
+
subsection was executed here; it is assembled from omac's documented
|
|
614
|
+
behavior (the facts below are from omac's docs, not invented) and the
|
|
615
|
+
bubblewrap recipe above, which omac wraps on Linux. Treat it as a
|
|
616
|
+
ready-to-run procedure with expected outcomes, and record what you find.
|
|
617
|
+
|
|
618
|
+
omac sandboxes agents with bubblewrap on Linux (seatbelt on macOS, WSL2
|
|
619
|
+
elsewhere) and **strips the harness environment** before starting the
|
|
620
|
+
agent, allowing through only variables listed in the sandbox profile's
|
|
621
|
+
`environment.allow_vars` — `~/.config/omac/sandbox-profiles/default.json`
|
|
622
|
+
by default. That file is the whole recipe:
|
|
623
|
+
|
|
624
|
+
1. Make sure the load-lock state dir is visible inside omac's bubblewrap
|
|
625
|
+
sandbox (the `LOAD_LOCK_SOCKET` path must resolve inside it). How
|
|
626
|
+
omac's sandbox mounts map to bwrap `--bind`s is an omac sandbox-config
|
|
627
|
+
question — omac's own facade socket under `~/.local/state/omac/run/`
|
|
628
|
+
is the project's precedent for a sandbox process connecting to a
|
|
629
|
+
host-owned socket through its profile.
|
|
630
|
+
2. Add the load-lock variables to the profile's `environment.allow_vars`:
|
|
631
|
+
|
|
632
|
+
```json
|
|
633
|
+
{
|
|
634
|
+
"environment": {
|
|
635
|
+
"allow_vars": [
|
|
636
|
+
"LOAD_LOCK_SOCKET",
|
|
637
|
+
"LOAD_LOCK_STATE_DIR",
|
|
638
|
+
"LOAD_LOCK_NO_SPAWN",
|
|
639
|
+
"LOAD_LOCK_LABEL_PREFIX"
|
|
640
|
+
]
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Set the values themselves in your harness env (or the profile's env
|
|
646
|
+
mechanism): `LOAD_LOCK_SOCKET` to the host daemon socket as seen inside
|
|
647
|
+
the sandbox, `LOAD_LOCK_NO_SPAWN=1`, and — recommended —
|
|
648
|
+
`LOAD_LOCK_LABEL_PREFIX=omac` so the dashboard shows holders as
|
|
649
|
+
`omac:<label>`.
|
|
650
|
+
3. Watch out: **omac never rewrites a profile it has created**, so if your
|
|
651
|
+
`default.json` already exists, edit it by hand (adding the entries
|
|
652
|
+
above); and **unknown profile fields are errors**, so don't invent
|
|
653
|
+
other keys — `allow_vars` entries are plain variable names.
|
|
654
|
+
|
|
655
|
+
Expected outcome (unverified here): with those four variables allowed and
|
|
656
|
+
the state dir mounted, an agent inside omac behaves exactly like the
|
|
657
|
+
verified bubblewrap case above — host daemon answers, labels carry the
|
|
658
|
+
`omac:` prefix, and long holds need `mark_started` + heartbeats (lease
|
|
659
|
+
mode) because of the PID namespace.
|
|
660
|
+
|
|
661
|
+
### Docker — contract-inspected
|
|
662
|
+
|
|
663
|
+
Docker *was* verified for in-container daemons (see the verified Docker
|
|
664
|
+
subsection above). What was **not** verified here is the thin-client shape
|
|
665
|
+
below: the verification machine has a `docker` binary but the verifying
|
|
666
|
+
user cannot reach `/var/run/docker.sock`, so this recipe is
|
|
667
|
+
contract-inspected — ready to run, expected outcomes marked, not executed
|
|
668
|
+
by us.
|
|
669
|
+
|
|
670
|
+
1. Run the daemon on the host (normal install, default state dir).
|
|
671
|
+
2. Run the container as a thin client of the *host* daemon:
|
|
672
|
+
|
|
673
|
+
```sh
|
|
674
|
+
docker run --rm -it \
|
|
675
|
+
-v ~/.local/state/load-lock:/run/load-lock \
|
|
676
|
+
-e LOAD_LOCK_SOCKET=/run/load-lock/daemon.sock \
|
|
677
|
+
-e LOAD_LOCK_NO_SPAWN=1 \
|
|
678
|
+
-e LOAD_LOCK_LABEL_PREFIX=docker \
|
|
679
|
+
node:24 bash
|
|
680
|
+
# inside: npm install -g <load-lock tarball> && load-lock status
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Expected: `load-lock status` reports the *host* daemon's pid and pool
|
|
684
|
+
counts, and holders created inside the container appear on the host
|
|
685
|
+
dashboard as `docker:<label>`.
|
|
686
|
+
|
|
687
|
+
Notes and caveats (unverified expectations):
|
|
688
|
+
|
|
689
|
+
- Bind the **state dir**, not the socket file (the socket inode is
|
|
690
|
+
replaced on daemon restart; the dir bind follows it — same reasoning as
|
|
691
|
+
bubblewrap above).
|
|
692
|
+
- Connecting to the socket needs *write* on the socket inode (mode `775`,
|
|
693
|
+
user/group). Container root gets this via `CAP_DAC_OVERRIDE` when no user
|
|
694
|
+
namespace remapping is in play; for non-root containers run with
|
|
695
|
+
`--user "$(id -u):$(id -g)"` and rely on the group permission, or relax
|
|
696
|
+
the socket mode/group to match your setup.
|
|
697
|
+
- Container pids are not host pids, so — exactly as in the bubblewrap
|
|
698
|
+
case — use `mark_started` + heartbeat lease mode for long holds, never
|
|
699
|
+
`attach_pid`/`start_job` from inside, unless the container runs with
|
|
700
|
+
`--pid=host`.
|
|
701
|
+
- The verified caveats from the Docker subsection above still apply from
|
|
702
|
+
the client's point of view (`/proc/loadavg` and `nproc` reflect the
|
|
703
|
+
host; the container's own cgroup quota is what `inspect` reports).
|
|
704
|
+
|
|
705
|
+
### macOS seatbelt — contract-inspected
|
|
706
|
+
|
|
707
|
+
**No macOS machine was available** for this verification, so this is
|
|
708
|
+
contract-inspected against the `sandbox-exec(5)`/seatbelt documentation,
|
|
709
|
+
not executed here.
|
|
710
|
+
|
|
711
|
+
On macOS the daemon listens on a Unix domain socket at
|
|
712
|
+
`~/.local/state/load-lock/daemon.sock`, and connecting to a UDS is
|
|
713
|
+
mediated by seatbelt as a *file* operation, not a network one — so a
|
|
714
|
+
profile needs read and write access to the socket path, e.g.:
|
|
715
|
+
|
|
716
|
+
```lisp
|
|
717
|
+
(allow file-read-data
|
|
718
|
+
(literal "/Users/you/.local/state/load-lock/daemon.sock"))
|
|
719
|
+
(allow file-write*
|
|
720
|
+
(literal "/Users/you/.local/state/load-lock/daemon.sock"))
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
(no `network-outbound` rules required for the path-socket transport;
|
|
724
|
+
`allow network*` forms are not the mechanism a UDS connect goes through
|
|
725
|
+
under seatbelt). Since the socket inode is recreated when the daemon
|
|
726
|
+
restarts, allow the containing directory too if your profile is
|
|
727
|
+
path-literal strict, or re-generate the profile. Expected outcome
|
|
728
|
+
(unverified): with those file rules plus the env vars
|
|
729
|
+
(`LOAD_LOCK_SOCKET`, `LOAD_LOCK_NO_SPAWN=1`,
|
|
730
|
+
`LOAD_LOCK_LABEL_PREFIX=…`) passed through to the agent, a seatbelt-sandboxed
|
|
731
|
+
client talks to the host daemon exactly as in the verified bubblewrap
|
|
732
|
+
case. omac's macOS sandboxing uses seatbelt under the hood with the same
|
|
733
|
+
`environment.allow_vars` env-stripping as on Linux, so the omac recipe
|
|
734
|
+
above (profile `allow_vars` + bridged socket path) applies unchanged.
|
|
735
|
+
|
|
736
|
+
### WSL2 — honest state
|
|
737
|
+
|
|
738
|
+
There is no verified cross-boundary story for WSL2, and we won't pretend
|
|
739
|
+
otherwise:
|
|
740
|
+
|
|
741
|
+
- The Windows transport is a named pipe (`\\.\pipe\load-lock`); named
|
|
742
|
+
pipes are not reachable from inside a WSL2 distro. A WSL-side Unix
|
|
743
|
+
socket is likewise not reachable from Windows-native processes.
|
|
744
|
+
- There is deliberately no TCP transport (a non-goal), so there is no
|
|
745
|
+
loopback bridge to fake one.
|
|
746
|
+
|
|
747
|
+
The realistic shape: **the WSL2 Linux VM is the host.** Run the daemon
|
|
748
|
+
inside the distro (`~/.local/state/load-lock`, normal install — see the
|
|
749
|
+
WSL2 procedure subsection above for the unverified-but-expected resource
|
|
750
|
+
reporting), and treat Windows-native agents as out of scope for now.
|
|
751
|
+
Cross-distro bridging (two distros sharing one daemon via a `/mnt/wsl`
|
|
752
|
+
shared directory bind-mounted as the state dir) is an *unverified
|
|
753
|
+
expectation*: `/mnt/wsl` is shared across distros, so the same
|
|
754
|
+
bind-the-state-dir recipe should apply, but nobody has run it — verify
|
|
755
|
+
and record before relying on it. Keep the state dir on the Linux
|
|
756
|
+
filesystem, not `/mnt/c` (drvfs/9p is slow and lock semantics differ, as
|
|
757
|
+
in the WSL2 caveats above).
|
|
758
|
+
|
|
384
759
|
## macOS / Windows notes
|
|
385
760
|
|
|
386
761
|
- macOS: no cgroup limits; process-liveness checks fall back from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aron98/load-lock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|