@aron98/load-lock 0.1.1 → 1.0.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.
Files changed (49) hide show
  1. package/README.md +37 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +31 -0
  5. package/dist/adapters/opencode/tui.js +268 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +740 -61
  8. package/dist/cli/main.js +36 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +98 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +12 -0
  18. package/dist/dashboard/frame.js +94 -0
  19. package/dist/dashboard/view.d.ts +148 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/dashboard.test.d.ts +1 -0
  33. package/dist/test/dashboard.test.js +405 -0
  34. package/dist/test/hook.test.js +53 -3
  35. package/dist/test/install.test.d.ts +1 -0
  36. package/dist/test/install.test.js +658 -0
  37. package/dist/test/mcp.test.js +36 -1
  38. package/dist/test/platform.test.d.ts +1 -0
  39. package/dist/test/platform.test.js +193 -0
  40. package/dist/test/testutil.js +4 -1
  41. package/dist/test/wait-turn.test.d.ts +1 -0
  42. package/dist/test/wait-turn.test.js +376 -0
  43. package/docs/ADAPTERS.md +5 -2
  44. package/docs/AGENT_GUIDE.md +33 -1
  45. package/docs/ARCHITECTURE.md +25 -5
  46. package/docs/COMPATIBILITY.md +215 -26
  47. package/docs/OPERATIONS.md +313 -8
  48. package/package.json +25 -1
  49. package/skills/load-lock/SKILL.md +4 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Cross-platform transport seam: endpoint paths, liveness probing, stale
