@aron98/load-lock 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,8 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
59
59
  ```sh
60
60
  npm install -g @aron98/load-lock
61
61
  load-lock install --harness opencode # or claude-code | codex | gemini-cli |
62
- # copilot-cli | cline | roo-code | goose | generic
62
+ # copilot-cli | cline | roo-code | goose |
63
+ # omac (sandbox grants) | generic
63
64
  load-lock doctor
64
65
  ```
65
66
 
@@ -81,6 +82,17 @@ opens it in-window, every session screen shows a clickable at-a-glance
81
82
  widget in the right sidebar, and the home screen has a footer indicator —
82
83
  same data, themed. See docs/OPERATIONS.md → "Dashboard".
83
84
 
85
+ ## Sandboxed agents
86
+
87
+ Agents running inside a sandbox (bubblewrap/omac, Docker, macOS seatbelt,
88
+ WSL2) share the host's budget instead of getting their own: bridge the
89
+ daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
90
+ client never spawns a useless sandbox-local daemon, and optionally tag
91
+ holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
92
+ Ready-to-run per-sandbox recipes — live-verified for bubblewrap and omac,
93
+ contract-inspected for the rest, plus the cross-PID-namespace lease
94
+ lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
95
+
84
96
  ## Layout
85
97
 
86
98
  ```
@@ -103,7 +115,7 @@ skills/ packaged skill
103
115
  - [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
104
116
  matrix with implemented / contract-tested / proposed labels.
105
117
  - [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
106
- troubleshooting, recovery, daemon lifecycle.
118
+ troubleshooting, recovery, daemon lifecycle, sandbox bridging.
107
119
  - [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
108
120
  - [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
109
121
  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, also bound to "gl") opens the
19
- * panel IN-WINDOW via presentation: "panel" instead of fullscreen
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, also bound to "gl") opens the
20
- * panel IN-WINDOW via presentation: "panel" instead of fullscreen
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 with the keymap
185
- * binding shown on the hint line.
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
- // Always-works fallback for the sidebar widget's click affordance
272
- // (some terminals don't deliver mouse events to the TUI).
273
- bind: "gl",
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: () => {
@@ -43,11 +43,22 @@
43
43
  * .cursor/rules/load-lock.mdc. Contract-tested against
44
44
  * the documented hooks JSON; not live-verified.
45
45
  * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
46
+ * - omac : merges load-lock grants into omac's user-global sandbox
47
+ * profile (~/.config/omac/sandbox-profiles/default.json):
48
+ * filesystem.allow_unix_dir += the state dir (host-daemon
49
+ * socket bridge), filesystem.read += the installed package
50
+ * tree when load-lock lives outside any granted workdir
51
+ * (global bun/npm installs; gotcha verified live), and
52
+ * environment.allow_vars += "LOAD_LOCK_*". omac parses
53
+ * profiles strictly (unknown fields are errors) and never
54
+ * rewrites a profile it has created, so the installer only
55
+ * writes known fields and touches nothing else.
46
56
  */
47
57
  import fs from "node:fs";
48
58
  import os from "node:os";
49
59
  import path from "node:path";
50
60
  import { POLICY_BRIEF } from "../policy/text.js";
61
+ import { resolveStateDir } from "../shared/transport.js";
51
62
  const HOME = os.homedir();
52
63
  const PKG_NAME = "@aron98/load-lock";
53
64
  export function pkgRoot(binDir) {
@@ -948,6 +959,206 @@ function uninstallCursor(opts) {
948
959
  pruneEmptyDirs(cursorDir, cursorDir);
949
960
  return actions;
950
961
  }
962
+ // --------------------------------------------------------------------- omac
963
+ // omac (verified against 0.11.0) sandboxes agents with bubblewrap +
964
+ // Landlock on Linux and strips the harness environment, allowing through
965
+ // only what the user-global sandbox profile lists. Known profile fields
966
+ // (strict parsing — unknown fields are hard errors): top-level `workdir`,
967
+ // `network`, `filesystem`, `environment`; `workdir.access`,
968
+ // `network.allow_domain`/`deny_domain`/`allow_tcp_connect`,
969
+ // `filesystem.allow`/`read`/`write`/`allow_unix_dir`/`deny`,
970
+ // `environment.allow_vars` — all lists of strings. omac never rewrites a
971
+ // profile it has created, so the profile file is the whole extension
972
+ // point and we only ever write known fields.
973
+ const OMAC_LL_ENV_ENTRY = "LOAD_LOCK_*"; // allow_vars trailing-* prefix (verified: accepted by 0.11.0)
974
+ /** omac's user-global sandbox profile (no project equivalent exists). */
975
+ function omacProfileFile() {
976
+ return path.join(configHome(), "omac", "sandbox-profiles", "default.json");
977
+ }
978
+ /** Read + shape-check the profile; undefined when the file does not exist. */
979
+ function omacReadProfile(file) {
980
+ if (!fs.existsSync(file))
981
+ return undefined;
982
+ let parsed;
983
+ try {
984
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
985
+ }
986
+ catch (e) {
987
+ throw new Error(`${file} is not valid JSON (${e instanceof Error ? e.message : String(e)}). omac parses ` +
988
+ `profiles strictly; load-lock refuses to overwrite it — fix or remove the file, then re-run install`);
989
+ }
990
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
991
+ throw new Error(`${file} is not a JSON object; refusing to overwrite`);
992
+ }
993
+ return parsed;
994
+ }
995
+ /** The profile sub-object `key`, created when absent; refuses non-objects. */
996
+ function omacSection(profile, key) {
997
+ const v = profile[key];
998
+ if (v === undefined) {
999
+ const section = {};
1000
+ profile[key] = section;
1001
+ return section;
1002
+ }
1003
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
1004
+ throw new Error(`omac profile: "${key}" is not an object; refusing to overwrite`);
1005
+ }
1006
+ return v;
1007
+ }
1008
+ /**
1009
+ * Merge string entries into section[key] (created only when something is
1010
+ * added, so we never write empty arrays). Returns the entries actually
1011
+ * added; refuses non-string-array values instead of mangling them.
1012
+ */
1013
+ function omacMergeList(section, key, entries) {
1014
+ const v = section[key];
1015
+ let list;
1016
+ if (v === undefined)
1017
+ list = [];
1018
+ else if (Array.isArray(v) && v.every((x) => typeof x === "string"))
1019
+ list = [...v];
1020
+ else
1021
+ throw new Error(`omac profile: "${key}" is not an array of strings; refusing to overwrite`);
1022
+ const added = entries.filter((e) => !list.includes(e));
1023
+ if (added.length)
1024
+ section[key] = [...list, ...added];
1025
+ return added;
1026
+ }
1027
+ /**
1028
+ * The installed package tree a sandboxed CLI must be able to *read*.
1029
+ * Live-verified gotcha: a global bun/npm install grants the bin dir, but
1030
+ * the launcher resolves the dist tree under `.../node_modules/@aron98/
1031
+ * load-lock` — without a read grant the CLI dies with ERR_MODULE_NOT_FOUND
1032
+ * before ever reaching the socket. Returns the realpath of the package
1033
+ * root, or null when load-lock lives in a repo checkout / worktree (the
1034
+ * nearest `.git`): there the agent's omac workdir grant already covers
1035
+ * it, and a read grant would be noise.
1036
+ */
1037
+ function omacReadGrant(P) {
1038
+ let root = P.root;
1039
+ try {
1040
+ root = fs.realpathSync(root);
1041
+ }
1042
+ catch {
1043
+ /* keep the unresolved path — omac grants are not existence-filtered */
1044
+ }
1045
+ for (let dir = root;;) {
1046
+ if (fs.existsSync(path.join(dir, ".git")))
1047
+ return null; // repo checkout / git worktree
1048
+ const parent = path.dirname(dir);
1049
+ if (parent === dir)
1050
+ break;
1051
+ dir = parent;
1052
+ }
1053
+ return root;
1054
+ }
1055
+ /**
1056
+ * Remove our entries (match-own-entries style: exact current state dir,
1057
+ * the "LOAD_LOCK_*" prefix entry, and read entries pointing at the
1058
+ * current package root or at a global `node_modules/@aron98/load-lock`
1059
+ * tree — the shape the installer writes). Keys whose lists we emptied are
1060
+ * dropped; sections that become empty are dropped; the file is removed
1061
+ * (and dirs pruned) when nothing user-owned remains.
1062
+ */
1063
+ function uninstallOmac() {
1064
+ const actions = [];
1065
+ const file = omacProfileFile();
1066
+ const profile = omacReadProfile(file);
1067
+ if (!profile)
1068
+ return actions; // nothing installed; uninstall never creates anything
1069
+ const stateDir = resolveStateDir(process.platform, process.env);
1070
+ const readTree = omacReadGrant(pkgPaths());
1071
+ const globalPkgSuffix = path.join("node_modules", PKG_NAME);
1072
+ let changed = false;
1073
+ const drop = (sectionKey, key, isOurs) => {
1074
+ const section = profile[sectionKey];
1075
+ if (!section || typeof section !== "object" || Array.isArray(section))
1076
+ return;
1077
+ const list = section[key];
1078
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
1079
+ return;
1080
+ const kept = list.filter((e) => !isOurs(e));
1081
+ if (kept.length === list.length)
1082
+ return;
1083
+ changed = true;
1084
+ if (kept.length)
1085
+ section[key] = kept;
1086
+ else
1087
+ delete section[key];
1088
+ };
1089
+ drop("filesystem", "allow_unix_dir", (e) => e === stateDir);
1090
+ drop("filesystem", "read", (e) => (readTree !== null && e === readTree) || e.endsWith(globalPkgSuffix));
1091
+ drop("environment", "allow_vars", (e) => e === OMAC_LL_ENV_ENTRY);
1092
+ // prune sections our removals emptied (never anything the user still has)
1093
+ for (const key of ["filesystem", "environment"]) {
1094
+ const section = profile[key];
1095
+ if (section && typeof section === "object" && !Array.isArray(section) && Object.keys(section).length === 0) {
1096
+ delete profile[key];
1097
+ }
1098
+ }
1099
+ if (!changed) {
1100
+ actions.push(`no load-lock grants found in ${file}`);
1101
+ return actions;
1102
+ }
1103
+ if (Object.keys(profile).length === 0) {
1104
+ fs.rmSync(file);
1105
+ actions.push(`removed ${file} (nothing else was in it)`);
1106
+ pruneEmptyDirs(path.dirname(file), path.join(configHome(), "omac"));
1107
+ }
1108
+ else {
1109
+ writeJson(file, profile);
1110
+ actions.push(`removed load-lock grants from ${file}`);
1111
+ }
1112
+ return actions;
1113
+ }
1114
+ function installOmac(opts, P) {
1115
+ const actions = [];
1116
+ const file = omacProfileFile();
1117
+ const existed = fs.existsSync(file);
1118
+ const profile = omacReadProfile(file) ?? {};
1119
+ const stateDir = resolveStateDir(process.platform, process.env);
1120
+ const grants = [];
1121
+ const fsSection = omacSection(profile, "filesystem");
1122
+ grants.push(...omacMergeList(fsSection, "allow_unix_dir", [stateDir]));
1123
+ const readTree = omacReadGrant(P);
1124
+ if (readTree)
1125
+ grants.push(...omacMergeList(fsSection, "read", [readTree]));
1126
+ const envSection = omacSection(profile, "environment");
1127
+ grants.push(...omacMergeList(envSection, "allow_vars", [OMAC_LL_ENV_ENTRY]));
1128
+ // merge-only: never rewrite the file when nothing changed (idempotent
1129
+ // re-install leaves the user's bytes untouched)
1130
+ if (grants.length || !existed)
1131
+ writeJson(file, profile);
1132
+ if (!existed)
1133
+ actions.push(`created ${file} (omac's user-global sandbox profile)`);
1134
+ else if (grants.length)
1135
+ actions.push(`grants merged into ${file}`);
1136
+ else
1137
+ actions.push(`all grants already present in ${file} — nothing to do`);
1138
+ for (const g of grants) {
1139
+ if (g === OMAC_LL_ENV_ENTRY) {
1140
+ actions.push(`environment.allow_vars += "${OMAC_LL_ENV_ENTRY}" (prefix pattern; passes all four LOAD_LOCK_* vars through the sandbox env filter)`);
1141
+ }
1142
+ else if (g === stateDir) {
1143
+ actions.push(`filesystem.allow_unix_dir += ${stateDir} (AF_UNIX connect to the host daemon socket + read/write on the state dir)`);
1144
+ }
1145
+ else {
1146
+ actions.push(`filesystem.read += ${g} (installed package tree — the bin-dir grant alone is not enough; without this the CLI dies with ERR_MODULE_NOT_FOUND before reaching the socket)`);
1147
+ }
1148
+ }
1149
+ if (!readTree) {
1150
+ actions.push(`skipped filesystem.read: load-lock lives in a repo checkout (${P.root}) — the omac workdir grant already covers it`);
1151
+ }
1152
+ actions.push("set the values in your harness env (the grants only allow the names through): " +
1153
+ "LOAD_LOCK_NO_SPAWN=1 (client-only; the daemon must run on the host, e.g. load-lock service start), " +
1154
+ "LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
1155
+ "LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
1156
+ actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
1157
+ "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service stop && load-lock service start)");
1158
+ actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
1159
+ "round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
1160
+ return actions;
1161
+ }
951
1162
  // -------------------------------------------------------------------- public
