@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
package/README.md CHANGED
@@ -43,8 +43,11 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
43
43
  and auto-respawn transparently.
44
44
  - **Honest notifications** — tool-boundary offer delivery everywhere; real
45
45
  idle wakeup where the harness supports it (live-verified on OpenCode;
46
- hook-based on Claude Code and Codex CLI); durable event ids for
47
- reconnects. A notification never authorizes work — only `claim` does.
46
+ hook-based on Claude Code and Codex CLI); a bounded blocking
47
+ `wait_for_turn` (`load-lock wait` on the CLI) that sleeps until your
48
+ turn and auto-claims for harnesses without idle wakeup; durable event
49
+ ids for reconnects. A notification never authorizes work — only
50
+ `claim` does.
48
51
  - **Policy that actually reaches the model** — installed per harness via
49
52
  its real mechanism (plugin context hook, SessionStart hooks incl.
50
53
  compact matchers, global instruction files), plus a detailed skill/guide.
@@ -55,7 +58,8 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
55
58
 
56
59
  ```sh
57
60
  npm install -g @aron98/load-lock
58
- load-lock install --harness opencode # or claude-code | codex | generic
61
+ load-lock install --harness opencode # or claude-code | codex | gemini-cli |
62
+ # copilot-cli | cline | roo-code | goose | generic
59
63
  load-lock doctor
60
64
  ```
61
65
 
@@ -67,9 +71,13 @@ use the generic stdio server; plain shells use the CLI:
67
71
  ```sh
68
72
  load-lock run --reason "nightly build" -- npm run build # acquire→run→release
69
73
  load-lock status # machine-wide view
74
+ load-lock top # live read-only dashboard (q quits)
70
75
  load-lock configure --capacity 2 --reason "16-core host" --author me
71
76
  ```
72
77
 
78
+ OpenCode users also get a live dashboard panel inside the TUI (`/load-lock`)
79
+ — same data, themed. See docs/OPERATIONS.md → "Dashboard".
80
+
73
81
  ## Layout
74
82
 
