@aron98/load-lock 0.1.2 → 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.
Files changed (53) hide show
  1. package/README.md +40 -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 +61 -0
  5. package/dist/adapters/opencode/tui.js +358 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +708 -24
  8. package/dist/cli/main.js +130 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +99 -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 +13 -0
  18. package/dist/dashboard/frame.js +95 -0
  19. package/dist/dashboard/view.d.ts +149 -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/cli.test.d.ts +1 -0
  33. package/dist/test/cli.test.js +99 -0
  34. package/dist/test/dashboard.test.d.ts +1 -0
  35. package/dist/test/dashboard.test.js +406 -0
  36. package/dist/test/hook.test.js +53 -3
  37. package/dist/test/install.test.d.ts +1 -0
  38. package/dist/test/install.test.js +658 -0
  39. package/dist/test/mcp.test.js +36 -1
  40. package/dist/test/platform.test.d.ts +1 -0
  41. package/dist/test/platform.test.js +193 -0
  42. package/dist/test/testutil.js +4 -1
  43. package/dist/test/tui.test.d.ts +1 -0
  44. package/dist/test/tui.test.js +89 -0
  45. package/dist/test/wait-turn.test.d.ts +1 -0
  46. package/dist/test/wait-turn.test.js +376 -0
  47. package/docs/ADAPTERS.md +5 -2
  48. package/docs/AGENT_GUIDE.md +34 -1
  49. package/docs/ARCHITECTURE.md +25 -5
  50. package/docs/COMPATIBILITY.md +217 -26
  51. package/docs/OPERATIONS.md +335 -8
  52. package/package.json +25 -1
  53. package/skills/load-lock/SKILL.md +4 -0
package/dist/cli/main.js CHANGED
@@ -7,12 +7,15 @@
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
- import { homedir } from "node:os";
11
10
  import { join } from "node:path";
12
11
  import { LLClient, withClient } from "../shared/client.js";
13
12
  import { ProtocolError } from "../shared/protocol.js";
13
+ import { waitForTurn } from "../shared/wait-turn.js";
14
+ import { stateDir, socketPath } from "../core/persist.js";
15
+ import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
14
16
  import { installCommand, uninstallCommand } from "./install.js";
15
17
  import { hookCommand } from "./hook.js";
18
+ import { topCommand } from "./top.js";
16
19
  // ---------------------------------------------------------------- arg parsing
17
20
  const argv = process.argv.slice(2);
18
21
  let flags = {};
@@ -58,7 +61,84 @@ function defaultAgent() {
58
61
  return (process.env.LOAD_LOCK_AGENT ||
59
62
  "cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
60
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
+ }
61
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
+ }
62
142
  switch (cmd) {
63
143
  // ------------------------------------------------------------- read-only
64
144
  case "status": {
@@ -81,6 +161,30 @@ async function main() {
81
161
  });
82
162
  return;
83
163
  }
164
+ // ------------------------------------------------------------- dashboard
165
+ case "dashboard":
166
+ case "top": {
167
+ // Full-screen live dashboard (read-only, never acquires a permit).
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).
171
+ await topCommand({ once: B("once") });
172
+ return;
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
+ }
84
188
  case "inspect":
85
189
  case "machine":
86
190
  out(await withClient((c) => c.request("inspect", {})));
@@ -108,6 +212,8 @@ async function main() {
108
212
  changes.offerTimeoutMs = N("offer-timeout-ms", 0);
109
213
  if (S("max-wait-ms"))
110
214
  changes.maxWaitMs = N("max-wait-ms", 0);
215
+ if (S("max-turn-wait-ms"))
216
+ changes.maxTurnWaitMs = N("max-turn-wait-ms", 0);
111
217
  if (S("start-timeout-ms"))
112
218
  changes.startTimeoutMs = N("start-timeout-ms", 0);
113
219
  if (S("untracked-lease-ms"))
@@ -115,7 +221,7 @@ async function main() {
115
221
  if (S("heavy-work-heuristic"))
116
222
  changes.guidance = { heavyWorkHeuristic: S("heavy-work-heuristic") };
117
223
  if (!Object.keys(changes).length)
118
- fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
224
+ fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --max-turn-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
119
225
  out(await c.request("configure", { expectedVersion: expected, changes, reason, author }));
120
226
  });
