@aron98/load-lock 0.1.1 → 1.0.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 (49) hide show
  1. package/README.md +37 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +31 -0
  5. package/dist/adapters/opencode/tui.js +268 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +740 -61
  8. package/dist/cli/main.js +36 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +98 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +12 -0
  18. package/dist/dashboard/frame.js +94 -0
  19. package/dist/dashboard/view.d.ts +148 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/dashboard.test.d.ts +1 -0
  33. package/dist/test/dashboard.test.js +405 -0
  34. package/dist/test/hook.test.js +53 -3
  35. package/dist/test/install.test.d.ts +1 -0
  36. package/dist/test/install.test.js +658 -0
  37. package/dist/test/mcp.test.js +36 -1
  38. package/dist/test/platform.test.d.ts +1 -0
  39. package/dist/test/platform.test.js +193 -0
  40. package/dist/test/testutil.js +4 -1
  41. package/dist/test/wait-turn.test.d.ts +1 -0
  42. package/dist/test/wait-turn.test.js +376 -0
  43. package/docs/ADAPTERS.md +5 -2
  44. package/docs/AGENT_GUIDE.md +33 -1
  45. package/docs/ARCHITECTURE.md +25 -5
  46. package/docs/COMPATIBILITY.md +215 -26
  47. package/docs/OPERATIONS.md +313 -8
  48. package/package.json +25 -1
  49. package/skills/load-lock/SKILL.md +4 -0
@@ -6,24 +6,50 @@
6
6
  * package) so uninstall removes exactly what install added.
7
7
  *
8
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.
9
+ * - opencode : registers the published npm package in the `plugins`
10
+ * array of ~/.config/opencode/opencode.json (opencode
11
+ * resolves/installs/updates the package itself; legacy
12
+ * local-stub installs are migrated away automatically).
13
13
  * - claude-code : user-scope MCP server in ~/.claude.json, SessionStart /
14
14
  * Stop hooks + background await-offer watcher in
15
15
  * ~/.claude/settings.json, CLAUDE.md section, skill.
16
16
  * - codex : ~/.codex/hooks.json (SessionStart/SubagentStart/Stop),
17
17
  * [mcp_servers.load-lock] in ~/.codex/config.toml,
18
18
  * ~/.codex/AGENTS.md pointer. Trust must be granted via /hooks.
19
+ * - gemini-cli : mcpServers.load-lock in ~/.gemini/settings.json,
20
+ * marked section in ~/.gemini/GEMINI.md. MCP-fallback
21
+ * tier: Gemini CLI has no hooks and no async push (no
22
+ * idle wakeup) — offers surface at tool boundaries.
23
+ * - copilot-cli : mcpServers.load-lock (type "local" / stdio) in
24
+ * ~/.copilot/mcp-config.json, marked section in the
25
+ * global ~/.copilot/copilot-instructions.md. MCP-fallback
26
+ * tier: Copilot CLI hooks are ecosystem-reported but NOT
27
+ * verified in official docs — we install none.
28
+ * - cline : mcpServers.load-lock in
29
+ * ~/.cline/data/settings/cline_mcp_settings.json
30
+ * (honors CLINE_MCP_SETTINGS_PATH), own rule file in
31
+ * ~/.cline/rules/. MCP-fallback tier: no hooks.
32
+ * - roo-code : project .roo/mcp.json (+ global Roo/Zoo mcp_settings.json
33
+ * when a VS Code globalStorage copy exists), own rule
34
+ * file in ~/.roo/rules/. MCP-fallback tier: no hooks.
35
+ * - goose : extensions.load-lock (type: stdio) merged into
36
+ * ~/.config/goose/config.yaml, marked section in the
37
+ * global hints file ~/.config/goose/.goosehints.
38
+ * MCP-fallback tier: no hooks.
39
+ * - cursor : project-level (per --project) .cursor/mcp.json
40
+ * (mcpServers.load-lock stdio), stop hook in
41
+ * .cursor/hooks.json (hooks v1: {"followup_message":...}
42
+ * continuation = idle wakeup), own Always-apply rule
43
+ * .cursor/rules/load-lock.mdc. Contract-tested against
44
+ * the documented hooks JSON; not live-verified.
19
45
  * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
20
46
  */
21
- import { spawnSync } from "node:child_process";
22
47
  import fs from "node:fs";
23
48
  import os from "node:os";
24
49
  import path from "node:path";
25
50
  import { POLICY_BRIEF } from "../policy/text.js";
26
51
  const HOME = os.homedir();