3
+ * endpoint cleanup, and process-group termination.
4
+ *
5
+ * POSIX (Linux/macOS): Unix-domain socket at `<state dir>/daemon.sock`,
6
+ * stale-socket cleanup by unlink, process groups via kill(-pgid, sig).
7
+ *
8
+ * Windows: named pipe `\\.\pipe\load-lock` (Node's `net` module accepts
9
+ * `\\.\pipe\...` paths in connect/listen transparently, so client and
10
+ * daemon need no other changes). Named pipes are kernel objects, not
11
+ * filesystem entries: there is no stale file to clean (a lost pipe simply
12
+ * stops existing), and existsSync() on a pipe path can block — callers
13
+ * must use probeTransport() instead. There are no POSIX process groups;
14
+ * job termination uses `taskkill /PID <pid> /T` to kill the tree.
15
+ */
16
+ import { connect } from "node:net";
17
+ import { unlinkSync } from "node:fs";
18
+ import { win32 as winPath, posix as posixPath } from "node:path";
19
+ import { spawn } from "node:child_process";
20
+ export const DEFAULT_PIPE_NAME = "load-lock";
21
+ export function isWindows(platform = process.platform) {
22
+ return platform === "win32";
23
+ }
24
+ /** Path join that follows the *target* platform, not the host running the code. */
25
+ function pathJoin(platform, ...segments) {
26
+ return (isWindows(platform) ? winPath : posixPath).join(...segments);
27
+ }
28
+ /** Windows named-pipe endpoint for the given pipe name. */
29
+ export function namedPipePath(name = DEFAULT_PIPE_NAME) {
30
+ return `\\\\.\\pipe\\${name}`;
31
+ }
32
+ /**
33
+ * Resolve the state directory. Precedence: explicit override >
34
+ * LOAD_LOCK_STATE_DIR > platform default (POSIX: XDG_STATE_HOME or
35
+ * ~/.local/state; Windows: %LOCALAPPDATA% or %USERPROFILE%\AppData\Local),
36
+ * each + "/load-lock". Pure function of its arguments so tests can
37
+ * exercise the Windows branches on Linux.
38
+ */
39
+ export function resolveStateDir(platform, env, override) {
40
+ if (override)
41
+ return override;
42
+ if (env.LOAD_LOCK_STATE_DIR)
43
+ return env.LOAD_LOCK_STATE_DIR;
44
+ if (isWindows(platform)) {
45
+ const base = env.LOCALAPPDATA || pathJoin(platform, env.USERPROFILE ?? "~", "AppData", "Local");
46
+ return pathJoin(platform, base, "load-lock");
47
+ }
48
+ const xdg = env.XDG_STATE_HOME || pathJoin(platform, env.HOME ?? "~", ".local", "state");
49
+ return pathJoin(platform, xdg, "load-lock");
50
+ }
51
+ /**
52
+ * Resolve the transport endpoint. Precedence: explicit override >
53
+ * LOAD_LOCK_SOCKET > platform default (Windows: named pipe; POSIX:
54
+ * <state dir>/daemon.sock). The same precedence rules are used by every
55
+ * caller, so the CLI, MCP server, hooks, and the daemon all agree on the
56
+ * endpoint without further coordination.
57
+ */
58
+ export function resolveSocketPath(platform, env, override) {
59
+ if (override)
60
+ return override;
61
+ if (env.LOAD_LOCK_SOCKET)
62
+ return env.LOAD_LOCK_SOCKET;
63
+ if (isWindows(platform))
64
+ return namedPipePath();
65
+ return pathJoin(platform, resolveStateDir(platform, env), "daemon.sock");
66
+ }
67
+ /**
68
+ * Probe whether a daemon is listening on the endpoint (UDS or named
69
+ * pipe — Node's connect handles both). Never throws.
70
+ */
71
+ export function probeTransport(path) {
72
+ return new Promise((resolve) => {
73
+ const s = connect(path);
74
+ let settled = false;
75
+ const done = (v) => {
76
+ if (!settled) {
77
+ settled = true;
78
+ resolve(v);
79
+ }
80
+ };
81
+ s.once("connect", () => {
82
+ s.end();
83
+ done(true);
84
+ });
85
+ s.once("error", () => {
86
+ s.destroy();
87
+ done(false);
88
+ });
89
+ });
90
+ }
91
+ /**
92
+ * Remove a stale endpoint left behind by a crashed daemon.
93
+ * POSIX: unlink the socket file (a crashed daemon leaves it behind).
94
+ * Windows: no-op — named pipes are kernel objects and vanish when the
95
+ * last handle closes, so there is nothing to clean up.
96
+ */
97
+ export function removeStaleEndpoint(path, platform = process.platform) {
98
+ if (isWindows(platform))
99
+ return;
100
+ try {
101
+ unlinkSync(path);
102
+ }
103
+ catch {
104
+ /* already gone — fine */
105
+ }
106
+ }
107
+ /**
108
+ * Terminate a job process tree. POSIX: negative-pid group kill (callers
109
+ * spawn jobs detached, so pgid === child pid). Windows: `taskkill /PID
110
+ * <pid> /T` (tree kill; add /F for the uncatchable SIGKILL equivalent —
111
+ * plain taskkill posts WM_CLOSE, which console processes don't handle).
112
+ * Fire-and-forget; never throws.
113
+ */
114
+ export function killProcessGroup(pid, signal, platform = process.platform) {
115
+ if (isWindows(platform)) {
116
+ const args = ["/PID", String(pid), "/T"];
117
+ if (signal === "SIGKILL")
118
+ args.push("/F");
119
+ try {
120
+ const child = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
121
+ child.once("error", () => {
122
+ /* taskkill missing — nothing we can do; swallow */
123
+ });
124
+ }
125
+ catch {
126
+ /* never fatal */
127
+ }
128
+ return;
129
+ }
130
+ try {
131
+ process.kill(-pid, signal);
132
+ }
133
+ catch {
134
+ /* group already gone */
135
+ }
136
+ }
@@ -0,0 +1,29 @@
1
+ import { WaitForTurnResult } from "./protocol.js";
2
+ import { LLClient } from "./client.js";
3
+ /** Thrown when the caller aborts the wait (AbortSignal). Benign — never an error surface. */
4
+ export declare class WaitAbortedError extends Error {
5
+ constructor();
6
+ }
7
+ export interface WaitForTurnOpts {
8
+ client: LLClient;
9
+ /** Agent identity whose requests we watch. */
10
+ agentId: string;
11
+ /** Explicit request id; defaults to the caller's oldest offered, then queued, then paused request. */
12
+ requestId?: string;
13
+ /** Requested bound (ms). Clamped to the machine's maxTurnWaitMs config. 0 = check-only. */
14
+ timeoutMs?: number;
15
+ /** Default bound when timeout_ms omitted (before clamping). */
16
+ defaultTimeoutMs?: number;
17
+ /** Poll cadence for the light events/pending pulls (default 200ms). */
18
+ pollMs?: number;
19
+ /** Caller cancellation — resolves the wait as soon as it fires. */
20
+ signal?: AbortSignal;
21
+ }
22
+ /**
23
+ * Bounded blocking wait for a queued request to reach this caller's turn.
24
+ * Resolves with a STRUCTURED result on every path — granted (with the
25
+ * permit auto-claimed), still_queued (timeout: position + holders), paused,
26
+ * cancelled, or not_found (e.g. daemon restarted) — never with an opaque
27
+ * error where a result is expressible.
28
+ */
29
+ export declare function waitForTurn(opts: WaitForTurnOpts): Promise<WaitForTurnResult>;
@@ -0,0 +1,315 @@
1
+ // wait_for_turn (issue #11): bounded, honest "sleep until it is my turn".
2
+ //
3
+ // One shared client-side implementation serves the CLI `wait` command, the
4
+ // MCP `wait_for_turn` tool and the OpenCode plugin's
5
+ // `load_lock_wait_for_turn` tool. The wait is deliberately CLIENT-side:
6
+ //
7
+ // - it holds no permit and consumes no capacity (light work by design);
8
+ // - cancelling the caller (AbortSignal / MCP cancellation / harness tool
9
+ // timeout) just stops the local loop — there is no daemon-side waiter
10
+ // to orphan, so daemon `pending` stays clean by construction;
11
+ // - it survives transient daemon unavailability (each iteration retries)
12
+ // and detects a daemon restart mid-wait, resolving bounded instead of
13
+ // hanging (queued requests do not outlive a daemon restart).
14
+ //
15
+ // No-missed-wakeup registration: we capture the events cursor BEFORE the
16
+ // first `pending` snapshot. Every machine transition emits exactly one
17
+ // event, so any state change visible in the snapshot is either already in
18
+ // the cursor's past or will be delivered by a subsequent events pull — the
19
+ // poll loop can never sleep through its own turn.
20
+ /** Thrown when the caller aborts the wait (AbortSignal). Benign — never an error surface. */
21
+ export class WaitAbortedError extends Error {
22
+ constructor() {
23
+ super("wait aborted by caller");
24
+ this.name = "AbortError";
25
+ }
26
+ }
27
+ const DEFAULT_TIMEOUT_MS = 600_000;
28
+ const DEFAULT_POLL_MS = 200;
29
+ function sleep(ms, signal) {
30
+ return new Promise((resolve, reject) => {
31
+ const t = setTimeout(() => {
32
+ signal?.removeEventListener("abort", onAbort);
33
+ resolve();
34
+ }, ms);
35
+ const onAbort = () => {
36
+ clearTimeout(t);
37
+ reject(new WaitAbortedError());
38
+ };
39
+ if (signal) {
40
+ if (signal.aborted)
41
+ return onAbort();
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ }
44
+ });
45
+ }
46
+ /**
47
+ * Bounded blocking wait for a queued request to reach this caller's turn.
48
+ * Resolves with a STRUCTURED result on every path — granted (with the
49
+ * permit auto-claimed), still_queued (timeout: position + holders), paused,
50
+ * cancelled, or not_found (e.g. daemon restarted) — never with an opaque
51
+ * error where a result is expressible.
52
+ */
53
+ export async function waitForTurn(opts) {
54
+ const client = opts.client;
55
+ const agentId = opts.agentId;
56
+ const signal = opts.signal;
57
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
58
+ const start = Date.now();
59
+ if (signal?.aborted)
60
+ throw new WaitAbortedError();
61
+ // ---- resolve the target request (before anything time-consuming) ----
62
+ let requestId = opts.requestId;
63
+ let targetPool;
64
+ if (!requestId) {
65
+ const pending = (await client.request("pending", { agentId }));
66
+ // Priority: an outstanding offer (claim window is ticking!), then the
67
+ // head-most queued entry, then the oldest paused entry.
68
+ const offered = pending.offers[0];
69
+ const queued = [...pending.queue].sort((a, b) => (a.position ?? 1) - (b.position ?? 1))[0];
70
+ const paused = pending.paused[0];
71
+ const target = offered ?? queued ?? paused;
72
+ if (!target) {
73
+ return {
74
+ state: "not_found",
75
+ waitedMs: 0,
76
+ nextAction: "No queued, offered or paused request found for this agent — acquire one first (load_lock acquire).",
77
+ };
78
+ }
79
+ requestId = target.requestId;
80
+ targetPool = target.pool;
81
+ }
82
+ // ---- clamp the wait budget to the machine config ----
83
+ let budget = opts.timeoutMs ?? opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
84
+ let clamped = false;
85
+ try {
86
+ const cfg = (await client.request("get_config", {}));
87
+ const cap = cfg.config?.maxTurnWaitMs;
88
+ if (typeof cap === "number" && budget > cap) {
89
+ budget = cap;
90
+ clamped = true;
91
+ }
92
+ }
93
+ catch {
94
+ /* config unreadable right now — proceed with the caller's budget */
95
+ }
96
+ const deadline = budget > 0 ? start + budget : 0; // budget 0 = check-only
97
+ const checkOnly = budget === 0;
98
+ // ---- atomic registration: cursor BEFORE the first pending snapshot ----
99
+ // MAX_SAFE_INTEGER asks for "just the latest cursor" without events.
100
+ let cursor = 0;
101
+ try {
102
+ const head = (await client.request("events", {
103
+ agentId,
104
+ sinceEventId: Number.MAX_SAFE_INTEGER,
105
+ }));
106
+ cursor = head.latestEventId ?? 0;
107
+ }
108
+ catch {
109
+ /* transient — pending() below still gives us truth */
110
+ }
111
+ let queuePosition;
112
+ let queueLength;
113
+ let holders;
114
+ while (true) {
115
+ if (signal?.aborted)
116
+ throw new WaitAbortedError();
117
+ if (deadline && Date.now() >= deadline)
118
+ break;
119
+ // 1) Drain events addressed to us since the cursor. Cheap (local UDS).
120
+ try {
121
+ const r = (await client.request("events", { agentId, sinceEventId: cursor }));
122
+ // New daemon instance: ids restart at 1, so a latestEventId below our
123
+ // cursor (or an explicit daemon_started/daemon_recovered event) means
124
+ // the wait crossed a restart. Queued requests do not survive it.
125
+ if (r.latestEventId < cursor) {
126
+ return {
127
+ state: "not_found",
128
+ requestId,
129
+ waitedMs: Date.now() - start,
130
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
131
+ nextAction: "The load-lock daemon restarted mid-wait and queued requests do not survive restarts. acquire again, then wait_for_turn.",
132
+ detail: `daemon restart detected (event cursor went ${cursor} -> ${r.latestEventId})`,
133
+ };
134
+ }
135
+ for (const ev of r.events) {
136
+ if (ev.id > cursor)
137
+ cursor = ev.id;
138
+ if (ev.type === "daemon_started" || ev.type === "daemon_recovered") {
139
+ return {
140
+ state: "not_found",
141
+ requestId,
142
+ waitedMs: Date.now() - start,
143
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
144
+ nextAction: "The load-lock daemon restarted mid-wait and queued requests do not survive restarts. acquire again, then wait_for_turn.",
145
+ detail: `daemon restart detected (${ev.type} event)`,
146
+ };
147
+ }
148
+ const evReq = ev.entry?.requestId;
149
+ if (evReq !== requestId)
150
+ continue;
151
+ if (ev.type === "cancelled" || ev.type === "declined" || ev.type === "reclaimed") {
152
+ return {
153
+ state: "cancelled",
154
+ requestId,
155
+ waitedMs: Date.now() - start,
156
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
157
+ nextAction: ev.type === "reclaimed"
158
+ ? "The permit was reclaimed (start deadline or lease exceeded). acquire again with a fresh op_key."
159
+ : "The request was cancelled. acquire again when you are ready.",
160
+ detail: `request ended via ${ev.type} event while waiting`,
161
+ };
162
+ }
163
+ }
164
+ }
165
+ catch {
166
+ /* transient RPC failure — pending() below is the source of truth */
167
+ }
168
+ // 2) pending() is the authoritative per-iteration truth.
169
+ try {
170
+ const pending = (await client.request("pending", { agentId }));
171
+ const entry = pending.offers.find((e) => e.requestId === requestId) ??
172
+ pending.queue.find((e) => e.requestId === requestId) ??
173
+ pending.paused.find((e) => e.requestId === requestId) ??
174
+ pending.held.find((e) => e.requestId === requestId);
175
+ if (!entry) {
176
+ // Absent from the owner's own pending set — the request ended
177
+ // (released / cancelled / pruned) or the daemon restarted and the
178
+ // ephemeral queue is gone. Either way it will not come back.
179
+ return {
180
+ state: "not_found",
181
+ requestId,
182
+ waitedMs: Date.now() - start,
183
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
184
+ nextAction: "Request not found — it ended, was cancelled, or the daemon restarted (queues are ephemeral). acquire again, then wait_for_turn.",
185
+ detail: "request disappeared from pending while waiting",
186
+ };
187
+ }
188
+ else {
189
+ targetPool = entry.pool;
190
+ if (entry.state === "offered") {
191
+ // Our turn! Claim inside the offer window, explicitly on the caller's behalf.
192
+ try {
193
+ const claimed = (await client.request("claim", { offerToken: entry.offerToken }));
194
+ return {
195
+ state: "granted",
196
+ requestId,
197
+ autoClaimed: true,
198
+ waitedMs: Date.now() - start,
199
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
200
+ entry: claimed.entry,
201
+ nextAction: "Permit claimed for you — start the heavy work now (mark_started / attach_pid, release when done).",
202
+ };
203
+ }
204
+ catch {
205
+ /* claim raced (expiry or another claim) — fall through to truth */
206
+ }
207
+ }
208
+ else if (entry.state === "held") {
209
+ // Already holding (e.g. granted between our registration and here).
210
+ return {
211
+ state: "granted",
212
+ requestId,
213
+ autoClaimed: false,
214
+ waitedMs: Date.now() - start,
215
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
216
+ entry,
217
+ nextAction: "You already hold the permit — start the heavy work now (mark_started / attach_pid, release when done).",
218
+ };
219
+ }
220
+ else if (entry.state === "paused") {
221
+ return {
222
+ state: "paused",
223
+ requestId,
224
+ waitedMs: Date.now() - start,
225
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
226
+ entry,
227
+ nextAction: "The offer expired or was declined and the request is paused. rejoin (load_lock rejoin) when you are ready to wait again.",
228
+ };
229
+ }
230
+ else if (entry.state === "queued") {
231
+ queuePosition = entry.position;
232
+ queueLength = pending.queue.length;
233
+ }
234
+ }
235
+ }
236
+ catch {
237
+ /* transient — retry on next iteration */
238
+ }
239
+ // 3) Not our turn yet: light sleep, then loop.
240
+ if (checkOnly)
241
+ break;
242
+ const remain = deadline ? deadline - Date.now() : pollMs;
243
+ if (remain <= 0)
244
+ break;
245
+ await sleep(Math.min(pollMs, remain), signal);
246
+ }
247
+ // ---- timed out (or check-only): still_queued with position + holders ----
248
+ let entry;
249
+ try {
250
+ const pending = (await client.request("pending", { agentId }));
251
+ const found = pending.queue.find((e) => e.requestId === requestId) ??
252
+ pending.paused.find((e) => e.requestId === requestId) ??
253
+ pending.held.find((e) => e.requestId === requestId);
254
+ if (found) {
255
+ entry = found;
256
+ targetPool = found.pool;
257
+ if (found.state === "held") {
258
+ return {
259
+ state: "granted",
260
+ requestId,
261
+ autoClaimed: false,
262
+ waitedMs: Date.now() - start,
263
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
264
+ entry: found,
265
+ nextAction: "You already hold the permit — start the heavy work now (mark_started / attach_pid, release when done).",
266
+ };
267
+ }
268
+ if (found.state === "paused") {
269
+ return {
270
+ state: "paused",
271
+ requestId,
272
+ waitedMs: Date.now() - start,
273
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
274
+ entry: found,
275
+ nextAction: "The offer expired or was declined and the request is paused. rejoin (load_lock rejoin) when you are ready to wait again.",
276
+ };
277
+ }
278
+ if (found.state === "queued") {
279
+ queuePosition = found.position;
280
+ queueLength = pending.queue.length;
281
+ }
282
+ }
283
+ if (targetPool) {
284
+ const st = (await client.request("status", { pool: targetPool }));
285
+ const pool = st.pools.find((p) => p.pool === targetPool);
286
+ if (pool) {
287
+ holders = pool.holders;
288
+ queueLength = queueLength ?? pool.queue.length;
289
+ }
290
+ }
291
+ }
292
+ catch {
293
+ /* best-effort enrichment */
294
+ }
295
+ if (!entry) {
296
+ return {
297
+ state: "not_found",
298
+ requestId,
299
+ waitedMs: Date.now() - start,
300
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
301
+ nextAction: "Request not found — it may have ended or been cancelled, or the daemon restarted. acquire again.",
302
+ };
303
+ }
304
+ return {
305
+ state: "still_queued",
306
+ requestId,
307
+ waitedMs: Date.now() - start,
308
+ ...(clamped ? { effectiveWaitMs: budget } : {}),
309
+ entry,
310
+ queuePosition,
311
+ queueLength,
312
+ holders,
313
+ nextAction: `Still queued at position ${queuePosition ?? "?"} of ${queueLength ?? "?"} — call wait_for_turn again to keep sleeping, or cancel to give up. This wait held no capacity.`,
314
+ };
315
+ }
@@ -0,0 +1 @@
1
+ export {};