@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
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 = {};
@@ -81,6 +84,13 @@ async function main() {
81
84
  });
82
85
  return;
83
86
  }
87
+ // ------------------------------------------------------------- dashboard
88
+ case "top": {
89
+ // Full-screen live dashboard (read-only, never acquires a permit).
90
+ // Falls back to a single rendered frame when stdout is not a TTY.
91
+ await topCommand({ once: B("once") });
92
+ return;
93
+ }
84
94
  case "inspect":
85
95
  case "machine":
86
96
  out(await withClient((c) => c.request("inspect", {})));
@@ -108,6 +118,8 @@ async function main() {
108
118
  changes.offerTimeoutMs = N("offer-timeout-ms", 0);
109
119
  if (S("max-wait-ms"))
110
120
  changes.maxWaitMs = N("max-wait-ms", 0);
121
+ if (S("max-turn-wait-ms"))
122
+ changes.maxTurnWaitMs = N("max-turn-wait-ms", 0);
111
123
  if (S("start-timeout-ms"))
112
124
  changes.startTimeoutMs = N("start-timeout-ms", 0);
113
125
  if (S("untracked-lease-ms"))
@@ -115,7 +127,7 @@ async function main() {
115
127
  if (S("heavy-work-heuristic"))
116
128
  changes.guidance = { heavyWorkHeuristic: S("heavy-work-heuristic") };
117
129
  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)");
130
+ 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
131
  out(await c.request("configure", { expectedVersion: expected, changes, reason, author }));
120
132
  });
121
133
  return;
