@aron98/load-lock 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Tests for the dependency-free plugin launcher (issue #60):
3
+ * plugins/claude-code/scripts/load-lock.mjs over planted fake runtime
4
+ * copies (PATH, state dir) and a fake npx-cli.js. Light: no daemon, no
5
+ * network; every case runs in its own temp HOME/state dir.
6
+ */
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, dirname, resolve } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const REPO_ROOT = resolve(HERE, "..", "..");
16
+ const LAUNCHER = join(REPO_ROOT, "plugins", "claude-code", "scripts", "load-lock.mjs");
17
+ const PKG_VERSION = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")).version;
18
+ const L = await import(pathToFileURL(LAUNCHER).href);
19
+ const TMP_BASE = process.env.LL_TEST_TMP ?? tmpdir();
20
+ mkdirSync(TMP_BASE, { recursive: true });
21
+ /** A fake entry that reports what it was run with, then exits per FAKE_EXIT / FAKE_HANG. */
22
+ const FAKE_ENTRY = (tag) => `
23
+ let s = "";
24
+ process.stdin.on("data", (d) => (s += d));
25
+ process.stdin.on("end", () => {
26
+ process.stdout.write(JSON.stringify({ entry: ${JSON.stringify(tag)}, argv: process.argv.slice(2), stdin: s }) + "\\n");
27
+ if (process.env.FAKE_HANG === "1") setInterval(() => {}, 1000);
28
+ else process.exitCode = Number(process.env.FAKE_EXIT ?? 0);
29
+ });
30
+ `;
31
+ /** Plant a fake @aron98/load-lock package root with both bins. */
32
+ function plantPkg(root, version, name = "@aron98/load-lock") {
33
+ mkdirSync(join(root, "dist", "cli"), { recursive: true });
34
+ mkdirSync(join(root, "dist", "mcp"), { recursive: true });
35
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name, version }));
36
+ writeFileSync(join(root, "dist", "cli", "main.js"), FAKE_ENTRY(`cli@${version}`));
37
+ writeFileSync(join(root, "dist", "mcp", "main.js"), FAKE_ENTRY(`mcp@${version}`));
38
+ return root;
39
+ }
40
+ function sandbox() {
41
+ const dir = mkdtempSync(join(TMP_BASE, "ll-launch-"));
42
+ const bin = join(dir, "bin");
43
+ const state = join(dir, "state");
44
+ mkdirSync(bin, { recursive: true });
45
+ mkdirSync(state, { recursive: true });
46
+ const env = {
47
+ HOME: dir,
48
+ PATH: bin, // only planted copies; the launcher runs node via process.execPath
49
+ LOAD_LOCK_STATE_DIR: state,
50
+ LOAD_LOCK_LAUNCHER_DEBUG: "1",
51
+ // No npx unless a test plants one: a missing override never falls back.
52
+ LOAD_LOCK_LAUNCHER_NPX_CLI: join(dir, "no-npx-cli.js"),
53
+ };
54
+ return { dir, bin, state, env };
55
+ }
56
+ /** Put a POSIX-npm-style `load-lock` link for `root` on the sandbox PATH. */
57
+ function linkOnPath(sb, root) {
58
+ symlinkSync(join(root, "dist", "cli", "main.js"), join(sb.bin, "load-lock"));
59
+ }
60
+ function run(sb, args, extraEnv = {}, input = '{"session_id":"s1"}') {
61
+ const t0 = Date.now();
62
+ const r = spawnSync(process.execPath, [LAUNCHER, ...args], {
63
+ env: { ...sb.env, ...extraEnv },
64
+ input,
65
+ encoding: "utf8",
66
+ timeout: 20_000,
67
+ });
68
+ return { ...r, ms: Date.now() - t0 };
69
+ }
70
+ const posix = process.platform !== "win32";
71
+ test("launcher pin equals package.json version", () => {
72
+ const text = readFileSync(LAUNCHER, "utf8");
73
+ assert.match(text, new RegExp(`"@aron98/load-lock@${PKG_VERSION.replace(/\./g, "\\.")}"`));
74
+ });
75
+ test("PATH branch: a new-enough load-lock on PATH runs its compiled entry with stdin and args", { skip: !posix }, () => {
76
+ const sb = sandbox();
77
+ try {
78
+ linkOnPath(sb, plantPkg(join(sb.dir, "global", "lib", "node_modules", "@aron98", "load-lock"), "99.0.0"));
79
+ const r = run(sb, ["hook", "claude-code", "stop"]);
80
+ assert.equal(r.status, 0, r.stderr);
81
+ const out = JSON.parse(r.stdout);
82
+ assert.equal(out.entry, "cli@99.0.0");
83
+ assert.deepEqual(out.argv, ["hook", "claude-code", "stop"]);
84
+ assert.equal(out.stdin, '{"session_id":"s1"}');
85
+ assert.match(r.stderr, /launcher: path 99\.0\.0/);
86
+ const m = run(sb, ["--bin", "load-lock-mcp"], {}, "");
87
+ assert.equal(m.status, 0, m.stderr);
88
+ assert.equal(JSON.parse(m.stdout).entry, "mcp@99.0.0");
89
+ }
90
+ finally {
91
+ rmSync(sb.dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+ test("state branch: a too-old PATH copy is skipped for the newest known copy >= pin", { skip: !posix }, () => {
95
+ const sb = sandbox();
96
+ try {
97
+ linkOnPath(sb, plantPkg(join(sb.dir, "old"), "0.1.0"));
98
+ const a = plantPkg(join(sb.dir, "copy-a"), "98.0.0");
99
+ const b = plantPkg(join(sb.dir, "copy-b"), "98.1.0");
100
+ const tooOld = plantPkg(join(sb.dir, "copy-old"), "0.0.1");
101
+ const notOurs = plantPkg(join(sb.dir, "copy-other"), "999.0.0", "something-else");
102
+ writeFileSync(join(sb.state, "runtimes.json"), JSON.stringify({
103
+ copies: [a, tooOld, notOurs, join(sb.dir, "gone")].map((path) => ({ path, version: "x", lastSeen: 1 })),
104
+ }));
105
+ writeFileSync(join(sb.state, "daemon.json"), JSON.stringify({ pid: 1, startedAt: 1, socket: "s", packageRoot: b }));
106
+ const r = run(sb, ["hook", "claude-code", "stop"]);
107
+ assert.equal(r.status, 0, r.stderr);
108
+ assert.equal(JSON.parse(r.stdout).entry, "cli@98.1.0");
109
+ assert.match(r.stderr, /launcher: state 98\.1\.0/);
110
+ }
111
+ finally {
112
+ rmSync(sb.dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+ test("npx branch: pinned --package, --offline only in hook mode, bin after --", () => {
116
+ const sb = sandbox();
117
+ try {
118
+ const npxCli = join(sb.dir, "npx-cli.js");
119
+ writeFileSync(npxCli, `process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n");`);
120
+ const env = { LOAD_LOCK_LAUNCHER_NPX_CLI: npxCli };
121
+ const h = run(sb, ["hook", "claude-code", "session-start"], env);
122
+ assert.equal(h.status, 0, h.stderr);
123
+ assert.deepEqual(JSON.parse(h.stdout), [
124
+ "--yes", "--offline", `--package=@aron98/load-lock@${PKG_VERSION}`, "--", "load-lock", "hook", "claude-code", "session-start",
125
+ ]);
126
+ const m = run(sb, ["--bin", "load-lock-mcp"], env, "");
127
+ assert.equal(m.status, 0, m.stderr);
128
+ assert.deepEqual(JSON.parse(m.stdout), ["--yes", `--package=@aron98/load-lock@${PKG_VERSION}`, "--", "load-lock-mcp"]);
129
+ }
130
+ finally {
131
+ rmSync(sb.dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ test("no runtime at all: hooks exit 0, SessionStart prints one install hint, the MCP server fails loudly", () => {
135
+ const sb = sandbox();
136
+ try {
137
+ const s = run(sb, ["hook", "claude-code", "session-start"]);
138
+ assert.equal(s.status, 0);
139
+ assert.equal(s.stdout.trim().split("\n").length, 1);
140
+ assert.match(s.stdout, /npm i -g @aron98\/load-lock/);
141
+ for (const ev of ["stop", "await-offer"]) {
142
+ const r = run(sb, ["hook", "claude-code", ev]);
143
+ assert.equal(r.status, 0);
144
+ assert.equal(r.stdout, "");
145
+ }
146
+ const m = run(sb, ["--bin", "load-lock-mcp"], {}, "");
147
+ assert.equal(m.status, 1);
148
+ assert.match(m.stderr, /no runtime found/);
149
+ assert.equal(run(sb, ["--bin", "nope"]).status, 64);
150
+ }
151
+ finally {
152
+ rmSync(sb.dir, { recursive: true, force: true });
153
+ }
154
+ });
155
+ test("exit codes: hooks pass 0/2 through and swallow the rest; the MCP server passes everything through", { skip: !posix }, () => {
156
+ const sb = sandbox();
157
+ try {
158
+ linkOnPath(sb, plantPkg(join(sb.dir, "pkg"), "99.0.0"));
159
+ assert.equal(run(sb, ["hook", "claude-code", "await-offer"], { FAKE_EXIT: "2" }).status, 2);
160
+ assert.equal(run(sb, ["hook", "claude-code", "stop"], { FAKE_EXIT: "1" }).status, 0);
161
+ // SessionStart that already printed output gets no extra hint.
162
+ const s = run(sb, ["hook", "claude-code", "session-start"], { FAKE_EXIT: "1" });
163
+ assert.equal(s.status, 0);
164
+ assert.doesNotMatch(s.stdout, /npm i -g/);
165
+ assert.equal(run(sb, ["--bin", "load-lock-mcp"], { FAKE_EXIT: "3" }, "").status, 3);
166
+ }
167
+ finally {
168
+ rmSync(sb.dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ test("timeout: a stalled hook is killed and exits 0 fast; await-offer is not bounded by it", { skip: !posix }, () => {
172
+ const sb = sandbox();
173
+ try {
174
+ const npxCli = join(sb.dir, "npx-cli.js");
175
+ // A stalled npx whose own child holds stdout: the process group dies too.
176
+ writeFileSync(npxCli, `const { spawn } = require("node:child_process");
177
+ spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });
178
+ setInterval(() => {}, 1000);`);
179
+ writeFileSync(join(sb.dir, "package.json"), JSON.stringify({ type: "commonjs" }));
180
+ const env = { LOAD_LOCK_LAUNCHER_NPX_CLI: npxCli, LOAD_LOCK_LAUNCHER_TIMEOUT_MS: "300" };
181
+ const r = run(sb, ["hook", "claude-code", "session-start"], env);
182
+ assert.equal(r.status, 0);
183
+ assert.ok(r.ms < 5000, `took ${r.ms}ms`);
184
+ assert.match(r.stdout, /npm i -g/); // nothing came out, so the hint does
185
+ linkOnPath(sb, plantPkg(join(sb.dir, "pkg"), "99.0.0"));
186
+ const w = spawnSync(process.execPath, [LAUNCHER, "hook", "claude-code", "await-offer"], {
187
+ env: { ...sb.env, FAKE_HANG: "1", LOAD_LOCK_LAUNCHER_TIMEOUT_MS: "200" },
188
+ input: "{}",
189
+ encoding: "utf8",
190
+ timeout: 1500,
191
+ });
192
+ assert.equal(w.error?.code, "ETIMEDOUT", "await-offer must outlive the hook timeout");
193
+ }
194
+ finally {
195
+ rmSync(sb.dir, { recursive: true, force: true });
196
+ }
197
+ });
198
+ test("resolution helpers: Windows npm shim layout, npx.cmd fallback, state dir precedence", () => {
199
+ const sb = sandbox();
200
+ try {
201
+ // Windows npm global layout: <prefix>\load-lock.cmd + <prefix>\node_modules\@aron98\load-lock
202
+ const prefix = join(sb.dir, "npm-prefix");
203
+ plantPkg(join(prefix, "node_modules", "@aron98", "load-lock"), "99.2.0");
204
+ writeFileSync(join(prefix, "load-lock.cmd"), "@echo off\r\n");
205
+ const found = L.pathCopy({ PATH: prefix }, "win32", "dist/cli/main.js");
206
+ assert.equal(found?.version, "99.2.0");
207
+ assert.equal(found?.source, "path");
208
+ const noNode = join(sb.dir, "nodeless", "node.exe");
209
+ assert.deepEqual(L.npxCommand({}, "win32", noNode), { command: "npx.cmd", args: [], shell: true });
210
+ assert.deepEqual(L.npxCommand({}, "linux", noNode), { command: "npx", args: [], shell: false });
211
+ assert.equal(L.stateDir({ LOAD_LOCK_STATE_DIR: "/x" }, "linux"), "/x");
212
+ assert.equal(L.stateDir({ XDG_STATE_HOME: "/xdg", HOME: "/h" }, "linux"), join("/xdg", "load-lock"));
213
+ assert.equal(L.stateDir({ HOME: "/h" }, "linux"), join("/h", ".local", "state", "load-lock"));
214
+ assert.equal(L.stateDir({ LOCALAPPDATA: "C:/AppData" }, "win32"), join("C:/AppData", "load-lock"));
215
+ assert.equal(L.compareVersions("1.10.0", "1.9.9"), 1);
216
+ assert.equal(L.compareVersions("1.5.0", "1.5"), 0);
217
+ assert.equal(L.compareVersions("1.4.9", "1.5.0"), -1);
218
+ }
219
+ finally {
220
+ rmSync(sb.dir, { recursive: true, force: true });
221
+ }
222
+ });
@@ -8,13 +8,21 @@ import assert from "node:assert/strict";
8
8
  import { spawn } from "node:child_process";
9
9
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
10
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
11
- import { startDaemon, delay, mcpMainPath } from "./testutil.js";
12
- async function connectMcp(d) {
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { startDaemon, delay, mcpMainPath, cliMainPath } from "./testutil.js";
15
+ async function connectMcp(d, extraEnv = {}, args = [mcpMainPath]) {
13
16
  const client = new Client({ name: "load-lock-test", version: "0.1.0" }, { capabilities: {} });
17
+ // The test runner itself may be inside a harness session: drop inherited
18
+ // session identity so each test controls it (issue #50).
19
+ const env = { ...d.env };
20
+ for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
21
+ delete env[k];
14
22
  const transport = new StdioClientTransport({
15
23
  command: process.execPath,
16
- args: [mcpMainPath],
17
- env: d.env,
24
+ args,
25
+ env: { ...env, ...extraEnv },
18
26
  });
19
27
  await client.connect(transport);
20
28
  return client;
@@ -24,15 +32,36 @@ async function callJson(c, name, args) {
24
32
  assert.equal(res.isError, undefined, `tool ${name} errored: ${JSON.stringify(res.content)}`);
25
33
  return JSON.parse(res.content[0].text);
26
34
  }
35
+ test("mcp: `load-lock mcp` serves the same MCP server as load-lock-mcp (#65)", async () => {
36
+ const d = await startDaemon();
37
+ const viaBin = await connectMcp(d);
38
+ const viaCli = await connectMcp(d, {}, [cliMainPath, "mcp"]);
39
+ try {
40
+ const names = async (c) => (await c.listTools()).tools.map((t) => t.name).sort();
41
+ assert.deepEqual(await names(viaCli), await names(viaBin), "identical tool set");
42
+ assert.equal(viaCli.getServerVersion()?.name, viaBin.getServerVersion()?.name);
43
+ const acq = await callJson(viaCli, "acquire", { reason: "build", op_key: "cli-mcp", agent_id: "cliMcpAgent" });
44
+ assert.equal(acq.state, "granted");
45
+ await callJson(viaCli, "release", { permit_token: acq.permit_token });
46
+ }
47
+ finally {
48
+ await viaCli.close();
49
+ await viaBin.close();
50
+ await d.stop();
51
+ }
52
+ });
27
53
  test("mcp: tool listing + acquire/release/configure round trip", async () => {
28
54
  const d = await startDaemon();
29
55
  const c = await connectMcp(d);
30
56
  try {
31
57
  const tools = await c.listTools();
32
58
  const names = tools.tools.map((t) => t.name);
33
- for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started", "wait_for_completion"]) {
59
+ for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started", "wait_for_completion", "agent_guide"]) {
34
60
  assert.ok(names.includes(expected), `tool ${expected} registered`);
35
61
  }
62
+ // agent_guide (issue #61): the same text `load-lock agent-guide` prints.
63
+ const guide = await c.callTool({ name: "agent_guide", arguments: {} });
64
+ assert.equal(guide.content[0].text, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "AGENT_GUIDE.md"), "utf8"));
36
65
  const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA", label_prefix: "omac" });
37
66
  assert.equal(acq.state, "granted");
38
67
  assert.ok(acq.permit_token, "acquire result carries permit_token");
@@ -265,8 +294,8 @@ test("mcp: wait_for_completion on a daemon-tracked request_id (attach_pid) and j
265
294
  });
266
295
  test("mcp: multiple server processes share one machine-wide budget", async () => {
267
296
  const d = await startDaemon();
268
- const c1 = await connectMcp(d);
269
- const c2 = await connectMcp(d); // separate process = separate harness session
297
+ const c1 = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "budget-session-1" });
298
+ const c2 = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "budget-session-2" }); // separate harness session
270
299
  try {
271
300
  const a1 = await callJson(c1, "acquire", { reason: "agent1 build", op_key: "a1" });
272
301
  assert.equal(a1.state, "granted");
@@ -287,3 +316,43 @@ test("mcp: multiple server processes share one machine-wide budget", async () =>
287
316
  await d.stop();
288
317
  }
289
318
  });
319
+ test("mcp: two server processes from one harness session share an identity (issue #50)", async () => {
320
+ const d = await startDaemon();
321
+ // Same parent process, no session env: both resolve to the same harness
322
+ // ancestor, like a plugin-bundled + a standalone registration.
323
+ const c1 = await connectMcp(d);
324
+ const c2 = await connectMcp(d);
325
+ const other = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "other-session" });
326
+ try {
327
+ const filler = await callJson(other, "acquire", { reason: "other build", op_key: "fill" });
328
+ assert.equal(filler.state, "granted");
329
+ const a = await callJson(c1, "acquire", { reason: "session build", op_key: "shared-op" });
330
+ assert.equal(a.state, "queued");
331
+ if (process.platform === "linux")
332
+ assert.match(a.entry.agentId, /^proc:\d+:\d+$/);
333
+ // acquire through one server shows up in pending through the other
334
+ const viaOther = await callJson(c2, "pending", {});
335
+ assert.deepEqual(viaOther.queue.map((e) => e.requestId), [a.request_id]);
336
+ // and the op_key dedupes across both registrations
337
+ const again = await callJson(c2, "acquire", { reason: "session build", op_key: "shared-op" });
338
+ assert.equal(again.request_id, a.request_id);
339
+ // a server from a different session gets a distinct id and sees none of it
340
+ const otherPending = await callJson(other, "pending", {});
341
+ assert.equal(otherPending.queue.length, 0);
342
+ assert.equal(otherPending.held[0].agentId, "claude-code:other-session");
343
+ assert.notEqual(otherPending.held[0].agentId, a.entry.agentId);
344
+ // the release hands the offer to the shared identity: visible via either server
345
+ await callJson(other, "release", { permit_token: filler.permit_token });
346
+ const p1 = await callJson(c1, "pending", {});
347
+ assert.equal(p1.offers.length, 1);
348
+ const claimed = await callJson(c2, "claim", { offer_token: p1.offers[0].offerToken });
349
+ assert.equal(claimed.state, "held");
350
+ await callJson(c1, "release", { permit_token: claimed.permit_token });
351
+ }
352
+ finally {
353
+ await c1.close();
354
+ await c2.close();
355
+ await other.close();
356
+ await d.stop();
357
+ }
358
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Claude Code plugin contract tests (issue #61): the manifest, hooks file
3
+ * and root marketplace.json under plugins/claude-code, checked against the
4
+ * documented plugin format and ACE's import rules (#64), plus the hook and
5
+ * MCP commands run exactly the way the plugin runs them (through the
6
+ * launcher, against this checkout's dist/ on an isolated daemon).
7
+ */
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, relative, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
17
+ import { cliMainPath, startDaemon } from "./testutil.js";
18
+ import { POLICY_BRIEF } from "../policy/text.js";
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = resolve(HERE, "..", "..");
21
+ const PLUGIN_ROOT = join(REPO_ROOT, "plugins", "claude-code");
22
+ const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
23
+ const manifest = readJson(join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"));
24
+ const hooksFile = readJson(join(PLUGIN_ROOT, "hooks", "hooks.json"));
25
+ const marketplace = readJson(join(REPO_ROOT, ".claude-plugin", "marketplace.json"));
26
+ const PKG_VERSION = readJson(join(REPO_ROOT, "package.json")).version;
27
+ const ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}";
28
+ const LAUNCHER_ARG = `${ROOT_VAR}/scripts/load-lock.mjs`;
29
+ /** A hook's shell-form command, capturing the hook event. */
30
+ const HOOK_COMMAND = /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/load-lock\.mjs" hook claude-code ([a-z-]+)$/;
31
+ /** ACE plugin_hooks_command_safe (#64): no shell metacharacters. */
32
+ const ACE_UNSAFE = /[;&|`]|\$\(/;
33
+ function filesUnder(dir) {
34
+ return readdirSync(dir).flatMap((name) => {
35
+ const p = join(dir, name);
36
+ return statSync(p).isDirectory() ? filesUnder(p) : [relative(PLUGIN_ROOT, p).split(sep).join("/")];
37
+ });
38
+ }
39
+ /** Every hook handler as [event, handler]. */
40
+ function handlers() {
41
+ return Object.entries(hooksFile.hooks).flatMap(([ev, groups]) => groups.flatMap((g) => g.hooks.map((h) => [ev, h])));
42
+ }
43
+ test("plugin.json: name, version pin, MCP server through the launcher, no secrets", () => {
44
+ assert.equal(manifest.name, "load-lock-plugin", "must differ from the skill's name (ACE install-dir clash, #64)");
45
+ assert.match(manifest.name, /^[a-z0-9]+(-[a-z0-9]+)*$/);
46
+ assert.equal(manifest.version, PKG_VERSION);
47
+ assert.equal(manifest.userConfig, undefined);
48
+ assert.equal(manifest.skills, undefined, "no skill in the plugin (#61)");
49
+ assert.deepEqual(Object.keys(manifest.mcpServers), ["load-lock"]);
50
+ const mcp = manifest.mcpServers["load-lock"];
51
+ assert.equal(mcp.command, "node");
52
+ assert.deepEqual(mcp.args, [LAUNCHER_ARG, "--bin", "load-lock-mcp"]);
53
+ assert.equal(mcp.env, undefined);
54
+ assert.equal(mcp.headers, undefined);
55
+ });
56
+ test("hooks.json: SessionStart policy + async await-offer watcher, Stop wakeup, shell form via the launcher", () => {
57
+ const got = handlers().map(([ev, h]) => [ev, HOOK_COMMAND.exec(h.command)?.[1], Boolean(h.async), Boolean(h.asyncRewake)]);
58
+ assert.deepEqual(got, [
59
+ ["SessionStart", "session-start", false, false],
60
+ ["SessionStart", "await-offer", true, true],
61
+ ["Stop", "stop", false, false],
62
+ ]);
63
+ for (const [, h] of handlers()) {
64
+ assert.equal(h.type, "command");
65
+ // Shell form (#63): one command string, no exec-form `args`; the root is quoted for paths with spaces.
66
+ assert.equal(h.args, undefined);
67
+ assert.ok(h.command.includes(`"${LAUNCHER_ARG}"`), h.command);
68
+ assert.doesNotMatch(h.command, ACE_UNSAFE);
69
+ assert.ok(Number.isInteger(h.timeout) && h.timeout > 0);
70
+ }
71
+ // asyncRewake hooks keep their timeout: leave room for the watcher's own 10 min bound.
72
+ const watcher = handlers().find(([, h]) => h.asyncRewake)[1];
73
+ assert.ok(watcher.timeout * 1000 > 10 * 60_000);
74
+ });
75
+ test("plugin folder: paths resolve inside it, ACE layout only, no SKILL.md or package.json", () => {
76
+ const launcher = LAUNCHER_ARG.replace(ROOT_VAR, PLUGIN_ROOT);
77
+ assert.ok(existsSync(launcher));
78
+ assert.ok(resolve(launcher).startsWith(PLUGIN_ROOT + sep));
79
+ const allowedDirs = ["skills", "commands", "agents", "hooks", "monitors", "themes", "output-styles", "bin", "scripts", ".claude-plugin"];
80
+ const allowedRoot = [".mcp.json", ".lsp.json", "settings.json", "README.md", "LICENSE", "CHANGELOG.md"];
81
+ for (const f of filesUnder(PLUGIN_ROOT)) {
82
+ assert.ok(allowedRoot.includes(f) || allowedDirs.includes(f.split("/")[0]), `unexpected file for ACE: ${f}`);
83
+ assert.notEqual(f.split("/").pop(), "SKILL.md");
84
+ assert.notEqual(f.split("/").pop(), "package.json");
85
+ }
86
+ // bin/ would land on the Bash tool's PATH and keeps a plugin off claude.ai; the launcher is not a CLI.
87
+ assert.ok(!existsSync(join(PLUGIN_ROOT, "bin")));
88
+ });
89
+ test("marketplace.json: the root marketplace lists the plugin subfolder", () => {
90
+ assert.equal(marketplace.name, "load-lock");
91
+ assert.ok(marketplace.owner?.name);
92
+ assert.equal(marketplace.plugins.length, 1);
93
+ const entry = marketplace.plugins[0];
94
+ assert.equal(entry.name, manifest.name);
95
+ assert.equal(entry.source, "./plugins/claude-code");
96
+ assert.equal(resolve(REPO_ROOT, entry.source), PLUGIN_ROOT);
97
+ assert.equal(entry.version, undefined, "plugin.json is the version authority");
98
+ for (const k of ["commands", "agents", "skills", "hooks", "mcpServers", "strict"])
99
+ assert.equal(entry[k], undefined, k);
100
+ });
101
+ test("plugin commands run as the plugin runs them (launcher -> PATH copy of this checkout)", async () => {
102
+ const d = await startDaemon();
103
+ const tmp = mkdtempSync(join(process.env.LL_TEST_TMP ?? tmpdir(), "ll-plug-"));
104
+ try {
105
+ const bin = join(tmp, "bin");
106
+ mkdirSync(bin);
107
+ symlinkSync(cliMainPath, join(bin, "load-lock"));
108
+ const env = { ...d.env, PATH: bin, LOAD_LOCK_LAUNCHER_DEBUG: "1" };
109
+ for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
110
+ delete env[k];
111
+ // Hooks run as the harness runs a shell-form command: through sh, with
112
+ // CLAUDE_PLUGIN_ROOT in the environment and `node` resolved from PATH.
113
+ symlinkSync(process.execPath, join(bin, "node"));
114
+ const sub = (args) => args.map((a) => a.replace(ROOT_VAR, PLUGIN_ROOT));
115
+ const runHook = (event, input) => {
116
+ const h = handlers().find(([, x]) => HOOK_COMMAND.exec(x.command)?.[1] === event)[1];
117
+ return spawnSync(h.command, { shell: "/bin/sh", env: { ...env, CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }, input: JSON.stringify(input), encoding: "utf8" });
118
+ };
119
+ const s = runHook("session-start", { session_id: "plug-1", cwd: tmp, source: "startup", hook_event_name: "SessionStart" });
120
+ assert.equal(s.status, 0, s.stderr);
121
+ assert.ok(s.stdout.startsWith(POLICY_BRIEF), s.stdout);
122
+ assert.match(s.stdout, /load-lock now: pool "heavy"/);
123
+ assert.match(s.stderr, /launcher: path /);
124
+ const t = runHook("stop", { session_id: "plug-1", cwd: tmp, hook_event_name: "Stop" });
125
+ assert.equal(t.status, 0, t.stderr);
126
+ assert.equal(t.stdout, "", "no offer, no continuation");
127
+ const mcp = manifest.mcpServers["load-lock"];
128
+ const transport = new StdioClientTransport({ command: process.execPath, args: sub(mcp.args), env, stderr: "ignore" });
129
+ const client = new Client({ name: "plugin-test", version: "0.0.0" }, { capabilities: {} });
130
+ await client.connect(transport);
131
+ try {
132
+ const names = (await client.listTools()).tools.map((x) => x.name);
133
+ for (const n of ["acquire", "claim", "pending", "agent_guide"])
134
+ assert.ok(names.includes(n), n);
135
+ }
136
+ finally {
137
+ await client.close();
138
+ }
139
+ }
140
+ finally {
141
+ rmSync(tmp, { recursive: true, force: true });
142
+ await d.stop();
143
+ }
144
+ });
@@ -0,0 +1 @@
1
+ export {};