@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.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/demo/demo.sh +112 -0
- package/demo/sim-agent.mjs +149 -0
- package/dist/adapters/opencode/plugin.d.ts +18 -0
- package/dist/adapters/opencode/plugin.js +301 -0
- package/dist/cli/hook.d.ts +1 -0
- package/dist/cli/hook.js +141 -0
- package/dist/cli/install.d.ts +14 -0
- package/dist/cli/install.js +377 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +461 -0
- package/dist/core/daemon-main.d.ts +2 -0
- package/dist/core/daemon-main.js +27 -0
- package/dist/core/daemon.d.ts +79 -0
- package/dist/core/daemon.js +526 -0
- package/dist/core/persist.d.ts +11 -0
- package/dist/core/persist.js +66 -0
- package/dist/core/state.d.ts +203 -0
- package/dist/core/state.js +909 -0
- package/dist/core/sysinfo.d.ts +2 -0
- package/dist/core/sysinfo.js +123 -0
- package/dist/mcp/main.d.ts +2 -0
- package/dist/mcp/main.js +4 -0
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +352 -0
- package/dist/policy/text.d.ts +30 -0
- package/dist/policy/text.js +58 -0
- package/dist/shared/client.d.ts +30 -0
- package/dist/shared/client.js +227 -0
- package/dist/shared/protocol.d.ts +333 -0
- package/dist/shared/protocol.js +22 -0
- package/dist/test/core.test.d.ts +1 -0
- package/dist/test/core.test.js +281 -0
- package/dist/test/daemon.test.d.ts +1 -0
- package/dist/test/daemon.test.js +384 -0
- package/dist/test/hook.test.d.ts +1 -0
- package/dist/test/hook.test.js +126 -0
- package/dist/test/mcp.test.d.ts +1 -0
- package/dist/test/mcp.test.js +139 -0
- package/dist/test/run-helper.test.d.ts +1 -0
- package/dist/test/run-helper.test.js +84 -0
- package/dist/test/testutil.d.ts +27 -0
- package/dist/test/testutil.js +111 -0
- package/docs/ADAPTERS.md +118 -0
- package/docs/AGENT_GUIDE.md +142 -0
- package/docs/ARCHITECTURE.md +195 -0
- package/docs/COMPATIBILITY.md +116 -0
- package/docs/OPERATIONS.md +126 -0
- package/package.json +102 -4
- package/skills/load-lock/SKILL.md +73 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* load-lock native plugin for OpenCode V2.
|
|
3
|
+
*
|
|
4
|
+
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
|
+
* Provides:
|
|
6
|
+
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
+
* - run_with_permit / job_status / job_stop: full execution lifecycle
|
|
8
|
+
* - automatic policy injection on every model call via the session
|
|
9
|
+
* "context" hook (compaction-proof by construction)
|
|
10
|
+
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
11
|
+
* targets an OpenCode session
|
|
12
|
+
* - automatic per-session heartbeats (untracked leases never lapse while
|
|
13
|
+
* the OpenCode service lives; the model never has to remember)
|
|
14
|
+
* - a registered skill with the detailed agent guide
|
|
15
|
+
*/
|
|
16
|
+
import { Plugin } from "@opencode/plugin";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { LLClient } from "../../shared/client.js";
|
|
22
|
+
import { POLICY_BRIEF } from "../../policy/text.js";
|
|
23
|
+
const client = new LLClient();
|
|
24
|
+
const knownSessions = new Set();
|
|
25
|
+
const jobs = new Map();
|
|
26
|
+
async function statusBrief() {
|
|
27
|
+
try {
|
|
28
|
+
const st = (await client.request("status", {}));
|
|
29
|
+
const parts = st.pools.map((p) => `pool "${p.pool}": ${p.used}/${p.capacity} in use${p.queue.length ? `, ${p.queue.length} queued` : ""}`);
|
|
30
|
+
return `load-lock now: ${parts.join("; ")}.`;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return `load-lock daemon unreachable; it auto-starts on the next load_lock tool call.`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const j = (v) => JSON.stringify(v, null, 2);
|
|
37
|
+
function toolContent(v) {
|
|
38
|
+
return { content: [{ type: "text", text: j(v) }] };
|
|
39
|
+
}
|
|
40
|
+
export default Plugin.define({
|
|
41
|
+
id: "load-lock",
|
|
42
|
+
setup(ctx) {
|
|
43
|
+
// ------------------------------------------------------------- tools
|
|
44
|
+
ctx.tool.transform((editor) => {
|
|
45
|
+
editor.namespace({
|
|
46
|
+
name: "load_lock",
|
|
47
|
+
description: "Machine-wide heavy-work budget shared by all agents on this machine. Acquire before heavy work; claim offers promptly; release when done.",
|
|
48
|
+
});
|
|
49
|
+
const add = (name, description, input, execute) => editor.add({
|
|
50
|
+
name,
|
|
51
|
+
description,
|
|
52
|
+
input: { type: "object", properties: input, ...inputSchemaBase(input) },
|
|
53
|
+
options: { namespace: "load_lock" },
|
|
54
|
+
execute: async (input, context) => {
|
|
55
|
+
knownSessions.add(context.sessionID);
|
|
56
|
+
const r = await execute(input, context);
|
|
57
|
+
return toolContent(r);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
add("acquire", "Acquire a machine-wide permit before resource-intensive local work (full builds, test suites). Returns granted (start heavy work now), or queued (do light work meanwhile; you'll get an offer). Idempotent per op_key. After a grant you MUST start execution within the start deadline via run_with_permit / attach_pid / mark_started.", {
|
|
61
|
+
reason: { type: "string", description: "Why you need the machine (shown to other agents)" },
|
|
62
|
+
op_key: { type: "string", description: "Stable key for the pending operation (idempotency)" },
|
|
63
|
+
wait_ms: { type: "number", description: "Bounded blocking wait for capacity (optional)" },
|
|
64
|
+
pool: { type: "string", description: "Resource pool (default 'heavy')" },
|
|
65
|
+
}, (input, context) => client
|
|
66
|
+
.request("acquire", {
|
|
67
|
+
pool: input.pool,
|
|
68
|
+
agentId: context.sessionID,
|
|
69
|
+
agentLabel: "opencode:" + (context.agent?.name ?? "agent"),
|
|
70
|
+
cwd: ctx.location.directory,
|
|
71
|
+
opKey: input.op_key,
|
|
72
|
+
reason: input.reason,
|
|
73
|
+
waitMs: input.wait_ms,
|
|
74
|
+
})
|
|
75
|
+
.then((res) => ({
|
|
76
|
+
state: res.state,
|
|
77
|
+
request_id: res.entry?.requestId,
|
|
78
|
+
queue_position: res.entry?.position,
|
|
79
|
+
offer_token: res.entry?.offerToken,
|
|
80
|
+
offer_expires_at: res.entry?.offerExpiresAt,
|
|
81
|
+
permit_token: res.entry?.permitToken,
|
|
82
|
+
start_deadline: res.entry?.startDeadline,
|
|
83
|
+
next_action: res.state === "granted"
|
|
84
|
+
? "You hold the permit. Start heavy work now via run_with_permit (best), attach_pid, or mark_started."
|
|
85
|
+
: res.state === "offered"
|
|
86
|
+
? "You have a reserved offer: call claim(offer_token) NOW before it expires."
|
|
87
|
+
: res.state === "queued"
|
|
88
|
+
? "Continue light work; you'll be offered capacity in FIFO order."
|
|
89
|
+
: "Paused; call rejoin(request_id) when ready.",
|
|
90
|
+
entry: res.entry,
|
|
91
|
+
pending: res.pending,
|
|
92
|
+
})));
|
|
93
|
+
add("claim", "Claim an offered permit within its claim window (offers expire, default 30s). Only a successful claim authorizes heavy work. Then start execution (run_with_permit / attach_pid / mark_started) before the start deadline.", { offer_token: { type: "string" } }, (input) => client.request("claim", { offerToken: input.offer_token }).then((res) => ({
|
|
94
|
+
state: "held",
|
|
95
|
+
request_id: res.entry?.requestId,
|
|
96
|
+
permit_token: res.entry?.permitToken,
|
|
97
|
+
phase: res.entry?.phase,
|
|
98
|
+
start_deadline: res.entry?.startDeadline,
|
|
99
|
+
next_action: "Permit claimed. Start the heavy work before the start deadline (run_with_permit / attach_pid / mark_started).",
|
|
100
|
+
entry: res.entry,
|
|
101
|
+
})));
|
|
102
|
+
add("release", "Release a held permit when the heavy work finishes (idempotent). Auto-release also happens when a tracked job exits — but release promptly yourself.", { permit_token: { type: "string" } }, (input) => client.request("release", { permitToken: input.permit_token }).then((res) => ({
|
|
103
|
+
released_now: res.releasedNow,
|
|
104
|
+
request_id: res.entry?.requestId,
|
|
105
|
+
note: res.releasedNow ? "Permit released; queued agents will be offered capacity." : "Already released (idempotent).",
|
|
106
|
+
})));
|
|
107
|
+
add("cancel", "Cancel a queued/offered/paused request you no longer need.", { request_id: { type: "string" } }, (input) => client.request("cancel", { requestId: input.request_id }));
|
|
108
|
+
add("rejoin", "Rejoin the FIFO queue after your offer expired or you declined (paused requests never auto-recycle).", { request_id: { type: "string" } }, (input) => client.request("rejoin", { requestId: input.request_id }));
|
|
109
|
+
add("pending", "Your outstanding offers (claim NOW — they expire), queue positions, paused and held requests.", {}, (_input, context) => client.request("pending", { agentId: context.sessionID }));
|
|
110
|
+
add("status", "Machine-wide pool snapshot: capacity, holders, offers, queue.", { pool: { type: "string" } }, (input) => client.request("status", input.pool ? { pool: input.pool } : {}));
|
|
111
|
+
add("inspect_machine", "Honest machine resources incl. cgroup/container limits and current pressure.", {}, () => client.request("inspect", {}));
|
|
112
|
+
add("get_config", "Current config and change history.", {}, () => client.request("get_config", {}));
|
|
113
|
+
add("configure", "Compare-and-swap config update (capacity, offer timeout, start deadline, lease, guidance). Read get_config first; pass its version as expected_version; explain your reason.", {
|
|
114
|
+
expected_version: { type: "number" },
|
|
115
|
+
changes: { type: "object" },
|
|
116
|
+
reason: { type: "string" },
|
|
117
|
+
}, (input, context) => client.request("configure", {
|
|
118
|
+
expectedVersion: input.expected_version,
|
|
119
|
+
changes: input.changes,
|
|
120
|
+
reason: input.reason,
|
|
121
|
+
author: "opencode:" + context.sessionID,
|
|
122
|
+
}));
|
|
123
|
+
add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Poll job_status.", {
|
|
124
|
+
permit_token: { type: "string" },
|
|
125
|
+
command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
|
|
126
|
+
cwd: { type: "string" },
|
|
127
|
+
}, async (input, context) => {
|
|
128
|
+
const child = spawn(input.command, {
|
|
129
|
+
shell: true,
|
|
130
|
+
detached: true,
|
|
131
|
+
cwd: input.cwd || ctx.location.directory,
|
|
132
|
+
env: process.env,
|
|
133
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
134
|
+
});
|
|
135
|
+
const job = {
|
|
136
|
+
id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
|
|
137
|
+
child,
|
|
138
|
+
permitToken: input.permit_token,
|
|
139
|
+
command: input.command,
|
|
140
|
+
sessionID: context.sessionID,
|
|
141
|
+
exited: false,
|
|
142
|
+
exitCode: null,
|
|
143
|
+
output: [],
|
|
144
|
+
};
|
|
145
|
+
const push = (b) => {
|
|
146
|
+
job.output.push(b.toString("utf8"));
|
|
147
|
+
if (job.output.length > 200)
|
|
148
|
+
job.output.shift();
|
|
149
|
+
};
|
|
150
|
+
child.stdout?.on("data", push);
|
|
151
|
+
child.stderr?.on("data", push);
|
|
152
|
+
// Watch for exit BEFORE associating (no awaits in between): a job
|
|
153
|
+
// that exits instantly must not lose its exit event while we
|
|
154
|
+
// round-trip to the daemon, or the permit would never auto-release.
|
|
155
|
+
child.on("exit", (code) => {
|
|
156
|
+
job.exited = true;
|
|
157
|
+
job.exitCode = code;
|
|
158
|
+
void client.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" }).catch(() => { });
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(-child.pid, "SIGKILL");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* already gone */
|
|
169
|
+
}
|
|
170
|
+
return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
|
|
171
|
+
}
|
|
172
|
+
jobs.set(job.id, job);
|
|
173
|
+
return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; poll job_status" };
|
|
174
|
+
});
|
|
175
|
+
add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
|
|
176
|
+
const job = jobs.get(input.job_id);
|
|
177
|
+
if (!job)
|
|
178
|
+
return { error: "unknown job_id" };
|
|
179
|
+
return {
|
|
180
|
+
job_id: job.id,
|
|
181
|
+
command: job.command,
|
|
182
|
+
running: !job.exited,
|
|
183
|
+
exit_code: job.exitCode,
|
|
184
|
+
output_tail: job.output.slice(-20).join(""),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
add("job_stop", "Terminate a run_with_permit job (SIGTERM then SIGKILL to its process group).", { job_id: { type: "string" } }, (input) => {
|
|
188
|
+
const job = jobs.get(input.job_id);
|
|
189
|
+
if (!job)
|
|
190
|
+
return { error: "unknown job_id" };
|
|
191
|
+
if (!job.exited) {
|
|
192
|
+
try {
|
|
193
|
+
process.kill(-job.child.pid, "SIGTERM");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* already gone */
|
|
197
|
+
}
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
if (!job.exited) {
|
|
200
|
+
try {
|
|
201
|
+
process.kill(-job.child.pid, "SIGKILL");
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* already gone */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, 5000).unref?.();
|
|
208
|
+
}
|
|
209
|
+
return { job_id: job.id, stopping: true };
|
|
210
|
+
});
|
|
211
|
+
add("attach_pid", "Associate a process you already started (e.g. a background shell task) with your claimed permit. Atomic; auto-releases when the process group exits.", { permit_token: { type: "string" }, pid: { type: "number" } }, (input) => client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid }));
|
|
212
|
+
add("mark_started", "Tell load-lock you started the work when no process can be associated. Weaker guarantee: heartbeat lease applies (see docs). Prefer run_with_permit.", { permit_token: { type: "string" } }, (input) => client.request("mark_started", { permitToken: input.permit_token }));
|
|
213
|
+
});
|
|
214
|
+
// ------------------------------------------- policy on every model call
|
|
215
|
+
void ctx.session.hook("context", async (event) => {
|
|
216
|
+
event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) });
|
|
217
|
+
});
|
|
218
|
+
// -------------------------------------------------- idle wakeup via watch
|
|
219
|
+
void (async () => {
|
|
220
|
+
try {
|
|
221
|
+
await client.connect();
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return; // next tool call will retry
|
|
225
|
+
}
|
|
226
|
+
await client.watch("*", async (ev) => {
|
|
227
|
+
if (ev.type !== "offered" || !ev.targetAgent)
|
|
228
|
+
return;
|
|
229
|
+
if (!knownSessions.has(ev.targetAgent))
|
|
230
|
+
return; // not an OpenCode session we serve
|
|
231
|
+
const token = ev.offerToken;
|
|
232
|
+
const seconds = Math.max(0, Math.round(((ev.entry?.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
|
|
233
|
+
try {
|
|
234
|
+
await ctx.session.synthetic({
|
|
235
|
+
sessionID: ev.targetAgent,
|
|
236
|
+
text: `load-lock: capacity for "${ev.entry?.pool}" was reserved for your request (op "${ev.entry?.opKey}"). ` +
|
|
237
|
+
`It expires in ~${seconds}s. Call load_lock_claim with offer_token "${token}" now, then start your heavy work.`,
|
|
238
|
+
description: "load-lock permit offer",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* session may be gone */
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
})();
|
|
246
|
+
// -------------------------------------------- automatic heartbeats
|
|
247
|
+
const hb = setInterval(() => {
|
|
248
|
+
for (const s of knownSessions) {
|
|
249
|
+
void client.request("heartbeat", { agentId: s }).catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
}, 30_000);
|
|
252
|
+
hb.unref?.();
|
|
253
|
+
// ------------------------------------------------------------ skill
|
|
254
|
+
try {
|
|
255
|
+
const skillContent = SKILL_CONTENT();
|
|
256
|
+
const skillFile = path.join(os.tmpdir(), "load-lock-skill.md");
|
|
257
|
+
try {
|
|
258
|
+
if (fs.readFileSync(skillFile, "utf8") !== skillContent) {
|
|
259
|
+
fs.writeFileSync(skillFile, skillContent);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
fs.writeFileSync(skillFile, skillContent);
|
|
264
|
+
}
|
|
265
|
+
ctx.skill.transform((editor) => {
|
|
266
|
+
editor.add({
|
|
267
|
+
id: "load-lock",
|
|
268
|
+
name: "load-lock coordination",
|
|
269
|
+
description: "Detailed guide for the machine-wide heavy-work semaphore: heavy-vs-light heuristics, offer claiming, background jobs, configuration and recovery.",
|
|
270
|
+
path: skillFile,
|
|
271
|
+
content: skillContent,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* skill API optional */
|
|
277
|
+
}
|
|
278
|
+
return () => {
|
|
279
|
+
clearInterval(hb);
|
|
280
|
+
client.close();
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
function inputSchemaBase(input) {
|
|
285
|
+
const required = Object.entries(input)
|
|
286
|
+
.filter(([, v]) => v?.required === true)
|
|
287
|
+
.map(([k]) => k);
|
|
288
|
+
return required.length ? { required } : {};
|
|
289
|
+
}
|
|
290
|
+
function SKILL_CONTENT() {
|
|
291
|
+
// The full guide ships in docs/AGENT_GUIDE.md next to the package dist.
|
|
292
|
+
try {
|
|
293
|
+
const p = path.join(import.meta.dirname ?? ".", "..", "..", "docs", "AGENT_GUIDE.md");
|
|
294
|
+
if (fs.existsSync(p))
|
|
295
|
+
return fs.readFileSync(p, "utf8");
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* fall through */
|
|
299
|
+
}
|
|
300
|
+
return POLICY_BRIEF;
|
|
301
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function hookCommand(rest: string[]): Promise<void>;
|
package/dist/cli/hook.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* load-lock harness hook handlers (Claude Code, Codex CLI).
|
|
3
|
+
*
|
|
4
|
+
* These are invoked by the harness's hook system with the harness's hook
|
|
5
|
+
* JSON on stdin. They are intentionally fast (one daemon RPC or none) and
|
|
6
|
+
* never fail the harness on daemon unavailability.
|
|
7
|
+
*
|
|
8
|
+
* Verified semantics (docs/COMPATIBILITY.md):
|
|
9
|
+
* - Claude Code SessionStart: plain-text stdout becomes context; matcher
|
|
10
|
+
* startup|resume|clear|compact|fork => policy survives compaction.
|
|
11
|
+
* - Claude Code Stop: {"decision":"block","reason":...} continues Claude
|
|
12
|
+
* with the reason as a new user prompt (idle wakeup); stop_hook_active
|
|
13
|
+
* guards against loops.
|
|
14
|
+
* - Claude Code async hooks (async + asyncRewake): exit code 2 + stderr
|
|
15
|
+
* wakes Claude with a system reminder — used by await-offer.
|
|
16
|
+
* - Codex SessionStart/SubagentStart: plain stdout = additionalContext.
|
|
17
|
+
* - Codex Stop: {"decision":"block","reason":...} continues; async hooks
|
|
18
|
+
* cannot wake an idle Codex session (documented limitation).
|
|
19
|
+
*/
|
|
20
|
+
import { LLClient } from "../shared/client.js";
|
|
21
|
+
import { POLICY_BRIEF } from "../policy/text.js";
|
|
22
|
+
async function readStdin() {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const c of process.stdin)
|
|
25
|
+
chunks.push(c);
|
|
26
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
27
|
+
if (!text)
|
|
28
|
+
return {};
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function machineBrief() {
|
|
37
|
+
try {
|
|
38
|
+
const c = new LLClient();
|
|
39
|
+
await c.connect();
|
|
40
|
+
try {
|
|
41
|
+
const st = (await c.request("status", {}));
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (const p of st.pools) {
|
|
44
|
+
parts.push(`pool "${p.pool}": ${p.used}/${p.capacity} in use` +
|
|
45
|
+
(p.queue.length ? `, ${p.queue.length} queued` : ""));
|
|
46
|
+
}
|
|
47
|
+
const insp = (await c.request("inspect", {}));
|
|
48
|
+
return `Current machine: ~${insp.cpu.effectiveCores} effective core(s), ${(insp.memory.effectiveLimitBytes / 1024 ** 3).toFixed(1)} GiB effective memory; ${insp.pressure.note}\nload-lock now: ${parts.join("; ")}.`;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
c.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "load-lock daemon is not reachable right now; it will auto-start on your first load-lock tool call. Use the tools per the policy above when doing heavy work.";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function claimInstruction(offer, seconds) {
|
|
59
|
+
return (`load-lock: capacity for "${offer.pool}" is now reserved for you (request ${offer.requestId}, ` +
|
|
60
|
+
`op "${offer.opKey}", reason: ${offer.reason}). Claim it NOW with the load-lock claim tool ` +
|
|
61
|
+
`passing offer_token "${offer.offerToken}" — the reservation expires in ~${seconds}s and passes to the next ` +
|
|
62
|
+
`agent if unused. After claiming, start your heavy work immediately (run_with_permit / attach_pid / mark_started).`);
|
|
63
|
+
}
|
|
64
|
+
export async function hookCommand(rest) {
|
|
65
|
+
const harness = rest[0];
|
|
66
|
+
const event = rest[1];
|
|
67
|
+
if (!harness || !event) {
|
|
68
|
+
process.exit(0); // never break the harness on invocation mistakes
|
|
69
|
+
}
|
|
70
|
+
const input = await readStdin();
|
|
71
|
+
const cwd = input.cwd || process.cwd();
|
|
72
|
+
switch (`${harness}/${event}`) {
|
|
73
|
+
// ------------------------------------------------------- session starts
|
|
74
|
+
case "claude-code/session-start":
|
|
75
|
+
case "codex/session-start":
|
|
76
|
+
case "codex/subagent-start": {
|
|
77
|
+
process.stdout.write(POLICY_BRIEF + "\n\n" + (await machineBrief()) + "\n");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// ------------------------------------------------------------- wakeups
|
|
81
|
+
case "claude-code/stop":
|
|
82
|
+
case "codex/stop": {
|
|
83
|
+
if (input.stop_hook_active) {
|
|
84
|
+
return; // we already woke once for this stop; do not loop
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const c = new LLClient({ spawnDaemon: false });
|
|
88
|
+
await c.connect();
|
|
89
|
+
try {
|
|
90
|
+
const { offers } = (await c.request("offers_for_cwd", { cwd }));
|
|
91
|
+
if (offers.length > 0) {
|
|
92
|
+
const o = offers[0];
|
|
93
|
+
const seconds = Math.max(0, Math.round(((o.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
|
|
94
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: claimInstruction(o, seconds) }) + "\n");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
c.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* daemon down: nothing to deliver */
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// async + asyncRewake watcher (Claude Code only; Codex async hooks cannot
|
|
107
|
+
// wake an idle session — documented).
|
|
108
|
+
case "claude-code/await-offer": {
|
|
109
|
+
const maxWaitMs = Number(process.env.LOAD_LOCK_AWAIT_MS ?? 10 * 60_000);
|
|
110
|
+
const c = new LLClient({ spawnDaemon: false });
|
|
111
|
+
try {
|
|
112
|
+
await c.connect();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
process.exit(0); // daemon down; policy hooks will cover it
|
|
116
|
+
}
|
|
117
|
+
let woken = false;
|
|
118
|
+
await new Promise((resolve) => {
|
|
119
|
+
const done = setTimeout(() => {
|
|
120
|
+
c.close();
|
|
121
|
+
resolve();
|
|
122
|
+
}, maxWaitMs);
|
|
123
|
+
void c.watch("*", (ev) => {
|
|
124
|
+
if (ev.type === "offered" && ev.entry?.cwd === cwd) {
|
|
125
|
+
woken = true;
|
|
126
|
+
clearTimeout(done);
|
|
127
|
+
const seconds = Math.max(0, Math.round(((ev.entry.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
|
|
128
|
+
process.stderr.write(`load-lock: capacity for "${ev.entry.pool}" was just reserved for a request from this directory ` +
|
|
129
|
+
`(op "${ev.entry.opKey}"). It expires in ~${seconds}s. Call your load-lock pending tool now and ` +
|
|
130
|
+
`claim the offer with claim(offer_token), then start your heavy work immediately.`);
|
|
131
|
+
c.close();
|
|
132
|
+
resolve();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
process.exit(woken ? 2 : 0); // asyncRewake: exit 2 + stderr wakes Claude
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InstallOptions {
|
|
2
|
+
harness: string;
|
|
3
|
+
project: string;
|
|
4
|
+
json?: boolean;
|
|
5
|
+
global?: boolean;
|
|
6
|
+
binDir?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UninstallOptions {
|
|
9
|
+
harness: string;
|
|
10
|
+
project: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function pkgRoot(binDir?: string): string;
|
|
13
|
+
export declare function installCommand(opts: InstallOptions): Promise<void>;
|
|
14
|
+
export declare function uninstallCommand(opts: UninstallOptions): Promise<void>;
|