@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.
- package/README.md +37 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +31 -0
- package/dist/adapters/opencode/tui.js +268 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +740 -61
- package/dist/cli/main.js +36 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +98 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +12 -0
- package/dist/dashboard/frame.js +94 -0
- package/dist/dashboard/view.d.ts +148 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +405 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +33 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +215 -26
- package/docs/OPERATIONS.md +313 -8
- package/package.json +25 -1
- 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);
|
|
47
|
-
|
|
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 |
|
|
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,
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": {
|