@aron98/load-lock 1.3.0 → 1.5.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 (47) hide show
  1. package/dist/adapters/opencode/plugin.d.ts +2 -1
  2. package/dist/adapters/opencode/plugin.js +100 -6
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +1 -9
  5. package/dist/cli/copies.js +2 -43
  6. package/dist/cli/install.js +1 -1
  7. package/dist/cli/main.js +82 -217
  8. package/dist/cli/service.d.ts +56 -0
  9. package/dist/cli/service.js +261 -0
  10. package/dist/cli/update.d.ts +53 -0
  11. package/dist/cli/update.js +274 -0
  12. package/dist/core/daemon.d.ts +24 -0
  13. package/dist/core/daemon.js +221 -0
  14. package/dist/core/state.d.ts +5 -0
  15. package/dist/core/state.js +16 -0
  16. package/dist/dashboard/feed.d.ts +23 -0
  17. package/dist/dashboard/feed.js +64 -2
  18. package/dist/dashboard/frame.js +2 -0
  19. package/dist/dashboard/view.d.ts +11 -0
  20. package/dist/dashboard/view.js +13 -0
  21. package/dist/mcp/server.d.ts +7 -0
  22. package/dist/mcp/server.js +117 -8
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +6 -0
  25. package/dist/shared/client.js +11 -1
  26. package/dist/shared/drift.d.ts +21 -0
  27. package/dist/shared/drift.js +77 -0
  28. package/dist/shared/installs.d.ts +39 -0
  29. package/dist/shared/installs.js +101 -0
  30. package/dist/shared/pkginfo.d.ts +16 -0
  31. package/dist/shared/pkginfo.js +54 -0
  32. package/dist/shared/protocol.d.ts +32 -0
  33. package/dist/shared/wait-completion.d.ts +67 -0
  34. package/dist/shared/wait-completion.js +505 -0
  35. package/dist/test/mcp.test.js +115 -1
  36. package/dist/test/reconnect.test.d.ts +1 -0
  37. package/dist/test/reconnect.test.js +389 -0
  38. package/dist/test/update-command.test.d.ts +1 -0
  39. package/dist/test/update-command.test.js +608 -0
  40. package/dist/test/wait-completion.test.d.ts +1 -0
  41. package/dist/test/wait-completion.test.js +418 -0
  42. package/docs/ADAPTERS.md +3 -2
  43. package/docs/AGENT_GUIDE.md +61 -3
  44. package/docs/ARCHITECTURE.md +15 -0
  45. package/docs/COMPATIBILITY.md +16 -2
  46. package/docs/OPERATIONS.md +118 -9
  47. package/package.json +1 -1
@@ -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,7 +22,10 @@ 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";
27
+ import { detectCliDrift } from "../../shared/drift.js";
28
+ import { packageVersion } from "../../shared/version.js";
25
29
  const client = new LLClient();
26
30
  const knownSessions = new Set();
27
31
  const jobs = new Map();