@@ -196,55 +208,24 @@ async function main() {
196
208
  case "wait": {
197
209
  // Watches a queued request; auto-claims the offer when capacity is
198
210
  // handed to it and prints the permit token. Honest bounded blocking
199
- // for scriptable flows (aider, plain shell scripts).
211
+ // for scriptable flows (aider, plain shell scripts). Shares the exact
212
+ // implementation with the MCP wait_for_turn tool and the OpenCode
213
+ // plugin (src/shared/wait-turn.ts).
200
214
  const id = S("id") || fail("wait: --id <request-id> required");
201
215
  const timeoutMs = N("timeout", 600_000);
202
216
  const agent = S("agent", defaultAgent());
203
217
  const c = new LLClient();
204
218
  await c.connect();
205
219
  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;
220
+ const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
221
+ if (r.state === "granted" && r.entry?.permitToken) {
222
+ out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
223
+ return;
224
+ }
225
+ out({ state: r.state, request_id: r.requestId, detail: r.detail ?? r.nextAction });
226
+ if (r.state === "still_queued")
227
+ fail(`wait: still queued (position ${r.queuePosition ?? "?"}) after ${r.waitedMs}ms`, 68);
228
+ fail(`wait: request is ${r.state.replace("_", " ")} — ${r.nextAction}`, 69);
248
229
  }
249
230
  finally {
250
231
  c.close();
@@ -264,7 +245,7 @@ async function main() {
264
245
  const sub = rest[0] ?? "status";
265
246
  if (sub === "stop") {
266
247
  // 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");
248
+ const dir = stateDir();
268
249
  try {
269
250
  const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
270
251
  process.kill(info.pid, "SIGTERM");
@@ -291,9 +272,13 @@ async function main() {
291
272
  const report = {};
292
273
  report.node = process.version;
293
274
  report.platform = process.platform;
294
- const dir = process.env.LOAD_LOCK_STATE_DIR || join(homedir(), ".local", "state", "load-lock");
275
+ const dir = stateDir();
295
276
  report.stateDir = dir;
296
- report.socketExists = existsSync(join(dir, "daemon.sock"));
277
+ const sock = socketPath();
278
+ report.socket = sock;
279
+ // Named pipes are kernel objects, not filesystem entries — probe by
280
+ // connecting on Windows; on POSIX a file check is enough.
281
+ report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
297
282
  report.configExists = existsSync(join(dir, "config.json"));
298
283
  report.journalExists = existsSync(join(dir, "journal.json"));
299
284
  try {
@@ -356,7 +341,7 @@ async function main() {
356
341
  return;
357
342
  }
358
343
  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`);
344
+ fail(`unknown command "${cmd}". Commands: status, top, inspect, config, pending, events, acquire, claim, release, cancel, rejoin, mark-started, attach-pid, wait, run, service, doctor, recover, install, uninstall, hook, agent-guide`);
360
345
  }
361
346
  }
362
347
  // ------------------------------------------------------------------ run helper
@@ -416,21 +401,11 @@ async function runWithPermit(jobArgv) {
416
401
  await client.request("start_job", { permitToken, pid: child.pid, pgid });
417
402
  }
418
403
  catch (err) {
419
- try {
420
- process.kill(-pgid, "SIGKILL");
421
- }
422
- catch {
423
- /* already gone */
424
- }
404
+ killProcessGroup(pgid, "SIGKILL");
425
405
  fail(`permit no longer startable (${err.message}); job not started`, 69);
426
406
  }
427
407
  const forward = (sig) => {
428
- try {
429
- process.kill(-pgid, sig);
430
- }
431
- catch {
432
- /* already gone */
433
- }
408
+ killProcessGroup(pgid, sig);
434
409
  };
435
410
  process.on("SIGTERM", () => forward("SIGTERM"));
436
411
  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,98 @@
1
+ /**
2
+ * `load-lock top` — portable, zero-dependency full-screen terminal dashboard
3
+ * for the machine-wide budget. Uses the library's own client + protocol
4
+ * (never scrapes CLI output); live updates come from the `watch` event
5
+ * stream, with reconnect + event-cursor resume across daemon restarts.
6
+ *
7
+ * Read-only. The viewer itself is light work — it never acquires a permit.
8
+ */
9
+ import { DashboardFeed } from "../dashboard/feed.js";
10
+ import { renderFrame } from "../dashboard/frame.js";
11
+ import { LLClient } from "../shared/client.js";
12
+ const HIDE_CURSOR = "\x1b[?25l";
13
+ const SHOW_CURSOR = "\x1b[?25h";
14
+ const ALT_SCREEN = "\x1b[?1049h";
15
+ const LEAVE_ALT = "\x1b[?1049l";
16
+ export async function topCommand(opts = {}) {
17
+ const once = opts.once || !process.stdout.isTTY;
18
+ const client = new LLClient();
19
+ const feed = new DashboardFeed(client);
20
+ try {
21
+ await feed.start();
22
+ if (once) {
23
+ const frame = renderFrame(feed.view(), process.stdout.columns || 100, 1000);
24
+ process.stdout.write(frame.join("\n") + "\n");
25
+ return;
26
+ }
27
+ await runLive(feed, opts);
28
+ }
29
+ finally {
30
+ await feed.stop();
31
+ }
32
+ }
33
+ async function runLive(feed, opts) {
34
+ const out = process.stdout;
35
+ const tickMs = Math.max(100, opts.tickMs ?? 500);
36
+ let stopped = false;
37
+ const paint = () => {
38
+ const width = out.columns || 100;
39
+ const height = out.rows || 40;
40
+ const frame = renderFrame(feed.view(), width, Math.max(1, height - 1));
41
+ // Pad to full height so old content never lingers after shrinking.
42
+ while (frame.length < height - 1)
43
+ frame.push("");
44
+ const body = frame.map((l) => l + "\x1b[K").join("\n");
45
+ out.write(`\x1b[H\x1b[2J${body}\n`);
46
+ };
47
+ // The feed signals change asynchronously; the tick loop below repaints on
48
+ // every tick anyway (cheap string render) so new events and countdowns
49
+ // both appear promptly without a paint storm per event burst.
50
+ out.write(ALT_SCREEN + HIDE_CURSOR);
51
+ out.write("\x1b[H\x1b[2J");
52
+ process.stdin.setRawMode?.(true);
53
+ process.stdin.resume();
54
+ const restore = () => {
55
+ process.stdin.setRawMode?.(false);
56
+ out.write(SHOW_CURSOR + LEAVE_ALT);
57
+ };
58
+ let exiting = false;
59
+ const quit = () => {
60
+ if (exiting)
61
+ return;
62
+ exiting = true;
63
+ restore();
64
+ stopped = true;
65
+ process.stdin.pause();
66
+ };
67
+ const onKey = (data) => {
68
+ const k = data.toString("utf8");
69
+ if (k === "q" || k === "Q" || k === "\x1b" || k === "\x03")
70
+ quit();
71
+ else if (k === "r" || k === "R")
72
+ void feed.resync().catch(() => { });
73
+ };
74
+ process.stdin.on("data", onKey);
75
+ const onResize = () => paint();
76
+ out.on("resize", onResize);
77
+ const onSignal = () => {
78
+ quit();
79
+ };
80
+ process.on("SIGINT", onSignal);
81
+ process.on("SIGTERM", onSignal);
82
+ paint();
83
+ try {
84
+ while (!stopped) {
85
+ await new Promise((r) => setTimeout(r, tickMs));
86
+ if (stopped)
87
+ continue;
88
+ paint(); // cheap local re-render keeps countdowns ticking and shows new events
89
+ }
90
+ }
91
+ finally {
92
+ restore();
93
+ process.stdin.removeListener("data", onKey);
94
+ out.removeListener("resize", onResize);
95
+ process.removeListener("SIGINT", onSignal);
96
+ process.removeListener("SIGTERM", onSignal);
97
+ }
98
+ }
@@ -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");
@@ -42,6 +42,7 @@ export function defaultConfig() {
42
42
  pools: { heavy: { capacity: 1 } },
43
43
  offerTimeoutMs: 30_000,
44
44
  maxWaitMs: 5_000,
45
+ maxTurnWaitMs: 600_000,
45
46
  startTimeoutMs: 120_000,
46
47
  untrackedLeaseMs: 15 * 60_000,
47
48
  guidance: {},
@@ -635,6 +636,7 @@ export class Machine {
635
636
  pools: this.config.pools,
636
637
  offerTimeoutMs: this.config.offerTimeoutMs,
637
638
  maxWaitMs: this.config.maxWaitMs,
639
+ maxTurnWaitMs: this.config.maxTurnWaitMs,
638
640
  startTimeoutMs: this.config.startTimeoutMs,
639
641
  untrackedLeaseMs: this.config.untrackedLeaseMs,
640
642
  },
@@ -755,6 +757,9 @@ export class Machine {
755
757
  if (c.maxWaitMs !== undefined && (c.maxWaitMs < 0 || c.maxWaitMs > 600_000)) {
756
758
  throw new ProtocolError("invalid", "maxWaitMs must be within [0, 600000]");
757
759
  }
760
+ if (c.maxTurnWaitMs !== undefined && (c.maxTurnWaitMs < 1000 || c.maxTurnWaitMs > 3_600_000)) {
761
+ throw new ProtocolError("invalid", "maxTurnWaitMs must be within [1000, 3600000]");
762
+ }
758
763
  if (c.startTimeoutMs !== undefined && (c.startTimeoutMs < 1000 || c.startTimeoutMs > 3_600_000)) {
759
764
  throw new ProtocolError("invalid", "startTimeoutMs must be within [1000, 3600000]");
760
765
  }
@@ -770,6 +775,8 @@ export class Machine {
770
775
  this.config.offerTimeoutMs = c.offerTimeoutMs;
771
776
  if (c.maxWaitMs !== undefined)
772
777
  this.config.maxWaitMs = c.maxWaitMs;
778
+ if (c.maxTurnWaitMs !== undefined)
779
+ this.config.maxTurnWaitMs = c.maxTurnWaitMs;
773
780
  if (c.startTimeoutMs !== undefined)
774
781
  this.config.startTimeoutMs = c.startTimeoutMs;
775
782
  if (c.untrackedLeaseMs !== undefined)
@@ -0,0 +1,74 @@
1
+ import { type DashboardView, type FeedState } from "./view.js";
2
+ /** Structural subset of LLClient the feed needs (LLClient satisfies it). */
3
+ export interface FeedClient {
4
+ connect(): Promise<void>;
5
+ request<T = any>(method: string, params?: Record<string, unknown>): Promise<T>;
6
+ watch(agentId: string, onEvent: (ev: Record<string, unknown>) => void): Promise<void>;
7
+ onDisconnect(cb: () => void): unknown;
8
+ close(): void;
9
+ }
10
+ export interface FeedOptions {
11
+ /** Rolling event history length (default 60). */
12
+ eventHistory?: number;
13
+ /** Slow safety refresh while live (default 15_000 ms; 0 disables). */
14
+ slowRefreshMs?: number;
15
+ /** Debounce window for post-event status pulls (default 40 ms). */
16
+ refreshDebounceMs?: number;
17
+ /** Inspect refresh cadence (default 60_000 ms). */
18
+ inspectEveryMs?: number;
19
+ /** Reconnect backoff base (default 250 ms). */
20
+ reconnectBaseMs?: number;
21
+ }
22
+ export declare class DashboardFeed {
23
+ private client;
24
+ private opts;
25
+ private events;
26
+ private latestEventId;
27
+ private status;
28
+ private inspect;
29
+ private inspectAt;
30
+ private lastSyncAt;
31
+ private state;
32
+ private restartNote;
33
+ private daemonStartedAt;
34
+ private refreshTimer;
35
+ private slowTimer;
36
+ private stopped;
37
+ private unsub;
38
+ private connecting;
39
+ private viewSeq;
40
+ /** Fired whenever the rendered view might have changed (state or data). */
41
+ onChange?: () => void;
42
+ /** Fired on connection-state transitions. */
43
+ onState?: (s: FeedState) => void;
44
+ constructor(client: FeedClient, opts?: FeedOptions);
45
+ currentState(): FeedState;
46
+ private setState;
47
+ private emitChange;
48
+ /** Connect, subscribe, and pull an initial full snapshot. */
49
+ start(): Promise<void>;
50
+ stop(): Promise<void>;
51
+ private connectAndSync;
52
+ /**
53
+ * Full snapshot: status + events since cursor (+ inspect if stale).
54
+ * Detects daemon restarts two ways — event ids going backwards, or the
55
+ * daemon's own startedAt changing — then resets the cursor and re-pulls
56
+ * history (ids are durable per daemon lifetime, not across restarts).
57
+ */
58
+ resync(): Promise<void>;
59
+ /** Status-only pull (used after event bursts; keeps inspect cached). */
60
+ refresh(): Promise<void>;
61
+ private refreshInspect;
62
+ private handleEvent;
63
+ private mergeEvents;
64
+ private handleDisconnect;
65
+ private reconnectLoop;
66
+ /**
67
+ * Admin force-release. Callers MUST gate this behind their own explicit
68
+ * confirm step — the feed itself never initiates destructive actions.
69
+ * Returns the reclaimed request's one-line description for toasts.
70
+ */
71
+ forceRelease(requestId: string): Promise<void>;
72
+ /** Render-ready snapshot; countdowns are computed at `now`. */
73
+ view(now?: number): DashboardView;
74
+ }