@aron98/load-lock 1.2.1 → 1.4.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.
@@ -4,7 +4,8 @@
4
4
  * Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
5
5
  * Provides:
6
6
  * - native tools in the `load_lock` namespace (acquire/claim/release/...)
7
- * - run_with_permit / job_status / job_stop: full execution lifecycle
7
+ * - run_with_permit / job_status / job_stop / wait_for_completion:
8
+ * full execution lifecycle incl. honest execution-phase sleeping
8
9
  * - automatic policy injection on every model call via the session
9
10
  * "context" hook (compaction-proof by construction)
10
11
  * - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
@@ -4,7 +4,8 @@
4
4
  * Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
5
5
  * Provides:
6
6
  * - native tools in the `load_lock` namespace (acquire/claim/release/...)
7
- * - run_with_permit / job_status / job_stop: full execution lifecycle
7
+ * - run_with_permit / job_status / job_stop / wait_for_completion:
8
+ * full execution lifecycle incl. honest execution-phase sleeping
8
9
  * - automatic policy injection on every model call via the session
9
10
  * "context" hook (compaction-proof by construction)
10
11
  * - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
@@ -21,6 +22,7 @@ import path from "node:path";
21
22
  import { LLClient } from "../../shared/client.js";
22
23
  import { POLICY_BRIEF } from "../../policy/text.js";
23
24
  import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
25
+ import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
24
26
  import { killProcessGroup } from "../../shared/transport.js";
25
27
  const client = new LLClient();
26
28
  const knownSessions = new Set();
@@ -156,10 +158,12 @@ export default Plugin.define({
156
158
  reason: input.reason,
157
159
  author: "opencode:" + context.sessionID,
158
160
  }));
159
- add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Poll job_status.", {
161
+ add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Set wait:true to sleep through execution in this one call; otherwise sleep via wait_for_completion(job_id), quick-check via job_status.", {
160
162
  permit_token: { type: "string" },
161
163
  command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
162
164
  cwd: { type: "string" },
