@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,377 @@
1
+ /**
2
+ * `load-lock install --harness <id>` — wires load-lock into a harness.
3
+ *
4
+ * Everything here MERGES into existing files; unrelated user config is never
5
+ * touched. Our own entries are tagged (LL marker / absolute paths into this
6
+ * package) so uninstall removes exactly what install added.
7
+ *
8
+ * Harnesses:
9
+ * - opencode : plugin dir ~/.config/opencode/plugins/load-lock/ (stub that
10
+ * re-exports the compiled plugin from this package, with a
11
+ * local package.json so `@opencode/plugin` + `load-lock`
12
+ * resolve), plus a `plugins` entry in opencode.json.
13
+ * - claude-code : user-scope MCP server in ~/.claude.json, SessionStart /
14
+ * Stop hooks + background await-offer watcher in
15
+ * ~/.claude/settings.json, CLAUDE.md section, skill.
16
+ * - codex : ~/.codex/hooks.json (SessionStart/SubagentStart/Stop),
17
+ * [mcp_servers.load-lock] in ~/.codex/config.toml,
18
+ * ~/.codex/AGENTS.md pointer. Trust must be granted via /hooks.
19
+ * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
20
+ */
21
+ import { spawnSync } from "node:child_process";
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { POLICY_BRIEF } from "../policy/text.js";
26
+ const HOME = os.homedir();
27
+ export function pkgRoot(binDir) {
28
+ const d = binDir ?? path.join(import.meta.dirname ?? ".", ".."); // dist/cli -> dist
29
+ return path.resolve(d, ".."); // dist -> package root
30
+ }
31
+ function pkgPaths(binDir) {
32
+ const root = pkgRoot(binDir);
33
+ return {
34
+ root,
35
+ cli: path.join(root, "dist", "cli", "main.js"),
36
+ mcp: path.join(root, "dist", "mcp", "main.js"),
37
+ docs: path.join(root, "docs"),
38
+ skill: path.join(root, "skills", "load-lock", "SKILL.md"),
39
+ node: process.execPath,
40
+ };
41
+ }
42
+ function say(opts, msg) {
43
+ if (!opts.json)
44
+ process.stdout.write(msg + "\n");
45
+ }
46
+ // ---------------------------------------------------------------- io helpers
47
+ function readJson(file) {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(file, "utf8"));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ function writeJson(file, data) {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ const tmp = file + ".tmp-ll-" + process.pid;
58
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
59
+ fs.renameSync(tmp, file);
60
+ }
61
+ /** Append a marked section to a markdown file once. */
62
+ function mdSection(file, marker, section) {
63
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
64
+ if (existing.includes(marker))
65
+ return false;
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ fs.writeFileSync(file, existing + (existing.endsWith("\n") ? "" : "\n") + section);
68
+ return true;
69
+ }
70
+ function removeMdSection(file, marker) {
71
+ if (!fs.existsSync(file))
72
+ return;
73
+ const text = fs.readFileSync(file, "utf8");
74
+ const idx = text.indexOf(marker);
75
+ if (idx === -1)
76
+ return;
77
+ const next = text.indexOf(marker, idx + marker.length);
78
+ const end = next === -1 ? text.length : next;
79
+ fs.writeFileSync(file, text.slice(0, idx) + text.slice(end));
80
+ }
81
+ const LL_HOOK_TAG = "dist/cli/main.js hook"; // how we recognize our hook entries
82
+ // ------------------------------------------------------------------ opencode
83
+ function installOpenCode(opts, P) {
84
+ const actions = [];
85
+ const pluginDir = path.join(HOME, ".config", "opencode", "plugins", "load-lock");
86
+ fs.mkdirSync(pluginDir, { recursive: true });
87
+ // Stub that re-exports the compiled plugin from this package.
88
+ fs.writeFileSync(path.join(pluginDir, "index.js"), `// installed by load-lock; re-exports the compiled plugin\n` +
89
+ `export { default } from "@aron98/load-lock/dist/adapters/opencode/plugin.js";\n`);
90
+ actions.push(`plugin stub: ${pluginDir}/index.js`);
91
+ fs.writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify({
92
+ name: "load-lock-opencode-plugin",
93
+ version: "0.1.0",
94
+ type: "module",
95
+ main: "index.js",
96
+ dependencies: {
97
+ "@opencode/plugin": "^2.0.29",
98
+ "@aron98/load-lock": "file:" + P.root,
99
+ },
100
+ }, null, 2) + "\n");
101
+ actions.push("plugin package.json (file: dep on this package)");
102
+ const npmInstall = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: pluginDir, encoding: "utf8" });
103
+ if (npmInstall.status !== 0) {
104
+ actions.push("WARNING: npm install inside plugin dir failed — see errors above");
105
+ }
106
+ else {
107
+ actions.push("plugin dependencies installed");
108
+ }
109
+ // Register in ~/.config/opencode/opencode.json (merge, preserve).
110
+ const cfgFile = path.join(HOME, ".config", "opencode", "opencode.json");
111
+ const cfg = readJson(cfgFile) ?? {};
112
+ if (!Array.isArray(cfg.plugins))
113
+ cfg.plugins = [];
114
+ if (!cfg.plugins.includes(pluginDir))
115
+ cfg.plugins.push(pluginDir);
116
+ writeJson(cfgFile, cfg);
117
+ actions.push(`plugins entry in ${cfgFile}`);
118
+ actions.push("restart or reload your opencode service to load the plugin");
119
+ return actions;
120
+ }
121
+ function uninstallOpenCode() {
122
+ const actions = [];
123
+ const pluginDir = path.join(HOME, ".config", "opencode", "plugins", "load-lock");
124
+ fs.rmSync(pluginDir, { recursive: true, force: true });
125
+ actions.push(`removed ${pluginDir}`);
126
+ const cfgFile = path.join(HOME, ".config", "opencode", "opencode.json");
127
+ const cfg = readJson(cfgFile);
128
+ if (cfg && Array.isArray(cfg.plugins)) {
129
+ cfg.plugins = cfg.plugins.filter((p) => p !== pluginDir);
130
+ writeJson(cfgFile, cfg);
131
+ actions.push(`removed plugins entry from ${cfgFile}`);
132
+ }
133
+ return actions;
134
+ }
135
+ // -------------------------------------------------------------- claude-code
136
+ function hookEntry(P, event, extra) {
137
+ const cmd = `${P.node} ${JSON.stringify(P.cli)} hook claude-code`;
138
+ return {
139
+ hooks: [
140
+ {
141
+ type: "command",
142
+ command: event === "session-start"
143
+ ? `${cmd} session-start`
144
+ : event === "stop"
145
+ ? `${cmd} stop`
146
+ : `${cmd} await-offer`,
147
+ ...extra,
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ function installClaudeCode(opts, P) {
153
+ const actions = [];
154
+ // 1. user-scope MCP server in ~/.claude.json
155
+ const claudeJson = path.join(HOME, ".claude.json");
156
+ const cj = readJson(claudeJson) ?? {};
157
+ if (typeof cj !== "object" || cj === null)
158
+ throw new Error(`${claudeJson} is not a JSON object; refusing to overwrite`);
159
+ if (!cj.mcpServers || typeof cj.mcpServers !== "object")
160
+ cj.mcpServers = {};
161
+ cj.mcpServers["load-lock"] = { type: "stdio", command: P.node, args: [P.mcp] };
162
+ writeJson(claudeJson, cj);
163
+ actions.push(`MCP server "load-lock" in ${claudeJson}`);
164
+ // 2. hooks in ~/.claude/settings.json (merge; replace only our own entries)
165
+ const settingsFile = path.join(HOME, ".claude", "settings.json");
166
+ const s = readJson(settingsFile) ?? {};
167
+ if (!s.hooks || typeof s.hooks !== "object")
168
+ s.hooks = {};
169
+ const ours = (list) => list.filter((e) => JSON.stringify(e).includes(LL_HOOK_TAG));
170
+ const cc = (event, entries) => {
171
+ const prev = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
172
+ const kept = prev.filter((e) => !ours([e]).length);
173
+ s.hooks[event] = [...kept, ...entries];
174
+ };
175
+ cc("SessionStart", [hookEntry(P, "session-start")]);
176
+ cc("Stop", [hookEntry(P, "stop")]);
177
+ // background watcher: async + asyncRewake wakes Claude (exit 2 + stderr)
178
+ s.hooks.SessionStart.push(hookEntry(P, "await-offer", { async: true, asyncRewake: true }));
179
+ writeJson(settingsFile, s);
180
+ actions.push(`hooks (SessionStart policy, Stop offer wakeup, await-offer watcher) in ${settingsFile}`);
181
+ // 3. CLAUDE.md pointer
182
+ const md = path.join(HOME, ".claude", "CLAUDE.md");
183
+ const marker = "<!-- load-lock:begin -->";
184
+ if (mdSection(md, marker, `\n${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
185
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
186
+ `1. Call the MCP tool \`mcp__load-lock__acquire\` (reason + op_key) — it grants a permit or queues you.\n` +
187
+ `2. While queued, keep doing light work. When an offer arrives (hook wakeup or \`mcp__load-lock__pending\`), claim it via \`mcp__load-lock__claim\` within the window (default 30s).\n` +
188
+ `3. Only start heavy work after acquire/claim succeeds. Use \`mcp__load-lock__run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` for processes you already started.\n` +
189
+ `4. Release (or cancel queued work) promptly. Inspect the machine with \`mcp__load-lock__inspect_machine\`; adjust capacity via \`mcp__load-lock__configure\` when warranted.\n\n` +
190
+ `Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
191
+ actions.push(`CLAUDE.md section in ${md}`);
192
+ // 4. skill
193
+ const skillDir = path.join(HOME, ".claude", "skills", "load-lock");
194
+ fs.mkdirSync(skillDir, { recursive: true });
195
+ fs.copyFileSync(P.skill, path.join(skillDir, "SKILL.md"));
196
+ actions.push(`skill in ${skillDir}/SKILL.md`);
197
+ return actions;
198
+ }
199
+ function uninstallClaudeCode() {
200
+ const actions = [];
201
+ const claudeJson = path.join(HOME, ".claude.json");
202
+ const cj = readJson(claudeJson);
203
+ if (cj?.mcpServers && typeof cj.mcpServers === "object" && "load-lock" in cj.mcpServers) {
204
+ delete cj.mcpServers["load-lock"];
205
+ writeJson(claudeJson, cj);
206
+ actions.push(`removed MCP server from ${claudeJson}`);
207
+ }
208
+ const settingsFile = path.join(HOME, ".claude", "settings.json");
209
+ const s = readJson(settingsFile);
210
+ if (s?.hooks && typeof s.hooks === "object") {
211
+ for (const ev of Object.keys(s.hooks)) {
212
+ const list = s.hooks[ev];
213
+ if (Array.isArray(list)) {
214
+ const kept = list.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG));
215
+ if (kept.length)
216
+ s.hooks[ev] = kept;
217
+ else
218
+ delete s.hooks[ev];
219
+ }
220
+ }
221
+ writeJson(settingsFile, s);
222
+ actions.push(`removed hooks from ${settingsFile}`);
223
+ }
224
+ const md = path.join(HOME, ".claude", "CLAUDE.md");
225
+ removeMdSection(md, "<!-- load-lock:begin -->");
226
+ actions.push(`removed CLAUDE.md section`);
227
+ fs.rmSync(path.join(HOME, ".claude", "skills", "load-lock"), { recursive: true, force: true });
228
+ actions.push("removed skill");
229
+ return actions;
230
+ }
231
+ // --------------------------------------------------------------------- codex
232
+ function installCodex(opts, P) {
233
+ const actions = [];
234
+ const codexHome = process.env.CODEX_HOME ?? path.join(HOME, ".codex");
235
+ // 1. hooks.json (merge by replacing only our entries)
236
+ const hooksFile = path.join(codexHome, "hooks.json");
237
+ const h = readJson(hooksFile) ?? {};
238
+ if (typeof h !== "object" || h === null)
239
+ throw new Error(`${hooksFile} is not a JSON object; refusing to overwrite`);
240
+ if (!h.hooks || typeof h.hooks !== "object")
241
+ h.hooks = {};
242
+ const mk = (event, sub) => [
243
+ {
244
+ hooks: [
245
+ {
246
+ type: "command",
247
+ command: `${P.node} ${JSON.stringify(P.cli)} hook codex ${sub}`,
248
+ },
249
+ ],
250
+ },
251
+ ];
252
+ const mergeEv = (event, entries) => {
253
+ const prev = Array.isArray(h.hooks[event]) ? h.hooks[event] : [];
254
+ h.hooks[event] = [...prev.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG)), ...entries];
255
+ };
256
+ mergeEv("SessionStart", [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex session-start` }] }]);
257
+ mergeEv("SubagentStart", [{ matcher: "*", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex subagent-start` }] }]);
258
+ mergeEv("Stop", mk("Stop", "stop"));
259
+ writeJson(hooksFile, h);
260
+ actions.push(`hooks in ${hooksFile} (SessionStart/SubagentStart/Stop)`);
261
+ // 2. MCP server in config.toml (append block only if absent)
262
+ const tomlFile = path.join(codexHome, "config.toml");
263
+ fs.mkdirSync(codexHome, { recursive: true });
264
+ const toml = fs.existsSync(tomlFile) ? fs.readFileSync(tomlFile, "utf8") : "";
265
+ if (!toml.includes("[mcp_servers.load-lock]")) {
266
+ fs.writeFileSync(tomlFile, toml +
267
+ (toml.endsWith("\n") ? "" : "\n") +
268
+ `\n# load-lock (added by \`load-lock install\`)\n[mcp_servers.load-lock]\n` +
269
+ `command = ${JSON.stringify(P.node)}\n` +
270
+ `args = [${JSON.stringify(P.mcp)}]\n`);
271
+ actions.push(`[mcp_servers.load-lock] appended to ${tomlFile}`);
272
+ }
273
+ else {
274
+ actions.push(`[mcp_servers.load-lock] already present in ${tomlFile}`);
275
+ }
276
+ // 3. AGENTS.md pointer
277
+ const agentsMd = path.join(codexHome, "AGENTS.md");
278
+ if (mdSection(agentsMd, "<!-- load-lock:begin -->", `\n<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
279
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (mcp__load-lock__acquire / claim / release / run_with_permit / pending / status / inspect_machine / configure). ` +
280
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done. Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
281
+ actions.push(`AGENTS.md section in ${agentsMd}`);
282
+ actions.push("IMPORTANT: run `/hooks` inside Codex and grant trust to the new hooks (they are inert until trusted)");
283
+ return actions;
284
+ }
285
+ function uninstallCodex() {
286
+ const actions = [];
287
+ const codexHome = process.env.CODEX_HOME ?? path.join(HOME, ".codex");
288
+ const hooksFile = path.join(codexHome, "hooks.json");
289
+ const h = readJson(hooksFile);
290
+ if (h?.hooks && typeof h.hooks === "object") {
291
+ for (const ev of Object.keys(h.hooks)) {
292
+ const list = h.hooks[ev];
293
+ if (Array.isArray(list)) {
294
+ const kept = list.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG));
295
+ if (kept.length)
296
+ h.hooks[ev] = kept;
297
+ else
298
+ delete h.hooks[ev];
299
+ }
300
+ }
301
+ writeJson(hooksFile, h);
302
+ actions.push(`removed hooks from ${hooksFile}`);
303
+ }
304
+ const tomlFile = path.join(codexHome, "config.toml");
305
+ if (fs.existsSync(tomlFile)) {
306
+ const toml = fs.readFileSync(tomlFile, "utf8");
307
+ const re = /\n?# load-lock \(added by `load-lock install`\)\n\[mcp_servers\.load-lock\]\n[^\n]*\n[^\n]*\n/;
308
+ if (re.test(toml)) {
309
+ fs.writeFileSync(tomlFile, toml.replace(re, ""));
310
+ actions.push(`removed [mcp_servers.load-lock] from ${tomlFile}`);
311
+ }
312
+ }
313
+ const agentsMd = path.join(codexHome, "AGENTS.md");
314
+ removeMdSection(agentsMd, "<!-- load-lock:begin -->");
315
+ actions.push("removed AGENTS.md section");
316
+ return actions;
317
+ }
318
+ // -------------------------------------------------------------------- public
319
+ export async function installCommand(opts) {
320
+ const P = pkgPaths(opts.binDir);
321
+ if (!fs.existsSync(P.mcp)) {
322
+ throw new Error(`compiled entry not found at ${P.mcp} — run \`npm run build\` inside the load-lock package first`);
323
+ }
324
+ const harness = opts.harness;
325
+ if (!harness)
326
+ throw new Error("install: --harness <opencode|claude-code|codex|generic> is required");
327
+ let actions;
328
+ switch (harness) {
329
+ case "opencode":
330
+ actions = installOpenCode(opts, P);
331
+ break;
332
+ case "claude-code":
333
+ case "claude":
334
+ actions = installClaudeCode(opts, P);
335
+ break;
336
+ case "codex":
337
+ actions = installCodex(opts, P);
338
+ break;
339
+ case "generic":
340
+ case "all":
341
+ default:
342
+ // generic: print an MCP stdio snippet for any MCP-capable harness
343
+ process.stdout.write(`\nAdd this MCP stdio server to your harness's MCP config:\n\n` +
344
+ ` name: load-lock\n command: ${P.node}\n args: [${JSON.stringify(P.mcp)}]\n\n` +
345
+ `Tools: acquire, claim, release, cancel, rejoin, pending, status, inspect_machine,\n` +
346
+ `get_config, configure, run_with_permit, job_status, job_stop, attach_pid, mark_started.\n` +
347
+ `Then make sure your harness instructions tell agents to use it (see docs/AGENT_GUIDE.md).\n\n`);
348
+ return;
349
+ }
350
+ say(opts, `\nload-lock installed for ${harness}:`);
351
+ for (const a of actions)
352
+ say(opts, " - " + a);
353
+ say(opts, "");
354
+ }
355
+ export async function uninstallCommand(opts) {
356
+ let actions;
357
+ switch (opts.harness) {
358
+ case "opencode":
359
+ actions = uninstallOpenCode();
360
+ break;
361
+ case "claude-code":
362
+ case "claude":
363
+ actions = uninstallClaudeCode();
364
+ break;
365
+ case "codex":
366
+ actions = uninstallCodex();
367
+ break;
368
+ default:
369
+ throw new Error(`uninstall: unknown harness "${opts.harness}"`);
370
+ }
371
+ process.stdout.write(`\nload-lock uninstalled for ${opts.harness}:\n`);
372
+ for (const a of actions)
373
+ process.stdout.write(" - " + a + "\n");
374
+ process.stdout.write("\n");
375
+ }
376
+ // keep POLICY_BRIEF referenced for potential future use in generic snippets
377
+ void POLICY_BRIEF;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};