@aron98/load-lock 0.1.2 → 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.
- package/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
package/dist/cli/install.js
CHANGED
|
@@ -16,6 +16,32 @@
|
|
|
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
47
|
import fs from "node:fs";
|
|
@@ -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
|
-
/**
|
|
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
|
|
104
|
+
fs.writeFileSync(file, existing === "" ? section : existing + "\n" + section);
|
|
68
105
|
return true;
|
|
69
106
|
}
|
|
70
107
|
function removeMdSection(file, marker) {
|
|
@@ -76,9 +113,43 @@ 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
|
-
|
|
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 = [];
|
|
@@ -158,10 +229,9 @@ function installClaudeCode(opts, P) {
|
|
|
158
229
|
const s = readJson(settingsFile) ?? {};
|
|
159
230
|
if (!s.hooks || typeof s.hooks !== "object")
|
|
160
231
|
s.hooks = {};
|
|
161
|
-
const ours = (list) => list.filter((e) => JSON.stringify(e).includes(LL_HOOK_TAG));
|
|
162
232
|
const cc = (event, entries) => {
|
|
163
233
|
const prev = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
|
|
164
|
-
const kept = prev.filter((e) => !
|
|
234
|
+
const kept = prev.filter((e) => !isOurHook(e, P.cli, "claude-code"));
|
|
165
235
|
s.hooks[event] = [...kept, ...entries];
|
|
166
236
|
};
|
|
167
237
|
cc("SessionStart", [hookEntry(P, "session-start")]);
|
|
@@ -173,7 +243,7 @@ function installClaudeCode(opts, P) {
|
|
|
173
243
|
// 3. CLAUDE.md pointer
|
|
174
244
|
const md = path.join(HOME, ".claude", "CLAUDE.md");
|
|
175
245
|
const marker = "<!-- load-lock:begin -->";
|
|
176
|
-
if (mdSection(md, marker,
|
|
246
|
+
if (mdSection(md, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
|
|
177
247
|
`Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
|
|
178
248
|
`1. Call the MCP tool \`mcp__load-lock__acquire\` (reason + op_key) — it grants a permit or queues you.\n` +
|
|
179
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` +
|
|
@@ -190,6 +260,7 @@ function installClaudeCode(opts, P) {
|
|
|
190
260
|
}
|
|
191
261
|
function uninstallClaudeCode() {
|
|
192
262
|
const actions = [];
|
|
263
|
+
const P = pkgPaths(); // cli path for our-entry matching below
|
|
193
264
|
const claudeJson = path.join(HOME, ".claude.json");
|
|
194
265
|
const cj = readJson(claudeJson);
|
|
195
266
|
if (cj?.mcpServers && typeof cj.mcpServers === "object" && "load-lock" in cj.mcpServers) {
|
|
@@ -200,23 +271,39 @@ function uninstallClaudeCode() {
|
|
|
200
271
|
const settingsFile = path.join(HOME, ".claude", "settings.json");
|
|
201
272
|
const s = readJson(settingsFile);
|
|
202
273
|
if (s?.hooks && typeof s.hooks === "object") {
|
|
274
|
+
let changed = false;
|
|
203
275
|
for (const ev of Object.keys(s.hooks)) {
|
|
204
276
|
const list = s.hooks[ev];
|
|
205
277
|
if (Array.isArray(list)) {
|
|
206
|
-
const kept = list.filter((e) => !
|
|
207
|
-
if (kept.length)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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}`);
|
|
211
299
|
}
|
|
212
300
|
}
|
|
213
|
-
writeJson(settingsFile, s);
|
|
214
|
-
actions.push(`removed hooks from ${settingsFile}`);
|
|
215
301
|
}
|
|
216
302
|
const md = path.join(HOME, ".claude", "CLAUDE.md");
|
|
217
303
|
removeMdSection(md, "<!-- load-lock:begin -->");
|
|
218
304
|
actions.push(`removed CLAUDE.md section`);
|
|
219
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"));
|
|
220
307
|
actions.push("removed skill");
|
|
221
308
|
return actions;
|
|
222
309
|
}
|
|
@@ -243,7 +330,7 @@ function installCodex(opts, P) {
|
|
|
243
330
|
];
|
|
244
331
|
const mergeEv = (event, entries) => {
|
|
245
332
|
const prev = Array.isArray(h.hooks[event]) ? h.hooks[event] : [];
|
|
246
|
-
h.hooks[event] = [...prev.filter((e) => !
|
|
333
|
+
h.hooks[event] = [...prev.filter((e) => !isOurHook(e, P.cli, "codex")), ...entries];
|
|
247
334
|
};
|
|
248
335
|
mergeEv("SessionStart", [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex session-start` }] }]);
|
|
249
336
|
mergeEv("SubagentStart", [{ matcher: "*", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex subagent-start` }] }]);
|
|
@@ -270,7 +357,7 @@ function installCodex(opts, P) {
|
|
|
270
357
|
}
|
|
271
358
|
// 3. AGENTS.md pointer
|
|
272
359
|
const agentsMd = path.join(codexHome, "AGENTS.md");
|
|
273
|
-
if (mdSection(agentsMd, "<!-- load-lock:begin -->",
|
|
360
|
+
if (mdSection(agentsMd, "<!-- load-lock:begin -->", `<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
|
|
274
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). ` +
|
|
275
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`))
|
|
276
363
|
actions.push(`AGENTS.md section in ${agentsMd}`);
|
|
@@ -279,22 +366,38 @@ function installCodex(opts, P) {
|
|
|
279
366
|
}
|
|
280
367
|
function uninstallCodex() {
|
|
281
368
|
const actions = [];
|
|
369
|
+
const P = pkgPaths(); // cli path for our-entry matching below
|
|
282
370
|
const codexHome = process.env.CODEX_HOME ?? path.join(HOME, ".codex");
|
|
283
371
|
const hooksFile = path.join(codexHome, "hooks.json");
|
|
284
372
|
const h = readJson(hooksFile);
|
|
285
373
|
if (h?.hooks && typeof h.hooks === "object") {
|
|
374
|
+
let changed = false;
|
|
286
375
|
for (const ev of Object.keys(h.hooks)) {
|
|
287
376
|
const list = h.hooks[ev];
|
|
288
377
|
if (Array.isArray(list)) {
|
|
289
|
-
const kept = list.filter((e) => !
|
|
290
|
-
if (kept.length)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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}`);
|
|
294
399
|
}
|
|
295
400
|
}
|
|
296
|
-
writeJson(hooksFile, h);
|
|
297
|
-
actions.push(`removed hooks from ${hooksFile}`);
|
|
298
401
|
}
|
|
299
402
|
const tomlFile = path.join(codexHome, "config.toml");
|
|
300
403
|
if (fs.existsSync(tomlFile)) {
|
|
@@ -310,6 +413,541 @@ function uninstallCodex() {
|
|
|
310
413
|
actions.push("removed AGENTS.md section");
|
|
311
414
|
return actions;
|
|
312
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
|
+
}
|
|
313
951
|
// -------------------------------------------------------------------- public
|
|
314
952
|
export async function installCommand(opts) {
|
|
315
953
|
const P = pkgPaths(opts.binDir);
|
|
@@ -318,7 +956,7 @@ export async function installCommand(opts) {
|
|
|
318
956
|
}
|
|
319
957
|
const harness = opts.harness;
|
|
320
958
|
if (!harness)
|
|
321
|
-
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");
|
|
322
960
|
let actions;
|
|
323
961
|
switch (harness) {
|
|
324
962
|
case "opencode":
|
|
@@ -331,6 +969,29 @@ export async function installCommand(opts) {
|
|
|
331
969
|
case "codex":
|
|
332
970
|
actions = installCodex(opts, P);
|
|
333
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;
|
|
334
995
|
case "generic":
|
|
335
996
|
case "all":
|
|
336
997
|
default:
|
|
@@ -360,6 +1021,29 @@ export async function uninstallCommand(opts) {
|
|
|
360
1021
|
case "codex":
|
|
361
1022
|
actions = uninstallCodex();
|
|
362
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;
|
|
363
1047
|
default:
|
|
364
1048
|
throw new Error(`uninstall: unknown harness "${opts.harness}"`);
|
|
365
1049
|
}
|