@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
@@ -0,0 +1,214 @@
1
+ // ---------------------------------------------------------------- formatters
2
+ /** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
3
+ export function fmtDuration(ms) {
4
+ if (!Number.isFinite(ms))
5
+ return "–";
6
+ const neg = ms < 0;
7
+ let s = Math.floor(Math.abs(ms) / 1000);
8
+ let out;
9
+ if (s < 1)
10
+ out = `${Math.max(0, Math.round(Math.abs(ms)))}ms`;
11
+ else if (s < 60)
12
+ out = `${s}s`;
13
+ else if (s < 3600)
14
+ out = `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
15
+ else if (s < 86400)
16
+ out = `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
17
+ else
18
+ out = `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
19
+ return neg ? `-${out}` : out;
20
+ }
21
+ /** Byte counts: "12.4Gi", "512Mi", "3.4Ki". */
22
+ export function fmtBytes(bytes) {
23
+ if (!Number.isFinite(bytes) || bytes < 0)
24
+ return "–";
25
+ const units = ["B", "Ki", "Mi", "Gi", "Ti"];
26
+ let v = bytes;
27
+ let u = 0;
28
+ while (v >= 1024 && u < units.length - 1) {
29
+ v /= 1024;
30
+ u++;
31
+ }
32
+ return `${v >= 100 || u === 0 ? Math.round(v) : v.toFixed(1)}${units[u]}`;
33
+ }
34
+ /** Local clock time HH:MM:SS. */
35
+ export function fmtClock(ts) {
36
+ const d = new Date(ts);
37
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
38
+ }
39
+ /** One-line label for a permit phase. */
40
+ export function phaseLabel(p) {
41
+ switch (p) {
42
+ case "claimed":
43
+ return "claimed (must start)";
44
+ case "running_tracked":
45
+ return "running (tracked)";
46
+ case "running_untracked":
47
+ return "running (untracked)";
48
+ }
49
+ }
50
+ /** Truncate with ellipsis, keeping the head (ids, reasons). */
51
+ export function trunc(s, max) {
52
+ if (s.length <= max)
53
+ return s;
54
+ return s.slice(0, Math.max(1, max - 1)) + "…";
55
+ }
56
+ export function agentName(e) {
57
+ return e.agentLabel || e.agentId;
58
+ }
59
+ // -------------------------------------------------------------- event text
60
+ /** One-line human sentence for a daemon event (durable ids are kept). */
61
+ export function eventText(ev) {
62
+ const who = ev.entry ? agentName(ev.entry) : ev.targetAgent && ev.targetAgent !== "*" ? ev.targetAgent : undefined;
63
+ const reason = ev.entry?.reason ? ` (${trunc(ev.entry.reason, 48)})` : "";
64
+ const w = who ? ` ${who}` : "";
65
+ const detail = ev.detail ? ` — ${ev.detail}` : "";
66
+ switch (ev.type) {
67
+ case "daemon_started":
68
+ return `daemon started${detail}`;
69
+ case "daemon_recovered":
70
+ return `daemon recovered after restart${detail}`;
71
+ case "granted":
72
+ return `granted ${ev.pool ?? ""}${w}${reason}`;
73
+ case "queued":
74
+ return `queued${w}${reason}`;
75
+ case "offered":
76
+ return `offered ${ev.pool ?? ""}${w} — awaiting claim${reason}`;
77
+ case "claimed":
78
+ return `claimed${w} — permit held${reason}`;
79
+ case "started":
80
+ return `started${w}: ${ev.detail ?? "execution began"}`;
81
+ case "released":
82
+ return `released ${ev.pool ?? ""}${w}${reason}${detail}`;
83
+ case "auto_released":
84
+ return `auto-released ${ev.pool ?? ""}${w}${detail}${reason}`;
85
+ case "reclaimed":
86
+ return `reclaimed ${ev.pool ?? ""}${w} — ${ev.detail ?? "permit revoked"}${reason}`;
87
+ case "offer_expired":
88
+ return `offer expired ${ev.pool ?? ""}${w} → paused (rejoin to requeue)`;
89
+ case "declined":
90
+ return `declined offer${w}${reason}`;
91
+ case "paused":
92
+ return `paused${w}${reason}`;
93
+ case "rejoined":
94
+ return `rejoined queue${w}${reason}`;
95
+ case "cancelled":
96
+ return `cancelled${w}${reason}`;
97
+ case "config_changed":
98
+ return `config changed: ${ev.detail ?? ""}`.trim();
99
+ default:
100
+ return `${String(ev.type)}${w}${detail}`;
101
+ }
102
+ }
103
+ function holderCard(e, now) {
104
+ const phase = e.phase ?? "claimed";
105
+ return {
106
+ requestId: e.requestId,
107
+ pool: e.pool,
108
+ agent: agentName(e),
109
+ agentId: e.agentId,
110
+ reason: e.reason,
111
+ weight: e.weight,
112
+ phase,
113
+ phaseLabel: phaseLabel(phase),
114
+ pid: e.pid,
115
+ pgid: e.pgid,
116
+ heldMs: now - e.since,
117
+ startDeadlineInMs: e.startDeadline !== undefined ? e.startDeadline - now : undefined,
118
+ leaseDeadlineInMs: e.leaseDeadline !== undefined ? e.leaseDeadline - now : undefined,
119
+ };
120
+ }
121
+ export function buildDashboardView(inps) {
122
+ const now = inps.now;
123
+ const pools = [];
124
+ for (const p of inps.status?.pools ?? []) {
125
+ const heldWeight = p.holders.reduce((a, h) => a + h.weight, 0);
126
+ const offeredWeight = p.offers.reduce((a, o) => a + o.weight, 0);
127
+ pools.push({
128
+ pool: p.pool,
129
+ capacity: p.capacity,
130
+ used: p.used,
131
+ heldWeight,
132
+ offeredWeight,
133
+ free: Math.max(0, p.capacity - p.used),
134
+ utilization: p.capacity > 0 ? Math.min(1, p.used / p.capacity) : 1,
135
+ holders: p.holders.map((h) => holderCard(h, now)),
136
+ offers: p.offers.map((o) => ({
137
+ requestId: o.requestId,
138
+ pool: o.pool,
139
+ agent: agentName(o),
140
+ agentId: o.agentId,
141
+ reason: o.reason,
142
+ weight: o.weight,
143
+ claimInMs: (o.offerExpiresAt ?? 0) - now,
144
+ expiresAt: o.offerExpiresAt ?? 0,
145
+ })),
146
+ queue: p.queue.map((q, i) => ({
147
+ requestId: q.requestId,
148
+ pool: q.pool,
149
+ agent: agentName(q),
150
+ agentId: q.agentId,
151
+ reason: q.reason,
152
+ weight: q.weight,
153
+ position: q.position ?? i + 1,
154
+ waitMs: now - q.since,
155
+ })),
156
+ paused: p.paused.map((q) => ({
157
+ requestId: q.requestId,
158
+ pool: q.pool,
159
+ agent: agentName(q),
160
+ agentId: q.agentId,
161
+ reason: q.reason,
162
+ weight: q.weight,
163
+ pausedMs: now - q.since,
164
+ })),
165
+ });
166
+ }
167
+ const d = inps.status?.daemon;
168
+ const machine = inps.inspect
169
+ ? {
170
+ cpuModel: inps.inspect.cpu.model,
171
+ logicalCores: inps.inspect.cpu.logicalCores,
172
+ effectiveCores: inps.inspect.cpu.effectiveCores,
173
+ cgroupVersion: inps.inspect.cpu.cgroupVersion,
174
+ cgroupCpuQuota: inps.inspect.cpu.cgroupCpuQuota,
175
+ loadavg: inps.inspect.cpu.loadavg,
176
+ loadPerEffectiveCore: inps.inspect.cpu.loadPerEffectiveCore,
177
+ memTotalBytes: inps.inspect.memory.totalBytes,
178
+ memAvailableBytes: inps.inspect.memory.availableBytes,
179
+ memEffectiveLimitBytes: inps.inspect.memory.effectiveLimitBytes,
180
+ loadPressure: inps.inspect.pressure.loadPressure,
181
+ memPressure: inps.inspect.pressure.memPressure,
182
+ note: inps.inspect.pressure.note,
183
+ suggestion: inps.inspect.loadLock.suggestion,
184
+ }
185
+ : null;
186
+ // Newest first; keep the tail (feed already bounds history length).
187
+ const evs = [...inps.events].reverse();
188
+ return {
189
+ now,
190
+ state: inps.state,
191
+ daemonRestartNote: inps.daemonRestartNote,
192
+ daemon: d
193
+ ? {
194
+ pid: d.pid,
195
+ startedAt: d.startedAt,
196
+ uptimeMs: now - d.startedAt,
197
+ protocolVersion: d.protocolVersion,
198
+ configVersion: inps.status?.config.version ?? 0,
199
+ }
200
+ : null,
201
+ pools,
202
+ events: evs.map((e) => ({
203
+ id: e.id,
204
+ ts: e.ts,
205
+ agoMs: now - e.ts,
206
+ type: e.type,
207
+ pool: e.pool,
208
+ text: eventText(e),
209
+ })),
210
+ latestEventId: inps.latestEventId,
211
+ machine,
212
+ lastSyncAt: inps.lastSyncAt,
213
+ };
214
+ }
@@ -10,7 +10,8 @@
10
10
  * into the model's context. load-lock therefore relies on:
