@aron98/load-lock 1.0.0 → 1.1.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,15 @@ 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".
80
83
 
81
84
  ## Layout
82
85
 
@@ -111,7 +114,7 @@ skills/ packaged skill
111
114
  (multi-process daemons, real processes, restarts, races, expiry,
112
115
  PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
113
116
  shaping, event-cursor resume across a daemon restart, reconnect, and a
114
- `load-lock top --once` render smoke test).
117
+ `load-lock dashboard --once` render smoke test).
115
118
  - OpenCode adapter: implemented and **live-verified** (tools, policy
116
119
  injection, compaction-proof context hook, execution-boundary wakeup
117
120
  with tokens, skill).
@@ -11,10 +11,15 @@
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, also bound to "gl") opens the
19
+ * panel IN-WINDOW via presentation: "panel" instead of fullscreen
20
+ * - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
21
+ * force-release is an admin action gated behind an explicit select +
22
+ * confirm dialog
18
23
  *
19
24
  * The viewer is read-only and light: it only ever READS daemon state
20
25
  * (status / events / inspect / watch) and never acquires a permit itself.
@@ -27,5 +32,30 @@
27
32
  * that boundary, eager evaluation is exactly the semantics we want.
28
33
  */
29
34
  import { Plugin } from "@opencode/plugin/tui";
35
+ import type { PanelPresentation } from "@opencode/plugin/tui/context";
36
+ import { type DashboardView } from "../../dashboard/view.js";
37
+ /**
38
+ * The dashboard opens as an in-window panel ("panel" is the SDK's in-window
39
+ * PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
40
+ * tests can pin the choice (issue #24).
41
+ */
42
+ export declare const PANEL_PRESENTATION: PanelPresentation;
43
+ /**
44
+ * Health of the at-a-glance summary. "down" means the daemon feed is not
45
+ * live (daemon down / connecting); the others describe the pool itself.
46
+ */
47
+ export type GlanceHealth = "good" | "warn" | "bad" | "down";
48
+ /** Render-ready at-a-glance summary of one pool (pure; unit-tested). */
49
+ export interface PoolGlance {
50
+ /** e.g. ["2/3", "Q1", "P2"] — used/capacity, then queue/paused counts. */
51
+ bits: string[];
52
+ health: GlanceHealth;
53
+ }
54
+ /**
55
+ * One-line heavy-pool summary shared by the home footer indicator and the
56
+ * sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
57
+ * and falls back to the first pool for custom configurations.
58
+ */
59
+ export declare function poolGlance(v: DashboardView): PoolGlance | null;
30
60
  declare const _default: Plugin.Definition;
31
61
  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,15 @@ 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, also bound to "gl") opens the
20
+ * panel IN-WINDOW via presentation: "panel" instead of fullscreen
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
19
24
  *
20
25
  * The viewer is read-only and light: it only ever READS daemon state
21
26
  * (status / events / inspect / watch) and never acquires a permit itself.
@@ -33,6 +38,12 @@ import { LLClient } from "../../shared/client.js";
33
38
  import { DashboardFeed } from "../../dashboard/feed.js";
34
39
  import { fmtBytes, fmtClock, fmtDuration, } from "../../dashboard/view.js";
35
40
  const PANEL_NAME = "load-lock.dashboard";