165
+ wait: { type: "boolean", description: "true = sleep through execution in this one call; wake promptly when the job finishes (bounded by wait_timeout_ms)" },
166
+ wait_timeout_ms: { type: "number", description: "Bounded sleep for wait:true (ms, default 600000); still_running checkpoint on timeout" },
163
167
  }, async (input, context) => {
164
168
  const child = spawn(input.command, {
165
169
  shell: true,
@@ -168,15 +172,20 @@ export default Plugin.define({
168
172
  env: process.env,
169
173
  stdio: ["ignore", "pipe", "pipe"],
170
174
  });
175
+ let resolveExit;
171
176
  const job = {
172
177
  id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
173
178
  child,
174
179
  permitToken: input.permit_token,
175
180
  command: input.command,
176
181
  sessionID: context.sessionID,
182
+ startedAt: Date.now(),
177
183
  exited: false,
178
184
  exitCode: null,
179
185
  output: [],
186
+ exitPromise: new Promise((resolve) => {
187
+ resolveExit = resolve;
188
+ }),
180
189
  };
181
190
  const push = (b) => {
182
191
  job.output.push(b.toString("utf8"));
@@ -190,18 +199,43 @@ export default Plugin.define({
190
199
  // round-trip to the daemon, or the permit would never auto-release.
191
200
  child.on("exit", (code) => {
192
201
  job.exited = true;
202
+ job.exitedAt = Date.now();
193
203
  job.exitCode = code;
194
- void client.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" }).catch(() => { });
204
+ // releasePromise is assigned BEFORE resolveExit so a woken waiter
205
+ // sees the release round-trip in flight (see waitForLocalJob).
206
+ job.releasePromise = client
207
+ .request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
208
+ .then(() => {
209
+ job.permitReleased = true;
210
+ }, () => { });
211
+ resolveExit(code);
195
212
  });
196
213
  try {
197
- await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
214
+ const res = await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
215
+ job.requestId = res.entry?.requestId;
198
216
  }
199
217
  catch (err) {
200
218
  killProcessGroup(child.pid, "SIGKILL");
201
219
  return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
202
220
  }
203
221
  jobs.set(job.id, job);
204
- return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; poll job_status" };
222
+ if (!input.wait) {
223
+ return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; sleep via wait_for_completion(job_id), quick-check via job_status" };
224
+ }
225
+ const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: context.signal });
226
+ return {
227
+ job_id: job.id,
228
+ pid: child.pid,
229
+ command: job.command,
230
+ state: r.state,
231
+ exit_code: r.exitCode,
232
+ output_tail: r.outputTail,
233
+ duration_ms: r.durationMs,
234
+ permit_released: r.permitReleased,
235
+ waited_ms: r.waitedMs,
236
+ next_action: r.nextAction,
237
+ detail: r.detail,
238
+ };
205
239
  });
206
240
  add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
207
241
  const job = jobs.get(input.job_id);
@@ -220,6 +254,7 @@ export default Plugin.define({
220
254
  if (!job)
221
255
  return { error: "unknown job_id" };
222
256
  if (!job.exited) {
257
+ job.stopRequested = true;
223
258
  killProcessGroup(job.child.pid, "SIGTERM");
224
259
  setTimeout(() => {
225
260
  if (!job.exited) {
@@ -229,6 +264,51 @@ export default Plugin.define({
229
264
  }
230
265
  return { job_id: job.id, stopping: true };
231
266
  });
267
+ add("wait_for_completion", "Sleep honestly through the EXECUTION phase (replaces sleep-and-poll): bounded blocking wait that wakes promptly when the heavy job finishes. Pass job_id (run_with_permit job; exact exit-code wake) or request_id (daemon-tracked permit; liveness-sweep wake, permit released before you are woken). Structured result on every path: finished (exit_code/output_tail/duration/permit_released), still_running (checkpoint — call again), timeout (wait aborted, job untouched), job_unknown, stopped_by_request. timeout_ms bounds one call (default 600000; 0 = check-only).", {
268
+ job_id: { type: "string", description: "A run_with_permit job_id" },
269
+ request_id: { type: "string", description: "A daemon-tracked permit's request_id" },
270
+ timeout_ms: { type: "number", description: "Bounded sleep (ms, default 600000); still_running checkpoint on timeout — re-call" },
271
+ }, async (input, context) => {
272
+ if (!input.job_id && !input.request_id) {
273
+ return {
274
+ state: "job_unknown",
275
+ waited_ms: 0,
276
+ next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
277
+ detail: "no identifier given",
278
+ };
279
+ }
280
+ if (input.job_id && jobs.has(input.job_id)) {
281
+ const job = jobs.get(input.job_id);
282
+ const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: context.signal });
283
+ return {
284
+ state: r.state,
285
+ job_id: job.id,
286
+ request_id: job.requestId,
287
+ exit_code: r.exitCode,
288
+ output_tail: r.outputTail,
289
+ duration_ms: r.durationMs,
290
+ permit_released: r.permitReleased,
291
+ waited_ms: r.waitedMs,
292
+ started_at: r.startedAt,
293
+ next_action: r.nextAction,
294
+ detail: r.detail,
295
+ };
296
+ }
297
+ const requestId = input.request_id ?? input.job_id;
298
+ const r = await waitForTrackedJob({ client, requestId, timeoutMs: input.timeout_ms, signal: context.signal });
299
+ return {
300
+ state: r.state,
301
+ request_id: r.requestId ?? requestId,
302
+ exit_code: r.exitCode,
303
+ duration_ms: r.durationMs,
304
+ permit_released: r.permitReleased,
305
+ waited_ms: r.waitedMs,
306
+ effective_wait_ms: r.effectiveWaitMs,
307
+ started_at: r.startedAt,
308
+ next_action: r.nextAction,
309
+ detail: r.detail,
310
+ };
311
+ });
232
312
  add("attach_pid", "Associate a process you already started (e.g. a background shell task) with your claimed permit. Atomic; auto-releases when the process group exits.", { permit_token: { type: "string" }, pid: { type: "number" } }, (input) => client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid }));
233
313
  add("mark_started", "Tell load-lock you started the work when no process can be associated. Weaker guarantee: heartbeat lease applies (see docs). Prefer run_with_permit.", { permit_token: { type: "string" } }, (input) => client.request("mark_started", { permitToken: input.permit_token }));
234
314
  });
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
89
89
  const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
90
90
  push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
91
91
  if (v.daemon) {
92
- 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
+ push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
93
+ (v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
94
+ ` · events through #${v.latestEventId}`, f.dim);
93
95
  }
94
96
  else {
95
97
  push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
96
98
  }
97
99
  if (v.daemonRestartNote)
98
100
  push(`note: ${v.daemonRestartNote}`, f.warn);
101
+ if (v.versionSkewNote)
102
+ push(`note: ${v.versionSkewNote}`, f.warn);
99
103
  if (v.machine) {
100
104
  const m = v.machine;
101
105
  const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -0,0 +1,28 @@
1
+ export interface InstalledCopy {
2
+ /** Where this copy was found. */
3
+ source: "cli" | "path" | "opencode-cache";
4
+ /** Path of the copy's package root (its package.json's directory). */
5
+ path: string;
6
+ /** Version string, or null when the package.json could not be read. */
7
+ version: string | null;
8
+ }
9
+ /**
10
+ * Find the package.json of the @aron98/load-lock copy that contains the
11
+ * given file, by climbing parent directories. Best-effort: returns null if
12
+ * no enclosing package.json matches (or is readable).
13
+ */
14
+ export declare function enclosingPackage(startDir: string, maxDepth?: number): {
15
+ path: string;
16
+ version: string | null;
17
+ } | null;
18
+ /** The copy of load-lock this process runs from (its own package root). */
19
+ export declare function thisCopy(): InstalledCopy | null;
20
+ /**
21
+ * All installed copies of load-lock detectable on this machine:
22
+ * - "cli": the copy this process runs from.
23
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
24
+ * so a symlink to the running copy is recognized and not double-listed
25
+ * — a copy is only listed under "path" when it differs from "cli").
26
+ * - "opencode-cache": copies under opencode's private npm cache.
27
+ */
28
+ export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Installed-copy detection (issue #34): on a given machine several copies
3
+ * of load-lock commonly coexist — the globally-installed CLI on PATH, and
4
+ * the private copy opencode maintains under its npm cache (because
5
+ * `install --harness opencode` registers the bare package name, opencode
6
+ * installs and refreshes its own copy on its own schedule).
7
+ *
8
+ * All scanning is best-effort: missing paths, unreadable files, and
9
+ * unexpected layouts yield skipped entries or null versions — never a throw.
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+ import { isWindows } from "../shared/transport.js";
16
+ /** Package name of this package (used to find enclosing package roots). */
17
+ const PKG_NAME = "@aron98/load-lock";
18
+ /**
19
+ * Find the package.json of the @aron98/load-lock copy that contains the
20
+ * given file, by climbing parent directories. Best-effort: returns null if
21
+ * no enclosing package.json matches (or is readable).
22
+ */
23
+ export function enclosingPackage(startDir, maxDepth = 12) {
24
+ let dir = startDir;
25
+ for (let i = 0; i < maxDepth; i++) {
26
+ const pkgPath = join(dir, "package.json");
27
+ try {
28
+ if (existsSync(pkgPath)) {
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
30
+ if (pkg?.name === PKG_NAME) {
31
+ return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
32
+ }
33
+ // A foreign package.json terminates the climb (e.g. a PATH bin
34
+ // inside some other tool's package).
35
+ return null;
36
+ }
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ const parent = dirname(dir);
42
+ if (parent === dir)
43
+ return null;
44
+ dir = parent;
45
+ }
46
+ return null;
47
+ }
48
+ /** The copy of load-lock this process runs from (its own package root). */
49
+ export function thisCopy() {
50
+ // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
51
+ const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
52
+ const found = enclosingPackage(startDir);
53
+ if (!found)
54
+ return null;
55
+ return { source: "cli", path: found.path, version: found.version };
56
+ }
57
+ /**
58
+ * All installed copies of load-lock detectable on this machine:
59
+ * - "cli": the copy this process runs from.
60
+ * - "path": any `load-lock` binary found on PATH (deduped by real path,
61
+ * so a symlink to the running copy is recognized and not double-listed
62
+ * — a copy is only listed under "path" when it differs from "cli").
63
+ * - "opencode-cache": copies under opencode's private npm cache.
64
+ */
65
+ export function findInstalledCopies(platform = process.platform, env = process.env) {
66
+ const copies = [];
67
+ const seen = new Set();
68
+ const add = (c) => {
69
+ if (!c)
70
+ return;
71
+ let real;
72
+ try {
73
+ real = realpathSync(c.path);
74
+ }
75
+ catch {
76
+ real = c.path;
77
+ }
78
+ if (seen.has(real))
79
+ return;
80
+ seen.add(real);
81
+ copies.push(c);
82
+ };
83
+ add(thisCopy());
84
+ // --- PATH scan ------------------------------------------------------------
85
+ try {
86
+ const binNames = isWindows(platform)
87
+ ? ["load-lock.cmd", "load-lock.exe", "load-lock"]
88
+ : ["load-lock"];
89
+ const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
90
+ for (const dir of dirs) {
91
+ for (const bin of binNames) {
92
+ try {
93
+ const binPath = join(dir, bin);
94
+ if (!statSync(binPath).isFile())
95
+ continue;
96
+ // A shim/wrapper script is not itself the copy — resolve through
97
+ // its enclosing package (works for npm/bun global layouts where
98
+ // the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
99
+ let realBin;
100
+ try {
101
+ realBin = realpathSync(binPath);
102
+ }
103
+ catch {
104
+ realBin = binPath;
105
+ }
106
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
107
+ if (found)
108
+ add({ source: "path", path: found.path, version: found.version });
109
+ }
110
+ catch {
111
+ /* unreadable PATH entry — skip */
112
+ }
113
+ }
114
+ }
115
+ }
116
+ catch {
117
+ /* PATH scan is best-effort */
118
+ }
119
+ // --- opencode npm-cache scan ----------------------------------------------
120
+ // Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
121
+ // where "@aron98" and "load-lock@latest" are two nested directories (a
122
+ // normal npm scoped layout); URL-escaped single-directory variants like
123
+ // "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
124
+ // real package at node_modules/@aron98/load-lock, or a top-level
125
+ // package.json (npm-install root).
126
+ try {
127
+ const cacheRoot = opencodeCacheRoot(platform, env);
128
+ if (cacheRoot) {
129
+ const npmDir = join(cacheRoot, "opencode", "npm");
130
+ if (existsSync(npmDir)) {
131
+ const scanTimestampDirs = (pkgBase) => {
132
+ let tsDirs = [];
133
+ try {
134
+ tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
135
+ }
136
+ catch {
137
+ return;
138
+ }
139
+ for (const ts of tsDirs) {
140
+ const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
141
+ const topLevel = join(pkgBase, ts);
142
+ for (const cand of [nested, topLevel]) {
143
+ try {
144
+ const pkgPath = join(cand, "package.json");
145
+ if (!existsSync(pkgPath))
146
+ continue;
147
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
148
+ if (pkg?.name !== PKG_NAME)
149
+ continue;
150
+ add({
151
+ source: "opencode-cache",
152
+ path: cand,
153
+ version: typeof pkg.version === "string" ? pkg.version : null,
154
+ });
155
+ break;
156
+ }
157
+ catch {
158
+ /* skip unreadable timestamp dir */
159
+ }
160
+ }
161
+ }
162
+ };
163
+ let scopeDirs = [];
164
+ try {
165
+ scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
166
+ }
167
+ catch {
168
+ scopeDirs = [];
169
+ }
170
+ for (const scopeDir of scopeDirs) {
171
+ if (scopeDir.startsWith("@aron98%2Fload-lock")) {
172
+ // URL-escaped single-directory form.
173
+ scanTimestampDirs(join(npmDir, scopeDir));
174
+ }
175
+ else if (scopeDir === "@aron98") {
176
+ // Scoped layout: @aron98/<name>@<version-range>/<ts>/...
177
+ let inner = [];
178
+ try {
179
+ inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ for (const nameDir of inner) {
185
+ if (nameDir.startsWith("load-lock"))
186
+ scanTimestampDirs(join(npmDir, scopeDir, nameDir));
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ catch {
194
+ /* cache scan is best-effort */
195
+ }
196
+ return copies;
197
+ }
198
+ /** Cache root the way opencode resolves it (its npm cache lives under it). */
199
+ function opencodeCacheRoot(platform, env) {
200
+ if (env.XDG_CACHE_HOME)
201
+ return env.XDG_CACHE_HOME;
202
+ if (platform === "win32")
203
+ return env.LOCALAPPDATA ?? null;
204
+ if (platform === "darwin")
205
+ return join(env.HOME ?? homedir(), "Library", "Caches");
206
+ return join(env.HOME ?? homedir(), ".cache");
207
+ }