11
11
  * (a) tool-boundary delivery — every acquire/claim/release/... response
12
12
  * carries the agent's pending offers and queue positions;
13
- * (b) bounded blocking wait in acquire (waitMs);
13
+ * (b) bounded blocking wait in acquire (waitMs) and wait_for_turn
14
+ * (the "sleep until it is my turn" tool);
14
15
  * (c) harness-specific idle wakeup (hooks/plugins) where verified.
15
16
  */
16
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -10,7 +10,8 @@
10
10
  * into the model's context. load-lock therefore relies on:
11
11
  * (a) tool-boundary delivery — every acquire/claim/release/... response
12
12
  * carries the agent's pending offers and queue positions;
13
- * (b) bounded blocking wait in acquire (waitMs);
13
+ * (b) bounded blocking wait in acquire (waitMs) and wait_for_turn
14
+ * (the "sleep until it is my turn" tool);
14
15
  * (c) harness-specific idle wakeup (hooks/plugins) where verified.
15
16
  */
16
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -19,6 +20,8 @@ import { z } from "zod";
19
20
  import { spawn } from "node:child_process";
20
21
  import { LLClient } from "../shared/client.js";
21
22
  import { POLICY_BRIEF } from "../policy/text.js";
23
+ import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
24
+ import { killProcessGroup } from "../shared/transport.js";
22
25
  const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
