@aron98/load-lock 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +37 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +31 -0
  5. package/dist/adapters/opencode/tui.js +268 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +740 -61
  8. package/dist/cli/main.js +36 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +98 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +12 -0
  18. package/dist/dashboard/frame.js +94 -0
  19. package/dist/dashboard/view.d.ts +148 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/dashboard.test.d.ts +1 -0
  33. package/dist/test/dashboard.test.js +405 -0
  34. package/dist/test/hook.test.js +53 -3
  35. package/dist/test/install.test.d.ts +1 -0
  36. package/dist/test/install.test.js +658 -0
  37. package/dist/test/mcp.test.js +36 -1
  38. package/dist/test/platform.test.d.ts +1 -0
  39. package/dist/test/platform.test.js +193 -0
  40. package/dist/test/testutil.js +4 -1
  41. package/dist/test/wait-turn.test.d.ts +1 -0
  42. package/dist/test/wait-turn.test.js +376 -0
  43. package/docs/ADAPTERS.md +5 -2
  44. package/docs/AGENT_GUIDE.md +33 -1
  45. package/docs/ARCHITECTURE.md +25 -5
  46. package/docs/COMPATIBILITY.md +215 -26
  47. package/docs/OPERATIONS.md +313 -8
  48. package/package.json +25 -1
  49. package/skills/load-lock/SKILL.md +4 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Dashboard tests (issue #10): the data plumbing behind `load-lock top`
3
+ * and the OpenCode TUI plugin panel.
4
+ *
5
+ * - view-model shaping (buildDashboardView) against protocol fixtures,
6
+ * with "now" pinned so countdowns are deterministic
7
+ * - formatters (fmtDuration / fmtBytes) and the utilization bar
8
+ * - event one-liners (eventText)
9
+ * - frame renderer bounds (width clamp, height slice)
10
+ * - DashboardFeed against a REAL daemon: live view, queue visibility,
11
+ * release, resync idempotence, and reconnect + event-cursor resume
12
+ * across a daemon SIGKILL -> fresh-daemon-on-the-same-socket restart
13
+ * - `load-lock top --once` smoke test (read-only, never acquires)
14
+ */
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { spawn } from "node:child_process";
18
+ import { rmSync } from "node:fs";
19
+ import { DashboardFeed } from "../dashboard/feed.js";
20
+ import { renderFrame, utilizationBar } from "../dashboard/frame.js";
21
+ import { buildDashboardView, eventText, fmtBytes, fmtDuration } from "../dashboard/view.js";
22
+ import { daemonMainPath, probe, runCli, startDaemon, until } from "./testutil.js";
23
+ const NOW = 1_760_000_000_000;
24
+ // ----------------------------------------------------------------- fixtures
25
+ function holder() {
26
+ return {
27
+ requestId: "req_holder_000001",
28
+ pool: "heavy",
29
+ agentId: "agent-a",
30
+ agentLabel: "claude-verify",
31
+ cwd: "/w",
32
+ opKey: "docker-build",
33
+ reason: "docker build of harness images",
34
+ weight: 1,
35
+ state: "held",
36
+ phase: "running_tracked",
37
+ since: NOW - 30_000,
38
+ pid: 111,
39
+ pgid: 111,
40
+ heldFor: 30_000,
41
+ };
42
+ }
43
+ function statusFixture() {
44
+ return {
45
+ daemon: { pid: 4242, startedAt: NOW - 90_000, protocolVersion: 1 },
46
+ config: {
47
+ version: 3,
48
+ pools: {},
49
+ offerTimeoutMs: 30_000,
50
+ maxWaitMs: 60_000,
51
+ maxTurnWaitMs: 600_000,
52
+ startTimeoutMs: 60_000,
53
+ untrackedLeaseMs: 60_000,
54
+ },
55
+ pools: [
56
+ {
57
+ pool: "heavy",
58
+ capacity: 2,
59
+ used: 2,
60
+ holders: [holder()],
61
+ offers: [
62
+ {
63
+ requestId: "req_offer_000001",
64
+ pool: "heavy",
65
+ agentId: "agent-b",
66
+ opKey: "npm-test",
67
+ reason: "npm test",
68
+ weight: 1,
69
+ state: "offered",
70
+ since: NOW - 2_000,
71
+ offerExpiresAt: NOW + 28_000,
72
+ },
73
+ ],
74
+ queue: [
75
+ {
76
+ requestId: "req_queue_000001",
77
+ pool: "heavy",
78
+ agentId: "agent-c",
79
+ opKey: "npm-ci",
80
+ reason: "npm ci",
81
+ weight: 1,
82
+ state: "queued",
83
+ since: NOW - 5_000,
84
+ position: 1,
85
+ },
86
+ ],
87
+ paused: [
88
+ {
89
+ requestId: "req_paused_000001",
90
+ pool: "heavy",
91
+ agentId: "agent-d",
92
+ opKey: "build",
93
+ reason: "docker build images",
94
+ weight: 1,
95
+ state: "paused",
96
+ since: NOW - 45_000,
97
+ },
98
+ ],
99
+ },
100
+ ],
101
+ events: { latestEventId: 7 },
102
+ };
103
+ }
104
+ function inspectFixture() {
105
+ return {
106
+ cpu: {
107
+ logicalCores: 16,
108
+ model: "Test CPU",
109
+ loadavg: [0.4, 0.3, 0.25],
110
+ cgroupVersion: "2",
111
+ cgroupCpuQuota: 200,
112
+ effectiveCores: 2,
113
+ loadPerEffectiveCore: 0.2,
114
+ },
115
+ memory: {
116
+ totalBytes: 34359738368,
117
+ availableBytes: 8000000000,
118
+ freeBytes: 7000000000,
119
+ effectiveLimitBytes: 17179869184,
120
+ },
121
+ pressure: { note: "cgroup limits apply", loadPressure: "low", memPressure: "moderate" },
122
+ loadLock: { pools: {}, suggestion: "1 parallel heavy job fits the quota" },
123
+ };
124
+ }
125
+ function eventsFixture() {
126
+ return [
127
+ {
128
+ id: 5,
129
+ ts: NOW - 1_000,
130
+ type: "claimed",
131
+ pool: "heavy",
132
+ entry: holder(),
133
+ },
134
+ {
135
+ id: 6,
136
+ ts: NOW - 500,
137
+ type: "started",
138
+ pool: "heavy",
139
+ entry: holder(),
140
+ detail: "tracked pid 111",
141
+ },
142
+ {
143
+ id: 7,
144
+ ts: NOW - 100,
145
+ type: "config_changed",
146
+ detail: "by aron98: bump heavy capacity to 2",
147
+ },
148
+ ];
149
+ }
150
+ function inputs(over = {}) {
151
+ return {
152
+ status: statusFixture(),
153
+ inspect: inspectFixture(),
154
+ events: eventsFixture(),
155
+ latestEventId: 7,
156
+ state: "live",
157
+ lastSyncAt: NOW - 250,
158
+ now: NOW,
159
+ ...over,
160
+ };
161
+ }
162
+ // --------------------------------------------------------------- formatters
163
+ test("fmtDuration covers ms..days and negatives", () => {
164
+ assert.equal(fmtDuration(412), "412ms");
165
+ assert.equal(fmtDuration(3_000), "3s");
166
+ assert.equal(fmtDuration(252_000), "4m 12s");
167
+ assert.equal(fmtDuration(7_500_000), "2h 05m");
168
+ assert.equal(fmtDuration((3 * 86_400 + 4 * 3_600) * 1000), "3d 4h");
169
+ assert.equal(fmtDuration(-500), "-500ms");
170
+ });
171
+ test("fmtBytes is compact and clamps units", () => {
172
+ assert.equal(fmtBytes(512), "512B");
173
+ assert.equal(fmtBytes(512 * 1024), "512Ki");
174
+ assert.equal(fmtBytes(12.4 * 1024 ** 3), "12.4Gi");
175
+ assert.equal(fmtBytes(200 * 1024 ** 2), "200Mi");
176
+ });
177
+ test("utilizationBar clamps and fills", () => {
178
+ assert.equal(utilizationBar(0.5, 10), "#####-----");
179
+ assert.equal(utilizationBar(1.5, 10), "##########");
180
+ assert.equal(utilizationBar(-1, 10), "----------");
181
+ assert.equal(utilizationBar(0, 0).length, 3); // width floor of 3
182
+ });
183
+ // ---------------------------------------------------------------- eventText
184
+ test("eventText renders one-liners per event type", () => {
185
+ assert.equal(eventText({ id: 6, ts: 0, type: "started", entry: holder(), detail: "tracked pid 111" }), "started claude-verify: tracked pid 111");
186
+ assert.match(eventText({ id: 2, ts: 0, type: "offer_expired", pool: "heavy" }), /paused \(rejoin to requeue\)/);
187
+ assert.equal(eventText({ id: 7, ts: 0, type: "config_changed", detail: "by aron98: bump capacity" }), "config changed: by aron98: bump capacity");
188
+ assert.equal(eventText({
189
+ id: 1,
190
+ ts: 0,
191
+ type: "released",
192
+ pool: "heavy",
193
+ entry: {
194
+ requestId: "r",
195
+ pool: "heavy",
196
+ agentId: "agent-x",
197
+ opKey: "k",
198
+ reason: "npm test",
199
+ weight: 1,
200
+ state: "released",
201
+ since: 0,
202
+ },
203
+ }), "released heavy agent-x (npm test)");
204
+ });
205
+ // ------------------------------------------------------------ view shaping
206
+ test("buildDashboardView shapes pools, cards, machine and events", () => {
207
+ const v = buildDashboardView(inputs());
208
+ assert.equal(v.state, "live");
209
+ assert.equal(v.latestEventId, 7);
210
+ assert.equal(v.daemon?.pid, 4242);
211
+ assert.equal(v.daemon?.uptimeMs, 90_000);
212
+ assert.equal(v.daemon?.configVersion, 3);
213
+ const pool = v.pools[0];
214
+ assert.equal(pool.pool, "heavy");
215
+ assert.equal(pool.capacity, 2);
216
+ assert.equal(pool.used, 2);
217
+ assert.equal(pool.free, 0);
218
+ assert.equal(pool.utilization, 1);
219
+ assert.equal(pool.heldWeight, 1);
220
+ assert.equal(pool.offeredWeight, 1);
221
+ const h = pool.holders[0];
222
+ assert.equal(h.agent, "claude-verify");
223
+ assert.equal(h.phase, "running_tracked");
224
+ assert.equal(h.phaseLabel, "running (tracked)");
225
+ assert.equal(h.pid, 111);
226
+ assert.equal(h.heldMs, 30_000);
227
+ assert.equal(h.startDeadlineInMs, undefined); // only for claimed
228
+ const o = pool.offers[0];
229
+ assert.equal(o.agentId, "agent-b");
230
+ assert.equal(o.claimInMs, 28_000);
231
+ const q = pool.queue[0];
232
+ assert.equal(q.position, 1);
233
+ assert.equal(q.waitMs, 5_000);
234
+ assert.equal(pool.paused[0].pausedMs, 45_000);
235
+ assert.equal(v.machine?.effectiveCores, 2);
236
+ assert.equal(v.machine?.cgroupCpuQuota, 200);
237
+ assert.equal(v.machine?.memPressure, "moderate");
238
+ assert.equal(v.machine?.suggestion, "1 parallel heavy job fits the quota");
239
+ // events are newest-first with agoMs relative to now
240
+ assert.deepEqual(v.events.map((e) => e.id), [7, 6, 5]);
241
+ assert.equal(v.events[2].agoMs, 1_000);
242
+ });
243
+ test("buildDashboardView tolerates missing status/inspect and carries the restart note", () => {
244
+ const v = buildDashboardView(inputs({ status: null, inspect: null, state: "reconnecting", daemonRestartNote: "daemon restarted (pid 7); event history re-synced" }));
245
+ assert.equal(v.daemon, null);
246
+ assert.equal(v.machine, null);
247
+ assert.deepEqual(v.pools, []);
248
+ assert.equal(v.state, "reconnecting");
249
+ assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
250
+ });
251
+ // ------------------------------------------------------------ frame bounds
252
+ test("renderFrame clamps width and height", () => {
253
+ const v = buildDashboardView(inputs());
254
+ for (const [w, h] of [
255
+ [60, 20],
256
+ [100, 40],
257
+ [10, 10], // width floors at 48
258
+ ]) {
259
+ const maxW = Math.max(48, Math.min(w, 240));
260
+ const lines = renderFrame(v, w, h);
261
+ assert.ok(lines.length <= h, `height respected for ${w}x${h}`);
262
+ for (const line of lines)
263
+ assert.ok(line.length <= maxW, `width respected for ${w}x${h}`);
264
+ }
265
+ const tall = renderFrame(v, 100, 1000);
266
+ assert.ok(tall.some((l) => l.includes("POOL heavy")));
267
+ assert.ok(tall.some((l) => l.includes("EVENTS (newest first)")));
268
+ assert.ok(tall.some((l) => l.includes("HELD claude-verify")));
269
+ assert.ok(tall.some((l) => l.includes("OFFER agent-b")));
270
+ assert.ok(tall.some((l) => l.includes("QUEUE #1")));
271
+ assert.ok(tall.some((l) => l.includes("PAUSED agent-d")));
272
+ });
273
+ // -------------------------------------------------- feed against a real daemon
274
+ /** Kill the daemon handle's child hard (SIGKILL) and wait for exit. */
275
+ async function killDaemon(child) {
276
+ child.kill("SIGKILL");
277
+ if (child.exitCode === null) {
278
+ await new Promise((resolve) => child.once("exit", () => resolve()));
279
+ }
280
+ }
281
+ test("feed: live view, queue, release, cursor resume across a daemon restart", async () => {
282
+ const d = await startDaemon();
283
+ const feed = new DashboardFeed(d.client(), {
284
+ refreshDebounceMs: 5,
285
+ reconnectBaseMs: 40,
286
+ slowRefreshMs: 60_000,
287
+ inspectEveryMs: 10_000,
288
+ eventHistory: 100,
289
+ });
290
+ let child2 = null;
291
+ try {
292
+ await feed.start();
293
+ assert.equal(feed.currentState(), "live");
294
+ const ctl = d.client();
295
+ const granted = (await ctl.request("acquire", {
296
+ agentId: "agent-a",
297
+ agentLabel: "holder-agent",
298
+ opKey: "build-a",
299
+ reason: "integration test heavy build",
300
+ }));
301
+ assert.equal(granted.state, "granted");
302
+ const tokenA = granted.entry.permitToken;
303
+ // Associate the pid via the documented flow (acquire -> start_job).
304
+ // Note: `AcquireParams.pid` is a deprecated shorthand the daemon does
305
+ // not implement — start_job is the supported association path.
306
+ await ctl.request("start_job", { permitToken: tokenA, pid: process.pid });
307
+ await until("holder card visible (tracked)", () => feed.view().pools[0]?.holders[0]?.phase === "running_tracked");
308
+ const card = feed.view().pools[0].holders[0];
309
+ assert.equal(card.agent, "holder-agent");
310
+ assert.equal(card.phase, "running_tracked");
311
+ assert.equal(card.pid, process.pid);
312
+ // Regression (watch_started cursor bug): the daemon's watch confirmation
313
+ // reports the full latestEventId, but the feed must NOT adopt it as its
314
+ // cursor — the initial sync still has to pull the whole event history.
315
+ await until("event feed populated from history", () => feed.view().events.length > 0);
316
+ assert.ok(feed.view().events.some((e) => e.text.includes("daemon started")), "daemon_started from history must be in the feed");
317
+ assert.ok(feed.view().events.some((e) => e.text.includes("holder-agent")), "the grant/claim events for holder-agent must be in the feed");
318
+ // second acquire queues (capacity 1 by default)
319
+ const queued = (await ctl.request("acquire", {
320
+ agentId: "agent-b",
321
+ agentLabel: "queued-agent",
322
+ opKey: "test-b",
323
+ reason: "npm test suite",
324
+ waitMs: 200,
325
+ }));
326
+ assert.equal(queued.state, "queued");
327
+ await until("queue card visible", () => feed.view().pools[0]?.queue.length === 1);
328
+ assert.equal(feed.view().pools[0].queue[0].agent, "queued-agent");
329
+ // resync is idempotent: repeated pulls never duplicate event ids
330
+ await feed.resync();
331
+ await feed.resync();
332
+ const ids = feed.view().events.map((e) => e.id);
333
+ assert.equal(new Set(ids).size, ids.length, "no duplicate event ids after repeated resync");
334
+ // release frees capacity; FIFO immediately reserves it for the queued
335
+ // head (agent-b) as an offer — the feed must surface the offer card live.
336
+ await ctl.request("release", { permitToken: tokenA, outcome: "success" });
337
+ await until("holder released", () => feed.view().pools[0]?.holders.length === 0);
338
+ await until("queued head receives the offer", () => feed.view().pools[0]?.offers.length === 1);
339
+ const offer = feed.view().pools[0].offers[0];
340
+ assert.equal(offer.agent, "queued-agent");
341
+ assert.ok(offer.claimInMs > 0, "claim-window countdown is live");
342
+ // retire the queued request so capacity frees up for the next acquire
343
+ await ctl.request("cancel", { requestId: queued.entry.requestId });
344
+ await until("offer and queue drained after cancel", () => (feed.view().pools[0]?.offers.length ?? 0) + (feed.view().pools[0]?.queue.length ?? 0) === 0);
345
+ // hold again so the journal has a permit to restore across the restart
346
+ const granted2 = (await ctl.request("acquire", {
347
+ agentId: "agent-a",
348
+ agentLabel: "holder-agent",
349
+ opKey: "build-a2",
350
+ reason: "survives daemon restart",
351
+ }));
352
+ const tokenA2 = granted2.entry.permitToken;
353
+ await ctl.request("start_job", { permitToken: tokenA2, pid: process.pid });
354
+ await until("second holder visible (tracked)", () => feed.view().pools[0]?.holders[0]?.phase === "running_tracked");
355
+ const oldPid = feed.view().daemon?.pid;
356
+ await killDaemon(d.child); // SIGKILL: no clean shutdown
357
+ await until("feed notices the loss", () => feed.currentState() === "reconnecting");
358
+ // fresh daemon on the SAME state dir + socket (journal recovery path)
359
+ child2 = spawn(process.execPath, [daemonMainPath, "--state-dir", d.dir, "--socket", d.sock], {
360
+ env: d.env,
361
+ stdio: "ignore",
362
+ });
363
+ await until("replacement daemon is up", () => probe(d.sock));
364
+ await until("feed is live again", () => feed.currentState() === "live");
365
+ const v = feed.view();
366
+ assert.notEqual(v.daemon?.pid, oldPid);
367
+ assert.match(v.daemonRestartNote ?? "", /daemon restarted/);
368
+ // event ids restarted from ~1 and the cursor reset with them
369
+ assert.ok(v.latestEventId < 20, `cursor reset across restart (got #${v.latestEventId})`);
370
+ // journal recovered the held permit and the event feed re-synced
371
+ await until("recovered holder visible", () => feed.view().pools[0]?.holders.length === 1);
372
+ const texts = feed.view().events.map((e) => e.text).join("\n");
373
+ assert.match(texts, /daemon recovered/);
374
+ // live updates still flow against the new daemon
375
+ const ctl2 = d.client();
376
+ await ctl2.request("release", { permitToken: tokenA2, outcome: "success" });
377
+ await until("post-restart release reflected", () => feed.view().pools[0]?.holders.length === 0);
378
+ ctl2.close();
379
+ ctl.close();
380
+ }
381
+ finally {
382
+ await feed.stop();
383
+ if (child2 && child2.exitCode === null) {
384
+ child2.kill("SIGKILL");
385
+ await new Promise((resolve) => child2.once("exit", () => resolve()));
386
+ }
387
+ rmSync(d.dir, { recursive: true, force: true });
388
+ d.child.kill("SIGKILL");
389
+ }
390
+ });
391
+ // ------------------------------------------------------------- top smoke
392
+ test("load-lock top --once renders one frame and exits clean", async () => {
393
+ const d = await startDaemon();
394
+ try {
395
+ const r = await runCli(d, ["top", "--once"]);
396
+ assert.equal(r.code, 0);
397
+ assert.ok(r.stdout.includes("load-lock top"), "banner present");
398
+ assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
399
+ assert.ok(r.stdout.includes("q quit"), "footer present");
400
+ assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
401
+ }
402
+ finally {
403
+ await d.stop();
404
+ }
405
+ });
@@ -11,6 +11,9 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { startDaemon, delay, cliMainPath } from "./testutil.js";
13
13
  const POLICY_MARK = "# load-lock: machine-wide heavy-work budget";
14
+ // Same override as daemon.test.ts / testutil.ts: never touch the user's
15
+ // real state dir; LL_TEST_TMP redirects where scratch dirs are created.
16
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
14
17
  function runHook(d, args, input, env) {
15
18
  return new Promise((resolve, reject) => {
16
19
  const child = spawn(process.execPath, [cliMainPath, "hook", ...args], {
@@ -59,7 +62,7 @@ test("hooks: session-start events print the policy brief + machine state", async
59
62
  test("hooks: claude-code stop with a matching offer -> decision block carrying the offer token", async () => {
60
63
  const d = await startDaemon();
61
64
  try {
62
- const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
65
+ const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
63
66
  const offer = await outstandingOffer(d, cwd);
64
67
  assert.ok(offer?.offerToken, "waiter has an outstanding offer");
65
68
  const r = await runHook(d, ["claude-code", "stop"], { session_id: "s1", cwd });
@@ -84,7 +87,7 @@ test("hooks: claude-code stop with a matching offer -> decision block carrying t
84
87
  test("hooks: codex stop behaves the same (decision block JSON)", async () => {
85
88
  const d = await startDaemon();
86
89
  try {
87
- const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
90
+ const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
88
91
  const offer = await outstandingOffer(d, cwd);
89
92
  assert.ok(offer?.offerToken);
90
93
  const r = await runHook(d, ["codex", "stop"], { session_id: "s1", cwd, stop_hook_active: false });
@@ -96,10 +99,57 @@ test("hooks: codex stop behaves the same (decision block JSON)", async () => {
96
99
  await d.stop();
97
100
  }
98
101
  });
102
+ test("hooks: cursor stop with a matching offer -> followup_message carrying the offer token", async () => {
103
+ const d = await startDaemon();
104
+ try {
105
+ const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
106
+ const offer = await outstandingOffer(d, cwd);
107
+ assert.ok(offer?.offerToken, "waiter has an outstanding offer");
108
+ // Cursor hooks v1 input: workspace_roots (no cwd), loop_count, status.
109
+ const r = await runHook(d, ["cursor", "stop"], {
110
+ conversation_id: "c1",
111
+ hook_event_name: "stop",
112
+ workspace_roots: [cwd],
113
+ loop_count: 0,
114
+ status: "completed",
115
+ });
116
+ assert.equal(r.code, 0);
117
+ const out = JSON.parse(r.stdout);
118
+ assert.equal(typeof out.followup_message, "string", "Cursor Stop continues via followup_message");
119
+ assert.ok(out.followup_message.includes(offer.offerToken), "message tells the agent which offer_token to claim");
120
+ assert.ok(out.followup_message.includes("claim"), "message instructs claiming");
121
+ assert.ok(out.followup_message.includes(offer.opKey));
122
+ assert.equal(out.decision, undefined, "Cursor does not use the Claude/Codex decision shape");
123
+ // loop_count > 0: never chain followups (Cursor's loop guard)
124
+ const quiet = await runHook(d, ["cursor", "stop"], {
125
+ conversation_id: "c1",
126
+ workspace_roots: [cwd],
127
+ loop_count: 1,
128
+ status: "completed",
129
+ });
130
+ assert.equal(quiet.code, 0);
131
+ assert.equal(quiet.stdout, "", "already-continued stops are silent");
132
+ // workspace_roots elsewhere -> silent
133
+ const none = await runHook(d, ["cursor", "stop"], {
134
+ conversation_id: "c1",
135
+ workspace_roots: ["/definitely/not/a/worktree"],
136
+ loop_count: 0,
137
+ status: "completed",
138
+ });
139
+ assert.equal(none.stdout, "");
140
+ // empty stdin (missing/blank payload) must not break the hook
141
+ const blank = await runHook(d, ["cursor", "stop"]);
142
+ assert.equal(blank.code, 0);
143
+ assert.equal(blank.stdout, "");
144
+ }
145
+ finally {
146
+ await d.stop();
147
+ }
148
+ });
99
149
  test("hooks: claude-code await-offer wakes (exit 2 + stderr, no token leak) on offer", async () => {
100
150
  const d = await startDaemon();
101
151
  try {
102
- const cwd = mkdtempSync(join(tmpdir(), "opencode", "ll-hook-wt-"));
152
+ const cwd = mkdtempSync(join(TMP_BASE, "ll-hook-wt-"));
103
153
  const c = d.client();
104
154
  await c.connect();
105
155
  // holder + waiter, offer NOT yet delivered
@@ -0,0 +1 @@
1
+ export {};