@aron98/load-lock 1.5.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 (66) hide show
  1. package/README.md +61 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +157 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +76 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the version-pin sync check (issue #48): scripts/check-pins.mjs
3
+ * over planted files in a temp root, plus the real script on this repo.
4
+ * Light: no daemon, files only.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname, resolve } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const REPO_ROOT = resolve(HERE, "..", "..");
15
+ const SCRIPT = join(REPO_ROOT, "scripts", "check-pins.mjs");
16
+ const pins = (await import(pathToFileURL(SCRIPT).href));
17
+ function plant(files) {
18
+ const root = mkdtempSync(join(tmpdir(), "ll-pins-"));
19
+ for (const [rel, body] of Object.entries(files)) {
20
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
21
+ writeFileSync(join(root, rel), body);
22
+ }
23
+ return root;
24
+ }
25
+ const DECLARED = [
26
+ { path: "skills/load-lock/SKILL.md", kind: "spec" },
27
+ { path: "ace/mcp.json", kind: "spec" },
28
+ { path: "plugin/.claude-plugin/plugin.json", kind: "json", key: "version" },
29
+ ];
30
+ function plantedRepo(skillPin, mcpPin, pluginVersion) {
31
+ return plant({
32
+ "skills/load-lock/SKILL.md": `Else run \`npx -y @aron98/load-lock@${skillPin} status\`.\n` +
33
+ `Upgrade with \`npm i -g @aron98/load-lock@latest\` (unpinned, ignored).\n`,
34
+ "ace/mcp.json": JSON.stringify({ mcpServers: { "load-lock": { command: "npx", args: ["-y", `@aron98/load-lock@${mcpPin}`] } } }, null, 2) + "\n",
35
+ "plugin/.claude-plugin/plugin.json": JSON.stringify({ name: "load-lock", version: pluginVersion, description: "x" }, null, 2) + "\n",
36
+ });
37
+ }
38
+ test("check-pins: in-sync planted files pass", () => {
39
+ const root = plantedRepo("1.6.0", "1.6.0", "1.6.0");
40
+ try {
41
+ assert.deepEqual(pins.checkPins(root, "1.6.0", DECLARED), []);
42
+ }
43
+ finally {
44
+ rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+ test("check-pins: mismatches are reported per pin, then --write fixes them all", () => {
48
+ const root = plantedRepo("1.5.0", "1.6.0-rc.1", "1.4.2");
49
+ try {
50
+ const problems = pins.checkPins(root, "1.6.0", DECLARED);
51
+ assert.equal(problems.length, 3, problems.join("\n"));
52
+ assert.ok(problems.some((p) => p.startsWith("skills/load-lock/SKILL.md:1: pins 1.5.0")), problems.join("\n"));
53
+ assert.ok(problems.some((p) => p.includes("ace/mcp.json") && p.includes("1.6.0-rc.1")), problems.join("\n"));
54
+ assert.ok(problems.some((p) => p.includes("plugin.json (version): pins 1.4.2")), problems.join("\n"));
55
+ const changed = pins.writePins(root, "1.6.0", DECLARED);
56
+ assert.deepEqual(changed.sort(), DECLARED.map((d) => d.path).sort());
57
+ assert.deepEqual(pins.checkPins(root, "1.6.0", DECLARED), []);
58
+ const skill = readFileSync(join(root, "skills/load-lock/SKILL.md"), "utf8");
59
+ assert.match(skill, /@aron98\/load-lock@1\.6\.0 status/);
60
+ assert.match(skill, /@aron98\/load-lock@latest/, "non-version tags are left alone");
61
+ const plugin = JSON.parse(readFileSync(join(root, "plugin/.claude-plugin/plugin.json"), "utf8"));
62
+ assert.deepEqual(plugin, { name: "load-lock", version: "1.6.0", description: "x" });
63
+ // idempotent
64
+ assert.deepEqual(pins.writePins(root, "1.6.0", DECLARED), []);
65
+ }
66
+ finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+ test("check-pins: --write keeps a JSON file's own formatting", () => {
71
+ const body = `{\n "name": "load-lock-plugin",\n "version": "1.5.0",\n "keywords": ["a", "b"],\n` +
72
+ ` "deps": { "@aron98/load-lock": "1.5.0" }\n}\n`;
73
+ const root = plant({ "p.json": body });
74
+ const declared = [
75
+ { path: "p.json", kind: "json", key: "version" },
76
+ { path: "p.json", kind: "json", key: "deps.@aron98/load-lock" },
77
+ ];
78
+ try {
79
+ pins.writePins(root, "1.6.0", declared);
80
+ assert.equal(readFileSync(join(root, "p.json"), "utf8"), body.replaceAll("1.5.0", "1.6.0"));
81
+ }
82
+ finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+ test("check-pins: a declared file that is missing or lost its pin fails", () => {
87
+ const root = plant({ "skills/load-lock/SKILL.md": "use `npx -y @aron98/load-lock@latest`\n" });
88
+ try {
89
+ const problems = pins.checkPins(root, "1.6.0", [
90
+ { path: "skills/load-lock/SKILL.md", kind: "spec" },
91
+ { path: "ace/mcp.json", kind: "spec" },
92
+ ]);
93
+ assert.equal(problems.length, 2, problems.join("\n"));
94
+ assert.match(problems[0], /SKILL\.md: no @aron98\/load-lock@<version> pin found/);
95
+ assert.match(problems[1], /ace\/mcp\.json: declared pinned file is missing/);
96
+ }
97
+ finally {
98
+ rmSync(root, { recursive: true, force: true });
99
+ }
100
+ });
101
+ test("check-pins: the script passes on this repo", () => {
102
+ const r = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
103
+ assert.equal(r.status, 0, r.stdout + r.stderr);
104
+ assert.match(r.stdout, /pins ok/);
105
+ // every declared path is relative and inside the repo
106
+ for (const e of pins.PINNED)
107
+ assert.ok(!e.path.startsWith("/") && !e.path.includes(".."), e.path);
108
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Persisted state format compatibility (issue #51): a state dir written by a
3
+ * NEWER format (higher formatVersion, extra fields, unknown journal entry
4
+ * kinds) must load in the current code, and unknown fields must survive a
5
+ * rewrite. Also: what we write stays readable by older daemons (the journal
6
+ * stays a bare array; config.json keeps `config` + `history`).
7
+ */
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { spawn } from "node:child_process";
11
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { LLClient } from "../shared/client.js";
15
+ import { Machine } from "../core/state.js";
16
+ import { loadState, saveState, loadJournal, saveJournal, STATE_FORMAT_VERSION } from "../core/persist.js";
17
+ import { daemonMainPath, probe, until } from "./testutil.js";
18
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
19
+ mkdirSync(TMP_BASE, { recursive: true });
20
+ const FUTURE = STATE_FORMAT_VERSION + 7;
21
+ function newerConfigFile() {
22
+ return {
23
+ formatVersion: FUTURE,
24
+ futureTopLevel: { keep: "me" },
25
+ config: {
26
+ version: 4,
27
+ pools: { heavy: { capacity: 2, futurePoolField: "pool-x" }, gpu: { capacity: 1 } },
28
+ offerTimeoutMs: 30_000,
29
+ maxWaitMs: 5_000,
30
+ maxTurnWaitMs: 600_000,
31
+ startTimeoutMs: 120_000,
32
+ untrackedLeaseMs: 15 * 60_000,
33
+ guidance: { notes: "n", futureGuidance: 42 },
34
+ futureConfigField: ["a", "b"],
35
+ },
36
+ history: [{ ts: 1, author: "newer", reason: "r", changes: {}, resultingVersion: 4, futureHistoryField: true }],
37
+ };
38
+ }
39
+ function newerJournal(now) {
40
+ return [
41
+ // a held permit with an extra field + newer per-entry version
42
+ {
43
+ formatVersion: FUTURE,
44
+ requestId: "req_held",
45
+ pool: "heavy",
46
+ agentId: "A",
47
+ opKey: "op1",
48
+ reason: "build",
49
+ weight: 1,
50
+ state: "held",
51
+ permitToken: "pmt_held",
52
+ phase: "running_untracked",
53
+ claimedAt: now,
54
+ futureEntryField: { x: 1 },
55
+ },
56
+ // an entry kind this version doesn't know
57
+ { formatVersion: FUTURE, requestId: "req_future", pool: "heavy", agentId: "B", opKey: "op", reason: "r", weight: 1, state: "suspended", claimedAt: now },
58
+ // a held permit in a phase this version doesn't know
59
+ { requestId: "req_phase", pool: "gpu", agentId: "C", opKey: "op", reason: "r", weight: 1, state: "held", permitToken: "pmt_phase", phase: "paused_by_future", claimedAt: now },
60
+ // malformed garbage
61
+ "not an entry",
62
+ { state: "held" },
63
+ // a queued request with an extra field
64
+ { requestId: "req_q", pool: "heavy", agentId: "D", opKey: "op", reason: "r", weight: 1, state: "queued", enqueuedAt: now, claimedAt: now, futureQueued: "q" },
65
+ ];
66
+ }
67
+ function tmpDir() {
68
+ return mkdtempSync(join(TMP_BASE, "ll-compat-"));
69
+ }
70
+ test("compat: newer config.json loads and unknown fields survive a config change", () => {
71
+ const dir = tmpDir();
72
+ try {
73
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
74
+ const st = loadState(dir);
75
+ assert.equal(st.formatVersion, FUTURE);
76
+ assert.equal(st.config.pools.heavy.capacity, 2);
77
+ const m = new Machine(Date.now, st.config);
78
+ m.history.push(...st.history);
79
+ m.configure({ expectedVersion: 4, changes: { pools: { heavy: { capacity: 3 } }, guidance: { notes: "n2" } }, reason: "t", author: "me" });
80
+ saveState(dir, m.config, m.history);
81
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
82
+ assert.equal(raw.formatVersion, STATE_FORMAT_VERSION);
83
+ assert.deepEqual(raw.futureTopLevel, { keep: "me" });
84
+ assert.deepEqual(raw.config.futureConfigField, ["a", "b"]);
85
+ assert.equal(raw.config.pools.heavy.capacity, 3);
86
+ assert.equal(raw.config.pools.heavy.futurePoolField, "pool-x");
87
+ assert.equal(raw.config.guidance.notes, "n2");
88
+ assert.equal(raw.config.guidance.futureGuidance, 42);
89
+ assert.equal(raw.history[0].futureHistoryField, true);
90
+ assert.equal(raw.history.length, 2);
91
+ }
92
+ finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+ test("compat: pre-versioning config.json reads as format 0; our writes keep the old shape", () => {
97
+ const dir = tmpDir();
98
+ try {
99
+ const old = newerConfigFile();
100
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ config: old.config, history: [] }));
101
+ const st = loadState(dir);
102
+ assert.equal(st.formatVersion, 0);
103
+ saveState(dir, st.config, st.history);
104
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
105
+ // older daemons read exactly `config` + `history`
106
+ assert.equal(typeof raw.config, "object");
107
+ assert.ok(Array.isArray(raw.history));
108
+ // journal stays a bare array (what every released daemon reads)
109
+ saveJournal(dir, [{ requestId: "r", pool: "heavy", agentId: "A", opKey: "o", reason: "r", weight: 1, claimedAt: 1, state: "queued" }]);
110
+ const j = JSON.parse(readFileSync(join(dir, "journal.json"), "utf8"));
111
+ assert.ok(Array.isArray(j));
112
+ assert.equal(j[0].formatVersion, STATE_FORMAT_VERSION);
113
+ }
114
+ finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+ test("compat: unknown journal entry kinds don't break restore; unknown fields round-trip", () => {
119
+ const dir = tmpDir();
120
+ try {
121
+ const now = Date.now();
122
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
123
+ writeFileSync(join(dir, "journal.json"), JSON.stringify(newerJournal(now)));
124
+ const st = loadState(dir);
125
+ const m = new Machine(Date.now, st.config);
126
+ const res = m.restore(loadJournal(dir), () => null);
127
+ assert.equal(res.kept, 2, "held + unknown-phase held");
128
+ assert.equal(res.queued, 1);
129
+ assert.equal(res.skipped, 2, "unknown kind + malformed entry");
130
+ const ev = m.eventsSince(0).find((e) => e.type === "daemon_recovered");
131
+ assert.match(String(ev?.detail), /skipped 2 unrecognized/, "skips are reported in the recovery event");
132
+ // unknown phase: kept, conservatively as untracked with a lease
133
+ const out = m.journalEntries();
134
+ const phase = out.find((e) => e.requestId === "req_phase");
135
+ assert.equal(phase.phase, "running_untracked");
136
+ assert.ok(phase.leaseDeadline && phase.leaseDeadline > now);
137
+ // unknown per-entry fields are written back while the entry lives
138
+ saveJournal(dir, out);
139
+ const back = loadJournal(dir);
140
+ assert.deepEqual(back.find((e) => e.requestId === "req_held").futureEntryField, { x: 1 });
141
+ assert.equal(back.find((e) => e.requestId === "req_q").futureQueued, "q");
142
+ assert.ok(back.every((e) => e.formatVersion === STATE_FORMAT_VERSION));
143
+ }
144
+ finally {
145
+ rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ });
148
+ test("compat: a daemon started on a newer-format state dir serves it and preserves unknown fields", async () => {
149
+ const dir = tmpDir();
150
+ const sock = join(dir, "daemon.sock");
151
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
152
+ writeFileSync(join(dir, "journal.json"), JSON.stringify(newerJournal(Date.now())));
153
+ const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
154
+ stdio: ["ignore", "pipe", "pipe"],
155
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock },
156
+ });
157
+ try {
158
+ await until("daemon listens", async () => await probe(sock));
159
+ const c = new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false });
160
+ await c.connect();
161
+ const st = (await c.request("status", {}));
162
+ const heavy = st.pools.find((p) => p.pool === "heavy");
163
+ assert.ok(heavy, "heavy pool present");
164
+ assert.equal(heavy.holders.length, 1, "known held permit restored");
165
+ const cfg = (await c.request("get_config", {}));
166
+ await c.request("configure", {
167
+ expectedVersion: cfg.config.version,
168
+ changes: { pools: { heavy: { capacity: 5 } } },
169
+ reason: "compat test",
170
+ author: "test",
171
+ });
172
+ // the restored token still releases
173
+ const rel = (await c.request("release", { permitToken: "pmt_held" }));
174
+ assert.equal(rel.releasedNow, true);
175
+ c.close();
176
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
177
+ assert.equal(raw.config.pools.heavy.capacity, 5);
178
+ assert.equal(raw.config.pools.heavy.futurePoolField, "pool-x");
179
+ assert.deepEqual(raw.config.futureConfigField, ["a", "b"]);
180
+ assert.deepEqual(raw.futureTopLevel, { keep: "me" });
181
+ const j = JSON.parse(readFileSync(join(dir, "journal.json"), "utf8"));
182
+ assert.ok(Array.isArray(j));
183
+ assert.equal(j.find((e) => e.requestId === "req_q")?.futureQueued, "q");
184
+ }
185
+ finally {
186
+ child.kill("SIGTERM");
187
+ await new Promise((r) => (child.exitCode !== null ? r() : child.once("exit", () => r())));
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });
@@ -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 {};