23
26
 
24
27
  Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
@@ -134,6 +137,51 @@ export function buildServer(opts) {
134
137
  entry: res.entry,
135
138
  });
136
139
  });
140
+ server.registerTool("wait_for_turn", {
141
+ title: "Sleep until it is your turn",
142
+ description: `Bounded blocking sleep for one of your queued/offered/paused requests: "sleep until it is my turn". Returns a STRUCTURED result on every path — never an error for a normal resolution:
143
+ - granted: capacity reached you and the offer was AUTO-CLAIMED on your behalf (permit_token in entry, auto_claimed: true) — start the heavy work now;
144
+ - still_queued: timed out; you are still queued — queue_position, queue_length and holders (who is ahead of you) included; call wait_for_turn again to keep sleeping;
145
+ - paused: the offer expired or was declined meanwhile; next_action has rejoin instructions;
146
+ - cancelled: the request was cancelled/declined/reclaimed meanwhile;
147
+ - not_found: the request ended or the daemon restarted (queues are ephemeral); acquire again.
148
+ Input: request_id (default: your oldest offered, then front-most queued, then oldest paused request), timeout_ms (default 600000, clamped to the machine's maxTurnWaitMs config — get_config shows the cap). timeout_ms 0 = check-only. The wait holds NO permit and NO capacity (it is light work); cancelling this call is safe and never strands anything — you simply stop sleeping and the request keeps its queue position. A daemon restart mid-wait resolves promptly with not_found + re-acquire instructions.`,
149
+ inputSchema: {
150
+ request_id: z.string().optional().describe("Request to watch (default: your oldest offered/front-most queued/oldest paused request)"),
151
+ timeout_ms: z.number().int().optional().describe("Bounded sleep (ms); default 600000, clamped to the machine's maxTurnWaitMs cap. 0 = check-only."),
152
+ agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
153
+ },
154
+ }, async (input, extra) => {
155
+ try {
156
+ const r = await waitForTurn({
157
+ client,
158
+ agentId: agentIdOf(input),
159
+ requestId: input.request_id,
160
+ timeoutMs: input.timeout_ms,
161
+ signal: extra?.signal,
162
+ });
163
+ return json({
164
+ state: r.state,
165
+ request_id: r.requestId,
166
+ auto_claimed: r.autoClaimed,
167
+ permit_token: r.state === "granted" ? r.entry?.permitToken : undefined,
168
+ waited_ms: r.waitedMs,
169
+ effective_wait_ms: r.effectiveWaitMs,
170
+ entry: r.entry,
171
+ queue_position: r.queuePosition,
172
+ queue_length: r.queueLength,
173
+ holders: r.holders,
174
+ next_action: r.nextAction,
175
+ detail: r.detail,
176
+ });
177
+ }
178
+ catch (err) {
179
+ if (err instanceof WaitAbortedError) {
180
+ return json({ state: "aborted", detail: "wait cancelled by the harness/caller; the request keeps its queue position" });
181
+ }
182
+ throw err;
183
+ }
184
+ });
137
185
  server.registerTool("pending", {
138
186
  title: "Your offers/queue/permits",
139
187
  description: "List your outstanding offers (claim these NOW — they expire), queue positions, paused and held requests.",
@@ -164,6 +212,7 @@ export function buildServer(opts) {
164
212
  pools: z.record(z.string(), z.object({ capacity: z.number().int() })).optional(),
165
213
  offer_timeout_ms: z.number().int().optional(),
166
214
  max_wait_ms: z.number().int().optional(),
215
+ max_turn_wait_ms: z.number().int().optional(),
167
216
  start_timeout_ms: z.number().int().optional(),
168
217
  untracked_lease_ms: z.number().int().optional(),
169
218
  guidance: z
@@ -185,6 +234,7 @@ export function buildServer(opts) {
185
234
  ...input.changes,
186
235
  offerTimeoutMs: input.changes.offer_timeout_ms,
187
236
  maxWaitMs: input.changes.max_wait_ms,
237
+ maxTurnWaitMs: input.changes.max_turn_wait_ms,
188
238
  startTimeoutMs: input.changes.start_timeout_ms,
189
239
  untrackedLeaseMs: input.changes.untracked_lease_ms,
190
240
  guidance: input.changes.guidance
@@ -265,12 +315,7 @@ export function buildServer(opts) {
265
315
  });
266
316
  }
267
317
  catch (err) {
268
- try {
269
- process.kill(-child.pid, "SIGKILL");
270
- }
271
- catch {
272
- /* already gone */
273
- }
318
+ killProcessGroup(child.pid, "SIGKILL");
274
319
  return json({
275
320
  error: `permit no longer startable (${err.message}); job killed before doing work; acquire again`,
276
321
  });
@@ -309,20 +354,10 @@ export function buildServer(opts) {
309
354
  if (!job)
310
355
  return json({ error: "unknown job_id" });
311
356
  if (!job.exited) {
312
- try {
313
- process.kill(-job.child.pid, "SIGTERM");
314
- }
315
- catch {
316
- /* already gone */
317
- }
357
+ killProcessGroup(job.child.pid, "SIGTERM");
318
358
  setTimeout(() => {
319
359
  if (!job.exited) {
320
- try {
321
- process.kill(-job.child.pid, "SIGKILL");
322
- }
323
- catch {
324
- /* already gone */
325
- }
360
+ killProcessGroup(job.child.pid, "SIGKILL");
326
361
  }
327
362
  }, 5_000).unref?.();
328
363
  }
@@ -11,7 +11,7 @@
11
11
  * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
12
  * in the skill / docs/AGENT_GUIDE.md.
13
13
  */
14
- export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
14
+ export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
15
15
  export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
16
16
  export declare function statusLine(st: {
17
17
  pools: {
@@ -28,6 +28,9 @@ Rules:
28
28
  3. If you are queued: keep working on light tasks. When you receive an offer
29
29
  (in any load-lock tool result or a wakeup message), claim it promptly —
30
30
  offers expire (default 30s) and capacity passes to the next waiter.
31
+ Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or
32
+ 'load-lock wait' (CLI) — a bounded sleep until your turn that
33
+ auto-claims and holds no capacity.
31
34
  4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work
32
35
  on a "queued" result; never treat a notification as authorization.
33
36
  5. Hold the permit until the heavy process actually finishes — including when
@@ -8,6 +8,8 @@ export declare class LLClient {
8
8
  private nextId;
9
9
  private pending;
10
10
  private eventCb;
11
+ /** Optional disconnect notifications (dashboard feeds and other watchers). */
12
+ private disconnectCbs;
11
13
  private buf;
12
14
  private opts;
13
15
  constructor(opts?: ClientOptions);
@@ -16,12 +18,20 @@ export declare class LLClient {
16
18
  connect(): Promise<void>;
17
19
  private tryConnect;
18
20
  private attach;
21
+ private fireDisconnect;
19
22
  private failAll;
20
23
  private ensureSock;
21
24
  /** Issue one RPC. Reconnects once if the connection was lost. */
22
25
  request<T = any>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
23
26
  /** Subscribe to daemon events targeted at agentId (or "*" for all). */
24
27
  watch(agentId: string, onEvent: (ev: Record<string, unknown>) => void): Promise<void>;
28
+ /**
29
+ * Register a callback fired whenever the daemon connection drops (socket
30
+ * closed or errored) — including after later reconnects; the callback
31
+ * stays registered until its unregister function is called. Dashboard
32
+ * feeds use this to reconnect and resume via the durable event cursor.
33
+ */
34
+ onDisconnect(cb: () => void): () => void;
25
35
  close(): void;
26
36
  }
27
37
  /** Idempotently ensure a daemon is running and listening on `sockPath`. */
@@ -1,13 +1,17 @@
1
1
  /**
2
- * NDJSON-over-UDS client for the load-lock daemon, shared by the CLI, the
2
+ * NDJSON transport client for the load-lock daemon, shared by the CLI, the
3
3
  * MCP server, and harness hook scripts. The OpenCode plugin ships a
4
4
  * self-contained copy of this logic (see adapters/opencode).
5
5
  *
6
- * Auto-spawn: if the daemon socket is absent, exactly one client becomes the
7
- * spawner (serialized by a mkdir-based lock — atomic on POSIX) and starts a
8
- * detached daemon; losers wait for the socket to appear. The freshly spawned
9
- * daemon itself re-checks the socket before binding, so a double-spawn is
10
- * harmless (the loser exits).
6
+ * Transport: Unix-domain socket on POSIX, named pipe (`\\.\pipe\load-lock`)
7
+ * on Windows — Node's `net` handles both transparently, so only endpoint
8
+ * resolution differs (see shared/transport.ts).
9
+ *
10
+ * Auto-spawn: if the daemon endpoint is absent, exactly one client becomes
11
+ * the spawner (serialized by a mkdir-based lock — atomic on POSIX and on
12
+ * NTFS) and starts a detached daemon; losers wait for the endpoint to
13
+ * appear. The freshly spawned daemon itself re-checks the endpoint before
14
+ * binding, so a double-spawn is harmless (the loser exits).
11
15
  */
12
16
  import { connect } from "node:net";
13
17
  import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
@@ -17,11 +21,14 @@ import { spawn } from "node:child_process";
17
21
  import { setTimeout as delay } from "node:timers/promises";
18
22
  import { ProtocolError } from "./protocol.js";
19
23
  import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
24
+ import { probeTransport } from "./transport.js";
20
25
  export class LLClient {
21
26
  sock = null;
22
27
  nextId = 1;
23
28
  pending = new Map();
24
29
  eventCb = null;
30
+ /** Optional disconnect notifications (dashboard feeds and other watchers). */
31
+ disconnectCbs = [];
25
32
  buf = "";
26
33
  opts;
27
34
  constructor(opts = {}) {
@@ -94,12 +101,27 @@ export class LLClient {
94
101
  sock.on("error", () => {
95
102
  this.failAll(new ProtocolError("unavailable", "daemon connection lost"));
96
103
  this.sock = null;
104
+ this.fireDisconnect();
97
105
  });
98
106
  sock.on("close", () => {
99
107
  this.failAll(new ProtocolError("unavailable", "daemon connection closed"));
100
108
  this.sock = null;
109
+ this.fireDisconnect();
101
110
  });
102
111
  }
112
+ fireDisconnect() {
113
+ // Callbacks stay registered: a long-lived consumer (e.g. a dashboard
114
+ // feed) reconnects and must keep hearing about later disconnects.
115
+ // Copy first — a callback may (un)register during iteration.
116
+ for (const cb of [...this.disconnectCbs]) {
117
+ try {
118
+ cb();
119
+ }
120
+ catch {
121
+ /* consumer errors must not break reconnect bookkeeping */
122
+ }
123
+ }
124
+ }
103
125
  failAll(err) {
104
126
  for (const p of this.pending.values())
105
127
  p.reject(err);
@@ -139,6 +161,18 @@ export class LLClient {
139
161
  this.eventCb = onEvent;
140
162
  await this.request("watch", { agentId });
141
163
  }
164
+ /**
165
+ * Register a callback fired whenever the daemon connection drops (socket
166
+ * closed or errored) — including after later reconnects; the callback
167
+ * stays registered until its unregister function is called. Dashboard
168
+ * feeds use this to reconnect and resume via the durable event cursor.
169
+ */
170
+ onDisconnect(cb) {
171
+ this.disconnectCbs.push(cb);
172
+ return () => {
173
+ this.disconnectCbs = this.disconnectCbs.filter((c) => c !== cb);
174
+ };
175
+ }
142
176
  close() {
143
177
  this.sock?.destroy();
144
178
  this.sock = null;
@@ -205,14 +239,7 @@ export async function ensureDaemon(stateDir, sockPath) {
205
239
  }
206
240
  }
207
241
  function probe(sockPath) {
208
- return new Promise((resolve) => {
209
- const s = connect(sockPath);
210
- s.once("connect", () => {
211
- s.end();
212
- resolve(true);
213
- });
214
- s.once("error", () => resolve(false));
215
- });
242
+ return probeTransport(sockPath);
216
243
  }
217
244
  /** One-shot helper: connect (auto-spawning), run fn, close. */
218
245
  export async function withClient(fn, opts = {}) {
@@ -29,6 +29,12 @@ export interface MachineConfig {
29
29
  offerTimeoutMs: number;
30
30
  /** Upper bound for bounded blocking waits in acquire (advisory). */
31
31
  maxWaitMs: number;
32
+ /**
33
+ * Upper bound for ONE wait_for_turn call ("sleep until it is my turn",
34
+ * issue #11), in milliseconds. Callers' timeout_ms is clamped to this —
35
+ * operators tune how long an agent may block in a single tool call.
36
+ */
37
+ maxTurnWaitMs: number;
32
38
  /**
33
39
  * Start deadline: after a permit is claimed/granted, execution must begin
34
40
  * (run_with_permit / attach_pid / mark_started) within this window, or the
@@ -61,6 +67,7 @@ export interface ConfigChange {
61
67
  pools?: Record<string, PoolConfig>;
62
68
  offerTimeoutMs?: number;
63
69
  maxWaitMs?: number;
70
+ maxTurnWaitMs?: number;
64
71
  startTimeoutMs?: number;
65
72
  untrackedLeaseMs?: number;
66
73
  guidance?: {
@@ -162,6 +169,7 @@ export interface StatusResult {
162
169
  pools: Record<string, PoolConfig>;
163
170
  offerTimeoutMs: number;
164
171
  maxWaitMs: number;
172
+ maxTurnWaitMs: number;
165
173
  startTimeoutMs: number;
166
174
  untrackedLeaseMs: number;
167
175
  };
@@ -319,6 +327,27 @@ export interface EventsResult {
319
327
  events: MachineEvent[];
320
328
  latestEventId: number;
321
329
  }
330
+ export type WaitForTurnState = "granted" | "still_queued" | "paused" | "cancelled" | "not_found";
331
+ export interface WaitForTurnResult {
332
+ state: WaitForTurnState;
333
+ requestId?: string;
334
+ /** True when THIS wait claimed the offer on the caller's behalf (explicit auto-claim). */
335
+ autoClaimed?: boolean;
336
+ /** How long the wait actually ran before resolving (ms). */
337
+ waitedMs: number;
338
+ /** The clamped budget actually applied, when it differed from the requested one. */
339
+ effectiveWaitMs?: number;
340
+ /** Owner snapshot of the request at resolution time (carries permit_token when granted). */
341
+ entry?: OwnerEntrySnapshot;
342
+ /** 1-based FIFO position (still_queued). */
343
+ queuePosition?: number;
344
+ /** Queue depth of the request's pool (still_queued). */
345
+ queueLength?: number;
346
+ /** Who holds the pool's capacity (still_queued) — the agents ahead of you. */
347
+ holders?: EntrySnapshot[];
348
+ nextAction: string;
349
+ detail?: string;
350
+ }
322
351
  /** Recovery diagnostics: held permits with holder + job information. */
323
352
  export interface HolderDiag {
324
353
  entry: EntrySnapshot;
@@ -0,0 +1,40 @@
1
+ export declare const DEFAULT_PIPE_NAME = "load-lock";
2
+ export declare function isWindows(platform?: NodeJS.Platform): boolean;
3
+ /** Windows named-pipe endpoint for the given pipe name. */
4
+ export declare function namedPipePath(name?: string): string;
5
+ /**
6
+ * Resolve the state directory. Precedence: explicit override >
7
+ * LOAD_LOCK_STATE_DIR > platform default (POSIX: XDG_STATE_HOME or
8
+ * ~/.local/state; Windows: %LOCALAPPDATA% or %USERPROFILE%\AppData\Local),
9
+ * each + "/load-lock". Pure function of its arguments so tests can
10
+ * exercise the Windows branches on Linux.
11
+ */
12
+ export declare function resolveStateDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, override?: string): string;
13
+ /**
14
+ * Resolve the transport endpoint. Precedence: explicit override >
15
+ * LOAD_LOCK_SOCKET > platform default (Windows: named pipe; POSIX:
16
+ * <state dir>/daemon.sock). The same precedence rules are used by every
17
+ * caller, so the CLI, MCP server, hooks, and the daemon all agree on the
18
+ * endpoint without further coordination.
19
+ */
20
+ export declare function resolveSocketPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, override?: string): string;
21
+ /**
22
+ * Probe whether a daemon is listening on the endpoint (UDS or named
23
+ * pipe — Node's connect handles both). Never throws.
24
+ */
25
+ export declare function probeTransport(path: string): Promise<boolean>;
26
+ /**
27
+ * Remove a stale endpoint left behind by a crashed daemon.
28
+ * POSIX: unlink the socket file (a crashed daemon leaves it behind).
29
+ * Windows: no-op — named pipes are kernel objects and vanish when the
30
+ * last handle closes, so there is nothing to clean up.
31
+ */
32
+ export declare function removeStaleEndpoint(path: string, platform?: NodeJS.Platform): void;
33
+ /**
34
+ * Terminate a job process tree. POSIX: negative-pid group kill (callers
35
+ * spawn jobs detached, so pgid === child pid). Windows: `taskkill /PID
36
+ * <pid> /T` (tree kill; add /F for the uncatchable SIGKILL equivalent —
37
+ * plain taskkill posts WM_CLOSE, which console processes don't handle).
38
+ * Fire-and-forget; never throws.
39
+ */
40
+ export declare function killProcessGroup(pid: number, signal: "SIGTERM" | "SIGINT" | "SIGKILL", platform?: NodeJS.Platform): void;