@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.
package/README.md CHANGED
@@ -71,12 +71,26 @@ use the generic stdio server; plain shells use the CLI:
71
71
  ```sh
72
72
  load-lock run --reason "nightly build" -- npm run build # acquire→run→release
73
73
  load-lock status # machine-wide view
74
- load-lock top # live read-only dashboard (q quits)
74
+ load-lock dashboard # live read-only dashboard (q quits)
75
+ load-lock help # one-screen usage of every command
75
76
  load-lock configure --capacity 2 --reason "16-core host" --author me
76
77
  ```
77
78
 
78
- OpenCode users also get a live dashboard panel inside the TUI (`/load-lock`)
79
- — same data, themed. See docs/OPERATIONS.md → "Dashboard".
79
+ OpenCode users also get a live dashboard inside the TUI: `/load-lock`
80
+ opens it in-window, every session screen shows a clickable at-a-glance
81
+ widget in the right sidebar, and the home screen has a footer indicator —
82
+ same data, themed. See docs/OPERATIONS.md → "Dashboard".
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".
80
94
 
81
95
  ## Layout
82
96
 
@@ -100,7 +114,7 @@ skills/ packaged skill
100
114
  - [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
101
115
  matrix with implemented / contract-tested / proposed labels.
102
116
  - [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
103
- troubleshooting, recovery, daemon lifecycle.
117
+ troubleshooting, recovery, daemon lifecycle, sandbox bridging.
104
118
  - [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
105
119
  - [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
106
120
  harness, using the published library surface.
@@ -111,7 +125,7 @@ skills/ packaged skill
111
125
  (multi-process daemons, real processes, restarts, races, expiry,
112
126
  PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
113
127
  shaping, event-cursor resume across a daemon restart, reconnect, and a
114
- `load-lock top --once` render smoke test).
128
+ `load-lock dashboard --once` render smoke test).
115
129
  - OpenCode adapter: implemented and **live-verified** (tools, policy
116
130
  injection, compaction-proof context hook, execution-boundary wakeup
117
131
  with tokens, skill).
@@ -11,10 +11,16 @@
11
11
  * holder cards (label, reason, phase, pid/pgid, age, lease deadline),
12
12
  * offer cards with claim-window countdown, FIFO queue positions,
13
13
  * paused requests, the recent event feed, and the inspect_machine panel
14
- * - a home.footer.status indicator summarizing the heavy pool
15
- * - /load-lock (palette + slash command) opens the panel full-screen
16
- * - 'r' resync and 'x' force-release while the panel is open; force-release
17
- * is an admin action gated behind an explicit select + confirm dialog
14
+ * - a home.footer.status indicator summarizing the heavy pool (home screen)
15
+ * - a clickable sidebar.footer widget with the same summary (session
16
+ * screens) — onMouseDown opens the dashboard; the two slots render on
17
+ * disjoint surfaces, so this is not a double-render
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)
21
+ * - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
22
+ * force-release is an admin action gated behind an explicit select +
23
+ * confirm dialog
18
24
  *
19
25
  * The viewer is read-only and light: it only ever READS daemon state
20
26
  * (status / events / inspect / watch) and never acquires a permit itself.
@@ -27,5 +33,30 @@
27
33
  * that boundary, eager evaluation is exactly the semantics we want.
28
34
  */
29
35
  import { Plugin } from "@opencode/plugin/tui";
36
+ import type { PanelPresentation } from "@opencode/plugin/tui/context";
37
+ import { type DashboardView } from "../../dashboard/view.js";
38
+ /**
39
+ * The dashboard opens as an in-window panel ("panel" is the SDK's in-window
40
+ * PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
41
+ * tests can pin the choice (issue #24).
42
+ */
43
+ export declare const PANEL_PRESENTATION: PanelPresentation;
44
+ /**
45
+ * Health of the at-a-glance summary. "down" means the daemon feed is not
46
+ * live (daemon down / connecting); the others describe the pool itself.
47
+ */
48
+ export type GlanceHealth = "good" | "warn" | "bad" | "down";
49
+ /** Render-ready at-a-glance summary of one pool (pure; unit-tested). */
50
+ export interface PoolGlance {
51
+ /** e.g. ["2/3", "Q1", "P2"] — used/capacity, then queue/paused counts. */
52
+ bits: string[];
53
+ health: GlanceHealth;
54
+ }
55
+ /**
56
+ * One-line heavy-pool summary shared by the home footer indicator and the
57
+ * sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
58
+ * and falls back to the first pool for custom configurations.
59
+ */
60
+ export declare function poolGlance(v: DashboardView): PoolGlance | null;
30
61
  declare const _default: Plugin.Definition;
31
62
  export default _default;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
2
2
  /**
3
3
  * load-lock TUI plugin for OpenCode V2 — live lock-status dashboard (issue #10).
4
4
  *
@@ -12,10 +12,16 @@ import { jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
12
12
  * holder cards (label, reason, phase, pid/pgid, age, lease deadline),
13
13
  * offer cards with claim-window countdown, FIFO queue positions,
14
14
  * paused requests, the recent event feed, and the inspect_machine panel
15
- * - a home.footer.status indicator summarizing the heavy pool
16
- * - /load-lock (palette + slash command) opens the panel full-screen
17
- * - 'r' resync and 'x' force-release while the panel is open; force-release
18
- * is an admin action gated behind an explicit select + confirm dialog
15
+ * - a home.footer.status indicator summarizing the heavy pool (home screen)
16
+ * - a clickable sidebar.footer widget with the same summary (session
17
+ * screens) — onMouseDown opens the dashboard; the two slots render on
18
+ * disjoint surfaces, so this is not a double-render
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)
22
+ * - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
23
+ * force-release is an admin action gated behind an explicit select +
24
+ * confirm dialog
19
25
  *
20
26
  * The viewer is read-only and light: it only ever READS daemon state
21
27
  * (status / events / inspect / watch) and never acquires a permit itself.
@@ -33,6 +39,12 @@ import { LLClient } from "../../shared/client.js";
33
39
  import { DashboardFeed } from "../../dashboard/feed.js";
34
40
  import { fmtBytes, fmtClock, fmtDuration, } from "../../dashboard/view.js";
35
41
  const PANEL_NAME = "load-lock.dashboard";
42
+ /**
43
+ * The dashboard opens as an in-window panel ("panel" is the SDK's in-window
44
+ * PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
45
+ * tests can pin the choice (issue #24).
46
+ */
47
+ export const PANEL_PRESENTATION = "panel";
36
48
  function themeFg(t) {
37
49
  return {
38
50
  base: t.text.base,
@@ -129,24 +141,64 @@ function PanelBody(v, panel, ctx) {
129
141
  const lines = dashboardLines(v, panel.width || 100, f);
130
142
  return (_jsx("box", { children: _jsx(_Fragment, { children: lines.map((l) => (_jsx("text", { fg: l.fg, children: l.text }))) }) }));
131
143
  }
132
- function MiniIndicator(v, ctx) {
133
- const f = themeFg(ctx.theme);
144
+ /**
145
+ * One-line heavy-pool summary shared by the home footer indicator and the
146
+ * sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
147
+ * and falls back to the first pool for custom configurations.
148
+ */
149
+ export function poolGlance(v) {
134
150
  const pool = v.pools.find((p) => p.pool === "heavy") ?? v.pools[0];
135
151
  if (!pool)
136
- return _jsx("text", { fg: f.dim, children: "lock: no pools" });
152
+ return null;
137
153
  const bits = [`${pool.used}/${pool.capacity}`];
138
154
  if (pool.queue.length)
139
155
  bits.push(`Q${pool.queue.length}`);
140
156
  if (pool.paused.length)
141
157
  bits.push(`P${pool.paused.length}`);
142
- const fg = v.state !== "live"
143
- ? f.bad
144
- : pool.used >= pool.capacity
145
- ? f.warn
146
- : pool.queue.length
147
- ? f.warn
148
- : f.good;
149
- return _jsx("text", { fg: fg, children: `lock ${bits.join("·")}` });
158
+ let health;
159
+ if (v.state !== "live")
160
+ health = "down";
161
+ else if ((pool.capacity > 0 && pool.used >= pool.capacity) || pool.queue.length > 0)
162
+ health = "warn";
163
+ else
164
+ health = "good";
165
+ return { bits, health };
166
+ }
167
+ function healthColor(h, f) {
168
+ if (h === "good")
169
+ return f.good;
170
+ if (h === "warn")
171
+ return f.warn;
172
+ return f.bad; // "bad" and "down" both read as the error color
173
+ }
174
+ function MiniIndicator(v, ctx) {
175
+ const f = themeFg(ctx.theme);
176
+ const g = poolGlance(v);
177
+ if (!g)
178
+ return _jsx("text", { fg: f.dim, children: "lock: no pools" });
179
+ return _jsx("text", { fg: healthColor(g.health, f), children: `lock ${g.bits.join("·")}` });
180
+ }
181
+ /**
182
+ * Right-sidebar widget: the same glance summary plus a "click to open"
183
+ * affordance. Click support is contract-level (opentui enables mouse and
184
+ * bubbles mouse-down events to slot renderables); terminals that don't
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.
187
+ */
188
+ function SidebarWidget(v, ctx) {
189
+ const f = themeFg(ctx.theme);
190
+ const openDashboard = (e) => {
191
+ if (e.button !== 0)
192
+ return; // 0 = MouseButton.LEFT; ignore other buttons
193
+ ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
194
+ };
195
+ const g = poolGlance(v);
196
+ if (!g) {
197
+ return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
198
+ }
199
+ const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
200
+ const hint = shortcut ? `${shortcut} / click opens dashboard` : "/load-lock or click opens dashboard";
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 })] }));
150
202
  }