75
83
  ```
@@ -99,18 +107,35 @@ skills/ packaged skill
99
107
 
100
108
  ## Status
101
109
 
102
- - Core daemon/protocol/CLI: implemented, 32 automated tests passing
110
+ - Core daemon/protocol/CLI: implemented, 68 automated tests passing
103
111
  (multi-process daemons, real processes, restarts, races, expiry,
104
- PID-reuse, subprocess survival).
112
+ PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
113
+ shaping, event-cursor resume across a daemon restart, reconnect, and a
114
+ `load-lock top --once` render smoke test).
105
115
  - OpenCode adapter: implemented and **live-verified** (tools, policy
106
116
  injection, compaction-proof context hook, execution-boundary wakeup
107
117
  with tokens, skill).
108
- - Claude Code + Codex CLI adapters: implemented, contract-tested against
109
- documented hook JSON; not yet live-verified (harnesses not installed on
110
- the verification machine).
111
- - Gemini CLI, Copilot CLI, Cursor, Cline, Roo Code, Goose, Aider:
112
- researched; MCP fallback tier (Aider: manual CLI tier). See
113
- COMPATIBILITY.md for the matrix.
118
+ - Claude Code adapter: implemented, contract-tested against documented
119
+ hook JSON; not yet live-verified (harness not installed on the
120
+ verification machine).
121
+ - Codex CLI adapter: implemented and **live-verified** end-to-end in
122
+ Docker (interactive TUI via pty: policy injection, MCP round-trip,
123
+ FIFO queueing behind a competing holder, Stop-hook decision-block
124
+ wakeup; see COMPATIBILITY.md for the two upstream/provider constraints
125
+ the verification harness works around).
126
+ - Tier-3 fallback installers (Gemini CLI, Copilot CLI, Cline, Roo Code,
127
+ Goose): implemented merge-only installers (MCP stdio server entry into
128
+ the harness's real config + a global instructions-file section/policy
129
+ rule file), config-tested via temp-HOME round-trips; fallback tier (no
130
+ hooks, no async push — no idle wakeup).
131
+ - Cursor adapter: implemented (issue #6) — merge-only project-level installer
132
+ (`.cursor/mcp.json` MCP entry, `.cursor/hooks.json` stop hook per the now-
133
+ officially-documented hooks v1 contract, `.cursor/rules/load-lock.mdc`
134
+ Always-apply rule) + `hook cursor stop` handler emitting
135
+ `{"followup_message":...}` idle wakeup. **Contract-tested**, not
136
+ live-verified (no Cursor on the verification machine).
137
+ - Aider: researched; manual CLI tier. See COMPATIBILITY.md for the matrix.
114
138
 
115
139
  Linux-first; macOS works with documented liveness-check limitations;
116
- Windows would need a named-pipe transport adapter (planned, not built).
140
+ Windows has a named-pipe transport adapter (implemented and unit-tested on
141
+ Linux via platform injection; not yet verified on a Windows machine).
package/demo/demo.sh CHANGED
@@ -99,7 +99,7 @@ say "final state"
99
99
  const j = JSON.parse(s);
100
100
  console.log(`config version ${j.config.version}; capacity(heavy)=${j.config.pools.heavy.capacity}; offer window ${j.config.offerTimeoutMs}ms`);
101
101
  console.log("change history:");
102
- for (const h of j.history) console.log(` v${h.version} by ${h.author}: ${h.reason}`);
102
+ for (const h of j.history) console.log(` v${h.resultingVersion} by ${h.author}: ${h.reason}`);
103
103
  });'
104
104
 
105
105
  say "event log (durable identities — every grant, offer, claim, release)"
@@ -20,6 +20,8 @@ import os from "node:os";
20
20
  import path from "node:path";
21
21
  import { LLClient } from "../../shared/client.js";
22
22
  import { POLICY_BRIEF } from "../../policy/text.js";
23
+ import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
24
+ import { killProcessGroup } from "../../shared/transport.js";
23
25
  const client = new LLClient();
24
26
  const knownSessions = new Set();
25
27
  const jobs = new Map();
@@ -106,6 +108,40 @@ export default Plugin.define({
106
108
  })));
107
109
  add("cancel", "Cancel a queued/offered/paused request you no longer need.", { request_id: { type: "string" } }, (input) => client.request("cancel", { requestId: input.request_id }));
108
110
  add("rejoin", "Rejoin the FIFO queue after your offer expired or you declined (paused requests never auto-recycle).", { request_id: { type: "string" } }, (input) => client.request("rejoin", { requestId: input.request_id }));
111
+ add("wait_for_turn", "Sleep until it is your turn (bounded blocking wait for a queued request). Returns a structured result on every path: granted (offer auto-claimed for you — permit_token in entry), still_queued (timed out; queue_position + holders shown), paused (rejoin instructions), cancelled, or not_found (daemon restarted — acquire again). Holds NO permit and NO capacity; cancelling this call is safe and never strands anything — the request keeps its queue position.", {
112
+ request_id: { type: "string", description: "Request to watch (default: your oldest offered/front-most queued/oldest paused request)" },
113
+ timeout_ms: { type: "number", description: "Bounded sleep in ms (default 600000, clamped to the machine's maxTurnWaitMs cap; 0 = check-only)" },
114
+ }, async (input, context) => {
115
+ try {
116
+ const r = await waitForTurn({
117
+ client,
118
+ agentId: context.sessionID,
119
+ requestId: input.request_id,
120
+ timeoutMs: input.timeout_ms,
121
+ signal: context.signal,
122
+ });
123
+ return {
124
+ state: r.state,
125
+ request_id: r.requestId,
126
+ auto_claimed: r.autoClaimed,
127
+ permit_token: r.state === "granted" ? r.entry?.permitToken : undefined,
128
+ waited_ms: r.waitedMs,
129
+ effective_wait_ms: r.effectiveWaitMs,
130
+ entry: r.entry,
131
+ queue_position: r.queuePosition,
132
+ queue_length: r.queueLength,
133
+ holders: r.holders,
134
+ next_action: r.nextAction,
135
+ detail: r.detail,
136
+ };
137
+ }
138
+ catch (err) {
139
+ if (err instanceof WaitAbortedError) {
140
+ return { state: "aborted", detail: "wait cancelled by the harness/caller; the request keeps its queue position" };
141
+ }
142
+ throw err;
143
+ }
144
+ });
109
145
  add("pending", "Your outstanding offers (claim NOW — they expire), queue positions, paused and held requests.", {}, (_input, context) => client.request("pending", { agentId: context.sessionID }));
110
146
  add("status", "Machine-wide pool snapshot: capacity, holders, offers, queue.", { pool: { type: "string" } }, (input) => client.request("status", input.pool ? { pool: input.pool } : {}));
111
147
  add("inspect_machine", "Honest machine resources incl. cgroup/container limits and current pressure.", {}, () => client.request("inspect", {}));
@@ -161,12 +197,7 @@ export default Plugin.define({
161
197
  await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
162
198
  }
163
199
  catch (err) {
164
- try {
165
- process.kill(-child.pid, "SIGKILL");
166
- }
167
- catch {
168
- /* already gone */
169
- }
200
+ killProcessGroup(child.pid, "SIGKILL");
170
201
  return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
171
202
  }
172
203
  jobs.set(job.id, job);
@@ -189,20 +220,10 @@ export default Plugin.define({
189
220
  if (!job)
190
221
  return { error: "unknown job_id" };
191
222
  if (!job.exited) {
192
- try {
193
- process.kill(-job.child.pid, "SIGTERM");
194
- }
195
- catch {
196
- /* already gone */
197
- }
223
+ killProcessGroup(job.child.pid, "SIGTERM");
198
224
  setTimeout(() => {
199
225
  if (!job.exited) {
200
- try {
201
- process.kill(-job.child.pid, "SIGKILL");
202
- }
203
- catch {
204
- /* already gone */
205
- }
226
+ killProcessGroup(job.child.pid, "SIGKILL");
206
227
  }
207
228
  }, 5000).unref?.();
208
229
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * load-lock TUI plugin for OpenCode V2 — live lock-status dashboard (issue #10).
3
+ *
4
+ * Companion to the main SDK plugin (src/adapters/opencode/plugin.ts, which
5
+ * owns tools/policy/wakeups and stays unchanged). Exposed through the
6
+ * package's "./tui" export; the OpenCode TUI loads it automatically beside
7
+ * the main plugin (see the plugin guide's "Publish and load" section).
8
+ *
9
+ * Surfaces:
10
+ * - "load-lock.dashboard" session panel: pools with capacity vs in-use,
11
+ * holder cards (label, reason, phase, pid/pgid, age, lease deadline),
12
+ * offer cards with claim-window countdown, FIFO queue positions,
13
+ * paused requests, the recent event feed, and the inspect_machine panel
14
+ * - a home.footer.status indicator summarizing the heavy pool
15
+ * - /load-lock (palette + slash command) opens the panel full-screen
16
+ * - 'r' resync and 'x' force-release while the panel is open; force-release
17
+ * is an admin action gated behind an explicit select + confirm dialog
18
+ *
19
+ * The viewer is read-only and light: it only ever READS daemon state
20
+ * (status / events / inspect / watch) and never acquires a permit itself.
21
+ *
22
+ * Reactivity note: this file is compiled by plain tsc (no Solid babel
23
+ * preset), so JSX expressions evaluate eagerly instead of becoming reactive
24
+ * getters. All liveness therefore flows through ONE keyed <Show> boundary
25
+ * (created with explicit property getters) that is recreated whenever the
26
+ * feed emits a new view snapshot or the 1s countdown tick fires; below
27
+ * that boundary, eager evaluation is exactly the semantics we want.
28
+ */
29
+ import { Plugin } from "@opencode/plugin/tui";
30
+ declare const _default: Plugin.Definition;
31
+ export default _default;
@@ -0,0 +1,268 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
2
+ /**
3
+ * load-lock TUI plugin for OpenCode V2 — live lock-status dashboard (issue #10).
4
+ *
5
+ * Companion to the main SDK plugin (src/adapters/opencode/plugin.ts, which
6
+ * owns tools/policy/wakeups and stays unchanged). Exposed through the
7
+ * package's "./tui" export; the OpenCode TUI loads it automatically beside
8
+ * the main plugin (see the plugin guide's "Publish and load" section).
9
+ *
10
+ * Surfaces:
11
+ * - "load-lock.dashboard" session panel: pools with capacity vs in-use,
12
+ * holder cards (label, reason, phase, pid/pgid, age, lease deadline),
13
+ * offer cards with claim-window countdown, FIFO queue positions,
14
+ * paused requests, the recent event feed, and the inspect_machine panel
15
+ * - a home.footer.status indicator summarizing the heavy pool
16
+ * - /load-lock (palette + slash command) opens the panel full-screen
17
+ * - 'r' resync and 'x' force-release while the panel is open; force-release
18
+ * is an admin action gated behind an explicit select + confirm dialog
19
+ *
20
+ * The viewer is read-only and light: it only ever READS daemon state
21
+ * (status / events / inspect / watch) and never acquires a permit itself.
22
+ *
23
+ * Reactivity note: this file is compiled by plain tsc (no Solid babel
24
+ * preset), so JSX expressions evaluate eagerly instead of becoming reactive
25
+ * getters. All liveness therefore flows through ONE keyed <Show> boundary
26
+ * (created with explicit property getters) that is recreated whenever the
27
+ * feed emits a new view snapshot or the 1s countdown tick fires; below
28
+ * that boundary, eager evaluation is exactly the semantics we want.
29
+ */
30
+ import { Plugin } from "@opencode/plugin/tui";
31
+ import { createComponent, createSignal, Show } from "solid-js";
32
+ import { LLClient } from "../../shared/client.js";
33
+ import { DashboardFeed } from "../../dashboard/feed.js";
34
+ import { fmtBytes, fmtClock, fmtDuration, } from "../../dashboard/view.js";
35
+ const PANEL_NAME = "load-lock.dashboard";
36
+ function themeFg(t) {
37
+ return {
38
+ base: t.text.base,
39
+ dim: t.text.muted,
40
+ accent: t.text.feedback.info.base,
41
+ good: t.text.feedback.success.base,
42
+ warn: t.text.feedback.warning.base,
43
+ bad: t.text.feedback.error.base,
44
+ };
45
+ }
46
+ function stateColor(s, f) {
47
+ if (s === "live")
48
+ return f.good;
49
+ if (s === "connecting")
50
+ return f.dim;
51
+ return f.bad;
52
+ }
53
+ const STATE_LABEL = {
54
+ live: "LIVE",
55
+ connecting: "CONNECTING",
56
+ reconnecting: "RECONNECTING",
57
+ down: "DAEMON DOWN",
58
+ };
59
+ function holderLines(h, out, fg) {
60
+ const id = h.requestId.slice(0, 14);
61
+ let meta = h.phaseLabel;
62
+ if (h.phase === "claimed" && h.startDeadlineInMs !== undefined)
63
+ meta += ` · must start in ${fmtDuration(h.startDeadlineInMs)}`;
64
+ if (h.phase === "running_tracked" && h.pid !== undefined)
65
+ meta += ` · pid ${h.pid}` + (h.pgid !== undefined && h.pgid !== h.pid ? ` · pgid ${h.pgid}` : "");
66
+ if (h.phase === "running_untracked" && h.leaseDeadlineInMs !== undefined)
67
+ meta += ` · lease ends in ${fmtDuration(h.leaseDeadlineInMs)}`;
68
+ out.push({ text: ` HELD ${h.agent} (${id}) · ${meta} · age ${fmtDuration(h.heldMs)}`, fg: fg.good });
69
+ out.push({ text: ` reason: ${h.reason}`, fg: fg.dim });
70
+ }
71
+ function dashboardLines(v, width, f) {
72
+ const W = Math.max(40, width - 2);
73
+ const out = [];
74
+ const push = (text, fg) => {
75
+ out.push({ text: text.length > W ? text.slice(0, W - 1) + "…" : text, fg });
76
+ };
77
+ const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
78
+ push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
79
+ if (v.daemon) {
80
+ push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion} · events through #${v.latestEventId}`, f.dim);
81
+ }
82
+ else {
83
+ push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
84
+ }
85
+ if (v.daemonRestartNote)
86
+ push(`note: ${v.daemonRestartNote}`, f.warn);
87
+ if (v.machine) {
88
+ const m = v.machine;
89
+ const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
90
+ push(`MACHINE ${m.logicalCores} cores (effective ${m.effectiveCores}${cg}) · load ${m.loadavg.map((n) => n.toFixed(2)).join(" ")} = ${m.loadPerEffectiveCore.toFixed(2)}/core · mem ${fmtBytes(m.memTotalBytes)} total, ${fmtBytes(m.memAvailableBytes)} avail (limit ${fmtBytes(m.memEffectiveLimitBytes)})`, f.accent);
91
+ push(` pressure: cpu ${m.loadPressure}, mem ${m.memPressure} — ${m.note}`, f.dim);
92
+ if (m.suggestion)
93
+ push(` suggestion: ${m.suggestion}`, f.dim);
94
+ }
95
+ push("");
96
+ if (!v.pools.length)
97
+ push(`no pools configured`, f.dim);
98
+ for (const p of v.pools) {
99
+ const full = p.capacity > 0 && p.used >= p.capacity;
100
+ push(`POOL ${p.pool} ${p.used}/${p.capacity} used (held ${p.heldWeight}, offered ${p.offeredWeight}, free ${p.free}) · queue ${p.queue.length} · paused ${p.paused.length}`, full ? f.warn : f.accent);
101
+ for (const h of p.holders)
102
+ holderLines(h, out, f);
103
+ for (const o of p.offers) {
104
+ const id = o.requestId.slice(0, 14);
105
+ const window = o.claimInMs >= 0 ? `claim within ${fmtDuration(o.claimInMs)}` : "claim window elapsed";
106
+ out.push({ text: ` OFFER ${o.agent} (${id}) · ${window} · expires ${fmtClock(o.expiresAt)}`, fg: f.warn });
107
+ out.push({ text: ` reason: ${o.reason}`, fg: f.dim });
108
+ }
109
+ for (const q of p.queue) {
110
+ push(` QUEUE #${q.position} ${q.agent} — ${q.reason} (waiting ${fmtDuration(q.waitMs)})`, f.base);
111
+ }
112
+ for (const q of p.paused) {
113
+ push(` PAUSED ${q.agent} — ${q.reason} (paused ${fmtDuration(q.pausedMs)} · rejoin required)`, f.bad);
114
+ }
115
+ }
116
+ push("");
117
+ push(`EVENTS (newest first)`, f.accent);
118
+ if (!v.events.length)
119
+ push(` (no events yet)`, f.dim);
120
+ for (const e of v.events.slice(0, 12)) {
121
+ push(` #${e.id} ${fmtClock(e.ts)} ${e.text}`);
122
+ }
123
+ push("");
124
+ push(`r resync · x force-release (admin, confirm required) · Esc closes · read-only viewer — never acquires a permit`, f.dim);
125
+ return out;
126
+ }
127
+ function PanelBody(v, panel, ctx) {
128
+ const f = themeFg(ctx.theme);
129
+ const lines = dashboardLines(v, panel.width || 100, f);
130
+ return (_jsx("box", { children: _jsx(_Fragment, { children: lines.map((l) => (_jsx("text", { fg: l.fg, children: l.text }))) }) }));
131
+ }
132
+ function MiniIndicator(v, ctx) {
133
+ const f = themeFg(ctx.theme);
134
+ const pool = v.pools.find((p) => p.pool === "heavy") ?? v.pools[0];
135
+ if (!pool)
136
+ return _jsx("text", { fg: f.dim, children: "lock: no pools" });
137
+ const bits = [`${pool.used}/${pool.capacity}`];
138
+ if (pool.queue.length)
139
+ bits.push(`Q${pool.queue.length}`);
140
+ if (pool.paused.length)
141
+ bits.push(`P${pool.paused.length}`);
142
+ const fg = v.state !== "live"
143
+ ? f.bad
144
+ : pool.used >= pool.capacity
145
+ ? f.warn
146
+ : pool.queue.length
147
+ ? f.warn
148
+ : f.good;
149
+ return _jsx("text", { fg: fg, children: `lock ${bits.join("·")}` });
150
+ }
151
+ // ---------------------------------------------------------------- plugin
152
+ export default Plugin.define({
153
+ id: "load-lock.tui",
154
+ setup(ctx) {
155
+ const [view, setView] = createSignal(null);
156
+ const feed = new DashboardFeed(new LLClient());
157
+ feed.onChange = () => setView(feed.view());
158
+ feed.onState = () => setView(feed.view());
159
+ void feed.start().catch(() => {
160
+ /* daemon down or spawning; the feed keeps retrying and will go live */
161
+ });
162
+ // Local countdown tick: produces a fresh snapshot once per second so
163
+ // ages/deadlines advance even between daemon events.
164
+ const tick = setInterval(() => setView(feed.view()), 1000);
165
+ tick.unref?.();
166
+ const panelOpen = () => ctx.ui.panel.current()?.name === PANEL_NAME;
167
+ // ------------------------------------------------------ force-release
168
+ async function forceReleaseFlow() {
169
+ const holders = (feed.view().pools ?? []).flatMap((p) => p.holders);
170
+ if (!holders.length) {
171
+ ctx.ui.toast.show({ message: "load-lock: no held permits to release", variant: "info" });
172
+ return;
173
+ }
174
+ const pick = await ctx.ui.dialog.select({
175
+ title: "Force-release a permit (admin)",
176
+ placeholder: "Select holder",
177
+ options: holders.map((h) => ({
178
+ title: `${h.agent} (${h.requestId.slice(0, 14)})`,
179
+ description: `${h.phaseLabel}${h.pid !== undefined ? ` · pid ${h.pid}` : ""} · held ${fmtDuration(h.heldMs)} · ${h.reason}`,
180
+ value: h.requestId,
181
+ })),
182
+ });
183
+ if (!pick)
184
+ return;
185
+ const confirmed = await ctx.ui.dialog.confirm({
186
+ title: "Force-release permit",
187
+ message: `Reclaim ${pick}? Its owner may still be mid-build; this is a last-resort recovery action.`,
188
+ label: { confirm: "Force-release", cancel: "Cancel" },
189
+ });
190
+ if (confirmed !== true)
191
+ return;
192
+ try {
193
+ await feed.forceRelease(pick);
194
+ ctx.ui.toast.show({ message: `load-lock: force-released ${pick.slice(0, 14)}`, variant: "warning" });
195
+ }
196
+ catch (e) {
197
+ ctx.ui.toast.show({ message: `load-lock: force-release failed: ${String(e)}`, variant: "error" });
198
+ }
199
+ }
200
+ // ----------------------------------------------------------- keymap
201
+ ctx.keymap.layer(() => ({
202
+ mode: "global",
203
+ priority: 5,
204
+ commands: [
205
+ {
206
+ id: "load-lock.dashboard.open",
207
+ title: "load-lock: open the lock-status dashboard",
208
+ group: "load-lock",
209
+ palette: true,
210
+ slash: { name: "load-lock", aliases: ["lock", "ll"] },
211
+ run: () => {
212
+ ctx.ui.panel.open(PANEL_NAME, { presentation: "fullscreen" });
213
+ },
214
+ },
215
+ {
216
+ id: "load-lock.dashboard.resync",
217
+ title: "load-lock: resync the dashboard",
218
+ group: "load-lock",
219
+ bind: "r",
220
+ enabled: panelOpen,
221
+ run: () => {
222
+ void feed.resync().catch(() => { });
223
+ },
224
+ },
225
+ {
226
+ id: "load-lock.dashboard.force-release",
227
+ title: "load-lock: force-release a permit (admin)",
228
+ group: "load-lock",
229
+ bind: "x",
230
+ enabled: panelOpen,
231
+ run: () => {
232
+ void forceReleaseFlow();
233
+ },
234
+ },
235
+ ],
236
+ }));
237
+ // ------------------------------------------------------------ slots
238
+ // Session panel contribution: renders only when our name is selected.
239
+ const offPanel = ctx.ui.slot({
240
+ append: "session.panel",
241
+ render: (panel) => createComponent(Show, {
242
+ get when() {
243
+ return panel.name === PANEL_NAME ? (view() ?? false) : false;
244
+ },
245
+ keyed: true,
246
+ children: (v) => PanelBody(v, panel, ctx),
247
+ }),
248
+ });
249
+ // Home footer mini indicator (heavy pool summary).
250
+ const offFooter = ctx.ui.slot({
251
+ append: "home.footer.status",
252
+ render: () => createComponent(Show, {
253
+ get when() {
254
+ return view() ?? false;
255
+ },
256
+ keyed: true,
257
+ children: (v) => MiniIndicator(v, ctx),
258
+ }),
259
+ });
260
+ return () => {
261
+ if (tick)
262
+ clearInterval(tick);
263
+ offPanel();
264
+ offFooter();
265
+ void feed.stop();
266
+ };
267
+ },
268
+ });
package/dist/cli/hook.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * load-lock harness hook handlers (Claude Code, Codex CLI).
2
+ * load-lock harness hook handlers (Claude Code, Codex CLI, Cursor).
3
3
  *
