@aron98/load-lock 0.0.0-stage → 0.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/demo/demo.sh +112 -0
  4. package/demo/sim-agent.mjs +149 -0
  5. package/dist/adapters/opencode/plugin.d.ts +18 -0
  6. package/dist/adapters/opencode/plugin.js +301 -0
  7. package/dist/cli/hook.d.ts +1 -0
  8. package/dist/cli/hook.js +141 -0
  9. package/dist/cli/install.d.ts +14 -0
  10. package/dist/cli/install.js +377 -0
  11. package/dist/cli/main.d.ts +2 -0
  12. package/dist/cli/main.js +461 -0
  13. package/dist/core/daemon-main.d.ts +2 -0
  14. package/dist/core/daemon-main.js +27 -0
  15. package/dist/core/daemon.d.ts +79 -0
  16. package/dist/core/daemon.js +526 -0
  17. package/dist/core/persist.d.ts +11 -0
  18. package/dist/core/persist.js +66 -0
  19. package/dist/core/state.d.ts +203 -0
  20. package/dist/core/state.js +909 -0
  21. package/dist/core/sysinfo.d.ts +2 -0
  22. package/dist/core/sysinfo.js +123 -0
  23. package/dist/mcp/main.d.ts +2 -0
  24. package/dist/mcp/main.js +4 -0
  25. package/dist/mcp/server.d.ts +27 -0
  26. package/dist/mcp/server.js +352 -0
  27. package/dist/policy/text.d.ts +30 -0
  28. package/dist/policy/text.js +58 -0
  29. package/dist/shared/client.d.ts +30 -0
  30. package/dist/shared/client.js +227 -0
  31. package/dist/shared/protocol.d.ts +333 -0
  32. package/dist/shared/protocol.js +22 -0
  33. package/dist/test/core.test.d.ts +1 -0
  34. package/dist/test/core.test.js +281 -0
  35. package/dist/test/daemon.test.d.ts +1 -0
  36. package/dist/test/daemon.test.js +384 -0
  37. package/dist/test/hook.test.d.ts +1 -0
  38. package/dist/test/hook.test.js +126 -0
  39. package/dist/test/mcp.test.d.ts +1 -0
  40. package/dist/test/mcp.test.js +139 -0
  41. package/dist/test/run-helper.test.d.ts +1 -0
  42. package/dist/test/run-helper.test.js +84 -0
  43. package/dist/test/testutil.d.ts +27 -0
  44. package/dist/test/testutil.js +111 -0
  45. package/docs/ADAPTERS.md +118 -0
  46. package/docs/AGENT_GUIDE.md +142 -0
  47. package/docs/ARCHITECTURE.md +195 -0
  48. package/docs/COMPATIBILITY.md +116 -0
  49. package/docs/OPERATIONS.md +126 -0
  50. package/package.json +102 -4
  51. package/skills/load-lock/SKILL.md +73 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Hook contract tests: `load-lock hook <harness> <event>` invoked exactly the