@@ -42,6 +46,18 @@ function toolContent(v) {
42
46
  export default Plugin.define({
43
47
  id: "load-lock",
44
48
  setup(ctx) {
49
+ // Drift notice (issue #38): when this adapter copy is NEWER than the
50
+ // `load-lock` CLI that PATH resolves to (opencode refreshed its cache
51
+ // copy but the global install was never updated), surface one clear
52
+ // notice naming the exact command — on stderr at init, and once in the
53
+ // injected context. Notify only: nothing is ever auto-installed. Note
54
+ // the honest limitation (docs/OPERATIONS.md): a harness session that
55
+ // was already running keeps the OLD adapter code in memory until the
56
+ // harness itself restarts — this fires from the NEW copy at its init.
57
+ const cliDrift = detectCliDrift(packageVersion());
58
+ if (cliDrift)
59
+ process.stderr.write(cliDrift.message + "\n");
60
+ const DRIFT_NOTE = cliDrift ? "\n\n" + cliDrift.message : "";
45
61
  // ------------------------------------------------------------- tools
46
62
  ctx.tool.transform((editor) => {
47
63
  editor.namespace({
@@ -156,10 +172,12 @@ export default Plugin.define({
156
172
  reason: input.reason,
157
173
  author: "opencode:" + context.sessionID,
158
174
  }));
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.", {
175
+ 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
176
  permit_token: { type: "string" },
161
177
  command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
162
178
  cwd: { type: "string" },
179
+ wait: { type: "boolean", description: "true = sleep through execution in this one call; wake promptly when the job finishes (bounded by wait_timeout_ms)" },
180
+ wait_timeout_ms: { type: "number", description: "Bounded sleep for wait:true (ms, default 600000); still_running checkpoint on timeout" },
163
181
  }, async (input, context) => {
164
182
  const child = spawn(input.command, {
165
183
  shell: true,
@@ -168,15 +186,20 @@ export default Plugin.define({
168
186
  env: process.env,
169
187
  stdio: ["ignore", "pipe", "pipe"],
170
188
  });
189
+ let resolveExit;
171
190
  const job = {
172
191
  id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
173
192
  child,
174
193
  permitToken: input.permit_token,
175
194
  command: input.command,
176
195
  sessionID: context.sessionID,
196
+ startedAt: Date.now(),
177
197
  exited: false,
178
198
  exitCode: null,
179
199
  output: [],
200
+ exitPromise: new Promise((resolve) => {
201
+ resolveExit = resolve;
202
+ }),
180
203
  };
181
204
  const push = (b) => {
182
205
  job.output.push(b.toString("utf8"));
@@ -190,18 +213,43 @@ export default Plugin.define({
190
213
  // round-trip to the daemon, or the permit would never auto-release.
191
214
  child.on("exit", (code) => {
192
215
  job.exited = true;
216
+ job.exitedAt = Date.now();
193
217
  job.exitCode = code;
194
- void client.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" }).catch(() => { });
218
+ // releasePromise is assigned BEFORE resolveExit so a woken waiter
219
+ // sees the release round-trip in flight (see waitForLocalJob).
220
+ job.releasePromise = client
221
+ .request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
222
+ .then(() => {
223
+ job.permitReleased = true;
224
+ }, () => { });
225
+ resolveExit(code);
195
226
  });
196
227
  try {
197
- await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
228
+ const res = await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
229
+ job.requestId = res.entry?.requestId;
198
230
  }
199
231
  catch (err) {
200
232
  killProcessGroup(child.pid, "SIGKILL");
201
233
  return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
202
234
  }
203
235
  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" };
236
+ if (!input.wait) {
237
+ 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" };
238
+ }
239
+ const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: context.signal });
240
+ return {
241
+ job_id: job.id,
242
+ pid: child.pid,
243
+ command: job.command,
244
+ state: r.state,
245
+ exit_code: r.exitCode,
246
+ output_tail: r.outputTail,
247
+ duration_ms: r.durationMs,
248
+ permit_released: r.permitReleased,
249
+ waited_ms: r.waitedMs,
250
+ next_action: r.nextAction,
251
+ detail: r.detail,
252
+ };
205
253
  });
206
254
  add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
207
255
  const job = jobs.get(input.job_id);
@@ -220,6 +268,7 @@ export default Plugin.define({
220
268
  if (!job)
221
269
  return { error: "unknown job_id" };
222
270
  if (!job.exited) {
271
+ job.stopRequested = true;
223
272
  killProcessGroup(job.child.pid, "SIGTERM");
224
273
  setTimeout(() => {
225
274
  if (!job.exited) {
@@ -229,12 +278,57 @@ export default Plugin.define({
229
278
  }
230
279
  return { job_id: job.id, stopping: true };
231
280
  });
281
+ 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).", {
282
+ job_id: { type: "string", description: "A run_with_permit job_id" },
283
+ request_id: { type: "string", description: "A daemon-tracked permit's request_id" },
284
+ timeout_ms: { type: "number", description: "Bounded sleep (ms, default 600000); still_running checkpoint on timeout — re-call" },
285
+ }, async (input, context) => {
286
+ if (!input.job_id && !input.request_id) {
287
+ return {
288
+ state: "job_unknown",
289
+ waited_ms: 0,
290
+ next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
291
+ detail: "no identifier given",
292
+ };
293
+ }
294
+ if (input.job_id && jobs.has(input.job_id)) {
295
+ const job = jobs.get(input.job_id);
296
+ const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: context.signal });
297
+ return {
298
+ state: r.state,
299
+ job_id: job.id,
300
+ request_id: job.requestId,
301
+ exit_code: r.exitCode,
302
+ output_tail: r.outputTail,
303
+ duration_ms: r.durationMs,
304
+ permit_released: r.permitReleased,
305
+ waited_ms: r.waitedMs,
306
+ started_at: r.startedAt,
307
+ next_action: r.nextAction,
308
+ detail: r.detail,
309
+ };
310
+ }
311
+ const requestId = input.request_id ?? input.job_id;
312
+ const r = await waitForTrackedJob({ client, requestId, timeoutMs: input.timeout_ms, signal: context.signal });
313
+ return {
314
+ state: r.state,
315
+ request_id: r.requestId ?? requestId,
316
+ exit_code: r.exitCode,
317
+ duration_ms: r.durationMs,
318
+ permit_released: r.permitReleased,
319
+ waited_ms: r.waitedMs,
320
+ effective_wait_ms: r.effectiveWaitMs,
321
+ started_at: r.startedAt,
322
+ next_action: r.nextAction,
323
+ detail: r.detail,
324
+ };
325
+ });
232
326
  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
327
  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
328
  });
235
329
  // ------------------------------------------- policy on every model call
236
330
  void ctx.session.hook("context", async (event) => {
237
- event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) });
331
+ event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) + DRIFT_NOTE });
238
332
  });