4
4
  * These are invoked by the harness's hook system with the harness's hook
5
5
  * JSON on stdin. They are intentionally fast (one daemon RPC or none) and
@@ -16,6 +16,11 @@
16
16
  * - Codex SessionStart/SubagentStart: plain stdout = additionalContext.
17
17
  * - Codex Stop: {"decision":"block","reason":...} continues; async hooks
18
18
  * cannot wake an idle Codex session (documented limitation).
19
+ * - Cursor Stop (hooks v1, https://cursor.com/docs/hooks): stdin carries
20
+ * workspace_roots (no cwd) and loop_count; stdout JSON
21
+ * {"followup_message":...} is auto-submitted as the next user message
22
+ * (idle wakeup). loop_count > 0 guards against followup loops. Verified
23
+ * against the official docs only (contract-tested, not live-verified).
19
24
  */
20
25
  import { LLClient } from "../shared/client.js";
21
26
  import { POLICY_BRIEF } from "../policy/text.js";
@@ -103,6 +108,35 @@ export async function hookCommand(rest) {
103
108
  }
104
109
  return;
105
110
  }
111
+ // Cursor Stop (hooks v1): {"followup_message":...} is auto-submitted by
112
+ // Cursor as the next user message — the documented loop-style idle
113
+ // wakeup. loop_count counts prior stop-hook followups for this
114
+ // conversation (starts at 0) — Cursor's stop_hook_active equivalent.
115
+ case "cursor/stop": {
116
+ if ((input.loop_count ?? 0) > 0) {
117
+ return; // already woke once for this conversation; do not loop
118
+ }
119
+ const cursorCwd = input.workspace_roots?.[0] || process.cwd();
120
+ try {
121
+ const c = new LLClient({ spawnDaemon: false });
122
+ await c.connect();
123
+ try {
124
+ const { offers } = (await c.request("offers_for_cwd", { cwd: cursorCwd }));
125
+ if (offers.length > 0) {
126
+ const o = offers[0];
127
+ const seconds = Math.max(0, Math.round(((o.offerExpiresAt ?? Date.now()) - Date.now()) / 1000));
128
+ process.stdout.write(JSON.stringify({ followup_message: claimInstruction(o, seconds) }) + "\n");
129
+ }
130
+ }
131
+ finally {
132
+ c.close();
133
+ }
134
+ }
135
+ catch {
136
+ /* daemon down: nothing to deliver */
137
+ }
138
+ return;
139
+ }
106
140
  // async + asyncRewake watcher (Claude Code only; Codex async hooks cannot
107
141
  // wake an idle session — documented).
108
142
  case "claude-code/await-offer": {