@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.
- 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 +740 -61
- 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
|
@@ -6,24 +6,50 @@
|
|
|
6
6
|
* package) so uninstall removes exactly what install added.
|
|
7
7
|
*
|
|
8
8
|
* Harnesses:
|
|
9
|
-
* - opencode :
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
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,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
|
-
|
|
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
|
-
|
|
87
|
-
//
|
|
88
|
-
fs.
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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.
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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) => !
|
|
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,
|
|
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) => !
|
|
215
|
-
if (kept.length)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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) => !
|
|
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
|
-
|
|
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 -->",
|
|
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) => !
|
|
295
|
-
if (kept.length)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
}
|