@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 +7 -4
- package/dist/adapters/opencode/tui.d.ts +34 -4
- package/dist/adapters/opencode/tui.js +107 -17
- package/dist/cli/main.js +96 -2
- package/dist/cli/top.js +3 -2
- package/dist/dashboard/frame.d.ts +3 -2
- package/dist/dashboard/frame.js +4 -3
- package/dist/dashboard/view.d.ts +2 -1
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.js +5 -4
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/COMPATIBILITY.md +15 -13
- package/docs/OPERATIONS.md +37 -15
- package/package.json +1 -1
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
|
|
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
|
|
79
|
-
|
|
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
|
|
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
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return
|
|
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:
|
|
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
|
-
//
|
|
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}"
|
|
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
|
|
3
|
-
* for the machine-wide
|
|
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
|
|
3
|
-
* terminal surface that wants the same dashboard without a
|
|
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
|
package/dist/dashboard/frame.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plain-text frame renderer for `load-lock top` — and
|
|
3
|
-
* terminal surface that wants the same dashboard without a
|
|
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
|
|
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
|
}
|
package/dist/dashboard/view.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -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
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -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 (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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).
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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
|
|
146
|
+
### `load-lock dashboard` (portable, any terminal)
|
|
147
147
|
|
|
148
148
|
```sh
|
|
149
|
-
load-lock
|
|
150
|
-
load-lock
|
|
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
|
|
169
|
-
session panel
|
|
170
|
-
live-updating
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
188
|
-
daemon, which every integration spawns). Harnesses without an in-app
|
|
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
|
|
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.
|
|
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",
|