952
1163
  export async function installCommand(opts) {
953
1164
  const P = pkgPaths(opts.binDir);
@@ -956,7 +1167,7 @@ export async function installCommand(opts) {
956
1167
  }
957
1168
  const harness = opts.harness;
958
1169
  if (!harness)
959
- throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|generic> is required");
1170
+ throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|omac|generic> is required");
960
1171
  let actions;
961
1172
  switch (harness) {
962
1173
  case "opencode":
@@ -992,6 +1203,9 @@ export async function installCommand(opts) {
992
1203
  case "goose":
993
1204
  actions = installGoose(opts, P);
994
1205
  break;
1206
+ case "omac":
1207
+ actions = installOmac(opts, P);
1208
+ break;
995
1209
  case "generic":
996
1210
  case "all":
997
1211
  default:
@@ -1044,6 +1258,9 @@ export async function uninstallCommand(opts) {
1044
1258
  case "goose":
1045
1259
  actions = uninstallGoose();
1046
1260
  break;
1261
+ case "omac":
1262
+ actions = uninstallOmac();
1263
+ break;
1047
1264
  default:
1048
1265
  throw new Error(`uninstall: unknown harness "${opts.harness}"`);
1049
1266
  }
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,
@@ -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: p.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: p.agentLabel,
293
+ agentLabel,
285
294
  cwd: p.cwd,
286
295
  opKey: p.opKey,
287
296
  reason: p.reason,
@@ -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,
@@ -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. */
@@ -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;
@@ -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
- const norm = (s) => s.replace(/synced \S+ ago/g, "synced N ago").replace(/uptime \S+/g, "uptime N");
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
  });
@@ -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
+ });