@aron98/load-lock 0.1.2 → 1.1.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 +40 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +61 -0
- package/dist/adapters/opencode/tui.js +358 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +130 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +99 -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 +13 -0
- package/dist/dashboard/frame.js +95 -0
- package/dist/dashboard/view.d.ts +149 -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/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +406 -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/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- 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 +34 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +217 -26
- package/docs/OPERATIONS.md +335 -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,16 @@ 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 dashboard # live read-only dashboard (q quits)
|
|
75
|
+
load-lock help # one-screen usage of every command
|
|
70
76
|
load-lock configure --capacity 2 --reason "16-core host" --author me
|
|
71
77
|
```
|
|
72
78
|
|
|
79
|
+
OpenCode users also get a live dashboard inside the TUI: `/load-lock`
|
|
80
|
+
opens it in-window, every session screen shows a clickable at-a-glance
|
|
81
|
+
widget in the right sidebar, and the home screen has a footer indicator —
|
|
82
|
+
same data, themed. See docs/OPERATIONS.md → "Dashboard".
|
|
83
|
+
|
|
73
84
|
## Layout
|
|
74
85
|
|
|
75
86
|
```
|
|
@@ -99,18 +110,35 @@ skills/ packaged skill
|
|
|
99
110
|
|
|
100
111
|
## Status
|
|
101
112
|
|
|
102
|
-
- Core daemon/protocol/CLI: implemented,
|
|
113
|
+
- Core daemon/protocol/CLI: implemented, 68 automated tests passing
|
|
103
114
|
(multi-process daemons, real processes, restarts, races, expiry,
|
|
104
|
-
PID-reuse, subprocess survival
|
|
115
|
+
PID-reuse, subprocess survival; plus 9 dashboard tests — view-model
|
|
116
|
+
shaping, event-cursor resume across a daemon restart, reconnect, and a
|
|
117
|
+
`load-lock dashboard --once` render smoke test).
|
|
105
118
|
- OpenCode adapter: implemented and **live-verified** (tools, policy
|
|
106
119
|
injection, compaction-proof context hook, execution-boundary wakeup
|
|
107
120
|
with tokens, skill).
|
|
108
|
-
- Claude Code
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
- Claude Code adapter: implemented, contract-tested against documented
|
|
122
|
+
hook JSON; not yet live-verified (harness not installed on the
|
|
123
|
+
verification machine).
|
|
124
|
+
- Codex CLI adapter: implemented and **live-verified** end-to-end in
|
|
125
|
+
Docker (interactive TUI via pty: policy injection, MCP round-trip,
|
|
126
|
+
FIFO queueing behind a competing holder, Stop-hook decision-block
|
|
127
|
+
wakeup; see COMPATIBILITY.md for the two upstream/provider constraints
|
|
128
|
+
the verification harness works around).
|
|
129
|
+
- Tier-3 fallback installers (Gemini CLI, Copilot CLI, Cline, Roo Code,
|
|
130
|
+
Goose): implemented merge-only installers (MCP stdio server entry into
|
|
131
|
+
the harness's real config + a global instructions-file section/policy
|
|
132
|
+
rule file), config-tested via temp-HOME round-trips; fallback tier (no
|
|
133
|
+
hooks, no async push — no idle wakeup).
|
|
134
|
+
- Cursor adapter: implemented (issue #6) — merge-only project-level installer
|
|
135
|
+
(`.cursor/mcp.json` MCP entry, `.cursor/hooks.json` stop hook per the now-
|
|
136
|
+
officially-documented hooks v1 contract, `.cursor/rules/load-lock.mdc`
|
|
137
|
+
Always-apply rule) + `hook cursor stop` handler emitting
|
|
138
|
+
`{"followup_message":...}` idle wakeup. **Contract-tested**, not
|
|
139
|
+
live-verified (no Cursor on the verification machine).
|
|
140
|
+
- Aider: researched; manual CLI tier. See COMPATIBILITY.md for the matrix.
|
|
114
141
|
|
|
115
142
|
Linux-first; macOS works with documented liveness-check limitations;
|
|
116
|
-
Windows
|
|
143
|
+
Windows has a named-pipe transport adapter (implemented and unit-tested on
|
|
144
|
+
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,61 @@
|
|
|
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 (home screen)
|
|
15
|
+
* - a clickable sidebar.footer widget with the same summary (session
|
|
16
|
+
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
17
|
+
* disjoint surfaces, so this is not a double-render
|
|
18
|
+
* - /load-lock (palette + slash command, also bound to "gl") opens the
|
|
19
|
+
* panel IN-WINDOW via presentation: "panel" instead of fullscreen
|
|
20
|
+
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
21
|
+
* force-release is an admin action gated behind an explicit select +
|
|
22
|
+
* confirm dialog
|
|
23
|
+
*
|
|
24
|
+
* The viewer is read-only and light: it only ever READS daemon state
|
|
25
|
+
* (status / events / inspect / watch) and never acquires a permit itself.
|
|
26
|
+
*
|
|
27
|
+
* Reactivity note: this file is compiled by plain tsc (no Solid babel
|
|
28
|
+
* preset), so JSX expressions evaluate eagerly instead of becoming reactive
|
|
29
|
+
* getters. All liveness therefore flows through ONE keyed <Show> boundary
|
|
30
|
+
* (created with explicit property getters) that is recreated whenever the
|
|
31
|
+
* feed emits a new view snapshot or the 1s countdown tick fires; below
|
|
32
|
+
* that boundary, eager evaluation is exactly the semantics we want.
|
|
33
|
+
*/
|
|
34
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
35
|
+
import type { PanelPresentation } from "@opencode/plugin/tui/context";
|
|
36
|
+
import { type DashboardView } from "../../dashboard/view.js";
|
|
37
|
+
/**
|
|
38
|
+
* The dashboard opens as an in-window panel ("panel" is the SDK's in-window
|
|
39
|
+
* PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
|
|
40
|
+
* tests can pin the choice (issue #24).
|
|
41
|
+
*/
|
|
42
|
+
export declare const PANEL_PRESENTATION: PanelPresentation;
|
|
43
|
+
/**
|
|
44
|
+
* Health of the at-a-glance summary. "down" means the daemon feed is not
|
|
45
|
+
* live (daemon down / connecting); the others describe the pool itself.
|
|
46
|
+
*/
|
|
47
|
+
export type GlanceHealth = "good" | "warn" | "bad" | "down";
|
|
48
|
+
/** Render-ready at-a-glance summary of one pool (pure; unit-tested). */
|
|
49
|
+
export interface PoolGlance {
|
|
50
|
+
/** e.g. ["2/3", "Q1", "P2"] — used/capacity, then queue/paused counts. */
|
|
51
|
+
bits: string[];
|
|
52
|
+
health: GlanceHealth;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One-line heavy-pool summary shared by the home footer indicator and the
|
|
56
|
+
* sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
|
|
57
|
+
* and falls back to the first pool for custom configurations.
|
|
58
|
+
*/
|
|
59
|
+
export declare function poolGlance(v: DashboardView): PoolGlance | null;
|
|
60
|
+
declare const _default: Plugin.Definition;
|
|
61
|
+
export default _default;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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 (home screen)
|
|
16
|
+
* - a clickable sidebar.footer widget with the same summary (session
|
|
17
|
+
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
18
|
+
* disjoint surfaces, so this is not a double-render
|
|
19
|
+
* - /load-lock (palette + slash command, also bound to "gl") opens the
|
|
20
|
+
* panel IN-WINDOW via presentation: "panel" instead of fullscreen
|
|
21
|
+
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
22
|
+
* force-release is an admin action gated behind an explicit select +
|
|
23
|
+
* confirm dialog
|
|
24
|
+
*
|
|
25
|
+
* The viewer is read-only and light: it only ever READS daemon state
|
|
26
|
+
* (status / events / inspect / watch) and never acquires a permit itself.
|
|
27
|
+
*
|
|
28
|
+
* Reactivity note: this file is compiled by plain tsc (no Solid babel
|
|
29
|
+
* preset), so JSX expressions evaluate eagerly instead of becoming reactive
|
|
30
|
+
* getters. All liveness therefore flows through ONE keyed <Show> boundary
|
|
31
|
+
* (created with explicit property getters) that is recreated whenever the
|
|
32
|
+
* feed emits a new view snapshot or the 1s countdown tick fires; below
|
|
33
|
+
* that boundary, eager evaluation is exactly the semantics we want.
|
|
34
|
+
*/
|
|
35
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
36
|
+
import { createComponent, createSignal, Show } from "solid-js";
|
|
37
|
+
import { LLClient } from "../../shared/client.js";
|
|
38
|
+
import { DashboardFeed } from "../../dashboard/feed.js";
|
|
39
|
+
import { fmtBytes, fmtClock, fmtDuration, } from "../../dashboard/view.js";
|
|
40
|
+
const PANEL_NAME = "load-lock.dashboard";
|
|
41
|
+
/**
|
|
42
|
+
* The dashboard opens as an in-window panel ("panel" is the SDK's in-window
|
|
43
|
+
* PanelPresentation; "fullscreen" takes over the whole TUI). Exported so
|
|
44
|
+
* tests can pin the choice (issue #24).
|
|
45
|
+
*/
|
|
46
|
+
export const PANEL_PRESENTATION = "panel";
|
|
47
|
+
function themeFg(t) {
|
|
48
|
+
return {
|
|
49
|
+
base: t.text.base,
|
|
50
|
+
dim: t.text.muted,
|
|
51
|
+
accent: t.text.feedback.info.base,
|
|
52
|
+
good: t.text.feedback.success.base,
|
|
53
|
+
warn: t.text.feedback.warning.base,
|
|
54
|
+
bad: t.text.feedback.error.base,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function stateColor(s, f) {
|
|
58
|
+
if (s === "live")
|
|
59
|
+
return f.good;
|
|
60
|
+
if (s === "connecting")
|
|
61
|
+
return f.dim;
|
|
62
|
+
return f.bad;
|
|
63
|
+
}
|
|
64
|
+
const STATE_LABEL = {
|
|
65
|
+
live: "LIVE",
|
|
66
|
+
connecting: "CONNECTING",
|
|
67
|
+
reconnecting: "RECONNECTING",
|
|
68
|
+
down: "DAEMON DOWN",
|
|
69
|
+
};
|
|
70
|
+
function holderLines(h, out, fg) {
|
|
71
|
+
const id = h.requestId.slice(0, 14);
|
|
72
|
+
let meta = h.phaseLabel;
|
|
73
|
+
if (h.phase === "claimed" && h.startDeadlineInMs !== undefined)
|
|
74
|
+
meta += ` · must start in ${fmtDuration(h.startDeadlineInMs)}`;
|
|
75
|
+
if (h.phase === "running_tracked" && h.pid !== undefined)
|
|
76
|
+
meta += ` · pid ${h.pid}` + (h.pgid !== undefined && h.pgid !== h.pid ? ` · pgid ${h.pgid}` : "");
|
|
77
|
+
if (h.phase === "running_untracked" && h.leaseDeadlineInMs !== undefined)
|
|
78
|
+
meta += ` · lease ends in ${fmtDuration(h.leaseDeadlineInMs)}`;
|
|
79
|
+
out.push({ text: ` HELD ${h.agent} (${id}) · ${meta} · age ${fmtDuration(h.heldMs)}`, fg: fg.good });
|
|
80
|
+
out.push({ text: ` reason: ${h.reason}`, fg: fg.dim });
|
|
81
|
+
}
|
|
82
|
+
function dashboardLines(v, width, f) {
|
|
83
|
+
const W = Math.max(40, width - 2);
|
|
84
|
+
const out = [];
|
|
85
|
+
const push = (text, fg) => {
|
|
86
|
+
out.push({ text: text.length > W ? text.slice(0, W - 1) + "…" : text, fg });
|
|
87
|
+
};
|
|
88
|
+
const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
|
|
89
|
+
push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
|
|
90
|
+
if (v.daemon) {
|
|
91
|
+
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);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
|
|
95
|
+
}
|
|
96
|
+
if (v.daemonRestartNote)
|
|
97
|
+
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
98
|
+
if (v.machine) {
|
|
99
|
+
const m = v.machine;
|
|
100
|
+
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
101
|
+
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);
|
|
102
|
+
push(` pressure: cpu ${m.loadPressure}, mem ${m.memPressure} — ${m.note}`, f.dim);
|
|
103
|
+
if (m.suggestion)
|
|
104
|
+
push(` suggestion: ${m.suggestion}`, f.dim);
|
|
105
|
+
}
|
|
106
|
+
push("");
|
|
107
|
+
if (!v.pools.length)
|
|
108
|
+
push(`no pools configured`, f.dim);
|
|
109
|
+
for (const p of v.pools) {
|
|
110
|
+
const full = p.capacity > 0 && p.used >= p.capacity;
|
|
111
|
+
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);
|
|
112
|
+
for (const h of p.holders)
|
|
113
|
+
holderLines(h, out, f);
|
|
114
|
+
for (const o of p.offers) {
|
|
115
|
+
const id = o.requestId.slice(0, 14);
|
|
116
|
+
const window = o.claimInMs >= 0 ? `claim within ${fmtDuration(o.claimInMs)}` : "claim window elapsed";
|
|
117
|
+
out.push({ text: ` OFFER ${o.agent} (${id}) · ${window} · expires ${fmtClock(o.expiresAt)}`, fg: f.warn });
|
|
118
|
+
out.push({ text: ` reason: ${o.reason}`, fg: f.dim });
|
|
119
|
+
}
|
|
120
|
+
for (const q of p.queue) {
|
|
121
|
+
push(` QUEUE #${q.position} ${q.agent} — ${q.reason} (waiting ${fmtDuration(q.waitMs)})`, f.base);
|
|
122
|
+
}
|
|
123
|
+
for (const q of p.paused) {
|
|
124
|
+
push(` PAUSED ${q.agent} — ${q.reason} (paused ${fmtDuration(q.pausedMs)} · rejoin required)`, f.bad);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
push("");
|
|
128
|
+
push(`EVENTS (newest first)`, f.accent);
|
|
129
|
+
if (!v.events.length)
|
|
130
|
+
push(` (no events yet)`, f.dim);
|
|
131
|
+
for (const e of v.events.slice(0, 12)) {
|
|
132
|
+
push(` #${e.id} ${fmtClock(e.ts)} ${e.text}`);
|
|
133
|
+
}
|
|
134
|
+
push("");
|
|
135
|
+
push(`r resync · x force-release (admin, confirm required) · Esc closes · read-only viewer — never acquires a permit`, f.dim);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function PanelBody(v, panel, ctx) {
|
|
139
|
+
const f = themeFg(ctx.theme);
|
|
140
|
+
const lines = dashboardLines(v, panel.width || 100, f);
|
|
141
|
+
return (_jsx("box", { children: _jsx(_Fragment, { children: lines.map((l) => (_jsx("text", { fg: l.fg, children: l.text }))) }) }));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* One-line heavy-pool summary shared by the home footer indicator and the
|
|
145
|
+
* sidebar widget, so both surfaces always agree. Prefers the "heavy" pool
|
|
146
|
+
* and falls back to the first pool for custom configurations.
|
|
147
|
+
*/
|
|
148
|
+
export function poolGlance(v) {
|
|
149
|
+
const pool = v.pools.find((p) => p.pool === "heavy") ?? v.pools[0];
|
|
150
|
+
if (!pool)
|
|
151
|
+
return null;
|
|
152
|
+
const bits = [`${pool.used}/${pool.capacity}`];
|
|
153
|
+
if (pool.queue.length)
|
|
154
|
+
bits.push(`Q${pool.queue.length}`);
|
|
155
|
+
if (pool.paused.length)
|
|
156
|
+
bits.push(`P${pool.paused.length}`);
|
|
157
|
+
let health;
|
|
158
|
+
if (v.state !== "live")
|
|
159
|
+
health = "down";
|
|
160
|
+
else if ((pool.capacity > 0 && pool.used >= pool.capacity) || pool.queue.length > 0)
|
|
161
|
+
health = "warn";
|
|
162
|
+
else
|
|
163
|
+
health = "good";
|
|
164
|
+
return { bits, health };
|
|
165
|
+
}
|
|
166
|
+
function healthColor(h, f) {
|
|
167
|
+
if (h === "good")
|
|
168
|
+
return f.good;
|
|
169
|
+
if (h === "warn")
|
|
170
|
+
return f.warn;
|
|
171
|
+
return f.bad; // "bad" and "down" both read as the error color
|
|
172
|
+
}
|
|
173
|
+
function MiniIndicator(v, ctx) {
|
|
174
|
+
const f = themeFg(ctx.theme);
|
|
175
|
+
const g = poolGlance(v);
|
|
176
|
+
if (!g)
|
|
177
|
+
return _jsx("text", { fg: f.dim, children: "lock: no pools" });
|
|
178
|
+
return _jsx("text", { fg: healthColor(g.health, f), children: `lock ${g.bits.join("·")}` });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Right-sidebar widget: the same glance summary plus a "click to open"
|
|
182
|
+
* affordance. Click support is contract-level (opentui enables mouse and
|
|
183
|
+
* bubbles mouse-down events to slot renderables); terminals that don't
|
|
184
|
+
* deliver mouse events can still open the dashboard with the keymap
|
|
185
|
+
* binding shown on the hint line.
|
|
186
|
+
*/
|
|
187
|
+
function SidebarWidget(v, ctx) {
|
|
188
|
+
const f = themeFg(ctx.theme);
|
|
189
|
+
const openDashboard = (e) => {
|
|
190
|
+
if (e.button !== 0)
|
|
191
|
+
return; // 0 = MouseButton.LEFT; ignore other buttons
|
|
192
|
+
ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
|
|
193
|
+
};
|
|
194
|
+
const g = poolGlance(v);
|
|
195
|
+
if (!g) {
|
|
196
|
+
return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
|
|
197
|
+
}
|
|
198
|
+
const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
|
|
199
|
+
const hint = shortcut ? `${shortcut} / click opens dashboard` : "click opens dashboard";
|
|
200
|
+
return (_jsxs("box", { onMouseDown: openDashboard, children: [_jsx("text", { fg: healthColor(g.health, f), children: `load-lock ${g.bits.join("·")}` }), _jsx("text", { fg: f.dim, children: hint })] }));
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------- plugin
|
|
203
|
+
export default Plugin.define({
|
|
204
|
+
id: "load-lock.tui",
|
|
205
|
+
setup(ctx) {
|
|
206
|
+
const [view, setView] = createSignal(null);
|
|
207
|
+
const feed = new DashboardFeed(new LLClient());
|
|
208
|
+
feed.onChange = () => setView(feed.view());
|
|
209
|
+
feed.onState = () => setView(feed.view());
|
|
210
|
+
void feed.start().catch(() => {
|
|
211
|
+
/* daemon down or spawning; the feed keeps retrying and will go live */
|
|
212
|
+
});
|
|
213
|
+
// Local countdown tick: produces a fresh snapshot once per second so
|
|
214
|
+
// ages/deadlines advance even between daemon events.
|
|
215
|
+
const tick = setInterval(() => setView(feed.view()), 1000);
|
|
216
|
+
tick.unref?.();
|
|
217
|
+
const panelOpen = () => ctx.ui.panel.current()?.name === PANEL_NAME;
|
|
218
|
+
// Esc-close gate: only when OUR panel is the current one and no dialog is
|
|
219
|
+
// on top. Dialogs (select/confirm/command palette) push the host's "modal"
|
|
220
|
+
// keymap mode; in any mode other than the documented default "base"
|
|
221
|
+
// (KeymapLayer.mode), this command stays unreachable so Esc keeps its
|
|
222
|
+
// native meaning there — cancelling the dialog — instead of closing the
|
|
223
|
+
// panel underneath it. With the dashboard open and no dialog, the mode is
|
|
224
|
+
// exactly "base": the panel opens fullscreen, which the host treats as
|
|
225
|
+
// non-interactive for the session pane, so session-scoped modes (composer,
|
|
226
|
+
// autocomplete, menu) are all suspended. ctx.ui.panel.close() closes only
|
|
227
|
+
// this plugin's panel.
|
|
228
|
+
const escClosesPanel = () => panelOpen() && ctx.keymap.mode.current() === "base";
|
|
229
|
+
// ------------------------------------------------------ force-release
|
|
230
|
+
async function forceReleaseFlow() {
|
|
231
|
+
const holders = (feed.view().pools ?? []).flatMap((p) => p.holders);
|
|
232
|
+
if (!holders.length) {
|
|
233
|
+
ctx.ui.toast.show({ message: "load-lock: no held permits to release", variant: "info" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const pick = await ctx.ui.dialog.select({
|
|
237
|
+
title: "Force-release a permit (admin)",
|
|
238
|
+
placeholder: "Select holder",
|
|
239
|
+
options: holders.map((h) => ({
|
|
240
|
+
title: `${h.agent} (${h.requestId.slice(0, 14)})`,
|
|
241
|
+
description: `${h.phaseLabel}${h.pid !== undefined ? ` · pid ${h.pid}` : ""} · held ${fmtDuration(h.heldMs)} · ${h.reason}`,
|
|
242
|
+
value: h.requestId,
|
|
243
|
+
})),
|
|
244
|
+
});
|
|
245
|
+
if (!pick)
|
|
246
|
+
return;
|
|
247
|
+
const confirmed = await ctx.ui.dialog.confirm({
|
|
248
|
+
title: "Force-release permit",
|
|
249
|
+
message: `Reclaim ${pick}? Its owner may still be mid-build; this is a last-resort recovery action.`,
|
|
250
|
+
label: { confirm: "Force-release", cancel: "Cancel" },
|
|
251
|
+
});
|
|
252
|
+
if (confirmed !== true)
|
|
253
|
+
return;
|
|
254
|
+
try {
|
|
255
|
+
await feed.forceRelease(pick);
|
|
256
|
+
ctx.ui.toast.show({ message: `load-lock: force-released ${pick.slice(0, 14)}`, variant: "warning" });
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
ctx.ui.toast.show({ message: `load-lock: force-release failed: ${String(e)}`, variant: "error" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ----------------------------------------------------------- keymap
|
|
263
|
+
ctx.keymap.layer(() => ({
|
|
264
|
+
mode: "global",
|
|
265
|
+
priority: 5,
|
|
266
|
+
commands: [
|
|
267
|
+
{
|
|
268
|
+
id: "load-lock.dashboard.open",
|
|
269
|
+
title: "load-lock: open the lock-status dashboard",
|
|
270
|
+
group: "load-lock",
|
|
271
|
+
// Always-works fallback for the sidebar widget's click affordance
|
|
272
|
+
// (some terminals don't deliver mouse events to the TUI).
|
|
273
|
+
bind: "gl",
|
|
274
|
+
palette: true,
|
|
275
|
+
slash: { name: "load-lock", aliases: ["lock", "ll"] },
|
|
276
|
+
run: () => {
|
|
277
|
+
ctx.ui.panel.open(PANEL_NAME, { presentation: PANEL_PRESENTATION });
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "load-lock.dashboard.close",
|
|
282
|
+
title: "load-lock: close the lock-status dashboard",
|
|
283
|
+
group: "load-lock",
|
|
284
|
+
bind: "escape",
|
|
285
|
+
enabled: escClosesPanel,
|
|
286
|
+
run: () => {
|
|
287
|
+
ctx.ui.panel.close();
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "load-lock.dashboard.resync",
|
|
292
|
+
title: "load-lock: resync the dashboard",
|
|
293
|
+
group: "load-lock",
|
|
294
|
+
bind: "r",
|
|
295
|
+
enabled: panelOpen,
|
|
296
|
+
run: () => {
|
|
297
|
+
void feed.resync().catch(() => { });
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "load-lock.dashboard.force-release",
|
|
302
|
+
title: "load-lock: force-release a permit (admin)",
|
|
303
|
+
group: "load-lock",
|
|
304
|
+
bind: "x",
|
|
305
|
+
enabled: panelOpen,
|
|
306
|
+
run: () => {
|
|
307
|
+
void forceReleaseFlow();
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
}));
|
|
312
|
+
// ------------------------------------------------------------ slots
|
|
313
|
+
// Session panel contribution: renders only when our name is selected.
|
|
314
|
+
const offPanel = ctx.ui.slot({
|
|
315
|
+
append: "session.panel",
|
|
316
|
+
render: (panel) => createComponent(Show, {
|
|
317
|
+
get when() {
|
|
318
|
+
return panel.name === PANEL_NAME ? (view() ?? false) : false;
|
|
319
|
+
},
|
|
320
|
+
keyed: true,
|
|
321
|
+
children: (v) => PanelBody(v, panel, ctx),
|
|
322
|
+
}),
|
|
323
|
+
});
|
|
324
|
+
// Home footer mini indicator (heavy pool summary).
|
|
325
|
+
const offFooter = ctx.ui.slot({
|
|
326
|
+
append: "home.footer.status",
|
|
327
|
+
render: () => createComponent(Show, {
|
|
328
|
+
get when() {
|
|
329
|
+
return view() ?? false;
|
|
330
|
+
},
|
|
331
|
+
keyed: true,
|
|
332
|
+
children: (v) => MiniIndicator(v, ctx),
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
// Right-sidebar at-a-glance widget (session screens). The sidebar.footer
|
|
336
|
+
// slot pins it at the bottom of the sidebar so it stays visible even
|
|
337
|
+
// when the session list is long. Disjoint from the home footer above,
|
|
338
|
+
// so the two indicators never render on the same screen.
|
|
339
|
+
const offSidebar = ctx.ui.slot({
|
|
340
|
+
append: "sidebar.footer",
|
|
341
|
+
render: () => createComponent(Show, {
|
|
342
|
+
get when() {
|
|
343
|
+
return view() ?? false;
|
|
344
|
+
},
|
|
345
|
+
keyed: true,
|
|
346
|
+
children: (v) => SidebarWidget(v, ctx),
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
return () => {
|
|
350
|
+
if (tick)
|
|
351
|
+
clearInterval(tick);
|
|
352
|
+
offPanel();
|
|
353
|
+
offFooter();
|
|
354
|
+
offSidebar();
|
|
355
|
+
void feed.stop();
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
});
|
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": {
|