@aron98/load-lock 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/dist/adapters/opencode/tui.d.ts +35 -4
- package/dist/adapters/opencode/tui.js +124 -17
- package/dist/cli/main.js +98 -2
- package/dist/cli/top.js +3 -2
- package/dist/core/state.js +11 -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/mcp/server.js +2 -0
- package/dist/shared/client.d.ts +13 -0
- package/dist/shared/client.js +48 -0
- package/dist/shared/protocol.d.ts +8 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +106 -0
- package/dist/test/core.test.js +27 -0
- package/dist/test/dashboard.test.js +5 -4
- package/dist/test/mcp.test.js +2 -1
- package/dist/test/sandbox.test.d.ts +1 -0
- package/dist/test/sandbox.test.js +155 -0
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/docs/ADAPTERS.md +1 -1
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/COMPATIBILITY.md +15 -13
- package/docs/OPERATIONS.md +413 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,12 +71,26 @@ use the generic stdio server; plain shells use the CLI:
|
|
|
71
71
|
```sh
|
|
72
72
|
load-lock run --reason "nightly build" -- npm run build # acquire→run→release
|
|
73
73
|
load-lock status # machine-wide view
|
|
74
|
-
load-lock
|
|
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".
|
|
83
|
+
|
|
84
|
+
## Sandboxed agents
|
|
85
|
+
|
|
86
|
+
Agents running inside a sandbox (bubblewrap/omac, Docker, macOS seatbelt,
|
|
87
|
+
WSL2) share the host's budget instead of getting their own: bridge the
|
|
88
|
+
daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
|
|
89
|
+
client never spawns a useless sandbox-local daemon, and optionally tag
|
|
90
|
+
holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
|
|
91
|
+
Ready-to-run per-sandbox recipes — live-verified for bubblewrap,
|
|
92
|
+
contract-inspected for the rest, plus the cross-PID-namespace lease
|
|
93
|
+
lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
|
|
80
94
|
|
|
81
95
|
## Layout
|
|
82
96
|
|
|
@@ -100,7 +114,7 @@ skills/ packaged skill
|
|
|
100
114
|
- [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
|
|
101
115
|
matrix with implemented / contract-tested / proposed labels.
|
|
102
116
|
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
|
|
103
|
-
troubleshooting, recovery, daemon lifecycle.
|
|
117
|
+
troubleshooting, recovery, daemon lifecycle, sandbox bridging.
|
|
104
118
|
- [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
|
|
105
119
|
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
|
|
106
120
|
harness, using the published library surface.
|
|
@@ -111,7 +125,7 @@ skills/ packaged skill
|
|
|
111
125
|
(multi-process daemons, real processes, restarts, races, expiry,
|
|
112
126
|
PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
|
|
113
127
|
shaping, event-cursor resume across a daemon restart, reconnect, and a
|
|
114
|
-
`load-lock
|
|
128
|
+
`load-lock dashboard --once` render smoke test).
|
|
115
129
|
- OpenCode adapter: implemented and **live-verified** (tools, policy
|
|
116
130
|
injection, compaction-proof context hook, execution-boundary wakeup
|
|
117
131
|
with tokens, skill).
|
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
* holder cards (label, reason, phase, pid/pgid, age, lease deadline),
|
|
12
12
|
* offer cards with claim-window countdown, FIFO queue positions,
|
|
13
13
|
* paused requests, the recent event feed, and the inspect_machine panel
|
|
14
|
-
* - a home.footer.status indicator summarizing the heavy pool
|
|
15
|
-
* -
|
|
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) opens the panel IN-WINDOW via
|
|
19
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
20
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
21
|
+
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
22
|
+
* force-release is an admin action gated behind an explicit select +
|
|
23
|
+
* confirm dialog
|
|
18
24
|
*
|
|
19
25
|
* The viewer is read-only and light: it only ever READS daemon state
|
|
20
26
|
* (status / events / inspect / watch) and never acquires a permit itself.
|
|
@@ -27,5 +33,30 @@
|
|
|
27
33
|
* that boundary, eager evaluation is exactly the semantics we want.
|
|
28
34
|
*/
|
|
29
35
|
import { Plugin } from "@opencode/plugin/tui";
|
|
36
|
+
import type { PanelPresentation } from "@opencode/plugin/tui/context";
|
|
37
|
+
import { type DashboardView } from "../../dashboard/view.js";
|
|
38
|
+
/**
|
|
39
|
+
* The dashboard opens as an in-window panel ("panel" is the SDK's in-window
|
|
40
|
+
* PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
|
|
41
|
+
* tests can pin the choice (issue #24).
|
|
42
|
+
*/
|
|
43
|
+
export declare const PANEL_PRESENTATION: PanelPresentation;
|
|
44
|
+
/**
|
|
45
|
+
* Health of the at-a-glance summary. "down" means the daemon feed is not
|
|
46
|
+
* live (daemon down / connecting); the others describe the pool itself.
|
|
47
|
+
*/
|
|
48
|
+
export type GlanceHealth = "good" | "warn" | "bad" | "down";
|
|
49
|
+
/** Render-ready at-a-glance summary of one pool (pure; unit-tested). */
|
|
50
|
+
export interface PoolGlance {
|
|
51
|
+
/** e.g. ["2/3", "Q1", "P2"] — used/capacity, then queue/paused counts. */
|
|
52
|
+
bits: string[];
|
|
53
|
+
health: GlanceHealth;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One-line heavy-pool summary shared by the home footer indicator and the
|
|
57
|
+
* sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
|
|
58
|
+
* and falls back to the first pool for custom configurations.
|
|
59
|
+
*/
|
|
60
|
+
export declare function poolGlance(v: DashboardView): PoolGlance | null;
|
|
30
61
|
declare const _default: Plugin.Definition;
|
|
31
62
|
export default _default;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* load-lock TUI plugin for OpenCode V2 — live lock-status dashboard (issue #10).
|
|
4
4
|
*
|
|
@@ -12,10 +12,16 @@ import { jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
|
|
|
12
12
|
* holder cards (label, reason, phase, pid/pgid, age, lease deadline),
|
|
13
13
|
* offer cards with claim-window countdown, FIFO queue positions,
|
|
14
14
|
* paused requests, the recent event feed, and the inspect_machine panel
|
|
15
|
-
* - a home.footer.status indicator summarizing the heavy pool
|
|
16
|
-
* -
|
|
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) opens the panel IN-WINDOW via
|
|
20
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
21
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
22
|
+
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
23
|
+
* force-release is an admin action gated behind an explicit select +
|
|
24
|
+
* confirm dialog
|
|
19
25
|
*
|
|
20
26
|
* The viewer is read-only and light: it only ever READS daemon state
|
|
21
27
|
* (status / events / inspect / watch) and never acquires a permit itself.
|
|
@@ -33,6 +39,12 @@ import { LLClient } from "../../shared/client.js";
|
|
|
33
39
|
import { DashboardFeed } from "../../dashboard/feed.js";
|
|
34
40
|
import { fmtBytes, fmtClock, fmtDuration, } from "../../dashboard/view.js";
|
|
35
41
|
const PANEL_NAME = "load-lock.dashboard";
|
|
42
|
+
/**
|
|
43
|
+
* The dashboard opens as an in-window panel ("panel" is the SDK's in-window
|
|
44
|
+
* PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
|
|
45
|
+
* tests can pin the choice (issue #24).
|
|
46
|
+
*/
|
|
47
|
+
export const PANEL_PRESENTATION = "panel";
|
|
36
48
|
function themeFg(t) {
|
|
37
49
|
return {
|
|
38
50
|
base: t.text.base,
|
|
@@ -129,24 +141,64 @@ function PanelBody(v, panel, ctx) {
|
|
|
129
141
|
const lines = dashboardLines(v, panel.width || 100, f);
|
|
130
142
|
return (_jsx("box", { children: _jsx(_Fragment, { children: lines.map((l) => (_jsx("text", { fg: l.fg, children: l.text }))) }) }));
|
|
131
143
|
}
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
/**
|
|
145
|
+
* One-line heavy-pool summary shared by the home footer indicator and the
|
|
146
|
+
* sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
|
|
147
|
+
* and falls back to the first pool for custom configurations.
|
|
148
|
+
*/
|
|
149
|
+
export function poolGlance(v) {
|
|
134
150
|
const pool = v.pools.find((p) => p.pool === "heavy") ?? v.pools[0];
|
|
135
151
|
if (!pool)
|
|
136
|
-
return
|
|
152
|
+
return null;
|
|
137
153
|
const bits = [`${pool.used}/${pool.capacity}`];
|
|
138
154
|
if (pool.queue.length)
|
|
139
155
|
bits.push(`Q${pool.queue.length}`);
|
|
140
156
|
if (pool.paused.length)
|
|
141
157
|
bits.push(`P${pool.paused.length}`);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return
|
|
158
|
+
let health;
|
|
159
|
+
if (v.state !== "live")
|
|
160
|
+
health = "down";
|
|
161
|
+
else if ((pool.capacity > 0 && pool.used >= pool.capacity) || pool.queue.length > 0)
|
|
162
|
+
health = "warn";
|
|
163
|
+
else
|
|
164
|
+
health = "good";
|
|
165
|
+
return { bits, health };
|
|
166
|
+
}
|
|
167
|
+
function healthColor(h, f) {
|
|
168
|
+
if (h === "good")
|
|
169
|
+
return f.good;
|
|
170
|
+
if (h === "warn")
|
|
171
|
+
return f.warn;
|
|
172
|
+
return f.bad; // "bad" and "down" both read as the error color
|
|
173
|
+
}
|
|
174
|
+
function MiniIndicator(v, ctx) {
|
|
175
|
+
const f = themeFg(ctx.theme);
|
|
176
|
+
const g = poolGlance(v);
|
|
177
|
+
if (!g)
|
|
178
|
+
return _jsx("text", { fg: f.dim, children: "lock: no pools" });
|
|
179
|
+
return _jsx("text", { fg: healthColor(g.health, f), children: `lock ${g.bits.join("·")}` });
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Right-sidebar widget: the same glance summary plus a "click to open"
|
|
183
|
+
* affordance. Click support is contract-level (opentui enables mouse and
|
|
184
|
+
* bubbles mouse-down events to slot renderables); terminals that don't
|
|
185
|
+
* deliver mouse events can still open the dashboard via the /load-lock
|
|
186
|
+
* slash command (or the command palette) named on the hint line.
|
|
187
|
+
*/
|
|
188
|
+
function SidebarWidget(v, ctx) {
|
|
189
|
+
const f = themeFg(ctx.theme);
|
|
190
|
+
const openDashboard = (e) => {
|
|
191
|
+
if (e.button !== 0)
|
|
192
|
+
return; // 0 = MouseButton.LEFT; ignore other buttons
|
|
193
|
+
ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
|
|
194
|
+
};
|
|
195
|
+
const g = poolGlance(v);
|
|
196
|
+
if (!g) {
|
|
197
|
+
return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
|
|
198
|
+
}
|
|
199
|
+
const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
|
|
200
|
+
const hint = shortcut ? `${shortcut} / click opens dashboard` : "/load-lock or click opens dashboard";
|
|
201
|
+
return (_jsxs("box", { onMouseDown: openDashboard, children: [_jsx("text", { fg: healthColor(g.health, f), children: `load-lock ${g.bits.join("·")}` }), _jsx("text", { fg: f.dim, children: hint })] }));
|
|
150
202
|
}
|
|
151
203
|
// ---------------------------------------------------------------- plugin
|
|
152
204
|
export default Plugin.define({
|
|
@@ -164,6 +216,17 @@ export default Plugin.define({
|
|
|
164
216
|
const tick = setInterval(() => setView(feed.view()), 1000);
|
|
165
217
|
tick.unref?.();
|
|
166
218
|
const panelOpen = () => ctx.ui.panel.current()?.name === PANEL_NAME;
|
|
219
|
+
// Esc-close gate: only when OUR panel is the current one and no dialog is
|
|
220
|
+
// on top. Dialogs (select/confirm/command palette) push the host's "modal"
|
|
221
|
+
// keymap mode; in any mode other than the documented default "base"
|
|
222
|
+
// (KeymapLayer.mode), this command stays unreachable so Esc keeps its
|
|
223
|
+
// native meaning there — cancelling the dialog — instead of closing the
|
|
224
|
+
// panel underneath it. With the dashboard open and no dialog, the mode is
|
|
225
|
+
// exactly "base": the panel opens fullscreen, which the host treats as
|
|
226
|
+
// non-interactive for the session pane, so session-scoped modes (composer,
|
|
227
|
+
// autocomplete, menu) are all suspended. ctx.ui.panel.close() closes only
|
|
228
|
+
// this plugin's panel.
|
|
229
|
+
const escClosesPanel = () => panelOpen() && ctx.keymap.mode.current() === "base";
|
|
167
230
|
// ------------------------------------------------------ force-release
|
|
168
231
|
async function forceReleaseFlow() {
|
|
169
232
|
const holders = (feed.view().pools ?? []).flatMap((p) => p.holders);
|
|
@@ -206,16 +269,45 @@ export default Plugin.define({
|
|
|
206
269
|
id: "load-lock.dashboard.open",
|
|
207
270
|
title: "load-lock: open the lock-status dashboard",
|
|
208
271
|
group: "load-lock",
|
|
272
|
+
// NO global key binding, on purpose (issue #28):
|
|
273
|
+
//
|
|
274
|
+
// 1. Never bind unmodified single letters or letter sequences
|
|
275
|
+
// (the old "gl") at global scope: while the keymap engine waits
|
|
276
|
+
// for the next key of a sequence it swallows the prefix, so
|
|
277
|
+
// plain "g" never reached the composer. Modifier chords don't
|
|
278
|
+
// have this problem.
|
|
279
|
+
// 2. The obvious chord, ctrl+g, is already the host's documented
|
|
280
|
+
// default binding for session.first ("Navigate to first
|
|
281
|
+
// message", bound to ctrl+g,home,alt+home in opencode >=
|
|
282
|
+
// 2.0.32); our layer is mode "global" + priority 5 and would
|
|
283
|
+
// shadow it. Rather than shadow the host or pick another
|
|
284
|
+
// unmodified key, the command ships unbound: the sidebar
|
|
285
|
+
// widget (click), the command palette, and the /load-lock
|
|
286
|
+
// slash command remain as entry points.
|
|
209
287
|
palette: true,
|
|
210
288
|
slash: { name: "load-lock", aliases: ["lock", "ll"] },
|
|
211
289
|
run: () => {
|
|
212
|
-
ctx.ui.panel.open(PANEL_NAME, { presentation:
|
|
290
|
+
ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "load-lock.dashboard.close",
|
|
295
|
+
title: "load-lock: close the lock-status dashboard",
|
|
296
|
+
group: "load-lock",
|
|
297
|
+
bind: "escape",
|
|
298
|
+
enabled: escClosesPanel,
|
|
299
|
+
run: () => {
|
|
300
|
+
ctx.ui.panel.close();
|
|
213
301
|
},
|
|
214
302
|
},
|
|
215
303
|
{
|
|
216
304
|
id: "load-lock.dashboard.resync",
|
|
217
305
|
title: "load-lock: resync the dashboard",
|
|
218
306
|
group: "load-lock",
|
|
307
|
+
// Unmodified letters are fine here (unlike the global-scope rule
|
|
308
|
+
// above): `enabled` gates these to moments when OUR panel is the
|
|
309
|
+
// fullscreen current one and the composer is suspended, so there is
|
|
310
|
+
// no typing to swallow.
|
|
219
311
|
bind: "r",
|
|
220
312
|
enabled: panelOpen,
|
|
221
313
|
run: () => {
|
|
@@ -257,11 +349,26 @@ export default Plugin.define({
|
|
|
257
349
|
children: (v) => MiniIndicator(v, ctx),
|
|
258
350
|
}),
|
|
259
351
|
});
|
|
352
|
+
// Right-sidebar at-a-glance widget (session screens). The sidebar.footer
|
|
353
|
+
// slot pins it at the bottom of the sidebar so it stays visible even
|
|
354
|
+
// when the session list is long. Disjoint from the home footer above,
|
|
355
|
+
// so the two indicators never render on the same screen.
|
|
356
|
+
const offSidebar = ctx.ui.slot({
|
|
357
|
+
append: "sidebar.footer",
|
|
358
|
+
render: () => createComponent(Show, {
|
|
359
|
+
get when() {
|
|
360
|
+
return view() ?? false;
|
|
361
|
+
},
|
|
362
|
+
keyed: true,
|
|
363
|
+
children: (v) => SidebarWidget(v, ctx),
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
260
366
|
return () => {
|
|
261
367
|
if (tick)
|
|
262
368
|
clearInterval(tick);
|
|
263
369
|
offPanel();
|
|
264
370
|
offFooter();
|
|
371
|
+
offSidebar();
|
|
265
372
|
void feed.stop();
|
|
266
373
|
};
|
|
267
374
|
},
|
package/dist/cli/main.js
CHANGED
|
@@ -61,7 +61,84 @@ function defaultAgent() {
|
|
|
61
61
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
62
62
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
63
63
|
}
|
|
64
|
+
const HELP_GROUPS = [
|
|
65
|
+
{
|
|
66
|
+
title: "permit operations",
|
|
67
|
+
items: [
|
|
68
|
+
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--label] [--label-prefix] [--wait-ms] [--no-queue]" },
|
|
69
|
+
{ names: ["claim"], desc: "claim an offered permit before its window expires: --offer <token>" },
|
|
70
|
+
{ names: ["release"], desc: "release a held permit: --permit <token> [--outcome success|failure]" },
|
|
71
|
+
{ names: ["cancel"], desc: "cancel a queued/offered/paused request: --id <request-id> [--admin]" },
|
|
72
|
+
{ names: ["rejoin"], desc: "rejoin the queue after your offer expired or you declined: --id <request-id>" },
|
|
73
|
+
{ names: ["mark-started"], desc: "mark a permit as started when no pid can be attached: --permit <token>" },
|
|
74
|
+
{ names: ["attach-pid"], desc: "tie a running process to a permit (auto-release on exit): --permit <token> --pid <pid>" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: "waiting & running",
|
|
79
|
+
items: [
|
|
80
|
+
{ names: ["wait"], desc: "sleep until a queued request's turn, then auto-claim the offer: --id <request-id> [--timeout <ms>]" },
|
|
81
|
+
{ names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: "read-only",
|
|
86
|
+
items: [
|
|
87
|
+
{ names: ["status"], desc: "pools, holders, offers, queue — the machine-wide view" },
|
|
88
|
+
{ names: ["pending"], desc: "your outstanding offers, queue entries and paused requests" },
|
|
89
|
+
{ names: ["events"], desc: "durable event log: [--since <id>] [--follow]" },
|
|
90
|
+
{ names: ["inspect", "machine"], desc: "machine resources (incl. cgroup limits) and current pressure" },
|
|
91
|
+
{ names: ["config", "get-config"], desc: "current configuration" },
|
|
92
|
+
{ names: ["configure"], desc: "change configuration (compare-and-swap): --reason required" },
|
|
93
|
+
{ names: ["dashboard", "top"], desc: "full-screen live dashboard, read-only (never acquires): [--once]" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: "service & diagnostics",
|
|
98
|
+
items: [
|
|
99
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | status" },
|
|
100
|
+
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
|
|
101
|
+
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
title: "setup & docs",
|
|
106
|
+
items: [
|
|
107
|
+
{ names: ["install"], desc: "install the load-lock adapter for a harness: --harness <name>" },
|
|
108
|
+
{ names: ["uninstall"], desc: "remove what install added: --harness <name>" },
|
|
109
|
+
{ names: ["hook"], desc: "harness hook handlers (called by integrations, not humans)" },
|
|
110
|
+
{ names: ["agent-guide"], desc: "print the full agent guide" },
|
|
111
|
+
{ names: ["help"], desc: "this usage; also bare load-lock, --help, -h; help <command> for one line" },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
function displayName(item) {
|
|
116
|
+
return item.names[0] + (item.names.length > 1 ? ` (${item.names.slice(1).join(", ")})` : "");
|
|
117
|
+
}
|
|
118
|
+
const HELP_PAD = Math.max(...HELP_GROUPS.flatMap((g) => g.items.map((i) => displayName(i).length)));
|
|
119
|
+
function helpLine(item) {
|
|
120
|
+
return ` ${displayName(item).padEnd(HELP_PAD)} ${item.desc}`;
|
|
121
|
+
}
|
|
122
|
+
/** Grouped one-screen usage, printed by `help`, bare `load-lock`, `--help`/`-h`. */
|
|
123
|
+
function usage() {
|
|
124
|
+
const lines = [
|
|
125
|
+
"load-lock — machine-wide heavy-work budget shared by all coding agents",
|
|
126
|
+
"usage: load-lock <command> [flags] (--json works on read commands)",
|
|
127
|
+
];
|
|
128
|
+
for (const g of HELP_GROUPS) {
|
|
129
|
+
lines.push("", `${g.title}:`);
|
|
130
|
+
for (const item of g.items)
|
|
131
|
+
lines.push(helpLine(item));
|
|
132
|
+
}
|
|
133
|
+
lines.push("", "exit codes: 0 ok, 64 usage, 65 protocol, 68 not granted yet, 69 offer/claim failed, 70 internal");
|
|
134
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
135
|
+
}
|
|
64
136
|
async function main() {
|
|
137
|
+
// `--help`, `-h` and a bare `load-lock` all print the same usage as `help`.
|
|
138
|
+
if (f["help"] !== undefined || cmd === "-h" || (positional.length === 0 && argv.length === 0)) {
|
|
139
|
+
usage();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
65
142
|
switch (cmd) {
|
|
66
143
|
// ------------------------------------------------------------- read-only
|
|
67
144
|
case "status": {
|
|
@@ -85,12 +162,29 @@ async function main() {
|
|
|
85
162
|
return;
|
|
86
163
|
}
|
|
87
164
|
// ------------------------------------------------------------- dashboard
|
|
165
|
+
case "dashboard":
|
|
88
166
|
case "top": {
|
|
89
167
|
// Full-screen live dashboard (read-only, never acquires a permit).
|
|
90
|
-
//
|
|
168
|
+
// `dashboard` is the primary name; `top` stays as a hidden compat
|
|
169
|
+
// alias. Falls back to a single rendered frame when stdout is not a
|
|
170
|
+
// TTY (or with --once).
|
|
91
171
|
await topCommand({ once: B("once") });
|
|
92
172
|
return;
|
|
93
173
|
}
|
|
174
|
+
case "help": {
|
|
175
|
+
// `load-lock help` — grouped one-screen usage. `help <command>` is
|
|
176
|
+
// the optional per-command form: just that command's one-liner.
|
|
177
|
+
const topic = rest[0];
|
|
178
|
+
if (topic === undefined) {
|
|
179
|
+
usage();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const item = HELP_GROUPS.flatMap((g) => g.items).find((i) => i.names.includes(topic));
|
|
183
|
+
if (!item)
|
|
184
|
+
fail(`help: unknown command "${topic}" — run "load-lock help" for the full list`);
|
|
185
|
+
process.stdout.write(`load-lock ${item.names[0]} — ${item.desc}\n`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
94
188
|
case "inspect":
|
|
95
189
|
case "machine":
|
|
96
190
|
out(await withClient((c) => c.request("inspect", {})));
|
|
@@ -159,6 +253,7 @@ async function main() {
|
|
|
159
253
|
pool: S("pool", "heavy"),
|
|
160
254
|
agentId: S("agent", defaultAgent()),
|
|
161
255
|
agentLabel: S("label") || undefined,
|
|
256
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
162
257
|
cwd: S("cwd", process.cwd()),
|
|
163
258
|
opKey: S("op-key") || fail("acquire: --op-key is required"),
|
|
164
259
|
reason: S("reason") || fail("acquire: --reason is required"),
|
|
@@ -341,7 +436,7 @@ async function main() {
|
|
|
341
436
|
return;
|
|
342
437
|
}
|
|
343
438
|
default:
|
|
344
|
-
fail(`unknown command "${cmd}"
|
|
439
|
+
fail(`unknown command "${cmd}" — run "load-lock help" for the list of commands`);
|
|
345
440
|
}
|
|
346
441
|
}
|
|
347
442
|
// ------------------------------------------------------------------ run helper
|
|
@@ -367,6 +462,7 @@ async function runWithPermit(jobArgv) {
|
|
|
367
462
|
pool: S("pool", "heavy"),
|
|
368
463
|
agentId: S("agent", defaultAgent()),
|
|
369
464
|
agentLabel: S("label") || "load-lock run",
|
|
465
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
370
466
|
cwd: process.cwd(),
|
|
371
467
|
opKey: S("op-key") || `run/${command}/${Date.now()}`,
|
|
372
468
|
reason: S("reason") || command,
|
package/dist/cli/top.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `load-lock
|
|
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
|
*
|
package/dist/core/state.js
CHANGED
|
@@ -239,6 +239,15 @@ export class Machine {
|
|
|
239
239
|
const weight = p.weight ?? 1;
|
|
240
240
|
if (!Number.isInteger(weight) || weight < 1)
|
|
241
241
|
throw new ProtocolError("invalid", "weight must be a positive integer");
|
|
242
|
+
// Holder-label prefix (sandbox tagging): composed here so every adapter
|
|
243
|
+
// gets identical behavior; falls back to agentId when no label is given.
|
|
244
|
+
let agentLabel = p.agentLabel;
|
|
245
|
+
if (p.labelPrefix !== undefined) {
|
|
246
|
+
const prefix = p.labelPrefix.trim();
|
|
247
|
+
if (!prefix)
|
|
248
|
+
throw new ProtocolError("invalid", "labelPrefix must be a non-empty string");
|
|
249
|
+
agentLabel = `${prefix}:${p.agentLabel ?? p.agentId}`;
|
|
250
|
+
}
|
|
242
251
|
// Idempotency: a live request for the same (agent, op) returns its state
|
|
243
252
|
// (including its offer/permit token for the owner).
|
|
244
253
|
const existing = this.liveEntryByOpKey(p.agentId, p.opKey);
|
|
@@ -259,7 +268,7 @@ export class Machine {
|
|
|
259
268
|
id: rid(),
|
|
260
269
|
pool,
|
|
261
270
|
agentId: p.agentId,
|
|
262
|
-
agentLabel
|
|
271
|
+
agentLabel,
|
|
263
272
|
cwd: p.cwd,
|
|
264
273
|
opKey: p.opKey,
|
|
265
274
|
reason: p.reason,
|
|
@@ -281,7 +290,7 @@ export class Machine {
|
|
|
281
290
|
id: rid(),
|
|
282
291
|
pool,
|
|
283
292
|
agentId: p.agentId,
|
|
284
|
-
agentLabel
|
|
293
|
+
agentLabel,
|
|
285
294
|
cwd: p.cwd,
|
|
286
295
|
opKey: p.opKey,
|
|
287
296
|
reason: p.reason,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plain-text frame renderer for `load-lock top` — and
|
|
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.
|
package/dist/mcp/server.js
CHANGED
|
@@ -42,6 +42,7 @@ export function buildServer(opts) {
|
|
|
42
42
|
reason: z.string().describe("Why you need the machine (shown to other agents, e.g. 'cargo test --all in worktree feat-x')"),
|
|
43
43
|
op_key: z.string().describe("Stable key for the pending operation (idempotency; e.g. 'wt-feat-x/cargo-test')"),
|
|
44
44
|
agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
|
|
45
|
+
label_prefix: z.string().optional().describe("Prefix prepended to your holder label so sandboxed clients are identifiable, e.g. 'omac' -> 'omac:<label>' (or set env LOAD_LOCK_LABEL_PREFIX)"),
|
|
45
46
|
wait_ms: z.number().int().optional().describe("Bounded blocking wait for capacity, clamped by config (default 0)"),
|
|
46
47
|
subscribe_if_busy: z.boolean().optional().describe("false = fail fast instead of queueing (default true)"),
|
|
47
48
|
pool: z.string().optional().describe("Resource pool (default 'heavy')"),
|
|
@@ -50,6 +51,7 @@ export function buildServer(opts) {
|
|
|
50
51
|
const res = await client.request("acquire", {
|
|
51
52
|
pool: input.pool,
|
|
52
53
|
agentId: agentIdOf(input),
|
|
54
|
+
labelPrefix: input.label_prefix,
|
|
53
55
|
cwd: process.cwd(),
|
|
54
56
|
opKey: input.op_key,
|
|
55
57
|
reason: input.reason,
|
package/dist/shared/client.d.ts
CHANGED
|
@@ -34,6 +34,19 @@ export declare class LLClient {
|
|
|
34
34
|
onDisconnect(cb: () => void): () => void;
|
|
35
35
|
close(): void;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
39
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
40
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
41
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare function noSpawnRequested(env?: NodeJS.ProcessEnv): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
46
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
47
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
48
|
+
*/
|
|
49
|
+
export declare function envLabelPrefix(env?: NodeJS.ProcessEnv): string | undefined;
|
|
37
50
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
38
51
|
export declare function ensureDaemon(stateDir: string, sockPath: string): Promise<void>;
|
|
39
52
|
/** One-shot helper: connect (auto-spawning), run fn, close. */
|