@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.
- package/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/dist/test/hook.test.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { mkdtempSync } from "node:fs";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { createServer } from "node:net";
|
|
10
11
|
import { tmpdir } from "node:os";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { startDaemon, delay, cliMainPath } from "./testutil.js";
|
|
@@ -72,6 +73,9 @@ test("hooks: claude-code stop with a matching offer -> decision block carrying t
|
|
|
72
73
|
assert.ok(out.reason.includes(offer.offerToken), "reason tells the agent which offer_token to claim");
|
|
73
74
|
assert.ok(out.reason.includes("claim"), "reason instructs claiming");
|
|
74
75
|
assert.ok(out.reason.includes(offer.opKey));
|
|
76
|
+
// issue #55: tool names differ per wiring (loose MCP vs plugin-bundled)
|
|
77
|
+
assert.ok(out.reason.includes("load-lock claim tool"), "reason names the claim tool neutrally");
|
|
78
|
+
assert.ok(!out.reason.includes("mcp__"), "reason hard-codes no MCP tool prefix");
|
|
75
79
|
// stop_hook_active: never loop
|
|
76
80
|
const quiet = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd, stop_hook_active: true });
|
|
77
81
|
assert.equal(quiet.code, 0);
|
|
@@ -119,6 +123,7 @@ test("hooks: cursor stop with a matching offer -> followup_message carrying the
|
|
|
119
123
|
assert.ok(out.followup_message.includes(offer.offerToken), "message tells the agent which offer_token to claim");
|
|
120
124
|
assert.ok(out.followup_message.includes("claim"), "message instructs claiming");
|
|
121
125
|
assert.ok(out.followup_message.includes(offer.opKey));
|
|
126
|
+
assert.ok(!out.followup_message.includes("mcp__"), "message hard-codes no MCP tool prefix");
|
|
122
127
|
assert.equal(out.decision, undefined, "Cursor does not use the Claude/Codex decision shape");
|
|
123
128
|
// loop_count > 0: never chain followups (Cursor's loop guard)
|
|
124
129
|
const quiet = await runHook(d, ["cursor", "stop"], {
|
|
@@ -163,6 +168,8 @@ test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on o
|
|
|
163
168
|
const r = await awaiting;
|
|
164
169
|
assert.equal(r.code, 2, "asyncRewake contract: exit code 2 wakes Claude");
|
|
165
170
|
assert.ok(r.stderr.includes("claim"), "stderr explains how to claim");
|
|
171
|
+
assert.ok(r.stderr.includes("load-lock claim tool"), "stderr names the claim tool neutrally");
|
|
172
|
+
assert.ok(!r.stderr.includes("mcp__"), "stderr hard-codes no MCP tool prefix");
|
|
166
173
|
assert.ok(!r.stderr.includes("off_"), "the offer token itself is NOT in stderr (session must claim via pending)");
|
|
167
174
|
assert.equal(r.stdout, "");
|
|
168
175
|
// timeout path: no offer in window -> exit 0, silent
|
|
@@ -174,3 +181,174 @@ test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on o
|
|
|
174
181
|
await d.stop();
|
|
175
182
|
}
|
|
176
183
|
});
|
|
184
|
+
// ------------------------------------------------------------ dedupe (#54)
|
|
185
|
+
// Duplicate wiring (loose hooks + plugin, two plugins) fires every hook
|
|
186
|
+
// twice with the same stdin. The daemon's hook_once lets exactly one act.
|
|
187
|
+
test("dedupe: duplicate session-start hooks inject the policy once per firing", async () => {
|
|
188
|
+
const d = await startDaemon();
|
|
189
|
+
try {
|
|
190
|
+
for (const ev of [
|
|
191
|
+
["claude-code", "session-start"],
|
|
192
|
+
["codex", "session-start"],
|
|
193
|
+
["codex", "subagent-start"],
|
|
194
|
+
]) {
|
|
195
|
+
const input = { session_id: `dup-${ev.join("-")}`, cwd: "/tmp", source: "startup" };
|
|
196
|
+
const rs = await Promise.all([runHook(d, ev, input), runHook(d, ev, input)]);
|
|
197
|
+
assert.deepEqual(rs.map((r) => r.code), [0, 0]);
|
|
198
|
+
assert.equal(rs.filter((r) => r.stdout.startsWith(POLICY_MARK)).length, 1, `${ev.join("/")}: exactly one injection`);
|
|
199
|
+
assert.equal(rs.filter((r) => r.stdout === "").length, 1, "the duplicate is silent");
|
|
200
|
+
// a later compaction is a new occurrence -> injected again
|
|
201
|
+
const again = await runHook(d, ev, { ...input, source: "compact" });
|
|
202
|
+
assert.ok(again.stdout.startsWith(POLICY_MARK), "compact re-injects");
|
|
203
|
+
// other sessions are independent
|
|
204
|
+
const other = await runHook(d, ev, { ...input, session_id: `${input.session_id}-other` });
|
|
205
|
+
assert.ok(other.stdout.startsWith(POLICY_MARK));
|
|
206
|
+
}
|
|
207
|
+
// no session id -> no dedupe possible, both act (today's behaviour)
|
|
208
|
+
const rs = await Promise.all([
|
|
209
|
+
runHook(d, ["claude-code", "session-start"], { cwd: "/tmp" }),
|
|
210
|
+
runHook(d, ["claude-code", "session-start"], { cwd: "/tmp" }),
|
|
211
|
+
]);
|
|
212
|
+
assert.ok(rs.every((r) => r.stdout.startsWith(POLICY_MARK)));
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await d.stop();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
test("dedupe: duplicate stop hooks deliver one continuation (claude-code, codex, cursor)", async () => {
|
|
219
|
+
for (const [harness, input, field] of [
|
|
220
|
+
["claude-code", (cwd) => ({ session_id: "dup-s", cwd }), "reason"],
|
|
221
|
+
["codex", (cwd) => ({ session_id: "dup-s", cwd, stop_hook_active: false }), "reason"],
|
|
222
|
+
["cursor", (cwd) => ({ conversation_id: "dup-c", workspace_roots: [cwd], loop_count: 0 }), "followup_message"],
|
|
223
|
+
]) {
|
|
224
|
+
const d = await startDaemon();
|
|
225
|
+
try {
|
|
226
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
227
|
+
const offer = await outstandingOffer(d, cwd);
|
|
228
|
+
const rs = await Promise.all([runHook(d, [harness, "stop"], input(cwd)), runHook(d, [harness, "stop"], input(cwd))]);
|
|
229
|
+
assert.deepEqual(rs.map((r) => r.code), [0, 0]);
|
|
230
|
+
const delivered = rs.filter((r) => r.stdout !== "");
|
|
231
|
+
assert.equal(delivered.length, 1, `${harness}: exactly one continuation`);
|
|
232
|
+
assert.ok(JSON.parse(delivered[0].stdout)[field].includes(offer.offerToken));
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await d.stop();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
test("dedupe: duplicate await-offer watchers -> one live watcher; key freed when it exits", async () => {
|
|
240
|
+
const d = await startDaemon();
|
|
241
|
+
try {
|
|
242
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
243
|
+
const c = d.client();
|
|
244
|
+
await c.connect();
|
|
245
|
+
await c.request("acquire", { agentId: "dup-holder", opKey: "h", reason: "holding" });
|
|
246
|
+
await c.request("acquire", { agentId: "dup-waiter", opKey: "w", reason: "waiting", cwd });
|
|
247
|
+
const env = { LOAD_LOCK_AWAIT_MS: "8000" };
|
|
248
|
+
const a = runHook(d, ["claude-code", "await-offer"], { session_id: "dup-w", cwd }, env);
|
|
249
|
+
const b = runHook(d, ["claude-code", "await-offer"], { session_id: "dup-w", cwd }, env);
|
|
250
|
+
// the loser exits at once, silently, while the winner keeps watching
|
|
251
|
+
const loser = await Promise.race([a, b]);
|
|
252
|
+
assert.equal(loser.code, 0);
|
|
253
|
+
assert.equal(loser.stderr, "");
|
|
254
|
+
const held = (await c.request("pending", { agentId: "dup-holder" }));
|
|
255
|
+
await c.request("release", { permitToken: held.held[0].permitToken });
|
|
256
|
+
const rs = await Promise.all([a, b]);
|
|
257
|
+
assert.deepEqual(rs.map((r) => r.code).sort(), [0, 2], "exactly one watcher woke the session");
|
|
258
|
+
// the winner exited -> its connection closed -> the key is free again
|
|
259
|
+
const next = await runHook(d, ["claude-code", "await-offer"], { session_id: "dup-w", cwd: "/nowhere" }, { LOAD_LOCK_AWAIT_MS: "600" });
|
|
260
|
+
assert.equal(next.code, 0);
|
|
261
|
+
const r = (await c.request("hook_once", { harness: "claude-code", sessionId: "dup-w", event: "await-offer", hold: true }));
|
|
262
|
+
assert.equal(r.first, true, "no stale hold after the watchers are gone");
|
|
263
|
+
c.close();
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await d.stop();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
test("dedupe: hook_once TTL keys expire; params are validated", async () => {
|
|
270
|
+
const d = await startDaemon();
|
|
271
|
+
try {
|
|
272
|
+
const c = d.client();
|
|
273
|
+
await c.connect();
|
|
274
|
+
const k = { harness: "codex", sessionId: "ttl", event: "stop", occurrence: "o1", ttlMs: 1000 };
|
|
275
|
+
assert.equal((await c.request("hook_once", k)).first, true);
|
|
276
|
+
assert.equal((await c.request("hook_once", k)).first, false);
|
|
277
|
+
assert.equal((await c.request("hook_once", { ...k, occurrence: "o2" })).first, true, "occurrence is part of the key");
|
|
278
|
+
await delay(1100);
|
|
279
|
+
assert.equal((await c.request("hook_once", k)).first, true, "expired keys can be claimed again");
|
|
280
|
+
await assert.rejects(c.request("hook_once", { harness: "codex", event: "stop" }), /sessionId/);
|
|
281
|
+
c.close();
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await d.stop();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
/**
|
|
288
|
+
* A daemon predating hook_once: answers `unknown method` for it, serves one
|
|
289
|
+
* cwd-matched offer and pushes one `offered` event to watchers.
|
|
290
|
+
*/
|
|
291
|
+
async function oldDaemon(cwd) {
|
|
292
|
+
const dir = mkdtempSync(join(TMP_BASE, "ll-old-daemon-"));
|
|
293
|
+
const sock = join(dir, "daemon.sock");
|
|
294
|
+
const entry = { requestId: "req_old", pool: "heavy", opKey: "w", reason: "waiting", cwd, offerToken: "off_old", offerExpiresAt: Date.now() + 30_000 };
|
|
295
|
+
const seen = [];
|
|
296
|
+
const server = createServer((s) => {
|
|
297
|
+
let buf = "";
|
|
298
|
+
s.on("error", () => s.destroy());
|
|
299
|
+
s.on("data", (chunk) => {
|
|
300
|
+
buf += chunk.toString("utf8");
|
|
301
|
+
let idx;
|
|
302
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
303
|
+
const msg = JSON.parse(buf.slice(0, idx));
|
|
304
|
+
buf = buf.slice(idx + 1);
|
|
305
|
+
seen.push(msg.method);
|
|
306
|
+
const reply = (o) => s.write(JSON.stringify(o) + "\n");
|
|
307
|
+
if (msg.method === "offers_for_cwd")
|
|
308
|
+
reply({ id: msg.id, ok: true, result: { offers: [entry] } });
|
|
309
|
+
else if (msg.method === "watch") {
|
|
310
|
+
reply({ id: msg.id, ok: true, result: { ok: true } });
|
|
311
|
+
setTimeout(() => reply({ event: true, type: "offered", entry }), 300);
|
|
312
|
+
}
|
|
313
|
+
else
|
|
314
|
+
reply({ id: msg.id, ok: false, error: { code: "invalid", message: `unknown method "${msg.method}"` } });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
await new Promise((r) => server.listen(sock, r));
|
|
319
|
+
const env = { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock, LOAD_LOCK_NO_SPAWN: "1" };
|
|
320
|
+
return {
|
|
321
|
+
handle: { env },
|
|
322
|
+
seen,
|
|
323
|
+
close: () => {
|
|
324
|
+
server.close();
|
|
325
|
+
rmSync(dir, { recursive: true, force: true });
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
test("dedupe fallback: a daemon without hook_once -> every hook acts as before", async () => {
|
|
330
|
+
const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
|
|
331
|
+
const old = await oldDaemon(cwd);
|
|
332
|
+
try {
|
|
333
|
+
const d = old.handle;
|
|
334
|
+
const two = (args, input, env) => Promise.all([runHook(d, args, input, env), runHook(d, args, input, env)]);
|
|
335
|
+
for (const r of await two(["claude-code", "session-start"], { session_id: "old", cwd, source: "startup" })) {
|
|
336
|
+
assert.ok(r.stdout.startsWith(POLICY_MARK), "both inject (no dedupe available)");
|
|
337
|
+
}
|
|
338
|
+
for (const h of ["claude-code", "codex"]) {
|
|
339
|
+
for (const r of await two([h, "stop"], { session_id: "old", cwd })) {
|
|
340
|
+
assert.equal(JSON.parse(r.stdout).decision, "block", `${h} stop still delivers`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const r of await two(["cursor", "stop"], { conversation_id: "old", workspace_roots: [cwd], loop_count: 0 })) {
|
|
344
|
+
assert.ok(JSON.parse(r.stdout).followup_message.includes("off_old"));
|
|
345
|
+
}
|
|
346
|
+
for (const r of await two(["claude-code", "await-offer"], { session_id: "old", cwd }, { LOAD_LOCK_AWAIT_MS: "5000" })) {
|
|
347
|
+
assert.equal(r.code, 2, "both watchers still wake");
|
|
348
|
+
}
|
|
349
|
+
assert.ok(old.seen.includes("hook_once"), "the hooks did try the new method");
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
old.close();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-derived agent identity (issue #49): the shared resolver
|
|
3
|
+
* (override → harness session env → stable harness ancestor → fallback)
|
|
4
|
+
* against planted /proc trees, plus the CLI default identity end to end:
|
|
5
|
+
* same session → one id (op-key dedupe, `pending`), different sessions →
|
|
6
|
+
* distinct ids.
|
|
7
|
+
*/
|
|
8
|
+
import test, { before, after } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { sessionIdentity, harnessAncestor, readProc } from "../shared/identity.js";
|
|
14
|
+
import { startDaemon, runCli } from "./testutil.js";
|
|
15
|
+
const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
|
|
16
|
+
mkdirSync(TMP_BASE, { recursive: true });
|
|
17
|
+
/** Plant /proc/<pid>/{stat,cmdline} entries: [pid, ppid, comm, startTime, argv]. */
|
|
18
|
+
function plantProc(rows) {
|
|
19
|
+
const root = mkdtempSync(join(TMP_BASE, "ll-proc-"));
|
|
20
|
+
for (const [pid, ppid, comm, start, argv] of rows) {
|
|
21
|
+
mkdirSync(join(root, String(pid)));
|
|
22
|
+
// Fields after comm: state ppid pgrp session tty tpgid flags minflt
|
|
23
|
+
// cminflt majflt cmajflt utime stime cutime cstime priority nice
|
|
24
|
+
// num_threads itrealvalue starttime ...
|
|
25
|
+
const after = ["S", ppid, pid, pid, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 0, 1, 0, start, 1000, 10];
|
|
26
|
+
writeFileSync(join(root, String(pid), "stat"), `${pid} (${comm}) ${after.join(" ")}\n`);
|
|
27
|
+
writeFileSync(join(root, String(pid), "cmdline"), argv.join("\0") + "\0");
|
|
28
|
+
}
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
const tree = plantProc([
|
|
32
|
+
[100, 1, "claude", 5000, ["claude"]],
|
|
33
|
+
[110, 100, "bash", 5100, ["/bin/bash", "-c", "load-lock acquire"]],
|
|
34
|
+
[111, 100, "bash", 5200, ["/bin/bash", "-c", "load-lock pending"]],
|
|
35
|
+
// npx launcher between the harness and the CLI: node running npx-cli.js, then sh.
|
|
36
|
+
[120, 110, "node", 5300, ["node", "/usr/lib/node_modules/npm/bin/npx-cli.js", "-y", "@aron98/load-lock"]],
|
|
37
|
+
[121, 120, "sh", 5301, ["sh", "-c", "load-lock acquire"]],
|
|
38
|
+
[200, 1, "codex", 6000, ["codex"]],
|
|
39
|
+
[210, 200, "zsh", 6100, ["zsh", "-c", "load-lock acquire"]],
|
|
40
|
+
// comm with spaces and parens must parse.
|
|
41
|
+
[300, 1, "tmux: (server)", 7000, ["tmux"]],
|
|
42
|
+
[310, 300, "fish", 7100, ["fish"]],
|
|
43
|
+
// a shell chain that ends at init: no harness.
|
|
44
|
+
[400, 1, "bash", 8000, ["bash"]],
|
|
45
|
+
]);
|
|
46
|
+
after(() => rmSync(tree, { recursive: true, force: true }));
|
|
47
|
+
const resolve = (startPid, env = {}, override) => sessionIdentity({ fallback: "cli:u@h", env, startPid, procRoot: tree, platform: "linux", override });
|
|
48
|
+
test("identity: two calls from the same harness session resolve to the same id", () => {
|
|
49
|
+
const a = resolve(110);
|
|
50
|
+
const b = resolve(111);
|
|
51
|
+
assert.equal(a.source, "ancestor");
|
|
52
|
+
assert.equal(a.id, "proc:100:5000");
|
|
53
|
+
assert.equal(b.id, a.id);
|
|
54
|
+
assert.equal(a.label, "claude:100");
|
|
55
|
+
// npx/sh wrappers are walked past too.
|
|
56
|
+
assert.equal(resolve(121).id, a.id);
|
|
57
|
+
});
|
|
58
|
+
test("identity: different harness sessions resolve to different ids", () => {
|
|
59
|
+
assert.equal(resolve(210).id, "proc:200:6000");
|
|
60
|
+
assert.notEqual(resolve(210).id, resolve(110).id);
|
|
61
|
+
assert.equal(resolve(310).id, "proc:300:7000");
|
|
62
|
+
assert.equal(readProc(300, tree)?.comm, "tmux: (server)");
|
|
63
|
+
});
|
|
64
|
+
test("identity: a reused pid with a new start time is a different session", () => {
|
|
65
|
+
const reused = plantProc([[100, 1, "claude", 9999, ["claude"]], [110, 100, "bash", 10000, ["bash"]]]);
|
|
66
|
+
try {
|
|
67
|
+
const id = sessionIdentity({ fallback: "f", env: {}, startPid: 110, procRoot: reused, platform: "linux" }).id;
|
|
68
|
+
assert.equal(id, "proc:100:9999");
|
|
69
|
+
assert.notEqual(id, resolve(110).id);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(reused, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test("identity: falls back when the walk reaches init, /proc is missing, or not on Linux", () => {
|
|
76
|
+
assert.equal(harnessAncestor(400, tree), null);
|
|
77
|
+
assert.deepEqual(resolve(400), { id: "cli:u@h", label: "cli:u@h", source: "fallback" });
|
|
78
|
+
assert.equal(resolve(99999).source, "fallback");
|
|
79
|
+
const mac = sessionIdentity({ fallback: "cli:u@h", env: {}, startPid: 110, procRoot: tree, platform: "darwin" });
|
|
80
|
+
assert.equal(mac.source, "fallback");
|
|
81
|
+
});
|
|
82
|
+
test("identity: harness session env vars beat the ancestor walk", () => {
|
|
83
|
+
const cc = resolve(110, { CLAUDE_CODE_SESSION_ID: "ee690007-78c4-48d0-8f80-77b74e95a7fd" });
|
|
84
|
+
assert.deepEqual(cc, {
|
|
85
|
+
id: "claude-code:ee690007-78c4-48d0-8f80-77b74e95a7fd",
|
|
86
|
+
label: "claude-code:ee690007",
|
|
87
|
+
source: "harness-env",
|
|
88
|
+
});
|
|
89
|
+
// OpenCode: the bare sessionID, i.e. the native plugin's agentId.
|
|
90
|
+
assert.equal(resolve(110, { OPENCODE_SESSION_ID: "ses_abc123" }).id, "ses_abc123");
|
|
91
|
+
});
|
|
92
|
+
test("identity: overrides still win (explicit > LOAD_LOCK_AGENT > harness env)", () => {
|
|
93
|
+
const env = { LOAD_LOCK_AGENT: "pinned", CLAUDE_CODE_SESSION_ID: "s1" };
|
|
94
|
+
assert.equal(resolve(110, env).id, "pinned");
|
|
95
|
+
assert.equal(resolve(110, env).source, "env");
|
|
96
|
+
assert.equal(resolve(110, env, " explicit ").id, "explicit");
|
|
97
|
+
assert.equal(resolve(110, { LOAD_LOCK_AGENT: " " }).source, "ancestor", "blank override ignored");
|
|
98
|
+
});
|
|
99
|
+
// ------------------------------------------------------------ CLI end to end
|
|
100
|
+
let d;
|
|
101
|
+
before(async () => {
|
|
102
|
+
d = await startDaemon();
|
|
103
|
+
// The test runner itself may be running inside a harness session.
|
|
104
|
+
for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
|
|
105
|
+
delete d.env[k];
|
|
106
|
+
});
|
|
107
|
+
after(async () => {
|
|
108
|
+
await d.stop();
|
|
109
|
+
});
|
|
110
|
+
function cliIn(env, args) {
|
|
111
|
+
return runCli({ ...d, env: { ...d.env, ...env } }, args);
|
|
112
|
+
}
|
|
113
|
+
async function acquireJson(env, opKey, extra = []) {
|
|
114
|
+
const r = await cliIn(env, ["acquire", "--op-key", opKey, "--reason", "identity test", "--json", ...extra]);
|
|
115
|
+
assert.ok(r.code === 0 || r.code === 68, r.stderr); // 68 = queued
|
|
116
|
+
return JSON.parse(r.stdout);
|
|
117
|
+
}
|
|
118
|
+
test("cli: same session shares one identity; different sessions get distinct ids", async () => {
|
|
119
|
+
const a1 = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/shared");
|
|
120
|
+
const a2 = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/shared");
|
|
121
|
+
const b = await acquireJson({ CLAUDE_CODE_SESSION_ID: "bbbbbbbb-2" }, "op/shared");
|
|
122
|
+
assert.equal(a1.entry.agentId, "claude-code:aaaaaaaa-1");
|
|
123
|
+
assert.equal(a1.entry.agentLabel, "claude-code:aaaaaaaa", "short readable label");
|
|
124
|
+
assert.equal(a2.entry.requestId, a1.entry.requestId, "same session: op-key dedupe returns the existing request");
|
|
125
|
+
assert.equal(b.entry.agentId, "claude-code:bbbbbbbb-2");
|
|
126
|
+
assert.notEqual(b.entry.requestId, a1.entry.requestId, "different sessions are not merged");
|
|
127
|
+
// Fill the pool so the next requests queue, then check `pending` isolation.
|
|
128
|
+
const status = JSON.parse((await cliIn({}, ["status", "--json"])).stdout);
|
|
129
|
+
const cap = status.config.pools.heavy.capacity;
|
|
130
|
+
for (let i = 2; i < cap; i++)
|
|
131
|
+
await acquireJson({ CLAUDE_CODE_SESSION_ID: "filler" }, `op/fill-${i}`);
|
|
132
|
+
const qa = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/queued");
|
|
133
|
+
assert.equal(qa.state, "queued");
|
|
134
|
+
const pa = JSON.parse((await cliIn({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, ["pending", "--json"])).stdout);
|
|
135
|
+
const pb = JSON.parse((await cliIn({ CLAUDE_CODE_SESSION_ID: "bbbbbbbb-2" }, ["pending", "--json"])).stdout);
|
|
136
|
+
assert.ok(JSON.stringify(pa).includes(qa.entry.requestId), "own queue entry visible");
|
|
137
|
+
assert.ok(!JSON.stringify(pb).includes(qa.entry.requestId), "other session does not see it");
|
|
138
|
+
});
|
|
139
|
+
test("cli: without session env, calls from one parent process share an ancestor identity", async () => {
|
|
140
|
+
if (process.platform !== "linux")
|
|
141
|
+
return;
|
|
142
|
+
const r1 = await acquireJson({}, "op/ancestor");
|
|
143
|
+
const r2 = await acquireJson({}, "op/ancestor");
|
|
144
|
+
// The CLI's parent is this test process (node, not a wrapper).
|
|
145
|
+
const start = readFileSync(`/proc/${process.pid}/stat`, "utf8").split(") ")[1].split(" ")[19];
|
|
146
|
+
assert.equal(r1.entry.agentId, `proc:${process.pid}:${start}`);
|
|
147
|
+
assert.equal(r2.entry.requestId, r1.entry.requestId);
|
|
148
|
+
});
|
|
149
|
+
test("cli: --agent and LOAD_LOCK_AGENT overrides still win", async () => {
|
|
150
|
+
const env = await acquireJson({ LOAD_LOCK_AGENT: "pinned-agent", CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/override");
|
|
151
|
+
assert.equal(env.entry.agentId, "pinned-agent");
|
|
152
|
+
assert.equal(env.entry.agentLabel, undefined, "explicit ids label themselves");
|
|
153
|
+
const flag = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/override", ["--agent", "flag-agent"]);
|
|
154
|
+
assert.equal(flag.entry.agentId, "flag-agent");
|
|
155
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
|
-
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync } from "node:fs";
|
|
8
|
+
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync, symlinkSync, lstatSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join, dirname, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -424,6 +424,7 @@ test("claude-code: install/uninstall round-trip on a populated HOME; re-install
|
|
|
424
424
|
const md = readFileSync(claudeMd, "utf8");
|
|
425
425
|
assert.ok(md.includes("# My rules"), "existing CLAUDE.md content preserved");
|
|
426
426
|
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
427
|
+
assert.ok(!md.includes("mcp__"), "section names tools neutrally (plugin tools are namespaced differently)");
|
|
427
428
|
assert.ok(existsSync(join(claudeDir, "skills", "load-lock", "SKILL.md")), "skill installed");
|
|
428
429
|
// -- idempotent re-install: no duplicate hook entries (issue #19)
|
|
429
430
|
r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
@@ -464,6 +465,43 @@ test("claude-code: bare HOME install then uninstall removes exactly what we adde
|
|
|
464
465
|
assert.ok(!("load-lock" in cj.mcpServers), "our MCP server key removed");
|
|
465
466
|
rmSync(home, { recursive: true, force: true });
|
|
466
467
|
});
|
|
468
|
+
test("claude-code: an ACE-stamped skill survives install and uninstall byte for byte (#89)", async () => {
|
|
469
|
+
const home = tempHome();
|
|
470
|
+
const skillDir = join(home, ".claude", "skills", "load-lock");
|
|
471
|
+
mkdirSync(skillDir, { recursive: true });
|
|
472
|
+
const aceSkill = "---\nname: load-lock\ndescription: from ACE\n---\nACE copy\n";
|
|
473
|
+
const stamp = JSON.stringify({ artifact: "load-lock", version: "1.4.0" }) + "\n";
|
|
474
|
+
writeFileSync(join(skillDir, "SKILL.md"), aceSkill);
|
|
475
|
+
writeFileSync(join(skillDir, ".ace.json"), stamp);
|
|
476
|
+
let r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
477
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
478
|
+
assert.match(r.stdout, /kept the skill .*managed by ACE/);
|
|
479
|
+
assert.equal(readFileSync(join(skillDir, "SKILL.md"), "utf8"), aceSkill, "install must not overwrite ACE's skill");
|
|
480
|
+
// the rest of the loose wiring is still installed
|
|
481
|
+
const cj = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
|
|
482
|
+
assert.ok(cj.mcpServers["load-lock"], "MCP server installed");
|
|
483
|
+
r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
484
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
485
|
+
assert.match(r.stdout, /kept the skill .*managed by ACE/);
|
|
486
|
+
assert.equal(readFileSync(join(skillDir, "SKILL.md"), "utf8"), aceSkill, "uninstall must not delete ACE's skill");
|
|
487
|
+
assert.equal(readFileSync(join(skillDir, ".ace.json"), "utf8"), stamp, "ACE stamp untouched");
|
|
488
|
+
rmSync(home, { recursive: true, force: true });
|
|
489
|
+
});
|
|
490
|
+
test("claude-code: a symlinked skill dir is neither written through nor removed (#89)", async () => {
|
|
491
|
+
const home = tempHome();
|
|
492
|
+
const checkout = join(home, "dev", "skill");
|
|
493
|
+
mkdirSync(checkout, { recursive: true });
|
|
494
|
+
writeFileSync(join(checkout, "SKILL.md"), "mine\n");
|
|
495
|
+
mkdirSync(join(home, ".claude", "skills"), { recursive: true });
|
|
496
|
+
symlinkSync(checkout, join(home, ".claude", "skills", "load-lock"));
|
|
497
|
+
let r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
498
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
499
|
+
assert.equal(readFileSync(join(checkout, "SKILL.md"), "utf8"), "mine\n", "install must not write through the symlink");
|
|
500
|
+
r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
501
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
502
|
+
assert.ok(lstatSync(join(home, ".claude", "skills", "load-lock")).isSymbolicLink(), "symlink kept");
|
|
503
|
+
rmSync(home, { recursive: true, force: true });
|
|
504
|
+
});
|
|
467
505
|
test("claude-code: uninstall with nothing installed is a harmless no-op", async () => {
|
|
468
506
|
const home = tempHome();
|
|
469
507
|
const r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
@@ -472,6 +510,101 @@ test("claude-code: uninstall with nothing installed is a harmless no-op", async
|
|
|
472
510
|
assert.ok(!existsSync(join(home, ".claude.json")), "no files created by uninstall");
|
|
473
511
|
rmSync(home, { recursive: true, force: true });
|
|
474
512
|
});
|
|
513
|
+
// claude-code plugin migration (#62)
|
|
514
|
+
const PLUGIN_KEY = "load-lock-plugin@load-lock";
|
|
515
|
+
/** `doctor --json` against a temp HOME and a state dir no daemon ever runs in. */
|
|
516
|
+
async function doctorMode(home) {
|
|
517
|
+
const state = join(home, "st");
|
|
518
|
+
const r = await runCli(home, ["doctor", "--json"], {
|
|
519
|
+
LOAD_LOCK_STATE_DIR: state,
|
|
520
|
+
LOAD_LOCK_SOCKET: join(state, "d.sock"),
|
|
521
|
+
LOAD_LOCK_NO_SPAWN: "1",
|
|
522
|
+
});
|
|
523
|
+
assert.equal(r.code, 0, `doctor failed: ${r.stderr}`);
|
|
524
|
+
const rep = JSON.parse(r.stdout);
|
|
525
|
+
return { mode: rep.claudeCodeMode, warnings: rep.warnings };
|
|
526
|
+
}
|
|
527
|
+
function enablePlugin(home, on) {
|
|
528
|
+
const file = join(home, ".claude", "settings.json");
|
|
529
|
+
const s = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
530
|
+
s.enabledPlugins = { ...s.enabledPlugins, [PLUGIN_KEY]: on };
|
|
531
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
532
|
+
writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
|
|
533
|
+
}
|
|
534
|
+
test("claude-code: loose → plugin migration, --legacy, uninstall; doctor reports the mode", async () => {
|
|
535
|
+
const home = tempHome();
|
|
536
|
+
const claudeDir = join(home, ".claude");
|
|
537
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
538
|
+
writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { fetch: { command: "uvx" } } }, null, 2) + "\n");
|
|
539
|
+
writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] } }, null, 2) + "\n");
|
|
540
|
+
writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n");
|
|
541
|
+
const claudeJson = join(home, ".claude.json");
|
|
542
|
+
const settingsFile = join(claudeDir, "settings.json");
|
|
543
|
+
const claudeMd = join(claudeDir, "CLAUDE.md");
|
|
544
|
+
const skill = join(claudeDir, "skills", "load-lock", "SKILL.md");
|
|
545
|
+
let r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
546
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
547
|
+
assert.equal((await doctorMode(home)).mode, "loose");
|
|
548
|
+
// the user enables the plugin: both are active, doctor flags it
|
|
549
|
+
enablePlugin(home, true);
|
|
550
|
+
let d = await doctorMode(home);
|
|
551
|
+
assert.equal(d.mode, "both");
|
|
552
|
+
assert.ok(d.warnings.some((w) => /plugin and the loose/.test(w)), "doctor flags both");
|
|
553
|
+
// -- install moves the loose entries out, reporting each step
|
|
554
|
+
r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
555
|
+
assert.equal(r.code, 0, `migrating install failed: ${r.stderr}`);
|
|
556
|
+
assert.match(r.stdout, new RegExp(`plugin ${PLUGIN_KEY} is enabled`));
|
|
557
|
+
assert.match(r.stdout, /removed mcpServers\.load-lock/);
|
|
558
|
+
assert.match(r.stdout, /removed load-lock hooks/);
|
|
559
|
+
assert.match(r.stdout, /removed the load-lock section/);
|
|
560
|
+
let cj = JSON.parse(readFileSync(claudeJson, "utf8"));
|
|
561
|
+
assert.ok(!("load-lock" in cj.mcpServers), "loose MCP server removed");
|
|
562
|
+
assert.ok(cj.mcpServers.fetch, "user MCP server untouched");
|
|
563
|
+
let s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
564
|
+
assert.ok(!("SessionStart" in s.hooks), "loose SessionStart hooks removed");
|
|
565
|
+
assert.deepEqual(s.hooks.Stop, [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }], "user hook untouched");
|
|
566
|
+
assert.equal(s.enabledPlugins[PLUGIN_KEY], true, "plugin state untouched");
|
|
567
|
+
assert.equal(s.theme, "dark");
|
|
568
|
+
assert.equal(readFileSync(claudeMd, "utf8"), "# My rules\n", "CLAUDE.md section removed exactly");
|
|
569
|
+
assert.ok(existsSync(skill), "the skill stays (the plugin ships none)");
|
|
570
|
+
assert.equal((await doctorMode(home)).mode, "plugin");
|
|
571
|
+
// a second run is a no-op
|
|
572
|
+
r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
573
|
+
assert.equal(r.code, 0);
|
|
574
|
+
assert.match(r.stdout, /no loose MCP server, hooks or CLAUDE\.md section to remove/);
|
|
575
|
+
// -- --legacy keeps the loose install next to the plugin
|
|
576
|
+
r = await runCli(home, ["install", "--harness", "claude-code", "--legacy"]);
|
|
577
|
+
assert.equal(r.code, 0, `legacy install failed: ${r.stderr}`);
|
|
578
|
+
assert.match(r.stdout, /--legacy: plugin .* is enabled too/);
|
|
579
|
+
cj = JSON.parse(readFileSync(claudeJson, "utf8"));
|
|
580
|
+
assert.ok(cj.mcpServers["load-lock"], "--legacy installs the loose MCP server");
|
|
581
|
+
s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
582
|
+
assert.equal(s.hooks.SessionStart.length, 2, "--legacy installs the loose hooks");
|
|
583
|
+
assert.equal((await doctorMode(home)).mode, "both");
|
|
584
|
+
// -- uninstall removes loose entries only and points at /plugin uninstall
|
|
585
|
+
r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
586
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
587
|
+
assert.match(r.stdout, new RegExp(`/plugin uninstall ${PLUGIN_KEY}`));
|
|
588
|
+
s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
589
|
+
assert.equal(s.enabledPlugins[PLUGIN_KEY], true, "uninstall never edits plugin state");
|
|
590
|
+
assert.ok(!("SessionStart" in s.hooks), "loose hooks removed");
|
|
591
|
+
cj = JSON.parse(readFileSync(claudeJson, "utf8"));
|
|
592
|
+
assert.ok(!("load-lock" in cj.mcpServers), "loose MCP server removed");
|
|
593
|
+
assert.equal(readFileSync(claudeMd, "utf8"), "# My rules\n");
|
|
594
|
+
assert.ok(!existsSync(skill), "our skill removed");
|
|
595
|
+
assert.equal((await doctorMode(home)).mode, "plugin");
|
|
596
|
+
rmSync(home, { recursive: true, force: true });
|
|
597
|
+
});
|
|
598
|
+
test("claude-code: a disabled plugin doesn't stop the loose install", async () => {
|
|
599
|
+
const home = tempHome();
|
|
600
|
+
enablePlugin(home, false);
|
|
601
|
+
const r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
602
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
603
|
+
const cj = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
|
|
604
|
+
assert.ok(cj.mcpServers["load-lock"], "loose MCP server installed");
|
|
605
|
+
assert.equal((await doctorMode(home)).mode, "loose");
|
|
606
|
+
rmSync(home, { recursive: true, force: true });
|
|
607
|
+
});
|
|
475
608
|
// --------------------------------------------------------------------- codex
|
|
476
609
|
test("codex: install/uninstall round-trip on a populated HOME; re-install never duplicates hooks", async () => {
|
|
477
610
|
const home = tempHome();
|
|
@@ -510,6 +643,7 @@ test("codex: install/uninstall round-trip on a populated HOME; re-install never
|
|
|
510
643
|
const md = readFileSync(agentsMd, "utf8");
|
|
511
644
|
assert.ok(md.includes("# My codex rules"), "existing AGENTS.md content preserved");
|
|
512
645
|
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
646
|
+
assert.ok(!md.includes("mcp__"), "section names tools neutrally");
|
|
513
647
|
// -- idempotent re-install: no duplicate hook entries (issue #19)
|
|
514
648
|
r = await runCli(home, ["install", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
515
649
|
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|