41
+ /**
42
+ * The dashboard opens as an in-window panel ("panel" is the SDK's in-window
43
+ * PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
44
+ * tests can pin the choice (issue #24).
45
+ */
46
+ export const PANEL_PRESENTATION = "panel";
36
47
  function themeFg(t) {
37
48
  return {
38
49
  base: t.text.base,
@@ -129,24 +140,64 @@ function PanelBody(v, panel, ctx) {
129
140
  const lines = dashboardLines(v, panel.width || 100, f);
130
141
  return (_jsx("box", { children: _jsx(_Fragment, { children: lines.map((l) => (_jsx("text", { fg: l.fg, children: l.text }))) }) }));
131
142
  }
132
- function MiniIndicator(v, ctx) {
133
- const f = themeFg(ctx.theme);
143
+ /**
144
+ * One-line heavy-pool summary shared by the home footer indicator and the
145
+ * sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
146
+ * and falls back to the first pool for custom configurations.
147
+ */
148
+ export function poolGlance(v) {
134
149
  const pool = v.pools.find((p) => p.pool === "heavy") ?? v.pools[0];
135
150
  if (!pool)
136
- return _jsx("text", { fg: f.dim, children: "lock: no pools" });
151
+ return null;
137
152
  const bits = [`${pool.used}/${pool.capacity}`];
138
153
  if (pool.queue.length)
139
154
  bits.push(`Q${pool.queue.length}`);
140
155
  if (pool.paused.length)
141
156
  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("·")}` });
157
+ let health;
158
+ if (v.state !== "live")
159
+ health = "down";
160
+ else if ((pool.capacity > 0 && pool.used >= pool.capacity) || pool.queue.length > 0)
161
+ health = "warn";
162
+ else
163
+ health = "good";
164
+ return { bits, health };
165
+ }
166
+ function healthColor(h, f) {
167
+ if (h === "good")
168
+ return f.good;
169
+ if (h === "warn")
170
+ return f.warn;
171
+ return f.bad; // "bad" and "down" both read as the error color
172
+ }
173
+ function MiniIndicator(v, ctx) {
174
+ const f = themeFg(ctx.theme);
175
+ const g = poolGlance(v);
176
+ if (!g)
177
+ return _jsx("text", { fg: f.dim, children: "lock: no pools" });
178
+ return _jsx("text", { fg: healthColor(g.health, f), children: `lock ${g.bits.join("·")}` });
179
+ }
180
+ /**
181
+ * Right-sidebar widget: the same glance summary plus a "click to open"
182
+ * affordance. Click support is contract-level (opentui enables mouse and
183
+ * 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.
186
+ */
187
+ function SidebarWidget(v, ctx) {
188
+ const f = themeFg(ctx.theme);
189
+ const openDashboard = (e) => {
190
+ if (e.button !== 0)
191
+ return; // 0 = MouseButton.LEFT; ignore other buttons
192
+ ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
193
+ };
194
+ const g = poolGlance(v);
195
+ if (!g) {
196
+ return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
197
+ }
198
+ const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
199
+ const hint = shortcut ? `${shortcut} / click opens dashboard` : "click opens dashboard";
200
+ 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
201
  }
151
202
  // ---------------------------------------------------------------- plugin
152
203
  export default Plugin.define({
@@ -164,6 +215,17 @@ export default Plugin.define({
164
215
  const tick = setInterval(() => setView(feed.view()), 1000);
165
216
  tick.unref?.();
166
217
  const panelOpen = () => ctx.ui.panel.current()?.name === PANEL_NAME;
218
+ // Esc-close gate: only when OUR panel is the current one and no dialog is
219
+ // on top. Dialogs (select/confirm/command palette) push the host's "modal"
220
+ // keymap mode; in any mode other than the documented default "base"
221
+ // (KeymapLayer.mode), this command stays unreachable so Esc keeps its
222
+ // native meaning there — cancelling the dialog — instead of closing the
223
+ // panel underneath it. With the dashboard open and no dialog, the mode is
224
+ // exactly "base": the panel opens fullscreen, which the host treats as
225
+ // non-interactive for the session pane, so session-scoped modes (composer,
226
+ // autocomplete, menu) are all suspended. ctx.ui.panel.close() closes only
227
+ // this plugin's panel.
228
+ const escClosesPanel = () => panelOpen() && ctx.keymap.mode.current() === "base";
167
229
  // ------------------------------------------------------ force-release
168
230
  async function forceReleaseFlow() {
169
231
  const holders = (feed.view().pools ?? []).flatMap((p) => p.holders);
@@ -206,10 +268,23 @@ export default Plugin.define({
206
268
  id: "load-lock.dashboard.open",
207
269
  title: "load-lock: open the lock-status dashboard",
208
270
  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",
209
274
  palette: true,
210
275
  slash: { name: "load-lock", aliases: ["lock", "ll"] },
211
276
  run: () => {
212
- ctx.ui.panel.open(PANEL_NAME, { presentation: "fullscreen" });
277
+ ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
278
+ },
279
+ },
280
+ {
281
+ id: "load-lock.dashboard.close",
282
+ title: "load-lock: close the lock-status dashboard",
283
+ group: "load-lock",
284
+ bind: "escape",
285
+ enabled: escClosesPanel,
286
+ run: () => {
287
+ ctx.ui.panel.close();
213
288
  },
214
289
  },
215
290
  {
@@ -257,11 +332,26 @@ export default Plugin.define({
257
332
  children: (v) => MiniIndicator(v, ctx),
258
333
  }),
259
334
  });
335
+ // Right-sidebar at-a-glance widget (session screens). The sidebar.footer
336
+ // slot pins it at the bottom of the sidebar so it stays visible even
337
+ // when the session list is long. Disjoint from the home footer above,
338
+ // so the two indicators never render on the same screen.
339
+ const offSidebar = ctx.ui.slot({
340
+ append: "sidebar.footer",
341
+ render: () => createComponent(Show, {
342
+ get when() {
343
+ return view() ?? false;
344
+ },
345
+ keyed: true,
346
+ children: (v) => SidebarWidget(v, ctx),
347
+ }),
348
+ });
260
349
  return () => {
261
350
  if (tick)
262
351
  clearInterval(tick);
263
352
  offPanel();
264
353
  offFooter();
354
+ offSidebar();
265
355
  void feed.stop();
266
356
  };
267
357
  },
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 [--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", {})));
@@ -341,7 +435,7 @@ async function main() {
341
435
  return;
342
436
  }
343
437
  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`);
438
+ fail(`unknown command "${cmd}" — run "load-lock help" for the list of commands`);
345
439
  }
346
440
  }
