@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,2 @@
1
+ import { InspectResult, MachineConfig } from "../shared/protocol.js";
2
+ export declare function inspectMachine(config?: MachineConfig): InspectResult;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Machine resource inspection: CPUs, memory, cgroup v1/v2 limits, load.
3
+ *
4
+ * Design note (spec): "Report CPU + memory incl. cgroup/container limits;
5
+ * don't assume 2 cores = 2 jobs." `effectiveCores` and `effectiveLimitBytes`
6
+ * are the honest numbers agents should reason with; the `suggestion` string
7
+ * is advisory only — nothing here configures the lock automatically.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import os from "node:os";
11
+ function readIfExists(path) {
12
+ try {
13
+ return readFileSync(path, "utf8").trim();
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ function parseUint(s) {
20
+ if (s === undefined)
21
+ return null;
22
+ const n = Number(s);
23
+ return Number.isFinite(n) && n >= 0 ? n : null;
24
+ }
25
+ function cgroupVersion() {
26
+ if (readIfExists("/sys/fs/cgroup/cgroup.controllers") !== undefined)
27
+ return "v2";
28
+ if (readIfExists("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") !== undefined)
29
+ return "v1";
30
+ return "none";
31
+ }
32
+ function cgroupCpuLimit() {
33
+ if (cgroupVersion() === "v2") {
34
+ // "max 100000" or "200000 100000"
35
+ const s = readIfExists("/sys/fs/cgroup/cpu.max");
36
+ if (!s)
37
+ return { quota: null, period: null };
38
+ const [q, p] = s.split(/\s+/);
39
+ const period = parseUint(p);
40
+ if (q === "max")
41
+ return { quota: null, period };
42
+ const quota = parseUint(q);
43
+ return { quota, period: period ?? 100_000 };
44
+ }
45
+ if (cgroupVersion() === "v1") {
46
+ const quota = parseUint(readIfExists("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"));
47
+ const period = parseUint(readIfExists("/sys/fs/cgroup/cpu/cpu.cfs_period_us"));
48
+ return { quota: quota && quota > 0 ? quota : null, period };
49
+ }
50
+ return { quota: null, period: null };
51
+ }
52
+ function cgroupMemoryLimit() {
53
+ const v = cgroupVersion();
54
+ if (v === "v2") {
55
+ const s = readIfExists("/sys/fs/cgroup/memory.max");
56
+ const u = readIfExists("/sys/fs/cgroup/memory.current");
57
+ return {
58
+ limit: s && s !== "max" ? parseUint(s) : null,
59
+ usage: parseUint(u),
60
+ };
61
+ }
62
+ if (v === "v1") {
63
+ const l = parseUint(readIfExists("/sys/fs/cgroup/memory/memory.limit_in_bytes"));
64
+ const u = parseUint(readIfExists("/sys/fs/cgroup/memory/memory.usage_in_bytes"));
65
+ // v1 reports a huge sentinel when unlimited
66
+ return { limit: l !== null && l < 1e15 ? l : null, usage: u };
67
+ }
68
+ return { limit: null, usage: null };
69
+ }
70
+ export function inspectMachine(config) {
71
+ const cpus = os.cpus();
72
+ const { quota, period } = cgroupCpuLimit();
73
+ const logical = cpus.length || 1;
74
+ let effectiveCores = logical;
75
+ if (quota !== null && period && period > 0) {
76
+ effectiveCores = Math.min(logical, Math.max(1, Math.floor(quota / period)));
77
+ }
78
+ const loadavg = os.loadavg();
79
+ const memTotal = os.totalmem();
80
+ const memAvailable = os.availableMemory ? os.availableMemory() : os.freemem();
81
+ const { limit: cgMemLimit, usage: cgMemUsage } = cgroupMemoryLimit();
82
+ const effectiveLimitBytes = cgMemLimit !== null ? Math.min(memTotal, cgMemLimit) : memTotal;
83
+ const loadPerCore = loadavg[0] / effectiveCores;
84
+ const memUsedFrac = cgMemUsage !== null && cgMemLimit ? cgMemUsage / cgMemLimit : 1 - memAvailable / memTotal;
85
+ const loadPressure = loadPerCore > 1.25 ? "high" : loadPerCore > 0.6 ? "moderate" : "low";
86
+ const memPressure = memUsedFrac > 0.85 ? "high" : memUsedFrac > 0.6 ? "moderate" : "low";
87
+ const pools = config?.pools ?? { heavy: { capacity: 1 } };
88
+ const totalCapacity = Object.values(pools).reduce((a, p) => a + p.capacity, 0);
89
+ const suggestion = `Machines vary: this one exposes ~${effectiveCores} effective core(s) and ~` +
90
+ `${(effectiveLimitBytes / 1024 ** 3).toFixed(1)} GiB effective memory (including container limits). ` +
91
+ `Current total lock capacity across pools is ${totalCapacity}. Judge heavy vs light by expected ` +
92
+ `CPU/memory/IO impact; adjust capacity via load-lock configure when warranted — this is a ` +
93
+ `cooperative budget, not an enforced one.`;
94
+ const cpu = {
95
+ logicalCores: logical,
96
+ model: cpus[0]?.model?.trim(),
97
+ loadavg: [loadavg[0], loadavg[1], loadavg[2]],
98
+ cgroupVersion: cgroupVersion(),
99
+ cgroupCpuQuota: quota,
100
+ cgroupCpuPeriod: period,
101
+ effectiveCores,
102
+ loadPerEffectiveCore: Number((loadPerCore).toFixed(2)),
103
+ };
104
+ const memory = {
105
+ totalBytes: memTotal,
106
+ availableBytes: memAvailable,
107
+ freeBytes: os.freemem(),
108
+ cgroupMemoryLimitBytes: cgMemLimit,
109
+ cgroupMemoryUsageBytes: cgMemUsage,
110
+ effectiveLimitBytes,
111
+ };
112
+ return {
113
+ cpu,
114
+ memory,
115
+ pressure: {
116
+ note: `1-min load per effective core: ${cpu.loadPerEffectiveCore} (${loadPressure}); ` +
117
+ `memory used ≈ ${(memUsedFrac * 100).toFixed(0)}% of effective limit (${memPressure}).`,
118
+ loadPressure,
119
+ memPressure,
120
+ },
121
+ loadLock: { pools, suggestion },
122
+ };
123
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ /** load-lock MCP stdio server entrypoint (harness-spawned). */
3
+ import { runStdioMcp } from "./server.js";
4
+ await runStdioMcp();
@@ -0,0 +1,27 @@
1
+ /**
2
+ * load-lock MCP server (stdio) — the universal adapter for harnesses that
3
+ * speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
4
+ * Goose, ...). Each harness session spawns one MCP server process; the
5
+ * process-scoped agent identity routes offers back through tool results and
6
+ * watch pushes.
7
+ *
8
+ * Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
9
+ * surface server-initiated *messages*, but most harnesses do not inject them
10
+ * into the model's context. load-lock therefore relies on:
11
+ * (a) tool-boundary delivery — every acquire/claim/release/... response
12
+ * carries the agent's pending offers and queue positions;
13
+ * (b) bounded blocking wait in acquire (waitMs);
14
+ * (c) harness-specific idle wakeup (hooks/plugins) where verified.
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { LLClient } from "../shared/client.js";
18
+ export interface McpServerOptions {
19
+ client: LLClient;
20
+ serverName?: string;
21
+ /** Overridden by OpenCode's per-request session metadata when present. */
22
+ agentId?: string;
23
+ }
24
+ export declare function buildServer(opts: McpServerOptions): McpServer;
25
+ export declare function runStdioMcp(opts?: {
26
+ agentId?: string;
27
+ }): Promise<void>;
@@ -0,0 +1,352 @@
1
+ /**
2
+ * load-lock MCP server (stdio) — the universal adapter for harnesses that
3
+ * speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
4
+ * Goose, ...). Each harness session spawns one MCP server process; the
5
+ * process-scoped agent identity routes offers back through tool results and
6
+ * watch pushes.
7
+ *
8
+ * Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
9
+ * surface server-initiated *messages*, but most harnesses do not inject them
10
+ * into the model's context. load-lock therefore relies on:
11
+ * (a) tool-boundary delivery — every acquire/claim/release/... response
12
+ * carries the agent's pending offers and queue positions;
13
+ * (b) bounded blocking wait in acquire (waitMs);
14
+ * (c) harness-specific idle wakeup (hooks/plugins) where verified.
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { z } from "zod";
19
+ import { spawn } from "node:child_process";
20
+ import { LLClient } from "../shared/client.js";
21
+ import { POLICY_BRIEF } from "../policy/text.js";
22
+ const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
23
+
24
+ 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.`;
25
+ const CLAIM_DESC = `Claim an offered permit within its claim window (offers expire, default 30s, then capacity passes to the next waiter and your request pauses until you rejoin). Only a successful claim authorizes heavy work. After claiming you MUST begin execution within the start deadline (default 120s) — via run_with_permit (best: full process tracking incl. background jobs), attach_pid, or mark_started — or the permit is reclaimed automatically.`;
26
+ const RELEASE_DESC = `Release a held permit immediately after the heavy work finishes — success, failure, or termination (idempotent: releasing twice is fine). A permit survives the tool call and session that acquired it; automatic cleanup releases it when a tracked job exits or, for untracked permits, when your adapter stops heartbeating — but do not rely on that: release as soon as the work is done.`;
27
+ export function buildServer(opts) {
28
+ const client = opts.client;
29
+ const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
30
+ const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF });
31
+ const json = (v) => ({
32
+ content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
33
+ });
34
+ const agentIdOf = (input) => input.agent_id?.trim() || defaultAgent;
35
+ server.registerTool("acquire", {
36
+ title: "Acquire heavy-work permit",
37
+ description: ACQUIRE_DESC,
38
+ inputSchema: {
39
+ reason: z.string().describe("Why you need the machine (shown to other agents, e.g. 'cargo test --all in worktree feat-x')"),
40
+ op_key: z.string().describe("Stable key for the pending operation (idempotency; e.g. 'wt-feat-x/cargo-test')"),
41
+ agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
42
+ wait_ms: z.number().int().optional().describe("Bounded blocking wait for capacity, clamped by config (default 0)"),
43
+ subscribe_if_busy: z.boolean().optional().describe("false = fail fast instead of queueing (default true)"),
44
+ pool: z.string().optional().describe("Resource pool (default 'heavy')"),
45
+ },
46
+ }, async (input) => {
47
+ const res = await client.request("acquire", {
48
+ pool: input.pool,
49
+ agentId: agentIdOf(input),
50
+ cwd: process.cwd(),
51
+ opKey: input.op_key,
52
+ reason: input.reason,
53
+ waitMs: input.wait_ms,
54
+ subscribeIfBusy: input.subscribe_if_busy,
55
+ });
56
+ const e = res.entry ?? {};
57
+ return json({
58
+ state: res.state,
59
+ request_id: e.requestId,
60
+ pool: e.pool ?? input.pool ?? "heavy",
61
+ queue_position: e.position,
62
+ offer_token: e.offerToken,
63
+ offer_expires_at: e.offerExpiresAt,
64
+ permit_token: e.permitToken,
65
+ start_deadline: e.startDeadline,
66
+ reason: e.reason,
67
+ next_action: res.state === "granted"
68
+ ? "You hold the permit. Start the heavy work now via run_with_permit (best), attach_pid, or mark_started; release when done."
69
+ : res.state === "offered"
70
+ ? "You have a reserved offer: call claim(offer_token) NOW — it expires and capacity passes to the next waiter."
71
+ : res.state === "queued"
72
+ ? "Keep doing light work. Capacity will be offered to you in FIFO order; check pending() at tool boundaries or pass wait_ms next time."
73
+ : "Your request was paused (offer expired or declined). Call rejoin(request_id) when ready to queue again.",
74
+ entry: res.entry,
75
+ pending: res.pending,
76
+ });
77
+ });
78
+ server.registerTool("claim", {
79
+ title: "Claim offered permit",
80
+ description: CLAIM_DESC,
81
+ inputSchema: { offer_token: z.string().describe("Token from your offer") },
82
+ }, async (input) => {
83
+ const res = await client.request("claim", { offerToken: input.offer_token });
84
+ const e = res.entry ?? {};
85
+ return json({
86
+ state: "held",
87
+ request_id: e.requestId,
88
+ permit_token: e.permitToken,
89
+ phase: e.phase,
90
+ start_deadline: e.startDeadline,
91
+ next_action: "Permit claimed. Begin the heavy work before the start deadline: run_with_permit (best), attach_pid, or mark_started. Release when finished.",
92
+ entry: res.entry,
93
+ });
94
+ });
95
+ server.registerTool("release", {
96
+ title: "Release permit",
97
+ description: RELEASE_DESC,
98
+ inputSchema: {
99
+ permit_token: z.string().describe("Token from acquire/claim"),
100
+ reason: z.string().optional().describe("Optional outcome note (e.g. 'tests passed')"),
101
+ },
102
+ }, async (input) => {
103
+ const res = await client.request("release", { permitToken: input.permit_token });
104
+ return json({
105
+ released_now: res.releasedNow,
106
+ request_id: res.entry?.requestId,
107
+ note: res.releasedNow ? "Permit released; queued agents will be offered capacity." : "Already released (idempotent).",
108
+ });
109
+ });
110
+ server.registerTool("cancel", {
111
+ title: "Cancel queued request",
112
+ description: "Cancel a queued/offered/paused request you no longer need. Held permits must be released instead.",
113
+ inputSchema: { request_id: z.string() },
114
+ }, async (input) => json(await client.request("cancel", { requestId: input.request_id })));
115
+ server.registerTool("decline", {
116
+ title: "Decline an offer",
117
+ description: "Explicitly decline an offered permit (e.g. you are mid light-work and will rejoin later). Capacity passes to the next waiter immediately.",
118
+ inputSchema: { offer_token: z.string() },
119
+ }, async (input) => json(await client.request("decline", { offerToken: input.offer_token })));
120
+ server.registerTool("rejoin", {
121
+ title: "Rejoin queue after pause",
122
+ description: "Rejoin the FIFO queue after your offer expired or you declined. Paused requests never auto-recycle — rejoining is your explicit choice.",
123
+ inputSchema: { request_id: z.string() },
124
+ }, async (input) => {
125
+ const res = await client.request("rejoin", { requestId: input.request_id });
126
+ return json({
127
+ state: res.state,
128
+ request_id: res.entry?.requestId,
129
+ queue_position: res.entry?.position,
130
+ offer_token: res.entry?.offerToken,
131
+ next_action: res.state === "offered"
132
+ ? "Capacity was free: you have a new offer — claim(offer_token) now."
133
+ : `Queued at position ${res.entry?.position ?? "?"}; continue light work.`,
134
+ entry: res.entry,
135
+ });
136
+ });
137
+ server.registerTool("pending", {
138
+ title: "Your offers/queue/permits",
139
+ description: "List your outstanding offers (claim these NOW — they expire), queue positions, paused and held requests.",
140
+ inputSchema: { agent_id: z.string().optional() },
141
+ }, async (input) => json(await client.request("pending", { agentId: agentIdOf(input) })));
142
+ server.registerTool("status", {
143
+ title: "Semaphore status",
144
+ description: "Machine-wide pool snapshot: capacity, holders, outstanding offers, FIFO queue, paused requests. Check this when deciding whether work is heavy vs light, or to see who is building.",
145
+ inputSchema: { pool: z.string().optional() },
146
+ }, async (input) => json(await client.request("status", input.pool ? { pool: input.pool } : {})));
147
+ server.registerTool("inspect_machine", {
148
+ title: "Inspect machine resources",
149
+ description: "Honest machine resources: effective CPU cores and memory INCLUDING cgroup/container limits, plus current pressure. Do not assume N logical cores = N parallel heavy jobs — quota-limited containers report fewer effective cores.",
150
+ inputSchema: {},
151
+ }, async () => json(await client.request("inspect", {})));
152
+ server.registerTool("get_config", {
153
+ title: "Read config",
154
+ description: "Current config: pools/capacity, offer timeout, heavy-work guidance, and change history with rationale.",
155
+ inputSchema: {},
156
+ }, async () => json(await client.request("get_config", {})));
157
+ server.registerTool("configure", {
158
+ title: "Update config (CAS)",
159
+ description: "Compare-and-swap config update so concurrent agents never silently overwrite each other. Read get_config, pass its version as expected_version, describe changes and a reason. Capacity decreases let current holders finish; increases admit queued requests immediately.",
160
+ inputSchema: {
161
+ expected_version: z.number().int(),
162
+ changes: z
163
+ .object({
164
+ pools: z.record(z.string(), z.object({ capacity: z.number().int() })).optional(),
165
+ offer_timeout_ms: z.number().int().optional(),
166
+ max_wait_ms: z.number().int().optional(),
167
+ start_timeout_ms: z.number().int().optional(),
168
+ untracked_lease_ms: z.number().int().optional(),
169
+ guidance: z
170
+ .object({
171
+ heavy_work_heuristic: z.string().optional(),
172
+ notes: z.string().optional(),
173
+ agent: z
174
+ .object({ id: z.string(), parallelism: z.string().optional(), note: z.string().optional() })
175
+ .optional(),
176
+ })
177
+ .optional(),
178
+ }),
179
+ reason: z.string().describe("Why this change is right for this machine"),
180
+ author: z.string().describe("Who you are, e.g. 'opencode session abc123'"),
181
+ },
182
+ }, async (input) => json(await client.request("configure", {
183
+ expectedVersion: input.expected_version,
184
+ changes: {
185
+ ...input.changes,
186
+ offerTimeoutMs: input.changes.offer_timeout_ms,
187
+ maxWaitMs: input.changes.max_wait_ms,
188
+ startTimeoutMs: input.changes.start_timeout_ms,
189
+ untrackedLeaseMs: input.changes.untracked_lease_ms,
190
+ guidance: input.changes.guidance
191
+ ? {
192
+ ...input.changes.guidance,
193
+ heavyWorkHeuristic: input.changes.guidance.heavy_work_heuristic,
194
+ }
195
+ : undefined,
196
+ },
197
+ reason: input.reason,
198
+ author: input.author,
199
+ })));
200
+ server.registerTool("attach_pid", {
201
+ title: "Attach pid to permit",
202
+ description: "Associate a process you already started (e.g. a harness background task's shell) with your claimed permit. Atomic: fails if the permit was reclaimed. The permit is then released automatically when the process (and its process group) exits — including for background jobs. On Linux this is pid-reuse-safe via /proc start times. Prefer run_with_permit for full lifecycle ownership.",
203
+ inputSchema: { permit_token: z.string(), pid: z.number().int().positive() },
204
+ }, async (input) => json(await client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid })));
205
+ server.registerTool("mark_started", {
206
+ title: "Mark execution started (untracked)",
207
+ description: "Tell load-lock you started the heavy work when you cannot associate a process (no run_with_permit, no attach_pid). The permit then runs on a heartbeat lease: this MCP server heartbeats automatically while your session lives; if your agent dies, the lease reclaims the permit. WEAKER GUARANTEE: if your process survives your dead session, another heavy job may overlap it. Prefer run_with_permit whenever possible.",
208
+ inputSchema: { permit_token: z.string() },
209
+ }, async (input) => json(await client.request("mark_started", { permitToken: input.permit_token })));
210
+ // ---- execution helper: owns the process lifecycle end-to-end ----------
211
+ const jobs = new Map();
212
+ server.registerTool("run_with_permit", {
213
+ title: "Run heavy command under permit",
214
+ description: "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns the command in its own process group, atomically associates it with your claimed permit, returns a job_id immediately (background execution — do NOT release the permit merely because this returned), and auto-releases the permit when the job exits (success, failure, or kill). Survives this tool call and your session: if this server dies, the daemon still watches the job's process group. Poll job_status for completion and output.",
215
+ inputSchema: {
216
+ permit_token: z.string().describe("Permit token from acquire/claim"),
217
+ command: z.string().describe("Shell command to run (the heavy work you acquired the permit for — do not pass unrelated light commands)"),
218
+ cwd: z.string().optional().describe("Working directory (default: this server's cwd)"),
219
+ },
220
+ }, async (input) => {
221
+ const child = spawn(input.command, {
222
+ shell: true,
223
+ detached: true, // own process group => pgid === child.pid on Linux
224
+ cwd: input.cwd || process.cwd(),
225
+ env: process.env,
226
+ stdio: ["ignore", "pipe", "pipe"],
227
+ });
228
+ const job = {
229
+ id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
230
+ child,
231
+ permitToken: input.permit_token,
232
+ command: input.command,
233
+ startedAt: Date.now(),
234
+ exited: false,
235
+ exitCode: null,
236
+ output: [],
237
+ };
238
+ // Watch for exit BEFORE associating (no awaits in between): a job that
239
+ // exits instantly must not lose its exit event while we round-trip to
240
+ // the daemon, or the permit would never be auto-released.
241
+ const push = (buf) => {
242
+ job.output.push(buf.toString("utf8"));
243
+ if (job.output.length > 200)
244
+ job.output.shift();
245
+ };
246
+ child.stdout?.on("data", push);
247
+ child.stderr?.on("data", push);
248
+ child.on("exit", (code) => {
249
+ job.exited = true;
250
+ job.exitCode = code;
251
+ // Auto-release on completion: success, failure, or termination.
252
+ client
253
+ .request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
254
+ .catch(() => {
255
+ /* already released/reclaimed — idempotent path */
256
+ });
257
+ });
258
+ // Atomic association; if the permit was reclaimed already, kill the job
259
+ // before it can do meaningful work and report the failure.
260
+ try {
261
+ await client.request("start_job", {
262
+ permitToken: input.permit_token,
263
+ pid: child.pid,
264
+ pgid: child.pid,
265
+ });
266
+ }
267
+ catch (err) {
268
+ try {
269
+ process.kill(-child.pid, "SIGKILL");
270
+ }
271
+ catch {
272
+ /* already gone */
273
+ }
274
+ return json({
275
+ error: `permit no longer startable (${err.message}); job killed before doing work; acquire again`,
276
+ });
277
+ }
278
+ jobs.set(job.id, job);
279
+ return json({
280
+ job_id: job.id,
281
+ pid: child.pid,
282
+ pgid: child.pid,
283
+ state: "running",
284
+ note: `Background job started under permit. Do NOT release the permit manually — it auto-releases when the job exits. Poll job_status(job_id) for completion; job_stop(job_id) to terminate.`,
285
+ });
286
+ });
287
+ server.registerTool("job_status", {
288
+ title: "Job status",
289
+ description: "Poll a job started with run_with_permit: running/exited, exit code, recent output. The permit auto-released when the job exited.",
290
+ inputSchema: { job_id: z.string() },
291
+ }, async (input) => {
292
+ const job = jobs.get(input.job_id);
293
+ if (!job)
294
+ return json({ error: `unknown job_id (jobs live in this MCP server process; it restarted if missing)` });
295
+ return json({
296
+ job_id: job.id,
297
+ command: job.command,
298
+ running: !job.exited,
299
+ exit_code: job.exitCode,
300
+ output_tail: job.output.slice(-20).join(""),
301
+ });
302
+ });
303
+ server.registerTool("job_stop", {
304
+ title: "Stop job",
305
+ description: "Terminate a job started with run_with_permit (SIGTERM to its process group, escalating to SIGKILL). The permit auto-releases once it exits.",
306
+ inputSchema: { job_id: z.string() },
307
+ }, async (input) => {
308
+ const job = jobs.get(input.job_id);
309
+ if (!job)
310
+ return json({ error: "unknown job_id" });
311
+ if (!job.exited) {
312
+ try {
313
+ process.kill(-job.child.pid, "SIGTERM");
314
+ }
315
+ catch {
316
+ /* already gone */
317
+ }
318
+ setTimeout(() => {
319
+ if (!job.exited) {
320
+ try {
321
+ process.kill(-job.child.pid, "SIGKILL");
322
+ }
323
+ catch {
324
+ /* already gone */
325
+ }
326
+ }
327
+ }, 5_000).unref?.();
328
+ }
329
+ return json({ job_id: job.id, stopping: true });
330
+ });
331
+ // Automatic adapter heartbeat: while this server process lives, untracked
332
+ // permits of this agent keep their lease; if the harness session dies, the
333
+ // lease lapses and the daemon reclaims. The model never has to remember.
334
+ const heartbeat = setInterval(() => {
335
+ void client.request("heartbeat", { agentId: defaultAgent }).catch(() => {
336
+ /* daemon unreachable; next tool call reconnects */
337
+ });
338
+ }, 30_000);
339
+ heartbeat.unref?.();
340
+ return server;
341
+ }
342
+ export async function runStdioMcp(opts = {}) {
343
+ const client = new LLClient();
344
+ await client.connect();
345
+ // Keep a watch subscription so the connection persists and the daemon sees
346
+ // liveness; offers are surfaced via tool results (tool-boundary delivery).
347
+ const server = buildServer({ client, agentId: opts.agentId });
348
+ const transport = new StdioServerTransport();
349
+ await server.connect(transport);
350
+ // Keep the process alive on the open stdio transport.
351
+ await new Promise(() => { });
352
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * load-lock policy text. One source of truth, delivered through each
3
+ * harness's instruction mechanism:
4
+ *
5
+ * - OpenCode plugin: injected via session "context" hook (every model call,
6
+ * compaction-proof) + registered skill for detail.
7
+ * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork).
8
+ * - Codex CLI: SessionStart/SubagentStart hook stdout (needs /hooks trust).
9
+ * - MCP server: `instructions` field on every harness that surfaces it.
10
+ *
11
+ * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
+ * in the skill / docs/AGENT_GUIDE.md.
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`.";
15
+ export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
16
+ export declare function statusLine(st: {
17
+ pools: {
18
+ pool: string;
19
+ capacity: number;
20
+ used: number;
21
+ holders: {
22
+ agentLabel?: string;
23
+ reason: string;
24
+ }[];
25
+ queue: unknown[];
26
+ }[];
27
+ cpu?: {
28
+ effectiveCores?: number;
29
+ };
30
+ }): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * load-lock policy text. One source of truth, delivered through each
3
+ * harness's instruction mechanism:
4
+ *
5
+ * - OpenCode plugin: injected via session "context" hook (every model call,
6
+ * compaction-proof) + registered skill for detail.
7
+ * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork).
8
+ * - Codex CLI: SessionStart/SubagentStart hook stdout (needs /hooks trust).
9
+ * - MCP server: `instructions` field on every harness that surfaces it.
10
+ *
11
+ * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
+ * in the skill / docs/AGENT_GUIDE.md.
13
+ */
14
+ export const POLICY_BRIEF = `# load-lock: machine-wide heavy-work budget
15
+
16
+ Multiple coding agents share this machine. Before resource-intensive local work
17
+ (builds, test suites, bulky containers, big compiles), get a permit from the
18
+ shared, machine-wide counting semaphore. While queued, keep doing useful light
19
+ work (reading, editing, searching, planning); you will be offered capacity.
20
+
21
+ Rules:
22
+ 1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.
23
+ A quick lint or single-file check is light. Full builds/tests are heavy.
24
+ When unsure, call the status tool — it shows current machine pressure.
25
+ 2. Immediately before starting heavy work: acquire a permit
26
+ (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and
27
+ a stable op_key for the pending operation.
28
+ 3. If you are queued: keep working on light tasks. When you receive an offer
29
+ (in any load-lock tool result or a wakeup message), claim it promptly —
30
+ offers expire (default 30s) and capacity passes to the next waiter.
31
+ 4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work
32
+ on a "queued" result; never treat a notification as authorization.
33
+ 5. Hold the permit until the heavy process actually finishes — including when
34
+ it runs in the background beyond this tool call or session. Optionally
35
+ attach its pid (load-lock attach_pid) so a dead process auto-releases.
36
+ 6. Release immediately when done (success or failure), and cancel queued
37
+ requests you no longer need.
38
+ 7. Capacity is a cooperative budget, not an enforced one. If the budget seems
39
+ wrong for this machine, check load-lock inspect_machine (real cores/memory,
40
+ including container limits) and adjust with configure (explain why). Don't
41
+ assume N cores = N parallel heavy jobs.
42
+
43
+ Detailed guide: the "load-lock" skill or \`load-lock agent-guide\`.`;
44
+ export function policyWithContext(statusSummary, configSummary) {
45
+ let s = POLICY_BRIEF + "\n\n";
46
+ s += statusSummary;
47
+ if (configSummary)
48
+ s += "\n" + configSummary;
49
+ return s;
50
+ }
51
+ export function statusLine(st) {
52
+ const parts = [];
53
+ for (const p of st.pools) {
54
+ const who = p.holders.map((h) => h.agentLabel || h.reason).join(", ");
55
+ parts.push(`pool "${p.pool}": ${p.used}/${p.capacity} in use (held+offers)${p.queue.length ? `, ${p.queue.length} queued` : ""}${who ? `, held by: ${who}` : ""}`);
56
+ }
57
+ return `load-lock status: ${parts.join("; ")}`;
58
+ }
@@ -0,0 +1,30 @@
1
+ export interface ClientOptions {
2
+ socket?: string;
3
+ stateDir?: string;
4
+ spawnDaemon?: boolean;
5
+ }
6
+ export declare class LLClient {
7
+ private sock;
8
+ private nextId;
9
+ private pending;
10
+ private eventCb;
11
+ private buf;
12
+ private opts;
13
+ constructor(opts?: ClientOptions);
14
+ private socketPath;
15
+ private stateDirPath;
16
+ connect(): Promise<void>;
17
+ private tryConnect;
18
+ private attach;
19
+ private failAll;
20
+ private ensureSock;
21
+ /** Issue one RPC. Reconnects once if the connection was lost. */
22
+ request<T = any>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
23
+ /** Subscribe to daemon events targeted at agentId (or "*" for all). */
24
+ watch(agentId: string, onEvent: (ev: Record<string, unknown>) => void): Promise<void>;
25
+ close(): void;
26
+ }
27
+ /** Idempotently ensure a daemon is running and listening on `sockPath`. */
28
+ export declare function ensureDaemon(stateDir: string, sockPath: string): Promise<void>;
29
+ /** One-shot helper: connect (auto-spawning), run fn, close. */
30
+ export declare function withClient<T>(fn: (c: LLClient) => Promise<T>, opts?: ClientOptions): Promise<T>;