3
+ * way Claude Code / Codex invoke hooks (harness JSON on stdin), against a real
4
+ * daemon with real offers outstanding.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawn } from "node:child_process";
9
+ import { mkdtempSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { startDaemon, delay, cliMainPath } from "./testutil.js";
13
+ const POLICY_MARK = "# load-lock: machine-wide heavy-work budget";
14
+ function runHook(d, args, input, env) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(process.execPath, [cliMainPath, "hook", ...args], {
17
+ env: { ...d.env, ...env },
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ child.stdout.on("data", (b) => (stdout += b));
23
+ child.stderr.on("data", (b) => (stderr += b));
24
+ child.on("exit", (code) => resolve({ code, stdout, stderr }));
25
+ child.on("error", reject);
26
+ child.stdin.end(input === undefined ? "" : JSON.stringify(input));
27
+ });
28
+ }
29
+ /** Set up: A holds, B queued with cwd; release A so B has an outstanding offer. */
30
+ async function outstandingOffer(d, cwd) {
31
+ const c = d.client();
32
+ await c.connect();
33
+ await c.request("acquire", { agentId: "hook-holder", opKey: "h1", reason: "holding" });
34
+ await c.request("acquire", { agentId: "hook-waiter", opKey: "w1", reason: "waiting", cwd });
35
+ const rel = (await c.request("release", { permitToken: (await c.request("pending", { agentId: "hook-holder" })).held[0].permitToken }));
36
+ const offers = (await c.request("offers_for_cwd", { cwd }));
37
+ c.close();
38
+ return offers.offers[0];
39
+ }
40
+ test("hooks: session-start events print the policy brief + machine state", async () => {
41
+ const d = await startDaemon();
42
+ try {
43
+ for (const ev of [
44
+ ["claude-code", "session-start"],
45
+ ["codex", "session-start"],
46
+ ["codex", "subagent-start"],
47
+ ]) {
48
+ const r = await runHook(d, ev, { session_id: "s1", cwd: "/tmp", source: "startup" });
49
+ assert.equal(r.code, 0, `${ev.join("/")} exits 0`);
50
+ assert.ok(r.stdout.startsWith(POLICY_MARK), `${ev.join("/")} emits the policy brief as stdout context`);
51
+ assert.ok(r.stdout.includes("effective core"), "includes machine awareness line");
52
+ assert.equal(r.stderr, "");
53
+ }
54
+ }
55
+ finally {
56
+ await d.stop();
57
+ }
58
+ });
59
+ test("hooks: claude-code stop with a matching offer -> decision block carrying the offer token", async () => {
60
+ const d = await startDaemon();
61
+ try {
62
+ const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
63
+ const offer = await outstandingOffer(d, cwd);
64
+ assert.ok(offer?.offerToken, "waiter has an outstanding offer");
65
+ const r = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd });
66
+ assert.equal(r.code, 0);
67
+ const out = JSON.parse(r.stdout);
68
+ assert.equal(out.decision, "block", "Stop hook continues the session");
69
+ assert.ok(out.reason.includes(offer.offerToken), "reason tells the agent which offer_token to claim");
70
+ assert.ok(out.reason.includes("claim"), "reason instructs claiming");
71
+ assert.ok(out.reason.includes(offer.opKey));
72
+ // stop_hook_active: never loop
73
+ const quiet = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd, stop_hook_active: true });
74
+ assert.equal(quiet.code, 0);
75
+ assert.equal(quiet.stdout, "", "already-continued stops are silent");
76
+ // no matching offers -> silent
77
+ const none = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd: "/definitely/not/a/worktree" });
78
+ assert.equal(none.stdout, "");
79
+ }
80
+ finally {
81
+ await d.stop();
82
+ }
83
+ });
84
+ test("hooks: codex stop behaves the same (decision block JSON)", async () => {
85
+ const d = await startDaemon();
86
+ try {
87
+ const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
88
+ const offer = await outstandingOffer(d, cwd);
89
+ assert.ok(offer?.offerToken);
90
+ const r = await runHook(d, ["codex", "stop"], { session_id: "s1", cwd, stop_hook_active: false });
91
+ const out = JSON.parse(r.stdout);
92
+ assert.equal(out.decision, "block");
93
+ assert.ok(out.reason.includes(offer.offerToken));
94
+ }
95
+ finally {
96
+ await d.stop();
97
+ }
98
+ });
99
+ test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on offer", async () => {
100
+ const d = await startDaemon();
101
+ try {
102
+ const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
103
+ const c = d.client();
104
+ await c.connect();
105
+ // holder + waiter, offer NOT yet delivered
106
+ await c.request("acquire", { agentId: "hook-holder2", opKey: "h2", reason: "holding" });
107
+ await c.request("acquire", { agentId: "hook-waiter2", opKey: "w2", reason: "waiting", cwd });
108
+ const awaiting = runHook(d, ["claude-code", "await-offer"], { session_id: "s1", cwd }, { LOAD_LOCK_AWAIT_MS: "8000" });
109
+ await delay(400);
110
+ // release the holder -> offer created for the waiter in `cwd`
111
+ const held = (await c.request("pending", { agentId: "hook-holder2" }));
112
+ await c.request("release", { permitToken: held.held[0].permitToken });
113
+ const r = await awaiting;
114
+ assert.equal(r.code, 2, "asyncRewake contract: exit code 2 wakes Claude");
115
+ assert.ok(r.stderr.includes("claim"), "stderr explains how to claim");
116
+ assert.ok(!r.stderr.includes("off_"), "the offer token itself is NOT in stderr (session must claim via pending)");
117
+ assert.equal(r.stdout, "");
118
+ // timeout path: no offer in window -> exit 0, silent
119
+ const t = await runHook(d, ["claude-code", "await-offer"], { session_id: "s1", cwd: "/nowhere" }, { LOAD_LOCK_AWAIT_MS: "500" });
120
+ assert.equal(t.code, 0);
121
+ assert.equal(t.stderr, "");
122
+ }
123
+ finally {
124
+ await d.stop();
125
+ }
126
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * MCP server integration tests: spawn dist/mcp/main.js as a real MCP stdio
3
+ * server (the way harnesses launch it) and exercise the tool round trips,
4
+ * including the run_with_permit lifecycle.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import { startDaemon, delay, mcpMainPath } from "./testutil.js";
11
+ async function connectMcp(d) {
12
+ const client = new Client({ name: "load-lock-test", version: "0.1.0" }, { capabilities: {} });
13
+ const transport = new StdioClientTransport({
14
+ command: process.execPath,
15
+ args: [mcpMainPath],
16
+ env: d.env,
17
+ });
18
+ await client.connect(transport);
19
+ return client;
20
+ }
21
+ async function callJson(c, name, args) {
22
+ const res = await c.callTool({ name, arguments: args });
23
+ assert.equal(res.isError, undefined, `tool ${name} errored: ${JSON.stringify(res.content)}`);
24
+ return JSON.parse(res.content[0].text);
25
+ }
26
+ test("mcp: tool listing + acquire/release/configure round trip", async () => {
27
+ const d = await startDaemon();
28
+ const c = await connectMcp(d);
29
+ try {
30
+ const tools = await c.listTools();
31
+ const names = tools.tools.map((t) => t.name);
32
+ for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
33
+ assert.ok(names.includes(expected), `tool ${expected} registered`);
34
+ }
35
+ const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
36
+ assert.equal(acq.state, "granted");
37
+ assert.ok(acq.permit_token, "acquire result carries permit_token");
38
+ assert.ok(acq.next_action, "structured next_action present");
39
+ // idempotent acquire with the same op_key
40
+ const acq2 = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
41
+ assert.equal(acq2.request_id, acq.request_id, "same op_key -> same request");
42
+ // a second agent queues
43
+ const acqB = await callJson(c, "acquire", { reason: "tests", op_key: "mcp-b", agent_id: "mcpAgentB" });
44
+ assert.equal(acqB.state, "queued");
45
+ assert.equal(acqB.queue_position, 1);
46
+ // configure: capacity up (CAS)
47
+ const cfg = await callJson(c, "get_config", {});
48
+ const conf = await callJson(c, "configure", {
49
+ expected_version: cfg.config.version,
50
+ changes: { pools: { heavy: { capacity: 2 } } },
51
+ reason: "test: two cores of headroom",
52
+ author: "mcp test",
53
+ });
54
+ assert.equal(conf.config.version, cfg.config.version + 1);
55
+ // capacity increase admits the queued request -> offered to B
56
+ const pend = await callJson(c, "pending", { agent_id: "mcpAgentB" });
57
+ assert.equal(pend.offers.length, 1, "B has an outstanding offer after capacity increase");
58
+ assert.ok(pend.offers[0].offerToken, "offer carries token");
59
+ // claim it
60
+ const claimed = await callJson(c, "claim", { offer_token: pend.offers[0].offerToken });
61
+ assert.equal(claimed.state, "held");
62
+ assert.ok(claimed.permit_token);
63
+ // duplicate claim must fail cleanly (expired/stale token), not corrupt state
64
+ const dup = await c.callTool({ name: "claim", arguments: { offer_token: pend.offers[0].offerToken } });
65
+ assert.ok(dup.isError, "duplicate claim rejected (stale offer token)");
66
+ // releases are idempotent
67
+ const rel1 = await callJson(c, "release", { permit_token: acq.permit_token });
68
+ assert.equal(rel1.released_now, true);
69
+ const rel2 = await callJson(c, "release", { permit_token: acq.permit_token });
70
+ assert.equal(rel2.released_now, false, "second release is idempotent");
71
+ const st = await callJson(c, "status", {});
72
+ assert.equal(st.pools[0].capacity, 2);
73
+ assert.ok(st.pools[0].holders.length <= 1);
74
+ }
75
+ finally {
76
+ await c.close();
77
+ await d.stop();
78
+ }
79
+ });
80
+ test("mcp: run_with_permit tracks a background job and auto-releases", async () => {
81
+ const d = await startDaemon();
82
+ const c = await connectMcp(d);
83
+ try {
84
+ const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-job" });
85
+ assert.equal(acq.state, "granted");
86
+ const job = await callJson(c, "run_with_permit", {
87
+ permit_token: acq.permit_token,
88
+ command: `sh -c 'sleep 1 && echo done'`,
89
+ });
90
+ assert.ok(job.job_id, "returns a job handle for background execution");
91
+ assert.equal(job.state, "running");
92
+ // status while running
93
+ const running = await callJson(c, "job_status", { job_id: job.job_id });
94
+ assert.equal(running.running, true);
95
+ // wait for completion (poll with cap)
96
+ let exited = null;
97
+ for (let i = 0; i < 40 && !exited; i++) {
98
+ await delay(150);
99
+ const s = await callJson(c, "job_status", { job_id: job.job_id });
100
+ if (!s.running)
101
+ exited = s;
102
+ }
103
+ assert.ok(exited, "job completed");
104
+ assert.equal(exited.exit_code, 0);
105
+ assert.ok(exited.output_tail.includes("done"));
106
+ // auto-release happened without an explicit release call
107
+ await delay(300);
108
+ const st = await callJson(c, "status", {});
109
+ assert.equal(st.pools[0].holders.length, 0, "permit auto-released after job exit");
110
+ }
111
+ finally {
112
+ await c.close();
113
+ await d.stop();
114
+ }
115
+ });
116
+ test("mcp: multiple server processes share one machine-wide budget", async () => {
117
+ const d = await startDaemon();
118
+ const c1 = await connectMcp(d);
119
+ const c2 = await connectMcp(d); // separate process = separate harness session
120
+ try {
121
+ const a1 = await callJson(c1, "acquire", { reason: "agent1 build", op_key: "a1" });
122
+ assert.equal(a1.state, "granted");
123
+ const a2 = await callJson(c2, "acquire", { reason: "agent2 tests", op_key: "a2" });
124
+ assert.equal(a2.state, "queued", "second harness session sees the same capacity budget");
125
+ assert.equal(a2.queue_position, 1);
126
+ await callJson(c1, "release", { permit_token: a1.permit_token });
127
+ // the offer for agent2 shows up in ITS tool boundary results
128
+ const pend = await callJson(c2, "pending", {});
129
+ assert.equal(pend.offers.length, 1);
130
+ const claimed = await callJson(c2, "claim", { offer_token: pend.offers[0].offerToken });
131
+ assert.equal(claimed.state, "held");
132
+ await callJson(c2, "release", { permit_token: claimed.permit_token });
133
+ }
134
+ finally {
135
+ await c1.close();
136
+ await c2.close();
137
+ await d.stop();
138
+ }
139
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * End-to-end tests for the `load-lock run` helper: separate CLI processes
3
+ * (independent OS processes, as a real agent would spawn them) against a real
4
+ * daemon; exit codes and automatic release semantics.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { startDaemon, runCli, delay } from "./testutil.js";
9
+ test("run: success and failure propagate exit codes and auto-release", async () => {
10
+ const d = await startDaemon();
11
+ try {
12
+ const ok = await runCli(d, ["run", "--reason", "build", "--op-key", "ok1", "--", "sh", "-c", "echo heavy-done"]);
13
+ assert.equal(ok.code, 0, `stdout: ${ok.stdout}\nstderr: ${ok.stderr}`);
14
+ assert.ok(ok.stdout.includes("heavy-done"));
15
+ const bad = await runCli(d, ["run", "--reason", "build", "--op-key", "bad1", "--", "sh", "-c", "exit 3"]);
16
+ assert.equal(bad.code, 3, "the job's exit code propagates");
17
+ // both permits auto-released
18
+ const c = d.client();
19
+ await c.connect();
20
+ const st = (await c.request("status", {}));
21
+ assert.equal(st.pools[0].holders.length, 0);
22
+ assert.equal(st.pools[0].used, 0);
23
+ c.close();
24
+ }
25
+ finally {
26
+ await d.stop();
27
+ }
28
+ });
29
+ test("run: exits 68 (queued) while another process holds the permit; FIFO continuation", async () => {
30
+ const d = await startDaemon();
31
+ const c = d.client();
32
+ try {
33
+ await c.connect();
34
+ // a holder that keeps the permit for ~2s, in a SEPARATE process
35
+ const holder = runCli(d, ["run", "--reason", "hold", "--op-key", "hold1", "--wait-ms", "0", "--", "sleep", "2"]);
36
+ await (async () => {
37
+ // wait until the holder actually holds
38
+ for (let i = 0; i < 50; i++) {
39
+ const st = (await c.request("status", {}));
40
+ if (st.pools[0].holders.length === 1)
41
+ return;
42
+ await delay(100);
43
+ }
44
+ throw new Error("holder did not acquire");
45
+ })();
46
+ const queued = await runCli(d, ["run", "--reason", "second", "--op-key", "q1", "--wait-ms", "0", "--", "true"]);
47
+ assert.equal(queued.code, 68, "queued run exits 68 instead of blocking forever");
48
+ assert.equal((await holder).code, 0);
49
+ // after the holder finishes, capacity is free again
50
+ const st2 = (await c.request("status", {}));
51
+ assert.equal(st2.pools[0].holders.length, 0);
52
+ }
53
+ finally {
54
+ await d.stop();
55
+ }
56
+ });
57
+ test("wait: bounded blocking offer-watcher for scriptable flows", async () => {
58
+ const d = await startDaemon();
59
+ const c = d.client();
60
+ try {
61
+ await c.connect();
62
+ // A holds; B queues (as a separate CLI process for realism)
63
+ const acqA = await runCli(d, ["acquire", "--agent", "agentA", "--op-key", "a1", "--reason", "hold", "--json"]);
64
+ assert.equal(acqA.code, 0);
65
+ const aPermit = JSON.parse(acqA.stdout).entry.permitToken;
66
+ const acqB = await runCli(d, ["acquire", "--agent", "agentB", "--op-key", "b1", "--reason", "waiter", "--json"]);
67
+ assert.equal(acqB.code, 68);
68
+ const bReqId = JSON.parse(acqB.stdout).entry.requestId;
69
+ // waiter watches for its offer in a separate process
70
+ const waiter = runCli(d, ["wait", "--id", bReqId, "--agent", "agentB", "--timeout", "8000", "--json"]);
71
+ await delay(300);
72
+ await c.request("release", { permitToken: aPermit });
73
+ const w = await waiter;
74
+ assert.equal(w.code, 0, `waiter stdout: ${w.stdout}\nstderr: ${w.stderr}`);
75
+ const permit = JSON.parse(w.stdout).permit_token;
76
+ assert.ok(permit, "wait prints the claimed permit token");
77
+ // clean up: release B
78
+ const rel = await runCli(d, ["release", "--permit", permit, "--json"]);
79
+ assert.equal(rel.code, 0);
80
+ }
81
+ finally {
82
+ await d.stop();
83
+ }
84
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared helpers for integration tests (not matched by the test-file pattern).
3
+ */
4
+ import { type ChildProcess } from "node:child_process";
5
+ import { LLClient } from "../shared/client.js";
6
+ export declare const daemonMainPath: string;
7
+ export declare const cliMainPath: string;
8
+ export declare const mcpMainPath: string;
9
+ export interface DaemonHandle {
10
+ child: ChildProcess;
11
+ dir: string;
12
+ sock: string;
13
+ env: Record<string, string>;
14
+ client(): LLClient;
15
+ stop(): Promise<void>;
16
+ }
17
+ export declare function startDaemon(): Promise<DaemonHandle>;
18
+ export declare function probe(sock: string): Promise<boolean>;
19
+ export declare function delay(ms: number): Promise<void>;
20
+ export declare function until(desc: string, fn: () => boolean | Promise<boolean>, timeoutMs?: number, step?: number): Promise<void>;
21
+ export interface CliResult {
22
+ code: number | null;
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+ /** Run the compiled CLI as a separate process against the given daemon env. */
27
+ export declare function runCli(d: DaemonHandle, args: string[], input?: string, timeoutMs?: number): Promise<CliResult>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared helpers for integration tests (not matched by the test-file pattern).
3
+ */
4
+ import { spawn } from "node:child_process";
5
+ import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join, dirname } from "node:path";
8
+ import { connect } from "node:net";
9
+ import { fileURLToPath } from "node:url";
10
+ import { LLClient } from "../shared/client.js";
11
+ const TMP_BASE = join(tmpdir(), "opencode");
12
+ mkdirSync(TMP_BASE, { recursive: true });
13
+ export const daemonMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "core", "daemon-main.js");
14
+ export const cliMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "main.js");
15
+ export const mcpMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "mcp", "main.js");
16
+ export async function startDaemon() {
17
+ const dir = mkdtempSync(join(TMP_BASE, "ll-daemon-"));
18
+ const sock = join(dir, "daemon.sock");
19
+ const env = {
20
+ ...process.env,
21
+ LOAD_LOCK_STATE_DIR: dir,
22
+ LOAD_LOCK_SOCKET: sock,
23
+ };
24
+ const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ env,
27
+ });
28
+ const deadline = Date.now() + 10_000;
29
+ for (;;) {
30
+ if (await probe(sock))
31
+ break;
32
+ if (Date.now() > deadline) {
33
+ child.kill("SIGKILL");
34
+ throw new Error("daemon did not come up");
35
+ }
36
+ await delay(100);
37
+ }
38
+ const handle = {
39
+ child,
40
+ dir,
41
+ sock,
42
+ env,
43
+ client: () => new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false }),
44
+ stop: async () => {
45
+ if (child.exitCode === null && !child.killed) {
46
+ child.kill("SIGTERM");
47
+ await new Promise((resolve) => {
48
+ const t = setTimeout(() => {
49
+ child.kill("SIGKILL");
50
+ resolve();
51
+ }, 3000);
52
+ child.once("exit", () => {
53
+ clearTimeout(t);
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+ rmSync(dir, { recursive: true, force: true });
59
+ },
60
+ };
61
+ return handle;
62
+ }
63
+ export function probe(sock) {
64
+ return new Promise((resolve) => {
65
+ const s = connect(sock);
66
+ s.once("connect", () => {
67
+ s.destroy();
68
+ resolve(true);
69
+ });
70
+ s.once("error", () => resolve(false));
71
+ });
72
+ }
73
+ export function delay(ms) {
74
+ return new Promise((r) => setTimeout(r, ms));
75
+ }
76
+ export async function until(desc, fn, timeoutMs = 15_000, step = 200) {
77
+ const deadline = Date.now() + timeoutMs;
78
+ for (;;) {
79
+ if (await fn())
80
+ return;
81
+ if (Date.now() > deadline)
82
+ throw new Error(`timeout waiting for: ${desc}`);
83
+ await delay(step);
84
+ }
85
+ }
86
+ /** Run the compiled CLI as a separate process against the given daemon env. */
87
+ export function runCli(d, args, input, timeoutMs = 60_000) {
88
+ return new Promise((resolve, reject) => {
89
+ const child = spawn(process.execPath, [cliMainPath, ...args], { env: d.env });
90
+ let stdout = "";
91
+ let stderr = "";
92
+ child.stdout.on("data", (b) => (stdout += b));
93
+ child.stderr.on("data", (b) => (stderr += b));
94
+ if (input !== undefined)
95
+ child.stdin.end(input);
96
+ else
97
+ child.stdin.end();
98
+ const timer = setTimeout(() => {
99
+ child.kill("SIGKILL");
100
+ reject(new Error(`CLI timed out: load-lock ${args.join(" ")}`));
101
+ }, timeoutMs);
102
+ child.once("exit", (code) => {
103
+ clearTimeout(timer);
104
+ resolve({ code, stdout, stderr });
105
+ });
106
+ child.once("error", (e) => {
107
+ clearTimeout(timer);
108
+ reject(e);
109
+ });
110
+ });
111
+ }
@@ -0,0 +1,118 @@
1
+ # Writing a harness adapter for load-lock
2
+
3
+ load-lock ships first-class support for OpenCode, Claude Code, and Codex CLI,
4
+ plus a generic MCP fallback that works with any MCP-capable harness. This
5
+ guide is for anyone adding support for a *new* harness. Everything you need
6
+ is importable from the published package — no private APIs required.
7
+
8
+ ```ts
9
+ import { LLClient, withClient } from "@aron98/load-lock/shared/client";
10
+ import type * as P from "@aron98/load-lock/shared/protocol";
11
+ import { POLICY_BRIEF } from "@aron98/load-lock/policy/text";
12
+ import { buildServer } from "@aron98/load-lock/mcp/server";
13
+ ```
14
+
15
+ ## What a good adapter provides (in priority order)
16
+
17
+ 1. **Tools** — the agent must be able to call acquire / claim / release /
18
+ cancel / pending / status / inspect_machine / get_config / configure, and
19
+ ideally the lifecycle helpers (run_with_permit / job_status / attach_pid /
20
+ mark_started).
21
+ 2. **Policy at session start** — inject `POLICY_BRIEF` where the harness
22
+ puts per-session instructions, and re-inject after context compaction if
23
+ the harness supports it. This is the part agents actually follow; tools
24
+ without teaching get ignored.
25
+ 3. **Idle wakeup (if the harness allows it)** — when the daemon emits an
26
+ `offered` event for your session, deliver it to the agent at a safe
27
+ point (execution boundary), including the claim token and expiry. Never
28
+ interrupt an in-flight tool call.
29
+ 4. **Heartbeats** — every ~30s while the session is alive, so
30
+ `mark_started` (untracked) permits aren't reclaimed while the agent is
31
+ merely quiet.
32
+
33
+ Pick the tier your harness allows:
34
+
35
+ ## Tier 1 — native plugin (best)
36
+
37
+ For harnesses with an in-process extension API (this is what the OpenCode
38
+ adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
39
+
40
+ - One module-level `LLClient` (it auto-spawns the daemon on first use; a
41
+ machine typically runs one client per plugin host).
42
+ - Register tools that translate your harness's tool-call format into
43
+ `client.request(...)`. Give the agent a stable identity (harness session
44
+ id) and pass the session's cwd at acquire time.
45
+ - Subscribe `client.watch("*")` and route `offered` events to the owning
46
+ session via the harness's wake/synthetic-message API.
47
+ - Inject `POLICY_BRIEF` on session start and after every compaction.
48
+ - For heavy work, implement a `run_with_permit` helper following the
49
+ lifecycle contract below — or teach agents to use the CLI
50
+ (`load-lock run --permit ... -- <cmd>`), which already implements it.
51
+
52
+ ## Tier 2 — hooks + MCP
53
+
54
+ For harnesses with lifecycle hooks but no in-process API (Claude Code and
55
+ Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary, or
56
+ `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
57
+ to the CLI:
58
+
59
+ - `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
60
+ machine summary for context injection (register for post-compaction too,
61
+ if the harness has a compact matcher).
62
+ - `load-lock hook <harness> stop` → checks cwd-matched offers and, if one
63
+ exists, prints the harness's "continue the session" JSON with the claim
64
+ token. Honors any stop-loop flags the harness provides.
65
+ - See `src/cli/hook.ts` for the implementation pattern and
66
+ `src/cli/install.ts` for the config-writing/installer pattern.
67
+
68
+ ## Tier 3 — MCP only
69
+
70
+ Register the MCP server with the harness and put a short policy section in
71
+ whatever global instructions file the harness reads (GEMINI.md / rules /
72
+ hints...). Offers are surfaced in every tool result (tool-boundary
73
+ delivery), so correctness is preserved; what you lose is idle wakeup —
74
+ document that honestly (see docs/COMPATIBILITY.md).
75
+
76
+ ## Protocol quick reference
77
+
78
+ Wire protocol: NDJSON over a Unix domain socket; `LLClient` handles
79
+ framing, timeouts, auto-spawn, and event subscriptions. Methods an adapter
80
+ normally touches:
81
+
82
+ | Method | Purpose |
83
+ | --- | --- |
84
+ | `acquire {pool?, agentId, agentLabel?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey) |
85
+ | `claim {offerToken}` | offer → permit (before starting heavy work) |
86
+ | `release {permitToken, outcome?}` | idempotent |
87
+ | `cancel {requestId}` / `rejoin {requestId}` / `decline {offerToken}` | queue hygiene |
88
+ | `pending {agentId}` | the caller's offers/queue/holds (attach to every tool result) |
89
+ | `status {pool?}` / `inspect` / `get_config` / `configure {expectedVersion, changes, reason, author}` | state + versioned CAS config |
90
+ | `start_job {permitToken, pid, pgid?}` | atomic permit↔process association (tracked) |
91
+ | `mark_started {permitToken}` | untracked lease fallback |
92
+ | `heartbeat {agentId}` | keep untracked leases alive |
93
+ | `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
94
+ | `offers_for_cwd {cwd}` | for hook scripts without session identity |
95
+
96
+ ## Lifecycle contract (the part that must not be improvised)
97
+
98
+ - Associate a permit with real execution **atomically**: spawn the job in
99
+ its own process group, then `start_job` with pid+pgid; if association
100
+ fails, kill the group immediately. Register exit listeners *before*
101
+ awaiting the association RPC (a fast-exiting job can otherwise lose its
102
+ release event — this was a real bug here).
103
+ - Never release because the shell returned a background-task id; release
104
+ (or auto-release) on actual process-group completion.
105
+ - Do not auto-release on agent disconnect; that's the whole point of the
106
+ daemon holding ownership.
107
+ - Untracked (`mark_started`) permits are only as good as your heartbeats;
108
+ document the tradeoff and steer agents toward tracked paths.
109
+
110
+ ## Testing your adapter
111
+
112
+ Reuse `src/test/testutil.ts`-style harness spawning (isolated
113
+ `LOAD_LOCK_STATE_DIR`/`LOAD_LOCK_SOCKET`, probe the socket, then
114
+ `LLClient`), or simply script against the CLI. Concurrency rules to verify:
115
+ capacity is never exceeded by held+offered, FIFO order holds, duplicate
116
+ acquires are idempotent, and a daemon restart keeps tracked permits
117
+ alive. Label your adapter honestly in docs/COMPATIBILITY.md:
118
+ implemented / contract-tested / live-verified.