151
203
  // ---------------------------------------------------------------- plugin
152
204
  export default Plugin.define({
@@ -164,6 +216,17 @@ export default Plugin.define({
164
216
  const tick = setInterval(() => setView(feed.view()), 1000);
165
217
  tick.unref?.();
166
218
  const panelOpen = () => ctx.ui.panel.current()?.name === PANEL_NAME;
219
+ // Esc-close gate: only when OUR panel is the current one and no dialog is
220
+ // on top. Dialogs (select/confirm/command palette) push the host's "modal"
221
+ // keymap mode; in any mode other than the documented default "base"
222
+ // (KeymapLayer.mode), this command stays unreachable so Esc keeps its
223
+ // native meaning there — cancelling the dialog — instead of closing the
224
+ // panel underneath it. With the dashboard open and no dialog, the mode is
225
+ // exactly "base": the panel opens fullscreen, which the host treats as
226
+ // non-interactive for the session pane, so session-scoped modes (composer,
227
+ // autocomplete, menu) are all suspended. ctx.ui.panel.close() closes only
228
+ // this plugin's panel.
229
+ const escClosesPanel = () => panelOpen() && ctx.keymap.mode.current() === "base";
167
230
  // ------------------------------------------------------ force-release
168
231
  async function forceReleaseFlow() {
169
232
  const holders = (feed.view().pools ?? []).flatMap((p) => p.holders);
@@ -206,16 +269,45 @@ export default Plugin.define({
206
269
  id: "load-lock.dashboard.open",
207
270
  title: "load-lock: open the lock-status dashboard",
208
271
  group: "load-lock",
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.
209
287
  palette: true,
210
288
  slash: { name: "load-lock", aliases: ["lock", "ll"] },
211
289
  run: () => {
212
- ctx.ui.panel.open(PANEL_NAME, { presentation: "fullscreen" });
290
+ ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
291
+ },
292
+ },
293
+ {
294
+ id: "load-lock.dashboard.close",
295
+ title: "load-lock: close the lock-status dashboard",
296
+ group: "load-lock",
297
+ bind: "escape",
298
+ enabled: escClosesPanel,
299
+ run: () => {
300
+ ctx.ui.panel.close();
213
301
  },
214
302
  },
215
303
  {
216
304
  id: "load-lock.dashboard.resync",
217
305
  title: "load-lock: resync the dashboard",
218
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.
219
311
  bind: "r",
220
312
  enabled: panelOpen,
221
313
  run: () => {
@@ -257,11 +349,26 @@ export default Plugin.define({
257
349
  children: (v) => MiniIndicator(v, ctx),
258
350
  }),
259
351
  });
352
+ // Right-sidebar at-a-glance widget (session screens). The sidebar.footer
353
+ // slot pins it at the bottom of the sidebar so it stays visible even
354
+ // when the session list is long. Disjoint from the home footer above,
355
+ // so the two indicators never render on the same screen.
356
+ const offSidebar = ctx.ui.slot({
357
+ append: "sidebar.footer",
358
+ render: () => createComponent(Show, {
359
+ get when() {
360
+ return view() ?? false;
361
+ },
362
+ keyed: true,
363
+ children: (v) => SidebarWidget(v, ctx),
364
+ }),
365
+ });
260
366
  return () => {
261
367
  if (tick)
262
368
  clearInterval(tick);
263
369
  offPanel();
264
370
  offFooter();
371
+ offSidebar();
265
372
  void feed.stop();
266
373
  };
267
374
  },
package/dist/cli/main.js CHANGED
@@ -61,7 +61,84 @@ function defaultAgent() {
61
61
  return (process.env.LOAD_LOCK_AGENT ||
62
62
  "cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
63
63
  }
64
+ const HELP_GROUPS = [
65
+ {
66
+ title: "permit operations",
67
+ items: [
68
+ { names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--label] [--label-prefix] [--wait-ms] [--no-queue]" },
69
+ { names: ["claim"], desc: "claim an offered permit before its window expires: --offer <token>" },
70
+ { names: ["release"], desc: "release a held permit: --permit <token> [--outcome success|failure]" },
71
+ { names: ["cancel"], desc: "cancel a queued/offered/paused request: --id <request-id> [--admin]" },
72
+ { names: ["rejoin"], desc: "rejoin the queue after your offer expired or you declined: --id <request-id>" },
73
+ { names: ["mark-started"], desc: "mark a permit as started when no pid can be attached: --permit <token>" },
74
+ { names: ["attach-pid"], desc: "tie a running process to a permit (auto-release on exit): --permit <token> --pid <pid>" },
75
+ ],
76
+ },
77
+ {
78
+ title: "waiting & running",
79
+ items: [
80
+ { names: ["wait"], desc: "sleep until a queued request's turn, then auto-claim the offer: --id <request-id> [--timeout <ms>]" },
81
+ { names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
82
+ ],
83
+ },
84
+ {
85
+ title: "read-only",
86
+ items: [
87
+ { names: ["status"], desc: "pools, holders, offers, queue — the machine-wide view" },
88
+ { names: ["pending"], desc: "your outstanding offers, queue entries and paused requests" },
89
+ { names: ["events"], desc: "durable event log: [--since <id>] [--follow]" },
90
+ { names: ["inspect", "machine"], desc: "machine resources (incl. cgroup limits) and current pressure" },
91
+ { names: ["config", "get-config"], desc: "current configuration" },
92
+ { names: ["configure"], desc: "change configuration (compare-and-swap): --reason required" },
93
+ { names: ["dashboard", "top"], desc: "full-screen live dashboard, read-only (never acquires): [--once]" },
94
+ ],
95
+ },
96
+ {
97
+ title: "service & diagnostics",
98
+ items: [
99
+ { names: ["service"], desc: "daemon lifecycle: start | stop | status" },
100
+ { names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
101
+ { names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
102
+ ],
103
+ },
104
+ {
105
+ title: "setup & docs",
106
+ items: [
107
+ { names: ["install"], desc: "install the load-lock adapter for a harness: --harness <name>" },
108
+ { names: ["uninstall"], desc: "remove what install added: --harness <name>" },
109
+ { names: ["hook"], desc: "harness hook handlers (called by integrations, not humans)" },
110
+ { names: ["agent-guide"], desc: "print the full agent guide" },
111
+ { names: ["help"], desc: "this usage; also bare load-lock, --help, -h; help <command> for one line" },
112
+ ],
113
+ },
114
+ ];
115
+ function displayName(item) {
116
+ return item.names[0] + (item.names.length > 1 ? ` (${item.names.slice(1).join(", ")})` : "");
117
+ }
118
+ const HELP_PAD = Math.max(...HELP_GROUPS.flatMap((g) => g.items.map((i) => displayName(i).length)));
119
+ function helpLine(item) {
120
+ return ` ${displayName(item).padEnd(HELP_PAD)} ${item.desc}`;
121
+ }
122
+ /** Grouped one-screen usage, printed by `help`, bare `load-lock`, `--help`/`-h`. */
123
+ function usage() {
124
+ const lines = [
125
+ "load-lock — machine-wide heavy-work budget shared by all coding agents",
126
+ "usage: load-lock <command> [flags] (--json works on read commands)",
127
+ ];
128
+ for (const g of HELP_GROUPS) {
129
+ lines.push("", `${g.title}:`);
130
+ for (const item of g.items)
131
+ lines.push(helpLine(item));
132
+ }
133
+ lines.push("", "exit codes: 0 ok, 64 usage, 65 protocol, 68 not granted yet, 69 offer/claim failed, 70 internal");
134
+ process.stdout.write(lines.join("\n") + "\n");
135
+ }
64
136
  async function main() {
137
+ // `--help`, `-h` and a bare `load-lock` all print the same usage as `help`.
138
+ if (f["help"] !== undefined || cmd === "-h" || (positional.length === 0 && argv.length === 0)) {
139
+ usage();
140
+ return;
141
+ }
65
142
  switch (cmd) {
66
143
  // ------------------------------------------------------------- read-only
67
144
  case "status": {
@@ -85,12 +162,29 @@ async function main() {
85
162
  return;
86
163
  }
87
164
  // ------------------------------------------------------------- dashboard
165
+ case "dashboard":
88
166
  case "top": {
89
167
  // Full-screen live dashboard (read-only, never acquires a permit).
90
- // Falls back to a single rendered frame when stdout is not a TTY.
168
+ // `dashboard` is the primary name; `top` stays as a hidden compat
169
+ // alias. Falls back to a single rendered frame when stdout is not a
170
+ // TTY (or with --once).
91
171
  await topCommand({ once: B("once") });
92
172
  return;
93
173
  }
174
+ case "help": {
175
+ // `load-lock help` — grouped one-screen usage. `help <command>` is
176
+ // the optional per-command form: just that command's one-liner.
177
+ const topic = rest[0];
178
+ if (topic === undefined) {
179
+ usage();
180
+ return;
181
+ }
182
+ const item = HELP_GROUPS.flatMap((g) => g.items).find((i) => i.names.includes(topic));
183
+ if (!item)
184
+ fail(`help: unknown command "${topic}" — run "load-lock help" for the full list`);
185
+ process.stdout.write(`load-lock ${item.names[0]} — ${item.desc}\n`);
186
+ return;
187
+ }
94
188
  case "inspect":
95
189
  case "machine":
96
190
  out(await withClient((c) => c.request("inspect", {})));
@@ -159,6 +253,7 @@ async function main() {
159
253
  pool: S("pool", "heavy"),
160
254
  agentId: S("agent", defaultAgent()),
161
255
  agentLabel: S("label") || undefined,
256
+ labelPrefix: S("label-prefix") || undefined,
162
257
  cwd: S("cwd", process.cwd()),
163
258
  opKey: S("op-key") || fail("acquire: --op-key is required"),
164
259
  reason: S("reason") || fail("acquire: --reason is required"),
@@ -341,7 +436,7 @@ async function main() {
341
436
  return;
342
437
  }
343
438
  default:
344
- fail(`unknown command "${cmd}". Commands: status, top, inspect, config, pending, events, acquire, claim, release, cancel, rejoin, mark-started, attach-pid, wait, run, service, doctor, recover, install, uninstall, hook, agent-guide`);
439
+ fail(`unknown command "${cmd}" — run "load-lock help" for the list of commands`);
345
440
  }
346
441
  }
347
442
  // ------------------------------------------------------------------ run helper
@@ -367,6 +462,7 @@ async function runWithPermit(jobArgv) {
367
462
  pool: S("pool", "heavy"),
368
463
  agentId: S("agent", defaultAgent()),
369
464
  agentLabel: S("label") || "load-lock run",
465
+ labelPrefix: S("label-prefix") || undefined,
370
466
  cwd: process.cwd(),
371
467
  opKey: S("op-key") || `run/${command}/${Date.now()}`,
372
468
  reason: S("reason") || command,
package/dist/cli/top.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `load-lock top` — portable, zero-dependency full-screen terminal dashboard
3
- * for the machine-wide budget. Uses the library's own client + protocol
2
+ * `load-lock dashboard` (hidden compat alias: `load-lock top`) — portable,
3
+ * zero-dependency full-screen terminal dashboard for the machine-wide
4
+ * budget. Uses the library's own client + protocol
4
5
  * (never scrapes CLI output); live updates come from the `watch` event
5
6
  * stream, with reconnect + event-cursor resume across daemon restarts.
6
7
  *
@@ -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,
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Plain-text frame renderer for `load-lock top` — and for any other
3
- * terminal surface that wants the same dashboard without a TUI toolkit.
2
+ * Plain-text frame renderer for `load-lock dashboard` (alias `top`) — and
3
+ * for any other terminal surface that wants the same dashboard without a
4
+ * TUI toolkit.
4
5
  * Pure function: view-model in, string[] out (one line per row).
5
6
  *
6
7
  * Deliberately zero-dependency (ANSI handled by the caller): this keeps
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Plain-text frame renderer for `load-lock top` — and for any other
3
- * terminal surface that wants the same dashboard without a TUI toolkit.
2
+ * Plain-text frame renderer for `load-lock dashboard` (alias `top`) — and
3
+ * for any other terminal surface that wants the same dashboard without a
4
+ * TUI toolkit.
4
5
  * Pure function: view-model in, string[] out (one line per row).
5
6
  *
6
7
  * Deliberately zero-dependency (ANSI handled by the caller): this keeps
@@ -56,7 +57,7 @@ export function renderFrame(view, width = 100, height = 40) {
56
57
  };
57
58
  const state = STATE_LABEL[view.state] ?? String(view.state);
58
59
  const syncAge = view.lastSyncAt ? fmtDuration(Math.max(0, view.now - view.lastSyncAt)) : "never";
59
- push(`load-lock top — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
60
+ push(`load-lock dashboard — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
60
61
  if (view.daemon) {
61
62
  push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion} · events through #${view.latestEventId}`);
62
63
  }
@@ -2,7 +2,8 @@
2
2
  * Dashboard view-model: pure shaping of daemon data (status / inspect /
3
3
  * durable events) into the cards every dashboard surface renders.
4
4
  *
5
- * Shared by `load-lock top` (src/cli/top.ts) and the OpenCode TUI plugin
5
+ * Shared by `load-lock dashboard` (alias `top`, src/cli/top.ts) and the
6
+ * OpenCode TUI plugin
6
7
  * (src/adapters/opencode/tui.tsx) so both show exactly the same state and
7
8
  * only differ in rendering. Everything here is pure and synchronous —
8
9
  * "now" is always an explicit parameter, which makes countdowns testable.
@@ -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. */