239
333
  // -------------------------------------------------- idle wakeup via watch
240
334
  void (async () => {
@@ -100,6 +100,8 @@ function dashboardLines(v, width, f) {
100
100
  push(`note: ${v.daemonRestartNote}`, f.warn);
101
101
  if (v.versionSkewNote)
102
102
  push(`note: ${v.versionSkewNote}`, f.warn);
103
+ if (v.installDriftNote)
104
+ push(`note: ${v.installDriftNote}`, f.warn);
103
105
  if (v.machine) {
104
106
  const m = v.machine;
105
107
  const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -209,11 +211,14 @@ export default Plugin.define({
209
211
  id: "load-lock.tui",
210
212
  setup(ctx) {
211
213
  const [view, setView] = createSignal(null);
212
- const feed = new DashboardFeed(new LLClient());
214
+ // driftSurface "opencode": when this session's copy is older than the
215
+ // PATH CLI, the hint says to restart opencode (plugins never hot-reload).
216
+ const feed = new DashboardFeed(new LLClient(), { driftSurface: "opencode" });
213
217
  feed.onChange = () => setView(feed.view());
214
218
  feed.onState = () => setView(feed.view());
215
219
  void feed.start().catch(() => {
216
- /* daemon down or spawning; the feed keeps retrying and will go live */
220
+ /* daemon down or spawning; the reconnect loop keeps retrying and
221
+ will go live — start() only reports the FIRST connect failure */
217
222
  });
218
223
  // Local countdown tick: produces a fresh snapshot once per second so
219
224
  // ages/deadlines advance even between daemon events.
@@ -1,3 +1,4 @@
1
+ export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
1
2
  export interface InstalledCopy {
2
3
  /** Where this copy was found. */
3
4
  source: "cli" | "path" | "opencode-cache";
@@ -6,15 +7,6 @@ export interface InstalledCopy {
6
7
  /** Version string, or null when the package.json could not be read. */
7
8
  version: string | null;
8
9
  }
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
10
  /** The copy of load-lock this process runs from (its own package root). */
19
11
  export declare function thisCopy(): InstalledCopy | null;
20
12
  /**
@@ -10,41 +10,10 @@
10
10
  */
11
11
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
- import { homedir } from "node:os";
14
13
  import { fileURLToPath } from "node:url";
15
14
  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
- }
15
+ import { enclosingPackage, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
16
+ export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
48
17
  /** The copy of load-lock this process runs from (its own package root). */
49
18
  export function thisCopy() {
50
19
  // dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
@@ -195,13 +164,3 @@ export function findInstalledCopies(platform = process.platform, env = process.e
195
164
  }
196
165
  return copies;
197
166
  }
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
- }
@@ -1154,7 +1154,7 @@ function installOmac(opts, P) {
1154
1154
  "LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
1155
1155
  "LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
1156
1156
  actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
1157
- "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service stop && load-lock service start)");
1157
+ "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service restart)");
1158
1158
  actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
1159
1159
  "round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
1160
1160
  return actions;