52
+ const PKG_NAME = "@aron98/load-lock";
27
53
  export function pkgRoot(binDir) {
28
54
  const d = binDir ?? path.join(import.meta.dirname ?? ".", ".."); // dist/cli -> dist
29
55
  return path.resolve(d, ".."); // dist -> package root
@@ -58,13 +84,24 @@ function writeJson(file, data) {
58
84
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
59
85
  fs.renameSync(tmp, file);
60
86
  }
61
- /** Append a marked section to a markdown file once. */
87
+ /** Atomic plain-text write (used for YAML / hints files). */
88
+ function writeText(file, text) {
89
+ fs.mkdirSync(path.dirname(file), { recursive: true });
90
+ const tmp = file + ".tmp-ll-" + process.pid;
91
+ fs.writeFileSync(tmp, text);
92
+ fs.renameSync(tmp, file);
93
+ }
94
+ /**
95
+ * Append a marked section to a markdown file once. The section must start with
96
+ * `marker`; exactly one newline separates the user's existing bytes from it, so
97
+ * a removeMdSection round-trip restores the original bytes exactly.
98
+ */
62
99
  function mdSection(file, marker, section) {
63
100
  const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
64
101
  if (existing.includes(marker))
65
102
  return false;
66
103
  fs.mkdirSync(path.dirname(file), { recursive: true });
67
- fs.writeFileSync(file, existing + (existing.endsWith("\n") ? "" : "\n") + section);
104
+ fs.writeFileSync(file, existing === "" ? section : existing + "\n" + section);
68
105
  return true;
69
106
  }
70
107
  function removeMdSection(file, marker) {
@@ -76,59 +113,85 @@ function removeMdSection(file, marker) {
76
113
  return;
77
114
  const next = text.indexOf(marker, idx + marker.length);
78
115
  const end = next === -1 ? text.length : next;
79
- fs.writeFileSync(file, text.slice(0, idx) + text.slice(end));
116
+ // mdSection put exactly one newline before our section; take it back.
117
+ const start = idx > 0 && text[idx - 1] === "\n" ? idx - 1 : idx;
118
+ fs.writeFileSync(file, text.slice(0, start) + text.slice(end));
119
+ }
120
+ /**
121
+ * Collect every string value of `key` anywhere in a nested object/array
122
+ * (hook entries nest the command inside a `hooks` array for Claude Code and
123
+ * Codex; Cursor hooks v1 keep it at the top level).
124
+ */
125
+ function collectCommandStrings(e, key = "command", out = []) {
126
+ if (Array.isArray(e)) {
127
+ for (const v of e)
128
+ collectCommandStrings(v, key, out);
129
+ }
130
+ else if (typeof e === "object" && e !== null) {
131
+ for (const [k, v] of Object.entries(e)) {
132
+ if (k === key && typeof v === "string")
133
+ out.push(v);
134
+ else
135
+ collectCommandStrings(v, key, out);
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Is this hooks entry ours? Our hook commands are
142
+ * `<node> "<...>/dist/cli/main.js" hook <harness> <sub>`, matched on the
143
+ * JSON-quoted cli path plus the ` hook <harness> ` marker INSIDE the actual
144
+ * command string. Substring-matching a re-serialized entry with an un-quoted
145
+ * path never matches our own entries — re-serialization escapes the path's
146
+ * closing quote (`main.js\" hook`) — which made install duplicate hook
147
+ * entries and uninstall leave them behind (issue #19).
148
+ */
149
+ function isOurHook(e, cliPath, harness) {
150
+ const tag = ` hook ${harness} `;
151
+ return collectCommandStrings(e).some((c) => c.includes(JSON.stringify(cliPath)) && c.includes(tag));
80
152
  }
81
- const LL_HOOK_TAG = "dist/cli/main.js hook"; // how we recognize our hook entries
82
153
  // ------------------------------------------------------------------ opencode
83
154
  function installOpenCode(opts, P) {
84
155
  const actions = [];
85
156
  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");
157
+ // Migrate away from the legacy stub dir (pre-publish installs used a
158
+ // local stub with a file: symlink dep, which bun does not resolve).
159
+ if (fs.existsSync(pluginDir)) {
160
+ fs.rmSync(pluginDir, { recursive: true, force: true });
161
+ actions.push(`removed legacy plugin stub ${pluginDir}`);
105
162
  }
106
- else {
107
- actions.push("plugin dependencies installed");
108
- }
109
- // Register in ~/.config/opencode/opencode.json (merge, preserve).
163
+ // Register the published npm package; opencode resolves, installs and
164
+ // updates it itself (shows up in /plugins with the real version).
110
165
  const cfgFile = path.join(HOME, ".config", "opencode", "opencode.json");
111
166
  const cfg = readJson(cfgFile) ?? {};
112
167
  if (!Array.isArray(cfg.plugins))
113
168
  cfg.plugins = [];
114
- if (!cfg.plugins.includes(pluginDir))
115
- cfg.plugins.push(pluginDir);
169
+ const before = cfg.plugins.length;
170
+ cfg.plugins = cfg.plugins.filter((p) => p !== pluginDir && p !== PKG_NAME);
171
+ cfg.plugins.push(PKG_NAME);
116
172
  writeJson(cfgFile, cfg);
117
- actions.push(`plugins entry in ${cfgFile}`);
173
+ actions.push(`plugins entry "${PKG_NAME}" in ${cfgFile}`);
174
+ if (before !== cfg.plugins.length)
175
+ actions.push("(replaced legacy plugins entry)");
118
176
  actions.push("restart or reload your opencode service to load the plugin");
119
177
  return actions;
120
178
  }
121
179
  function uninstallOpenCode() {
122
180
  const actions = [];
123
181
  const pluginDir = path.join(HOME, ".config", "opencode", "plugins", "load-lock");
124
- fs.rmSync(pluginDir, { recursive: true, force: true });
125
- actions.push(`removed ${pluginDir}`);
182
+ if (fs.existsSync(pluginDir)) {
183
+ fs.rmSync(pluginDir, { recursive: true, force: true });
184
+ actions.push(`removed ${pluginDir}`);
185
+ }
126
186
  const cfgFile = path.join(HOME, ".config", "opencode", "opencode.json");
127
187
  const cfg = readJson(cfgFile);
128
188
  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}`);
189
+ const before = cfg.plugins.length;
190
+ cfg.plugins = cfg.plugins.filter((p) => p !== pluginDir && p !== PKG_NAME);
191
+ if (cfg.plugins.length !== before) {
192
+ writeJson(cfgFile, cfg);
193
+ actions.push(`removed plugins entry from ${cfgFile}`);
194
+ }
132
195
  }
133
196
  return actions;
134
197
  }
@@ -166,10 +229,9 @@ function installClaudeCode(opts, P) {
166
229
  const s = readJson(settingsFile) ?? {};
167
230
  if (!s.hooks || typeof s.hooks !== "object")
168
231
  s.hooks = {};
169
- const ours = (list) => list.filter((e) => JSON.stringify(e).includes(LL_HOOK_TAG));
170
232
  const cc = (event, entries) => {
171
233
  const prev = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
172
- const kept = prev.filter((e) => !ours([e]).length);
234
+ const kept = prev.filter((e) => !isOurHook(e, P.cli, "claude-code"));
173
235
  s.hooks[event] = [...kept, ...entries];
174
236
  };
175
237
  cc("SessionStart", [hookEntry(P, "session-start")]);
@@ -181,7 +243,7 @@ function installClaudeCode(opts, P) {
181
243
  // 3. CLAUDE.md pointer
182
244
  const md = path.join(HOME, ".claude", "CLAUDE.md");
183
245
  const marker = "<!-- load-lock:begin -->";
184
- if (mdSection(md, marker, `\n${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
246
+ if (mdSection(md, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
185
247
  `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
186
248
  `1. Call the MCP tool \`mcp__load-lock__acquire\` (reason + op_key) — it grants a permit or queues you.\n` +
187
249
  `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` +
@@ -198,6 +260,7 @@ function installClaudeCode(opts, P) {
198
260
  }
199
261
  function uninstallClaudeCode() {
200
262
  const actions = [];
263
+ const P = pkgPaths(); // cli path for our-entry matching below
201
264
  const claudeJson = path.join(HOME, ".claude.json");
202
265
  const cj = readJson(claudeJson);
203
266
  if (cj?.mcpServers && typeof cj.mcpServers === "object" && "load-lock" in cj.mcpServers) {
@@ -208,23 +271,39 @@ function uninstallClaudeCode() {
208
271
  const settingsFile = path.join(HOME, ".claude", "settings.json");
209
272
  const s = readJson(settingsFile);
210
273
  if (s?.hooks && typeof s.hooks === "object") {
274
+ let changed = false;
211
275
  for (const ev of Object.keys(s.hooks)) {
212
276
  const list = s.hooks[ev];
213
277
  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];
278
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "claude-code"));
279
+ if (kept.length !== list.length) {
280
+ changed = true;
281
+ if (kept.length)
282
+ s.hooks[ev] = kept;
283
+ else
284
+ delete s.hooks[ev];
285
+ }
286
+ }
287
+ }
288
+ if (Object.keys(s.hooks).length === 0)
289
+ delete s.hooks;
290
+ if (changed) {
291
+ // If nothing but our bare-install shape remains, the file is ours.
292
+ if (Object.keys(s).length === 0) {
293
+ fs.rmSync(settingsFile);
294
+ actions.push(`removed ${settingsFile} (nothing else was in it)`);
295
+ }
296
+ else {
297
+ writeJson(settingsFile, s);
298
+ actions.push(`removed hooks from ${settingsFile}`);
219
299
  }
220
300
  }
221
- writeJson(settingsFile, s);
222
- actions.push(`removed hooks from ${settingsFile}`);
223
301
  }
224
302
  const md = path.join(HOME, ".claude", "CLAUDE.md");
225
303
  removeMdSection(md, "<!-- load-lock:begin -->");
226
304
  actions.push(`removed CLAUDE.md section`);
227
305
  fs.rmSync(path.join(HOME, ".claude", "skills", "load-lock"), { recursive: true, force: true });
306
+ pruneEmptyDirs(path.join(HOME, ".claude", "skills"), path.join(HOME, ".claude"));
228
307
  actions.push("removed skill");
229
308
  return actions;
230
309
  }
@@ -251,7 +330,7 @@ function installCodex(opts, P) {
251
330
  ];
252
331
  const mergeEv = (event, entries) => {
253
332
  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];
333
+ h.hooks[event] = [...prev.filter((e) => !isOurHook(e, P.cli, "codex")), ...entries];
255
334
  };
256
335
  mergeEv("SessionStart", [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex session-start` }] }]);
257
336
  mergeEv("SubagentStart", [{ matcher: "*", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex subagent-start` }] }]);
@@ -262,7 +341,10 @@ function installCodex(opts, P) {
262
341
  const tomlFile = path.join(codexHome, "config.toml");
263
342
  fs.mkdirSync(codexHome, { recursive: true });
264
343
  const toml = fs.existsSync(tomlFile) ? fs.readFileSync(tomlFile, "utf8") : "";
265
- if (!toml.includes("[mcp_servers.load-lock]")) {
344
+ // 2. MCP server in config.toml (append block only if absent).
345
+ // Match only a real table header at line start - a comment or docs text
346
+ // mentioning the section must not be treated as "already installed".
347
+ if (!/^\[mcp_servers\.load-lock\]\s*$/m.test(toml)) {
266
348
  fs.writeFileSync(tomlFile, toml +
267
349
  (toml.endsWith("\n") ? "" : "\n") +
268
350
  `\n# load-lock (added by \`load-lock install\`)\n[mcp_servers.load-lock]\n` +
@@ -275,7 +357,7 @@ function installCodex(opts, P) {
275
357
  }
276
358
  // 3. AGENTS.md pointer
277
359
  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` +
360
+ if (mdSection(agentsMd, "<!-- load-lock:begin -->", `<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
279
361
  `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
362
  `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
363
  actions.push(`AGENTS.md section in ${agentsMd}`);
@@ -284,22 +366,38 @@ function installCodex(opts, P) {
284
366
  }
285
367
  function uninstallCodex() {
286
368
  const actions = [];
369
+ const P = pkgPaths(); // cli path for our-entry matching below
287
370
  const codexHome = process.env.CODEX_HOME ?? path.join(HOME, ".codex");
288
371
  const hooksFile = path.join(codexHome, "hooks.json");
289
372
  const h = readJson(hooksFile);
290
373
  if (h?.hooks && typeof h.hooks === "object") {
374
+ let changed = false;
291
375
  for (const ev of Object.keys(h.hooks)) {
292
376
  const list = h.hooks[ev];
293
377
  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];
378
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "codex"));
379
+ if (kept.length !== list.length) {
380
+ changed = true;
381
+ if (kept.length)
382
+ h.hooks[ev] = kept;
383
+ else
384
+ delete h.hooks[ev];
385
+ }
386
+ }
387
+ }
388
+ if (Object.keys(h.hooks).length === 0)
389
+ delete h.hooks;
390
+ if (changed) {
391
+ // If nothing but our bare-install shape remains, the file is ours.
392
+ if (Object.keys(h).length === 0) {
393
+ fs.rmSync(hooksFile);
394
+ actions.push(`removed ${hooksFile} (nothing else was in it)`);
395
+ }
396
+ else {
397
+ writeJson(hooksFile, h);
398
+ actions.push(`removed hooks from ${hooksFile}`);
299
399
  }
300
400
  }
301
- writeJson(hooksFile, h);
302
- actions.push(`removed hooks from ${hooksFile}`);
303
401
  }
304
402
  const tomlFile = path.join(codexHome, "config.toml");
305
403
  if (fs.existsSync(tomlFile)) {
@@ -315,6 +413,541 @@ function uninstallCodex() {
315
413
  actions.push("removed AGENTS.md section");
316
414
  return actions;
317
415
  }
416
+ // ---------------------------------------------------------------- gemini-cli
417
+ function installGeminiCli(opts, P) {
418
+ const actions = [];
419
+ // 1. mcpServers.load-lock in ~/.gemini/settings.json (merge; replace only
420
+ // our own entry). Gemini CLI's stdio shape is { command, args } — the
421
+ // presence of `command` selects the stdio transport.
422
+ const settingsFile = path.join(HOME, ".gemini", "settings.json");
423
+ const s = readJson(settingsFile) ?? {};
424
+ if (typeof s !== "object" || s === null || Array.isArray(s)) {
425
+ throw new Error(`${settingsFile} is not a JSON object; refusing to overwrite`);
426
+ }
427
+ if (!s.mcpServers || typeof s.mcpServers !== "object" || s.mcpServers === null)
428
+ s.mcpServers = {};
429
+ s.mcpServers["load-lock"] = { command: P.node, args: [P.mcp] };
430
+ writeJson(settingsFile, s);
431
+ actions.push(`MCP server "load-lock" (stdio) in ${settingsFile}`);
432
+ // 2. marked policy section in ~/.gemini/GEMINI.md (global instructions file)
433
+ const geminiMd = path.join(HOME, ".gemini", "GEMINI.md");
434
+ const marker = "<!-- load-lock:begin -->";
435
+ if (mdSection(geminiMd, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
436
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
437
+ `1. Call the \`load-lock\` MCP \`acquire\` tool (reason + op_key) — it grants a permit or queues you. Pass \`wait_ms\` for a bounded blocking wait, or keep doing light work while queued.\n` +
438
+ `2. Offers surface in every tool result (tool-boundary delivery) — check \`pending\` and claim promptly via \`claim\` within the window (default 30s); expired offers pass capacity to the next waiter.\n` +
439
+ `3. Only start heavy work after acquire/claim succeeds. Prefer \`run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` / \`mark_started\` for processes you already started.\n` +
440
+ `4. Release (or cancel queued work) promptly when done. Inspect the machine with \`inspect_machine\`; adjust capacity via \`configure\` when warranted.\n\n` +
441
+ `Note: Gemini CLI has no hooks and no async push — there is NO idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.\n` +
442
+ `Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
443
+ actions.push(`GEMINI.md section in ${geminiMd}`);
444
+ // 3. be honest about the tier
445
+ actions.push("tier: MCP fallback — offers arrive at tool boundaries (tool-result delivery + bounded wait_ms); " +
446
+ "Gemini CLI has no hooks and no async push, so there is no idle wakeup");
447
+ actions.push("restart Gemini CLI to pick up the MCP server");
448
+ return actions;
449
+ }
450
+ function uninstallGeminiCli() {
451
+ const actions = [];
452
+ const settingsFile = path.join(HOME, ".gemini", "settings.json");
453
+ const s = readJson(settingsFile);
454
+ if (s?.mcpServers && typeof s.mcpServers === "object" && "load-lock" in s.mcpServers) {
455
+ delete s.mcpServers["load-lock"];
456
+ writeJson(settingsFile, s);
457
+ actions.push(`removed MCP server from ${settingsFile}`);
458
+ }
459
+ const geminiMd = path.join(HOME, ".gemini", "GEMINI.md");
460
+ removeMdSection(geminiMd, "<!-- load-lock:begin -->");
461
+ actions.push("removed GEMINI.md section");
462
+ return actions;
463
+ }
464
+ // ------------------------------------------------- shared MCP-fallback bits
465
+ /** XDG-ish config home (~/.config on Linux/macOS default). */
466
+ function configHome() {
467
+ const xdg = process.env.XDG_CONFIG_HOME;
468
+ return xdg && xdg.trim() ? path.resolve(xdg) : path.join(HOME, ".config");
469
+ }
470
+ /**
471
+ * Policy section for MCP-fallback-tier harnesses (no hooks, no async push).
472
+ * `note` states the harness-specific honesty line (never claim wakeup).
473
+ */
474
+ function fallbackSection(note) {
475
+ return (`## load-lock: machine-wide heavy-work budget\n\n` +
476
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
477
+ `1. Call the \`load-lock\` MCP \`acquire\` tool (reason + op_key) — it grants a permit or queues you. Pass \`wait_ms\` for a bounded blocking wait, or keep doing light work while queued.\n` +
478
+ `2. Offers surface in every tool result (tool-boundary delivery) — check \`pending\` and claim promptly via \`claim\` within the window (default 30s); expired offers pass capacity to the next waiter.\n` +
479
+ `3. Only start heavy work after acquire/claim succeeds. Prefer \`run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` / \`mark_started\` for processes you already started.\n` +
480
+ `4. Release (or cancel queued work) promptly when done. Inspect the machine with \`inspect_machine\`; adjust capacity via \`configure\` when warranted.\n\n` +
481
+ `Note: ${note}\n` +
482
+ `Full guide: \`load-lock agent-guide\`.\n`);
483
+ }
484
+ const NO_WAKEUP_NOTE = (h) => `${h} has no hooks and no async push — there is NO idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.`;
485
+ /** Merge `key` under `obj.mcpServers` in a JSON settings file (create-safe). */
486
+ function mergeMcpServer(file, entry, action) {
487
+ const cfg = readJson(file) ?? {};
488
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) {
489
+ throw new Error(`${file} is not a JSON object; refusing to overwrite`);
490
+ }
491
+ if (!cfg.mcpServers || typeof cfg.mcpServers !== "object" || cfg.mcpServers === null) {
492
+ cfg.mcpServers = {};
493
+ }
494
+ cfg.mcpServers["load-lock"] = entry;
495
+ writeJson(file, cfg);
496
+ return [action];
497
+ }
498
+ /** Remove our `mcpServers["load-lock"]` from a JSON settings file, if present. */
499
+ function removeMcpServer(file, action) {
500
+ const actions = [];
501
+ const cfg = readJson(file);
502
+ if (cfg?.mcpServers && typeof cfg.mcpServers === "object" && "load-lock" in cfg.mcpServers) {
503
+ delete cfg.mcpServers["load-lock"];
504
+ writeJson(file, cfg);
505
+ actions.push(action);
506
+ }
507
+ return actions;
508
+ }
509
+ /**
510
+ * rmdir empty directories from `startDir` upward, never leaving `stopDir`'s
511
+ * parent (only dirs that are empty because we created them disappear).
512
+ */
513
+ function pruneEmptyDirs(startDir, stopDir) {
514
+ if (path.relative(stopDir, startDir).startsWith(".."))
515
+ return;
516
+ let up = startDir;
517
+ while (path.relative(stopDir, up) !== "..") {
518
+ try {
519
+ fs.rmdirSync(up); // only succeeds when empty
520
+ }
521
+ catch {
522
+ break;
523
+ }
524
+ up = path.dirname(up);
525
+ }
526
+ }
527
+ // --------------------------------------------------------------- copilot-cli
528
+ function installCopilotCli(opts, P) {
529
+ const actions = [];
530
+ // 1. mcpServers.load-lock (type "local" == stdio) in ~/.copilot/mcp-config.json
531
+ // (the file `copilot mcp add` writes; per official Copilot CLI docs).
532
+ const cfgFile = path.join(HOME, ".copilot", "mcp-config.json");
533
+ actions.push(...mergeMcpServer(cfgFile, { type: "local", command: P.node, args: [P.mcp], env: {}, tools: ["*"] }, `MCP server "load-lock" (type: local / stdio) in ${cfgFile}`));
534
+ // 2. marked policy section in the global personal instructions file
535
+ const instructions = path.join(HOME, ".copilot", "copilot-instructions.md");
536
+ const marker = "<!-- load-lock:begin -->";
537
+ if (mdSection(instructions, marker, `${marker}\n` +
538
+ fallbackSection(`Copilot CLI hook support is ecosystem-reported but NOT verified in official docs — load-lock installs no hooks here; there is no idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.`) +
539
+ `<!-- load-lock:end -->\n`))
540
+ actions.push(`marked section in ${instructions} (global personal instructions)`);
541
+ // 3. be honest about the tier
542
+ actions.push("tier: MCP fallback — Copilot CLI hooks are ecosystem-reported but not verified in official docs; " +
543
+ "we install none, so there is no idle wakeup — offers surface at tool boundaries");
544
+ actions.push("verify with `copilot mcp list` (or `/mcp` inside Copilot CLI)");
545
+ return actions;
546
+ }
547
+ function uninstallCopilotCli() {
548
+ const actions = [];
549
+ const cfgFile = path.join(HOME, ".copilot", "mcp-config.json");
550
+ actions.push(...removeMcpServer(cfgFile, `removed MCP server from ${cfgFile}`));
551
+ removeMdSection(path.join(HOME, ".copilot", "copilot-instructions.md"), "<!-- load-lock:begin -->");
552
+ actions.push("removed copilot-instructions.md section");
553
+ return actions;
554
+ }
555
+ // -------------------------------------------------------------------- cline
556
+ function clineMcpSettingsFile() {
557
+ // Official Cline docs: CLI reads ~/.cline/data/settings/cline_mcp_settings.json,
558
+ // overridable via CLINE_MCP_SETTINGS_PATH.
559
+ return process.env.CLINE_MCP_SETTINGS_PATH?.trim()
560
+ ? path.resolve(process.env.CLINE_MCP_SETTINGS_PATH)
561
+ : path.join(HOME, ".cline", "data", "settings", "cline_mcp_settings.json");
562
+ }
563
+ function installCline(opts, P) {
564
+ const actions = [];
565
+ // 1. mcpServers.load-lock (stdio) in the MCP settings file
566
+ const cfgFile = clineMcpSettingsFile();
567
+ actions.push(...mergeMcpServer(cfgFile, { command: P.node, args: [P.mcp], env: {}, disabled: false, autoApprove: [] }, `MCP server "load-lock" (stdio) in ${cfgFile}`));
568
+ // 2. own rule file in the global rules dir (whole file is ours → trivially clean uninstall)
569
+ const ruleFile = path.join(HOME, ".cline", "rules", "load-lock.md");
570
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
571
+ fs.writeFileSync(ruleFile, `# load-lock: machine-wide heavy-work budget\n\n` +
572
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
573
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
574
+ `${NO_WAKEUP_NOTE("Cline")}\n` +
575
+ `Full guide: \`load-lock agent-guide\`.\n`);
576
+ actions.push(`global rule file ${ruleFile}`);
577
+ // 3. be honest about the tier
578
+ actions.push("tier: MCP fallback — Cline has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
579
+ actions.push("restart Cline (or reload the window) to pick up the MCP server");
580
+ return actions;
581
+ }
582
+ function uninstallCline() {
583
+ const actions = [];
584
+ const cfgFile = clineMcpSettingsFile();
585
+ actions.push(...removeMcpServer(cfgFile, `removed MCP server from ${cfgFile}`));
586
+ const ruleFile = path.join(HOME, ".cline", "rules", "load-lock.md");
587
+ if (fs.existsSync(ruleFile)) {
588
+ fs.rmSync(ruleFile);
589
+ actions.push(`removed ${ruleFile}`);
590
+ pruneEmptyDirs(path.dirname(ruleFile), path.join(HOME, ".cline"));
591
+ }
592
+ return actions;
593
+ }
594
+ // ----------------------------------------------------------------- roo-code
595
+ /**
596
+ * Existing global Roo Code / Zoo Code `mcp_settings.json` files (VS Code keeps
597
+ * them in per-extension globalStorage; the extension id changed across
598
+ * roo-cline → roo-code → zoo-code releases, and the editor may be Code,
599
+ * Code - OSS or VSCodium). We merge into every one we FIND; we never create
600
+ * one — if it is absent the user manages the global file via the Roo/Zoo
601
+ * settings UI ("Edit Global MCP") and we write the project file instead.
602
+ */
603
+ function rooGlobalMcpFiles() {
604
+ const appData = process.platform === "darwin" ? path.join(HOME, "Library", "Application Support") : configHome();
605
+ const out = [];
606
+ for (const editor of ["Code", "Code - OSS", "VSCodium"]) {
607
+ for (const ext of ["roocode.roo-cline", "RooCodeInc.roo-code", "ZooCodeOrganization.zoo-code"]) {
608
+ const f = path.join(appData, editor, "User", "globalStorage", ext, "settings", "mcp_settings.json");
609
+ if (fs.existsSync(f))
610
+ out.push(f);
611
+ }
612
+ }
613
+ return out;
614
+ }
615
+ function installRooCode(opts, P) {
616
+ const actions = [];
617
+ // 1. project-level .roo/mcp.json (documented, auto-loaded, shareable)
618
+ const projMcp = path.join(opts.project, ".roo", "mcp.json");
619
+ actions.push(...mergeMcpServer(projMcp, { command: P.node, args: [P.mcp], disabled: false }, `MCP server "load-lock" (stdio) in ${projMcp} (project-level; Roo/Zoo auto-loads it)`));
620
+ // 2. own rule file in the global rules dir
621
+ const ruleFile = path.join(HOME, ".roo", "rules", "load-lock.md");
622
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
623
+ fs.writeFileSync(ruleFile, `# load-lock: machine-wide heavy-work budget\n\n` +
624
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
625
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
626
+ `${NO_WAKEUP_NOTE("Roo Code")}\n` +
627
+ `Full guide: \`load-lock agent-guide\`.\n`);
628
+ actions.push(`global rule file ${ruleFile}`);
629
+ // 3. global mcp_settings.json copies that already exist
630
+ const globals = rooGlobalMcpFiles();
631
+ for (const f of globals) {
632
+ actions.push(...mergeMcpServer(f, { command: P.node, args: [P.mcp], disabled: false }, `MCP server "load-lock" in existing global ${f}`));
633
+ }
634
+ if (!globals.length) {
635
+ actions.push("no global Roo/Zoo mcp_settings.json found — to add the server globally, use " +
636
+ "\"Edit Global MCP\" in the Roo/Zoo MCP settings UI and paste the same stdio entry");
637
+ }
638
+ // 4. be honest about the tier
639
+ actions.push("tier: MCP fallback — Roo Code has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
640
+ return actions;
641
+ }
642
+ function uninstallRooCode(opts) {
643
+ const actions = [];
644
+ const projMcp = path.join(opts.project, ".roo", "mcp.json");
645
+ actions.push(...removeMcpServer(projMcp, `removed MCP server from ${projMcp}`));
646
+ for (const f of rooGlobalMcpFiles()) {
647
+ actions.push(...removeMcpServer(f, `removed MCP server from ${f}`));
648
+ }
649
+ const ruleFile = path.join(HOME, ".roo", "rules", "load-lock.md");
650
+ if (fs.existsSync(ruleFile)) {
651
+ fs.rmSync(ruleFile);
652
+ actions.push(`removed ${ruleFile}`);
653
+ pruneEmptyDirs(path.dirname(ruleFile), path.join(HOME, ".roo"));
654
+ }
655
+ return actions;
656
+ }
657
+ // -------------------------------------------------------------------- goose
658
+ // goose config.yaml is YAML. Rather than a full parser (a new dependency, and
659
+ // a formatting-destroying one), we do a careful line-oriented block-mapping
660
+ // merge for the one key we touch (`extensions`), and refuse anything we do
661
+ // not understand (flow style) instead of guessing.
662
+ const GOOSE_BEGIN = "# load-lock:begin (managed by `load-lock install`)";
663
+ const GOOSE_END = "# load-lock:end";
664
+ const GOOSE_HEADER_TAG = "# load-lock-created (managed by `load-lock install`)";
665
+ function gooseConfigFile() {
666
+ // Official goose docs: macOS/Linux ~/.config/goose/config.yaml
667
+ // (Windows: %APPDATA%\Block\goose\config\config.yaml — not supported by load-lock).
668
+ return path.join(configHome(), "goose", "config.yaml");
669
+ }
670
+ function gooseBlockLines(P) {
671
+ return [
672
+ ` ${GOOSE_BEGIN}`,
673
+ " load-lock:",
674
+ " type: stdio",
675
+ " name: load-lock",
676
+ " enabled: true",
677
+ ` cmd: ${JSON.stringify(P.node)}`,
678
+ ` args: [${JSON.stringify(P.mcp)}]`,
679
+ ` ${GOOSE_END}`,
680
+ ];
681
+ }
682
+ /** Remove our marked child block(s) from a goose config.yaml text. */
683
+ function gooseRemoveMarked(lines) {
684
+ const out = [];
685
+ let skipping = false;
686
+ for (const line of lines) {
687
+ if (/^\s+# load-lock:begin \(managed by `load-lock install`\)$/.test(line)) {
688
+ skipping = true;
689
+ continue;
690
+ }
691
+ if (skipping && /^\s+# load-lock:end$/.test(line)) {
692
+ skipping = false;
693
+ continue;
694
+ }
695
+ if (!skipping)
696
+ out.push(line);
697
+ }
698
+ return out;
699
+ }
700
+ /**
701
+ * Remove an unmarked `load-lock:` child block under `extensions:` (pre-existing
702
+ * name collision) so we never write a duplicate YAML key. Only touches the
703
+ * `load-lock` child; sibling extensions are preserved.
704
+ */
705
+ function gooseRemoveCollidingChild(lines, extIdx) {
706
+ const childRe = /^(\s+)load-lock:\s*(#.*)?$/;
707
+ for (let i = extIdx + 1; i < lines.length; i++) {
708
+ if (/^\S/.test(lines[i]))
709
+ break; // left the extensions block
710
+ const m = childRe.exec(lines[i]);
711
+ if (m) {
712
+ const indent = m[1].length;
713
+ let j = i + 1;
714
+ while (j < lines.length && (lines[j].trim() === "" || new RegExp(`^\\s{${indent + 1},}\\S`).test(lines[j])))
715
+ j++;
716
+ while (j > i + 1 && lines[j - 1].trim() === "")
717
+ j--; // keep trailing blank separators
718
+ lines.splice(i, j - i);
719
+ break;
720
+ }
721
+ }
722
+ return lines;
723
+ }
724
+ /**
725
+ * If our tagged `extensions:` header has no other children, remove it — and the
726
+ * separator blank we own immediately before it, when the header sits at end of
727
+ * file — so an install/uninstall round-trip restores the user's bytes exactly.
728
+ */
729
+ function gooseDropChildlessTaggedHeader(lines) {
730
+ const taggedIdx = lines.findIndex((l) => l === `extensions: ${GOOSE_HEADER_TAG}`);
731
+ if (taggedIdx === -1)
732
+ return lines;
733
+ let hasChild = false;
734
+ for (let i = taggedIdx + 1; i < lines.length && !/^\S/.test(lines[i]); i++) {
735
+ if (/\S/.test(lines[i]) && !/^\s*#/.test(lines[i]))
736
+ hasChild = true;
737
+ }
738
+ if (!hasChild) {
739
+ lines.splice(taggedIdx, 1);
740
+ if (taggedIdx > 0 &&
741
+ lines[taggedIdx - 1].trim() === "" &&
742
+ lines.slice(taggedIdx).every((l) => l.trim() === "")) {
743
+ lines.splice(taggedIdx - 1, 1);
744
+ }
745
+ }
746
+ return lines;
747
+ }
748
+ /** Merge/replace the `extensions.load-lock` stdio entry in goose config.yaml text. */
749
+ function gooseUpsertExtension(text, P) {
750
+ // NOTE: [ \t]* (not \s*) — \s would span the newline and misread the first
751
+ // block-mapping child as a flow-style value on the header line.
752
+ const flow = /^extensions:[ \t]*[^\s#]/m.exec(text);
753
+ if (flow) {
754
+ throw new Error("goose config.yaml uses a non-block-style `extensions:` value; load-lock refuses to guess — " +
755
+ "convert it to a block mapping or add the load-lock extension manually");
756
+ }
757
+ let lines = gooseRemoveMarked(text.split("\n"));
758
+ lines = gooseDropChildlessTaggedHeader(lines);
759
+ const block = gooseBlockLines(P);
760
+ const extIdx = lines.findIndex((l) => /^extensions:\s*(#.*)?$/.test(l));
761
+ if (extIdx === -1) {
762
+ // no extensions key yet → append one, tagged as ours
763
+ if (lines.length && lines[lines.length - 1] === "")
764
+ lines.pop(); // normalize trailing newline
765
+ if (lines.length)
766
+ lines.push(""); // separator blank (ours; removed on uninstall)
767
+ lines.push(`extensions: ${GOOSE_HEADER_TAG}`, ...block, "");
768
+ }
769
+ else {
770
+ lines = gooseRemoveCollidingChild(lines, extIdx);
771
+ lines.splice(extIdx + 1, 0, ...block);
772
+ }
773
+ return lines.join("\n");
774
+ }
775
+ /** Remove our extension from a goose config.yaml text (idempotent, no-op safe). */
776
+ function gooseRemoveExtension(text) {
777
+ let lines = gooseRemoveMarked(text.split("\n"));
778
+ lines = gooseDropChildlessTaggedHeader(lines);
779
+ return lines.join("\n");
780
+ }
781
+ function installGoose(opts, P) {
782
+ const actions = [];
783
+ const cfgFile = gooseConfigFile();
784
+ fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
785
+ const prev = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, "utf8") : "";
786
+ writeText(cfgFile, gooseUpsertExtension(prev, P));
787
+ actions.push(`extensions.load-lock (type: stdio) in ${cfgFile}`);
788
+ // 2. marked section in the global hints file (~/.config/goose/.goosehints)
789
+ const hints = path.join(configHome(), "goose", ".goosehints");
790
+ const marker = "<!-- load-lock:begin -->";
791
+ if (mdSection(hints, marker, `${marker}\n` + fallbackSection(NO_WAKEUP_NOTE("Goose")) + `<!-- load-lock:end -->\n`))
792
+ actions.push(`marked section in ${hints} (global hints)`);
793
+ // 3. be honest about the tier
794
+ actions.push("tier: MCP fallback — Goose has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
795
+ actions.push("restart goose sessions to pick up the extension");
796
+ return actions;
797
+ }
798
+ function uninstallGoose() {
799
+ const actions = [];
800
+ const cfgFile = gooseConfigFile();
801
+ if (fs.existsSync(cfgFile)) {
802
+ const prev = fs.readFileSync(cfgFile, "utf8");
803
+ const next = gooseRemoveExtension(prev);
804
+ if (next !== prev)
805
+ writeText(cfgFile, next);
806
+ if (next.trim() === "") {
807
+ fs.rmSync(cfgFile);
808
+ actions.push(`removed ${cfgFile} (nothing else was in it)`);
809
+ }
810
+ else {
811
+ actions.push(`removed extensions.load-lock from ${cfgFile}`);
812
+ }
813
+ }
814
+ const hints = path.join(configHome(), "goose", ".goosehints");
815
+ removeMdSection(hints, "<!-- load-lock:begin -->");
816
+ actions.push("removed hints section");
817
+ if (fs.existsSync(hints) && fs.readFileSync(hints, "utf8").trim() === "") {
818
+ fs.rmSync(hints);
819
+ actions.push(`removed ${hints} (nothing else was in it)`);
820
+ }
821
+ pruneEmptyDirs(path.dirname(cfgFile), path.dirname(cfgFile));
822
+ return actions;
823
+ }
824
+ // ------------------------------------------------------------------ cursor
825
+ /**
826
+ * Policy text for the dedicated .cursor/rules/load-lock.mdc rule file
827
+ * (YAML frontmatter with alwaysApply: true — plain .md is ignored by Cursor).
828
+ */
829
+ function cursorRuleText() {
830
+ return (`---\nalwaysApply: true\n---\n` +
831
+ `# load-lock: machine-wide heavy-work budget\n\n` +
832
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
833
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
834
+ `When you are queued and idle, a load-lock stop hook wakes you: Cursor auto-submits a follow-up message telling you which offer_token to claim — claim it immediately (it expires) and start your heavy work right away.\n\n` +
835
+ `Full guide: \`load-lock agent-guide\`.\n`);
836
+ }
837
+ /**
838
+ * Cursor (hooks v1, documented at https://cursor.com/docs/hooks — no longer
839
+ * beta). Everything is project-level under --project/.cursor/:
840
+ * - mcp.json: mcpServers.load-lock stdio entry
841
+ * - hooks.json: { version: 1, hooks: { stop: [ ... ] } } — FLAT entries
842
+ * ({type:"command", command}), merged; only our own entries are replaced.
843
+ * - rules/load-lock.mdc: our own Always-apply rule (clean uninstall).
844
+ */
845
+ function installCursor(opts, P) {
846
+ const actions = [];
847
+ const cursorDir = path.join(opts.project, ".cursor");
848
+ // 1. MCP server (stdio) in the project .cursor/mcp.json (documented path)
849
+ const mcpFile = path.join(cursorDir, "mcp.json");
850
+ actions.push(...mergeMcpServer(mcpFile, { command: P.node, args: [P.mcp] }, `MCP server "load-lock" (stdio) in ${mcpFile} (project-level)`));
851
+ // 2. stop hook in the project .cursor/hooks.json (merge; replace only ours)
852
+ const hooksFile = path.join(cursorDir, "hooks.json");
853
+ const h = readJson(hooksFile) ?? {};
854
+ if (typeof h !== "object" || h === null || Array.isArray(h)) {
855
+ throw new Error(`${hooksFile} is not a JSON object; refusing to overwrite`);
856
+ }
857
+ if (h.version === undefined)
858
+ h.version = 1; // required positive integer per docs
859
+ if (h.hooks === undefined) {
860
+ h.hooks = {};
861
+ }
862
+ else if (typeof h.hooks !== "object" || h.hooks === null || Array.isArray(h.hooks)) {
863
+ throw new Error(`${hooksFile} has a non-object "hooks" key; refusing to overwrite`);
864
+ }
865
+ if (h.hooks.stop === undefined) {
866
+ h.hooks.stop = [];
867
+ }
868
+ else if (!Array.isArray(h.hooks.stop)) {
869
+ throw new Error(`${hooksFile} has a non-array "hooks.stop" key; refusing to overwrite`);
870
+ }
871
+ h.hooks.stop = [
872
+ ...h.hooks.stop.filter((e) => !isOurHook(e, P.cli, "cursor")),
873
+ { type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook cursor stop` },
874
+ ];
875
+ writeJson(hooksFile, h);
876
+ actions.push(`stop hook in ${hooksFile} (hooks v1, project-level)`);
877
+ // 3. own Always-apply rule file (whole file is ours → trivially clean uninstall)
878
+ const ruleFile = path.join(cursorDir, "rules", "load-lock.mdc");
879
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
880
+ fs.writeFileSync(ruleFile, cursorRuleText());
881
+ actions.push(`Always-apply rule file ${ruleFile} (.cursor/rules, survives compaction)`);
882
+ // 4. be honest about the tier and verification status
883
+ actions.push("tier: MCP + stop-hook wakeup — the stop hook returns {\"followup_message\":...} so Cursor continues " +
884
+ "the session when a load-lock offer arrives; contract-tested against the documented Cursor hooks " +
885
+ "JSON (https://cursor.com/docs/hooks), not live-verified");
886
+ actions.push("restart Cursor (or reload the window) to pick up the MCP server and hooks");
887
+ return actions;
888
+ }
889
+ function uninstallCursor(opts) {
890
+ const actions = [];
891
+ const cursorDir = path.join(opts.project, ".cursor");
892
+ const P = pkgPaths(); // only needed for our-entry matching below
893
+ // 1. MCP server
894
+ const mcpFile = path.join(cursorDir, "mcp.json");
895
+ actions.push(...removeMcpServer(mcpFile, `removed MCP server from ${mcpFile}`));
896
+ const mcpAfter = readJson(mcpFile);
897
+ if (mcpAfter &&
898
+ typeof mcpAfter === "object" &&
899
+ Object.keys(mcpAfter).length === 1 &&
900
+ typeof mcpAfter.mcpServers === "object" &&
901
+ Object.keys(mcpAfter.mcpServers).length === 0) {
902
+ fs.rmSync(mcpFile); // we created it; nothing user-owned was ever in it
903
+ actions.push(`removed ${mcpFile} (nothing else was in it)`);
904
+ }
905
+ // 2. hooks: remove exactly our entries from every event
906
+ const hooksFile = path.join(cursorDir, "hooks.json");
907
+ const h = readJson(hooksFile);
908
+ if (h && typeof h === "object" && h !== null) {
909
+ let changed = false;
910
+ if (h.hooks && typeof h.hooks === "object" && !Array.isArray(h.hooks)) {
911
+ for (const ev of Object.keys(h.hooks)) {
912
+ if (!Array.isArray(h.hooks[ev]))
913
+ continue;
914
+ const list = h.hooks[ev];
915
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "cursor"));
916
+ if (kept.length !== list.length) {
917
+ changed = true;
918
+ if (kept.length)
919
+ h.hooks[ev] = kept;
920
+ else
921
+ delete h.hooks[ev];
922
+ }
923
+ }
924
+ if (Object.keys(h.hooks).length === 0)
925
+ delete h.hooks;
926
+ }
927
+ if (changed) {
928
+ // If only our bare-install shape remains ({version:1} or {}), the file
929
+ // is ours — remove it. Anything user-owned stays untouched.
930
+ if (Object.keys(h).length === 0 || (Object.keys(h).length === 1 && h.version === 1)) {
931
+ fs.rmSync(hooksFile);
932
+ actions.push(`removed ${hooksFile} (nothing else was in it)`);
933
+ }
934
+ else {
935
+ writeJson(hooksFile, h);
936
+ actions.push(`removed stop hook from ${hooksFile}`);
937
+ }
938
+ }
939
+ }
940
+ // 3. our rule file
941
+ const ruleFile = path.join(cursorDir, "rules", "load-lock.mdc");
942
+ if (fs.existsSync(ruleFile)) {
943
+ fs.rmSync(ruleFile);
944
+ actions.push(`removed ${ruleFile}`);
945
+ pruneEmptyDirs(path.dirname(ruleFile), path.dirname(ruleFile)); // rules/ if now empty
946
+ }
947
+ // .cursor/ itself, if we left it empty
948
+ pruneEmptyDirs(cursorDir, cursorDir);
949
+ return actions;
950
+ }
318
951
  // -------------------------------------------------------------------- public
319
952
  export async function installCommand(opts) {
320
953
  const P = pkgPaths(opts.binDir);
@@ -323,7 +956,7 @@ export async function installCommand(opts) {
323
956
  }
324
957
  const harness = opts.harness;
325
958
  if (!harness)
326
- throw new Error("install: --harness <opencode|claude-code|codex|generic> is required");
959
+ throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|generic> is required");
327
960
  let actions;
328
961
  switch (harness) {
329
962
  case "opencode":
@@ -336,6 +969,29 @@ export async function installCommand(opts) {
336
969
  case "codex":
337
970
  actions = installCodex(opts, P);
338
971
  break;
972
+ case "cursor":
973
+ actions = installCursor(opts, P);
974
+ break;
975
+ case "gemini-cli":
976
+ case "gemini":
977
+ actions = installGeminiCli(opts, P);
978
+ break;
979
+ case "copilot-cli":
980
+ case "copilot":
981
+ actions = installCopilotCli(opts, P);
982
+ break;
983
+ case "cline":
984
+ actions = installCline(opts, P);
985
+ break;
986
+ case "roo-code":
987
+ case "roo":
988
+ case "zoo-code":
989
+ case "zoo":
990
+ actions = installRooCode(opts, P);
991
+ break;
992
+ case "goose":
993
+ actions = installGoose(opts, P);
994
+ break;
339
995
  case "generic":
340
996
  case "all":
341
997
  default:
@@ -365,6 +1021,29 @@ export async function uninstallCommand(opts) {
365
1021
  case "codex":
366
1022
  actions = uninstallCodex();
367
1023
  break;
1024
+ case "cursor":
1025
+ actions = uninstallCursor(opts);
1026
+ break;
1027
+ case "gemini-cli":
1028
+ case "gemini":
1029
+ actions = uninstallGeminiCli();
1030
+ break;
1031
+ case "copilot-cli":
1032
+ case "copilot":
1033
+ actions = uninstallCopilotCli();
1034
+ break;
1035
+ case "cline":
1036
+ actions = uninstallCline();
1037
+ break;
1038
+ case "roo-code":
1039
+ case "roo":
1040
+ case "zoo-code":
1041
+ case "zoo":
1042
+ actions = uninstallRooCode(opts);
1043
+ break;
1044
+ case "goose":
1045
+ actions = uninstallGoose();
1046
+ break;
368
1047
  default:
369
1048
  throw new Error(`uninstall: unknown harness "${opts.harness}"`);
370
1049
  }