121
227
  return;
@@ -196,55 +302,24 @@ async function main() {
196
302
  case "wait": {
197
303
  // Watches a queued request; auto-claims the offer when capacity is
198
304
  // handed to it and prints the permit token. Honest bounded blocking
199
- // for scriptable flows (aider, plain shell scripts).
305
+ // for scriptable flows (aider, plain shell scripts). Shares the exact
306
+ // implementation with the MCP wait_for_turn tool and the OpenCode
307
+ // plugin (src/shared/wait-turn.ts).
200
308
  const id = S("id") || fail("wait: --id <request-id> required");
201
309
  const timeoutMs = N("timeout", 600_000);
202
310
  const agent = S("agent", defaultAgent());
203
311
  const c = new LLClient();
204
312
  await c.connect();
205
313
  try {
206
- let last = 0;
207
- let resolved = false;
208
- const permit = await new Promise((resolve) => {
209
- const finish = (v) => {
210
- if (!resolved) {
211
- resolved = true;
212
- resolve(v);
213
- }
214
- };
215
- const timer = timeoutMs > 0 ? setTimeout(() => finish(null), timeoutMs) : null;
216
- void (async () => {
217
- while (!resolved) {
218
- try {
219
- const r = (await c.request("events", { sinceEventId: last, agentId: agent }));
220
- last = r.latestEventId;
221
- for (const ev of r.events) {
222
- if (ev.entry?.requestId !== id)
223
- continue;
224
- if (ev.type === "offered" && ev.offerToken) {
225
- const claimed = (await c.request("claim", { offerToken: ev.offerToken }));
226
- if (timer)
227
- clearTimeout(timer);
228
- finish(claimed.entry.permitToken);
229
- }
230
- else if (ev.type === "cancelled" || ev.type === "paused" || ev.type === "reclaimed") {
231
- if (timer)
232
- clearTimeout(timer);
233
- finish(null);
234
- }
235
- }
236
- }
237
- catch {
238
- /* transient; retry */
239
- }
240
- await new Promise((r2) => setTimeout(r2, 200));
241
- }
242
- })();
243
- });
244
- if (!permit)
245
- fail("wait: timed out or the request was cancelled/paused/reclaimed", 69);
246
- out({ permit_token: permit });
247
- return;
314
+ const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
315
+ if (r.state === "granted" && r.entry?.permitToken) {
316
+ out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
317
+ return;
318
+ }
319
+ out({ state: r.state, request_id: r.requestId, detail: r.detail ?? r.nextAction });
320
+ if (r.state === "still_queued")
321
+ fail(`wait: still queued (position ${r.queuePosition ?? "?"}) after ${r.waitedMs}ms`, 68);
322
+ fail(`wait: request is ${r.state.replace("_", " ")} — ${r.nextAction}`, 69);
248
323
  }
249
324
  finally {
250
325
  c.close();
@@ -264,7 +339,7 @@ async function main() {
264
339
  const sub = rest[0] ?? "status";
265
340
  if (sub === "stop") {
266
341
  // Connect and ask... simplest: SIGTERM via daemon.json pid.
267
- const dir = process.env.LOAD_LOCK_STATE_DIR || join(process.env.HOME ?? "~", ".local", "state", "load-lock");
342
+ const dir = stateDir();
268
343
  try {
269
344
  const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
270
345
  process.kill(info.pid, "SIGTERM");
@@ -291,9 +366,13 @@ async function main() {
291
366
  const report = {};
292
367
  report.node = process.version;
293
368
  report.platform = process.platform;
294
- const dir = process.env.LOAD_LOCK_STATE_DIR || join(homedir(), ".local", "state", "load-lock");
369
+ const dir = stateDir();
295
370
  report.stateDir = dir;
296
- report.socketExists = existsSync(join(dir, "daemon.sock"));
371
+ const sock = socketPath();
372
+ report.socket = sock;
373
+ // Named pipes are kernel objects, not filesystem entries — probe by
374
+ // connecting on Windows; on POSIX a file check is enough.
375
+ report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
297
376
  report.configExists = existsSync(join(dir, "config.json"));
298
377
  report.journalExists = existsSync(join(dir, "journal.json"));
299
378
  try {
@@ -356,7 +435,7 @@ async function main() {
356
435
  return;
357
436
  }
358
437
  default:
359
- fail(`unknown command "${cmd}". Commands: status, inspect, config, pending, events, acquire, claim, release, cancel, rejoin, mark-started, attach-pid, wait, run, service, doctor, recover, install, uninstall, hook, agent-guide`);
438
+ fail(`unknown command "${cmd}" — run "load-lock help" for the list of commands`);
360
439
  }
361
440
  }
362
441
  // ------------------------------------------------------------------ run helper
@@ -416,21 +495,11 @@ async function runWithPermit(jobArgv) {
416
495
  await client.request("start_job", { permitToken, pid: child.pid, pgid });
417
496
  }
418
497
  catch (err) {
419
- try {
420
- process.kill(-pgid, "SIGKILL");
421
- }
422
- catch {
423
- /* already gone */
424
- }
498
+ killProcessGroup(pgid, "SIGKILL");
425
499
  fail(`permit no longer startable (${err.message}); job not started`, 69);
426
500
  }
427
501
  const forward = (sig) => {
428
- try {
429
- process.kill(-pgid, sig);
430
- }
431
- catch {
432
- /* already gone */
433
- }
502
+ killProcessGroup(pgid, sig);
434
503
  };
435
504
  process.on("SIGTERM", () => forward("SIGTERM"));
436
505
  process.on("SIGINT", () => forward("SIGINT"));
@@ -0,0 +1,7 @@
1
+ export interface TopOptions {
2
+ /** Render one frame and exit (also implied when stdout is not a TTY). */
3
+ once?: boolean;
4
+ /** Local re-render cadence for countdowns (default 500 ms). */
5
+ tickMs?: number;
6
+ }
7
+ export declare function topCommand(opts?: TopOptions): Promise<void>;
@@ -0,0 +1,99 @@
1
+ /**
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
5
+ * (never scrapes CLI output); live updates come from the `watch` event
6
+ * stream, with reconnect + event-cursor resume across daemon restarts.
7
+ *
8
+ * Read-only. The viewer itself is light work — it never acquires a permit.
9
+ */
10
+ import { DashboardFeed } from "../dashboard/feed.js";
11
+ import { renderFrame } from "../dashboard/frame.js";
12
+ import { LLClient } from "../shared/client.js";
13
+ const HIDE_CURSOR = "\x1b[?25l";
14
+ const SHOW_CURSOR = "\x1b[?25h";
15
+ const ALT_SCREEN = "\x1b[?1049h";
16
+ const LEAVE_ALT = "\x1b[?1049l";
17
+ export async function topCommand(opts = {}) {
18
+ const once = opts.once || !process.stdout.isTTY;
19
+ const client = new LLClient();
20
+ const feed = new DashboardFeed(client);
21
+ try {
22
+ await feed.start();
23
+ if (once) {
24
+ const frame = renderFrame(feed.view(), process.stdout.columns || 100, 1000);
25
+ process.stdout.write(frame.join("\n") + "\n");
26
+ return;
27
+ }
28
+ await runLive(feed, opts);
29
+ }
30
+ finally {
31
+ await feed.stop();
32
+ }
33
+ }
34
+ async function runLive(feed, opts) {
35
+ const out = process.stdout;
36
+ const tickMs = Math.max(100, opts.tickMs ?? 500);
37
+ let stopped = false;
38
+ const paint = () => {
39
+ const width = out.columns || 100;
40
+ const height = out.rows || 40;
41
+ const frame = renderFrame(feed.view(), width, Math.max(1, height - 1));
42
+ // Pad to full height so old content never lingers after shrinking.
43
+ while (frame.length < height - 1)
44
+ frame.push("");
45
+ const body = frame.map((l) => l + "\x1b[K").join("\n");
46
+ out.write(`\x1b[H\x1b[2J${body}\n`);
47
+ };
48
+ // The feed signals change asynchronously; the tick loop below repaints on
49
+ // every tick anyway (cheap string render) so new events and countdowns
50
+ // both appear promptly without a paint storm per event burst.
51
+ out.write(ALT_SCREEN + HIDE_CURSOR);
52
+ out.write("\x1b[H\x1b[2J");
53
+ process.stdin.setRawMode?.(true);
54
+ process.stdin.resume();
55
+ const restore = () => {
56
+ process.stdin.setRawMode?.(false);
57
+ out.write(SHOW_CURSOR + LEAVE_ALT);
58
+ };
59
+ let exiting = false;
60
+ const quit = () => {
61
+ if (exiting)
62
+ return;
63
+ exiting = true;
64
+ restore();
65
+ stopped = true;
66
+ process.stdin.pause();
67
+ };
68
+ const onKey = (data) => {
69
+ const k = data.toString("utf8");
70
+ if (k === "q" || k === "Q" || k === "\x1b" || k === "\x03")
71
+ quit();
72
+ else if (k === "r" || k === "R")
73
+ void feed.resync().catch(() => { });
74
+ };
75
+ process.stdin.on("data", onKey);
76
+ const onResize = () => paint();
77
+ out.on("resize", onResize);
78
+ const onSignal = () => {
79
+ quit();
80
+ };
81
+ process.on("SIGINT", onSignal);
82
+ process.on("SIGTERM", onSignal);
83
+ paint();
84
+ try {
85
+ while (!stopped) {
86
+ await new Promise((r) => setTimeout(r, tickMs));
87
+ if (stopped)
88
+ continue;
89
+ paint(); // cheap local re-render keeps countdowns ticking and shows new events
90
+ }
91
+ }
92
+ finally {
93
+ restore();
94
+ process.stdin.removeListener("data", onKey);
95
+ out.removeListener("resize", onResize);
96
+ process.removeListener("SIGINT", onSignal);
97
+ process.removeListener("SIGTERM", onSignal);
98
+ }
99
+ }
@@ -22,6 +22,10 @@ export declare class Daemon {
22
22
  machine: Machine;
23
23
  server: Server | null;
24
24
  private watchers;
25
+ /** All open client connections — destroyed on stop() so a graceful
26
+ * shutdown cuts waiting clients loose (they reconnect + detect the
27
+ * restart via the event cursor) and the process can exit promptly. */
28
+ private conns;
25
29
  private waiters;
26
30
  private sentEventId;
27
31
  private stateDirPath;
@@ -36,7 +40,11 @@ export declare class Daemon {
36
40
  recover?: boolean;
37
41
  });
38
42
  start(): Promise<void>;
39
- /** Bind the socket, replacing it only if it is verifiably stale. */
43
+ /**
44
+ * Bind the endpoint, replacing it only if it is verifiably stale.
45
+ * Windows: named pipes are kernel objects — no stale file can exist, so
46
+ * just refuse if another daemon owns the pipe.
47
+ */
40
48
  private claimSocket;
41
49
  stop(): void;
42
50
  private onConnection;
@@ -65,15 +73,28 @@ export declare class Daemon {
65
73
  */
66
74
  private sweep;
67
75
  }
68
- /** Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse. */
69
- export declare function isProcessAlive(pid: number, expectedStartTime?: string): boolean | null;
76
+ /**
77
+ * Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
78
+ * EPERM counts as alive: the process exists but is not signalable by us.
79
+ */
80
+ export declare function aliveBySignalZero(pid: number): boolean;
81
+ /**
82
+ * Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
83
+ * Windows: no /proc and no start time — signal-0 fallback only; the
84
+ * pid-reuse defense is unavailable there (documented limitation).
85
+ * macOS: /proc is absent, so the signal-0 fallback applies too.
86
+ */
87
+ export declare function isProcessAlive(pid: number, expectedStartTime?: string, platform?: NodeJS.Platform): boolean | null;
70
88
  /**
71
89
  * Job liveness: a tracked job is alive if its pid is alive (start-time
72
90
  * matched) OR any process in its process group is alive — covering
73
91
  * subprocesses and background jobs that outlive their parent. Returns
74
92
  * true/false on Linux; on platforms without /proc the pgid scan is
75
93
  * unavailable and the result may be null (unknown => conservative keep).
94
+ * Windows: no process groups — only the main pid is checked, so a permit
95
+ * is released once the main pid dies even if children survive (documented
96
+ * limitation; adapters should kill the tree via taskkill /T).
76
97
  */
77
- export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string): boolean | null;
98
+ export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
78
99
  /** Read the Linux start time of a pid (pid-reuse-safe association). */
79
100
  export declare function processStartTime(pid: number): string | undefined;
@@ -16,7 +16,7 @@
16
16
  * All state mutations run on one event loop => every RPC is atomic wrt all
17
17
  * others; start_job is therefore atomic wrt reclamation.
18
18
  */
19
- import { connect, createServer } from "node:net";
19
+ import { createServer } from "node:net";
20
20
  import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
21
21
  import { readdirSync } from "node:fs";
22
22
  import { join } from "node:path";
@@ -24,10 +24,15 @@ import { Machine } from "./state.js";
24
24
  import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
25
25
  import { inspectMachine } from "./sysinfo.js";
26
26
  import { ProtocolError } from "../shared/protocol.js";
27
+ import { isWindows, probeTransport } from "../shared/transport.js";
27
28
  export class Daemon {
28
29
  machine;
29
30
  server = null;
30
31
  watchers = new Set();
32
+ /** All open client connections — destroyed on stop() so a graceful
33
+ * shutdown cuts waiting clients loose (they reconnect + detect the
34
+ * restart via the event cursor) and the process can exit promptly. */
35
+ conns = new Set();
31
36
  waiters = new Map();
32
37
  sentEventId;
33
38
  stateDirPath;
@@ -70,10 +75,20 @@ export class Daemon {
70
75
  this.sweepTimer.unref?.();
71
76
  this.sweep(); // catch anything that expired while we were down
72
77
  }
73
- /** Bind the socket, replacing it only if it is verifiably stale. */
78
+ /**
79
+ * Bind the endpoint, replacing it only if it is verifiably stale.
80
+ * Windows: named pipes are kernel objects — no stale file can exist, so
81
+ * just refuse if another daemon owns the pipe.
82
+ */
74
83
  async claimSocket() {
84
+ if (isWindows()) {
85
+ if (await probeTransport(this.socketPath)) {
86
+ throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
87
+ }
88
+ return;
89
+ }
75
90
  if (existsSync(this.socketPath)) {
76
- const live = await probeUnixSocket(this.socketPath);
91
+ const live = await probeTransport(this.socketPath);
77
92
  if (live) {
78
93
  throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
79
94
  }
@@ -96,13 +111,23 @@ export class Daemon {
96
111
  for (const w of this.watchers)
97
112
  w.socket.destroy();
98
113
  this.watchers.clear();
114
+ // Cut every client loose too: a graceful stop must not leave a zombie
115
+ // serving already-connected clients while the socket path moves on.
116
+ // Clients see the disconnect, reconnect, and detect the restart.
117
+ for (const s of this.conns)
118
+ s.destroy();
119
+ this.conns.clear();
99
120
  this.server?.close();
100
- try {
101
- if (existsSync(this.socketPath))
102
- unlinkSync(this.socketPath);
103
- }
104
- catch {
105
- /* ignore */
121
+ if (!isWindows()) {
122
+ // Windows: the named pipe is a kernel object and vanishes on close;
123
+ // touching the pipe path from the filesystem side can block.
124
+ try {
125
+ if (existsSync(this.socketPath))
126
+ unlinkSync(this.socketPath);
127
+ }
128
+ catch {
129
+ /* ignore */
130
+ }
106
131
  }
107
132
  try {
108
133
  rmSync(join(this.stateDirPath, "daemon.json"), { force: true });
@@ -113,6 +138,7 @@ export class Daemon {
113
138
  }
114
139
  // ----------------------------------------------------------------- framing
115
140
  onConnection(sock) {
141
+ this.conns.add(sock);
116
142
  let buf = "";
117
143
  sock.on("data", (chunk) => {
118
144
  buf += chunk.toString("utf8");
@@ -126,6 +152,7 @@ export class Daemon {
126
152
  });
127
153
  sock.on("error", () => sock.destroy());
128
154
  sock.on("close", () => {
155
+ this.conns.delete(sock);
129
156
  for (const w of [...this.watchers])
130
157
  if (w.socket === sock)
131
158
  this.watchers.delete(w);
@@ -428,18 +455,31 @@ export class Daemon {
428
455
  }
429
456
  }
430
457
  // --------------------------------------------------------------------- helpers
431
- async function probeUnixSocket(path) {
432
- return new Promise((resolve) => {
433
- const sock = connect(path);
434
- sock.once("connect", () => {
435
- sock.end();
436
- resolve(true);
437
- });
438
- sock.once("error", () => resolve(false));
439
- });
458
+ /**
459
+ * Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
460
+ * EPERM counts as alive: the process exists but is not signalable by us.
461
+ */
462
+ export function aliveBySignalZero(pid) {
463
+ try {
464
+ process.kill(pid, 0);
465
+ return true;
466
+ }
467
+ catch (err) {
468
+ return err?.code === "EPERM";
469
+ }
440
470
  }
441
- /** Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse. */
442
- export function isProcessAlive(pid, expectedStartTime) {
471
+ /**
472
+ * Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
473
+ * Windows: no /proc and no start time — signal-0 fallback only; the
474
+ * pid-reuse defense is unavailable there (documented limitation).
475
+ * macOS: /proc is absent, so the signal-0 fallback applies too.
476
+ */
477
+ export function isProcessAlive(pid, expectedStartTime, platform = process.platform) {
478
+ if (isWindows(platform)) {
479
+ // Start time is unobtainable without native APIs; pid reuse is possible
480
+ // during this window (documented in docs/OPERATIONS.md).
481
+ return aliveBySignalZero(pid);
482
+ }
443
483
  try {
444
484
  const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
445
485
  if (expectedStartTime !== undefined) {
@@ -455,13 +495,7 @@ export function isProcessAlive(pid, expectedStartTime) {
455
495
  // /proc unavailable (macOS): fall back to signal 0. Pid reuse is possible
456
496
  // here; documented limitation — that is why adapters should prefer
457
497
  // run_with_permit / process-group tracking where available.
458
- try {
459
- process.kill(pid, 0);
460
- return true;
461
- }
462
- catch {
463
- return false;
464
- }
498
+ return aliveBySignalZero(pid);
465
499
  }
466
500
  }
467
501
  /**
@@ -470,14 +504,17 @@ export function isProcessAlive(pid, expectedStartTime) {
470
504
  * subprocesses and background jobs that outlive their parent. Returns
471
505
  * true/false on Linux; on platforms without /proc the pgid scan is
472
506
  * unavailable and the result may be null (unknown => conservative keep).
507
+ * Windows: no process groups — only the main pid is checked, so a permit
508
+ * is released once the main pid dies even if children survive (documented
509
+ * limitation; adapters should kill the tree via taskkill /T).
473
510
  */
474
- export function jobAlive(pid, pgid, pidStartTime) {
511
+ export function jobAlive(pid, pgid, pidStartTime, platform = process.platform) {
475
512
  if (pid !== undefined) {
476
- const alive = isProcessAlive(pid, pidStartTime);
513
+ const alive = isProcessAlive(pid, pidStartTime, platform);
477
514
  if (alive === true)
478
515
  return true;
479
516
  }
480
- if (pgid !== undefined) {
517
+ if (pgid !== undefined && !isWindows(platform)) {
481
518
  const scan = scanProcessGroup(pgid);
482
519
  if (scan !== null)
483
520
  return scan;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Atomic JSON persistence for daemon config + change history.
3
- * State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock).
3
+ * State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
4
+ * Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
4
5
  *
5
6
  * Only *configuration* persists across daemon restarts. Permits and queue
6
7
  * are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
@@ -9,20 +10,12 @@
9
10
  import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
10
11
  import { dirname, join } from "node:path";
11
12
  import { defaultConfig } from "./state.js";
13
+ import { resolveStateDir, resolveSocketPath } from "../shared/transport.js";
12
14
  export function stateDir(override) {
13
- if (override)
14
- return override;
15
- if (process.env.LOAD_LOCK_STATE_DIR)
16
- return process.env.LOAD_LOCK_STATE_DIR;
17
- const xdg = process.env.XDG_STATE_HOME || join(process.env.HOME ?? "~", ".local", "state");
18
- return join(xdg, "load-lock");
15
+ return resolveStateDir(process.platform, process.env, override);
19
16
  }
20
17
  export function socketPath(override) {
21
- if (override)
22
- return override;
23
- if (process.env.LOAD_LOCK_SOCKET)
24
- return process.env.LOAD_LOCK_SOCKET;
25
- return join(stateDir(), "daemon.sock");
18
+ return resolveSocketPath(process.platform, process.env, override);
26
19
  }
27
20
  export function loadState(dir) {
28
21
  const path = join(dir, "config.json");