@aron98/load-lock 1.0.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.
@@ -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;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,106 @@
1
+ /**
2
+ * CLI dispatch tests (issue #22): `dashboard` as the primary name of the
3
+ * live dashboard (with `top` as a hidden compat alias) and the `help`
4
+ * command (bare `load-lock`, `--help`, `-h`, `help`, `help <command>`),
5
+ * plus the unknown-command pointer to `load-lock help`.
6
+ *
7
+ * The help paths never connect to the daemon; the dashboard paths run the
8
+ * real CLI against a real daemon (piped stdout, so `--once` behavior is
9
+ * exercised both explicitly and via the non-TTY auto-once fallback).
10
+ */
11
+ import test, { before, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { startDaemon, runCli } from "./testutil.js";
14
+ let d;
15
+ before(async () => {
16
+ d = await startDaemon();
17
+ });
18
+ after(async () => {
19
+ await d.stop();
20
+ });
21
+ // ------------------------------------------------------------------ help
22
+ test("load-lock help prints a grouped one-screen usage", async () => {
23
+ const r = await runCli(d, ["help"]);
24
+ assert.equal(r.code, 0);
25
+ for (const group of ["permit operations:", "waiting & running:", "read-only:", "service & diagnostics:", "setup & docs:"]) {
26
+ assert.ok(r.stdout.includes(group), `group "${group}" present`);
27
+ }
28
+ // Every command (primary names and aliases) gets a one-liner.
29
+ for (const name of [
30
+ "acquire", "claim", "release", "cancel", "rejoin", "mark-started", "attach-pid",
31
+ "wait", "run", "status", "pending", "events", "inspect", "config", "configure",
32
+ "dashboard", "top", "service", "doctor", "recover", "install", "uninstall",
33
+ "hook", "agent-guide", "help",
34
+ ]) {
35
+ assert.ok(r.stdout.includes(name), `command "${name}" described`);
36
+ }
37
+ assert.ok(r.stdout.includes("exit codes"), "exit codes line present");
38
+ });
39
+ test("bare load-lock and --help/-h print the same usage as help", async () => {
40
+ const help = await runCli(d, ["help"]);
41
+ const bare = await runCli(d, []);
42
+ const longFlag = await runCli(d, ["--help"]);
43
+ const shortFlag = await runCli(d, ["-h"]);
44
+ for (const r of [help, bare, longFlag, shortFlag])
45
+ assert.equal(r.code, 0);
46
+ assert.equal(bare.stdout, help.stdout, "bare load-lock matches help");
47
+ assert.equal(longFlag.stdout, help.stdout, "--help matches help");
48
+ assert.equal(shortFlag.stdout, help.stdout, "-h matches help");
49
+ });
50
+ test("load-lock help <command> prints one line; unknown topic fails", async () => {
51
+ const r = await runCli(d, ["help", "dashboard"]);
52
+ assert.equal(r.code, 0);
53
+ assert.ok(r.stdout.includes("load-lock dashboard"), "primary name shown");
54
+ assert.ok(r.stdout.includes("--once"), "one-liner mentions --once");
55
+ const alias = await runCli(d, ["help", "top"]);
56
+ assert.equal(alias.code, 0);
57
+ assert.equal(alias.stdout, r.stdout, "alias resolves to the same command");
58
+ const bad = await runCli(d, ["help", "frobnicate"]);
59
+ assert.equal(bad.code, 64);
60
+ assert.ok(bad.stderr.includes("load-lock help"), "points back to help");
61
+ });
62
+ test("unknown command points at load-lock help", async () => {
63
+ const r = await runCli(d, ["frobnicate"]);
64
+ assert.equal(r.code, 64);
65
+ assert.ok(r.stderr.includes('unknown command "frobnicate"'), "names the bad command");
66
+ assert.ok(r.stderr.includes("load-lock help"), "points to help");
67
+ });
68
+ test("flags without a command still default to status (not help)", async () => {
69
+ // Regression guard: only a *bare* invocation prints usage; `--json` alone
70
+ // keeps the historical status default.
71
+ const r = await runCli(d, ["--json"]);
72
+ assert.equal(r.code, 0);
73
+ const parsed = JSON.parse(r.stdout);
74
+ assert.ok(Array.isArray(parsed.pools), "status JSON");
75
+ });
76
+ // -------------------------------------------------------------- dashboard
77
+ test("load-lock dashboard --once renders one frame and exits clean", async () => {
78
+ const r = await runCli(d, ["dashboard", "--once"]);
79
+ assert.equal(r.code, 0);
80
+ assert.ok(r.stdout.includes("load-lock dashboard"), "banner present");
81
+ assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
82
+ assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
83
+ });
84
+ test("load-lock dashboard auto-once when stdout is not a TTY", async () => {
85
+ // Piped stdout must not enter the alt-screen live loop.
86
+ const r = await runCli(d, ["dashboard"]);
87
+ assert.equal(r.code, 0);
88
+ assert.ok(r.stdout.includes("load-lock dashboard"), "frame rendered");
89
+ assert.ok(!r.stdout.includes("\x1b[?1049h"), "no alt-screen escape in a pipe");
90
+ });
91
+ test("top stays a working compat alias for dashboard", async () => {
92
+ const alias = await runCli(d, ["top", "--once"]);
93
+ const primary = await runCli(d, ["dashboard", "--once"]);
94
+ assert.equal(alias.code, 0);
95
+ assert.equal(primary.code, 0);
96
+ // "synced N ago" and daemon uptime tick between runs; normalize before comparing.
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");
105
+ assert.equal(norm(alias.stdout), norm(primary.stdout), "identical rendering");
106
+ });
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Dashboard tests (issue #10): the data plumbing behind `load-lock top`
3
- * and the OpenCode TUI plugin panel.
2
+ * Dashboard tests (issue #10): the data plumbing behind `load-lock
3
+ * dashboard` (alias `top`) and the OpenCode TUI plugin panel.
4
4
  *
5
5
  * - view-model shaping (buildDashboardView) against protocol fixtures,
6
6
  * with "now" pinned so countdowns are deterministic
@@ -10,7 +10,8 @@
10
10
  * - DashboardFeed against a REAL daemon: live view, queue visibility,
11
11
  * release, resync idempotence, and reconnect + event-cursor resume
12
12
  * across a daemon SIGKILL -> fresh-daemon-on-the-same-socket restart
13
- * - `load-lock top --once` smoke test (read-only, never acquires)
13
+ * - `load-lock top --once` smoke test (read-only, never acquires; `top`
14
+ * is the compat alias of the same `dashboard` command)
14
15
  */
15
16
  import test from "node:test";
16
17
  import assert from "node:assert/strict";
@@ -394,7 +395,7 @@ test("load-lock top --once renders one frame and exits clean", async () => {
394
395
  try {
395
396
  const r = await runCli(d, ["top", "--once"]);
396
397
  assert.equal(r.code, 0);
397
- assert.ok(r.stdout.includes("load-lock top"), "banner present");
398
+ assert.ok(r.stdout.includes("load-lock dashboard"), "banner present");
398
399
  assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
399
400
  assert.ok(r.stdout.includes("q quit"), "footer present");
400
401
  assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
@@ -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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * TUI plugin tests (issue #24): the at-a-glance summary behind the
3
+ * home.footer.status indicator and the new clickable sidebar.footer widget.
4
+ *
5
+ * What is testable here without a TUI host:
6
+ * - the compiled plugin module loads headless (its runtime imports are
7
+ * only @opencode/plugin/tui, solid-js, and the shared client/feed —
8
+ * @opentui/* are type-only) and self-describes as "load-lock.tui"
9
+ * - the dashboard opens via the in-window PanelPresentation ("panel",
10
+ * never fullscreen), pinned by the exported PANEL_PRESENTATION constant
11
+ * - poolGlance(): the pure per-pool summary (bits + health) both
12
+ * indicator surfaces share
13
+ *
14
+ * What is NOT testable here: actual slot rendering, mouse delivery in a
15
+ * live terminal, and panel.open behavior — those are live-verified in a
16
+ * real OpenCode session and documented in the PR (see docs/ updates).
17
+ * plugin.setup() is deliberately NOT invoked: it starts a DashboardFeed
18
+ * against a real daemon and would spawn/connect outside test isolation.
19
+ */
20
+ import test from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import tui, { PANEL_PRESENTATION, poolGlance } from "../adapters/opencode/tui.js";
23
+ // ------------------------------------------------------------- fixtures
24
+ function poolView(over = {}) {
25
+ return {
26
+ pool: "heavy",
27
+ capacity: 3,
28
+ used: 1,
29
+ heldWeight: 1,
30
+ offeredWeight: 0,
31
+ free: 2,
32
+ utilization: 1 / 3,
33
+ holders: [],
34
+ offers: [],
35
+ queue: [],
36
+ paused: [],
37
+ ...over,
38
+ };
39
+ }
40
+ function view(pools, state = "live") {
41
+ return {
42
+ now: 1_760_000_000_000,
43
+ state,
44
+ daemon: null,
45
+ pools,
46
+ events: [],
47
+ latestEventId: 0,
48
+ machine: null,
49
+ lastSyncAt: 1_760_000_000_000,
50
+ };
51
+ }
52
+ // ------------------------------------------------------ module contract
53
+ test("tui plugin module loads headless and self-describes", () => {
54
+ assert.ok(tui, "default export exists");
55
+ // @opencode/plugin's define() is a passthrough, so the raw shape is
56
+ // visible without a TUI host.
57
+ const anyTui = tui;
58
+ assert.equal(anyTui.id, "load-lock.tui");
59
+ });
60
+ test("dashboard opens in-window: PANEL_PRESENTATION is \"panel\"", () => {
61
+ // PanelPresentation is exactly "panel" | "fullscreen"; "panel" is the
62
+ // in-window presentation (issue #24 requirement 1).
63
+ assert.equal(PANEL_PRESENTATION, "panel");
64
+ });
65
+ // ------------------------------------------------------------ poolGlance
66
+ test("poolGlance reports bits and health per pool state", () => {
67
+ const healthy = poolGlance(view([poolView({ used: 1, capacity: 3 })]));
68
+ assert.deepEqual(healthy, { bits: ["1/3"], health: "good" });
69
+ const full = poolGlance(view([poolView({ used: 3, capacity: 3, free: 0, utilization: 1 })]));
70
+ assert.deepEqual(full, { bits: ["3/3"], health: "warn" });
71
+ const queued = poolGlance(view([poolView({ used: 1, capacity: 3, queue: [{}, {}] })]));
72
+ assert.deepEqual(queued, { bits: ["1/3", "Q2"], health: "warn" });
73
+ const paused = poolGlance(view([poolView({ used: 1, capacity: 3, paused: [{}] })]));
74
+ // paused shows in bits but does not degrade health on its own
75
+ assert.deepEqual(paused, { bits: ["1/3", "P1"], health: "good" });
76
+ const down = poolGlance(view([poolView({ used: 1, capacity: 3 })], "reconnecting"));
77
+ assert.deepEqual(down, { bits: ["1/3"], health: "down" });
78
+ });
79
+ test("poolGlance prefers the heavy pool and tolerates missing pools", () => {
80
+ const heavy = poolGlance(view([
81
+ poolView({ pool: "gpu-heavy", used: 1, capacity: 1 }),
82
+ poolView({ pool: "heavy", used: 0, capacity: 3, free: 3, utilization: 0 }),
83
+ ]));
84
+ assert.deepEqual(heavy, { bits: ["0/3"], health: "good" });
85
+ // custom configs: falls back to the first pool when no "heavy" exists
86
+ const fallback = poolGlance(view([poolView({ pool: "build", used: 2, capacity: 2, free: 0, utilization: 1 })]));
87
+ assert.deepEqual(fallback, { bits: ["2/2"], health: "warn" });
88
+ assert.equal(poolGlance(view([])), null);
89
+ });
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 |
@@ -137,6 +137,7 @@ load-lock run --permit <permit-token> -- cargo test --quiet # tracks + rel
137
137
  # or acquire+run in one go:
138
138
  load-lock run --reason "nightly build" -- npm run build
139
139
  load-lock wait --id <request-id> --timeout 120000 # sleep until your turn → auto-claim
140
+ load-lock help # one-screen usage of every command
140
141
  ```
141
142
 
142
143
  ## 4. Reading state
@@ -208,7 +208,7 @@ is configuration, not code.
208
208
 
209
209
  The read-only status surface also extends without protocol changes: the
210
210
  `status` + `events` (durable ids) + `watch` + `inspect` methods are enough
211
- to build live viewers. `load-lock top` and the OpenCode TUI panel
211
+ to build live viewers. `load-lock dashboard` (alias `top`) and the OpenCode TUI panel
212
212
  (`src/dashboard/`) are exactly that — pure view-model shaping over the
213
213
  library client (`./shared/client`), never CLI-output scraping, and never
214
214
  acquiring a permit themselves. Any future harness UI can reuse the same
@@ -59,11 +59,13 @@ Global plugin (`load-lock install --harness opencode`) registers:
59
59
  - per-session heartbeats,
60
60
  - the package's `./tui` export: a **live lock-status dashboard panel**
61
61
  (issue #10) inside the opencode TUI — `/load-lock` opens the
62
- `load-lock.dashboard` session panel (pools, holder cards, offer
63
- countdowns, queue, paused, event feed, `inspect_machine`), a home-footer
64
- `lock 1/1·Q2` indicator, and an `x` force-release flow gated behind an
65
- explicit select + confirm (the panel itself is read-only and never
66
- acquires a permit).
62
+ `load-lock.dashboard` session panel in-window (issue #24: side panel,
63
+ not full-screen) with pools, holder cards, offer countdowns, queue,
64
+ paused, event feed, `inspect_machine`; a clickable sidebar widget
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
+ gated behind an explicit select + confirm (the panel itself is read-only
68
+ and never acquires a permit).
67
69
 
68
70
  Verified live on opencode v2.0.29: plugin load, policy visible to the model,
69
71
  tool round-trip (acquire → queued → wakeup → claim → release), execution-
@@ -242,19 +244,19 @@ acquire→run→release in one command line.
242
244
 
243
245
  ## Live dashboard surfaces (issue #10)
244
246
 
245
- `load-lock top` is portable and harness-agnostic: any integration that
247
+ `load-lock dashboard` is portable and harness-agnostic: any integration that
246
248
  spawns the daemon gets the same live dashboard in any terminal. The table
247
249
  below is deliberately honest about what each harness gets **inside its own
248
250
  UI** — where no UI surface exists, we say so rather than claim parity.
249
251
 
250
252
  | Harness | In-harness dashboard | Also available |
251
253
  | --- | --- | --- |
252
- | **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` (session panel, full-screen) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified against opencode 2.0.29: panel render + palette/slash command + confirm-gated force-release exercised in the real TUI. | `load-lock top` |
253
- | **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 top` (any terminal) |
254
- | **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock top` |
255
- | **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock top` |
256
- | **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock top` |
257
- | **Aider** | None (no MCP either). | `load-lock top` |
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
+ | **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
+ | **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
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`) |
258
+ | **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
259
+ | **Aider** | None (no MCP either). | `load-lock dashboard` (alias `top`) |
258
260
 
259
261
  All dashboard surfaces are **read-only viewers that never acquire a
260
262
  permit**; the single admin action (force-release) is gated behind an
@@ -297,7 +299,7 @@ reconnect, view-model shaping, frame rendering) is covered by
297
299
  - Core daemon/protocol/CLI: 68 automated tests + scripted 3-worktree demo
298
300
  (three independent agent processes, real git worktrees, one pool), including
299
301
  9 dashboard tests (view-model shaping, event-cursor resume across a daemon
300
- restart, reconnect, and a `load-lock top --once` render smoke test) and
302
+ restart, reconnect, and a `load-lock dashboard --once` render smoke test) and
301
303
  9 bounded-wait tests (multi-process `wait_for_turn`: FIFO wake with
302
304
  auto-claim, timeout/pause/cancel/restart/abort paths, `maxTurnWaitMs` cap,
303
305
  MCP snake_case round trip).