347
441
  // ------------------------------------------------------------------ run helper
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
  *
@@ -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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
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
+ const norm = (s) => s.replace(/synced \S+ ago/g, "synced N ago").replace(/uptime \S+/g, "uptime N");
98
+ assert.equal(norm(alias.stdout), norm(primary.stdout), "identical rendering");
99
+ });
@@ -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");
@@ -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
+ });
@@ -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; `gl` as the mouse-free fallback) and
66
+ 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 the `gl` binding or a click on the sidebar widget) 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, `gl`, in-window presentation). | `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).
@@ -143,13 +143,17 @@ construction: they only ever call `status` / `events` / `inspect` / `watch`.
143
143
  The one admin action they expose — force-release — sits behind an explicit
144
144
  select + confirm step and never runs automatically.
145
145
 
146
- ### `load-lock top` (portable, any terminal)
146
+ ### `load-lock dashboard` (portable, any terminal)
147
147
 
148
148
  ```sh
149
- load-lock top # full-screen live dashboard (q quit, r resync)
150
- load-lock top --once # render one frame and exit (pipes, scripts, CI logs)
149
+ load-lock dashboard # full-screen live dashboard (q quit, r resync)
150
+ load-lock dashboard --once # render one frame and exit (pipes, scripts, CI logs)
151
151
  ```
152
152
 
153
+ `top` still works as a hidden compatibility alias for `dashboard` (same
154
+ command, same flags) — existing scripts keep running; new docs and muscle
155
+ memory should use `dashboard`.
156
+
153
157
  Shows pools with capacity vs in-use, holder cards (label, reason, phase,
154
158
  pid/pgid, age, start-deadline or lease countdown), offer cards with the
155
159
  claim-window countdown, FIFO queue positions, paused requests, the recent
@@ -165,16 +169,33 @@ alternate screen, so it works anywhere the CLI works.
165
169
  The OpenCode installer also ships the package's `./tui` plugin export (a
166
170
  companion to the main plugin, which is unchanged). Inside the opencode TUI:
167
171
 
168
- - `/load-lock` (or the command palette) opens the **load-lock.dashboard**
169
- session panel full-screen: same data as `load-lock top`, themed,
170
- live-updating.
171
- - The home footer gains a small `lock 1/1·Q2` indicator.
172
+ - `/load-lock` (or the command palette, or the `gl` keybinding) opens the
173
+ **load-lock.dashboard** session panel **in-window** as a side panel —
174
+ same data as `load-lock dashboard`, themed, live-updating, with the rest
175
+ of the TUI layout still visible beside it.
176
+ - Every session screen shows an at-a-glance widget at the bottom of the
177
+ right sidebar: `load-lock 0/3` colored by pool health (green ok, yellow
178
+ full/queued, red daemon down) plus a hint line. Clicking it opens the
179
+ dashboard; `gl` is the fallback for terminals that don't deliver mouse
180
+ events. The home screen keeps its `lock 1/1·Q2` footer indicator — the
181
+ sidebar widget only renders on session screens, so the two never
182
+ double-render.
172
183
  - `r` resyncs; `x` opens force-release, which requires an explicit
173
184
  select + confirm — the panel itself is read-only.
174
-
175
- Verified live against opencode 2.0.29: the panel renders pools, holder
176
- cards, queue positions and the event feed against the real daemon, and the
177
- `x` flow refuses to release anything until the confirm dialog accepts.
185
+ - `Esc` closes the panel (the footer has always advertised this). It is
186
+ deliberately inactive while a dialog — force-release, the command
187
+ palette — is open, so Esc keeps cancelling the dialog instead of
188
+ closing the panel underneath it.
189
+
190
+ Verified live against opencode 2.0.32 (real pty session): the sidebar
191
+ widget renders with live daemon data, a mouse press on the widget opens
192
+ the dashboard in-window with the chat still visible beside it, `gl` opens
193
+ it as well, and the `/load-lock` command appears in the slash suggestions.
194
+ Earlier live verification (2.0.29): panel render, palette/slash entry
195
+ points, and the confirm-gated force-release flow. Click handling itself
196
+ is contract-level from opentui (mouse reporting enabled by default,
197
+ mouse-down events bubble to slot renderables) — the pty test above
198
+ exercises the full path; the `gl` binding works regardless.
178
199
  Plugins configured in `opencode.json` that expose `./tui` are loaded
179
200
  automatically by the CLI, so the published package needs no extra config.
180
201
  For local development against a checkout, opencode also auto-discovers
@@ -184,13 +205,14 @@ plugins laid out like a package under `<project>/.opencode/plugins/<name>/`
184
205
 
185
206
  ### Notes for other harnesses
186
207
 
187
- `load-lock top` is the dashboard for every harness (it only needs the
188
- daemon, which every integration spawns). Harnesses without an in-app UI
189
- surface get exactly that — no parity claims; see `docs/COMPATIBILITY.md`.
208
+ `load-lock dashboard` is the dashboard for every harness (it only needs
209
+ the daemon, which every integration spawns). Harnesses without an in-app
210
+ UI surface get exactly that — no parity claims; see `docs/COMPATIBILITY.md`.
190
211
 
191
212
  ## Diagnostics & recovery
192
213
 
193
214
  ```sh
215
+ load-lock help # one-screen usage of every command
194
216
  load-lock doctor # state dir, socket, daemon reachability, effective cores, pressure
195
217
  load-lock status # human view: usage, holders w/ phase+pid, offers, queue, paused
196
218
  load-lock pending # your own offers/queue entries (JSON)
@@ -401,7 +423,7 @@ What the adapter does:
401
423
  reaches the daemon over the pipe (it probes by connecting, since a pipe
402
424
  is not a filesystem entry).
403
425
 
404
- 2. Run two `load-lock acquire` in separate shells, then `load-lock top --once`.
426
+ 2. Run two `load-lock acquire` in separate shells, then `load-lock dashboard --once`.
405
427
 
406
428
  Expected: the same queue/permit behavior as on Linux — the pipe carries
407
429
  the identical NDJSON protocol.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",