@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
@@ -21,7 +21,10 @@ import { spawn } from "node:child_process";
21
21
  import { LLClient } from "../shared/client.js";
22
22
  import { POLICY_BRIEF } from "../policy/text.js";
23
23
  import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
24
+ import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
24
25
  import { killProcessGroup } from "../shared/transport.js";
26
+ import { detectCliDrift } from "../shared/drift.js";
27
+ import { packageVersion } from "../shared/version.js";
25
28
  const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
26
29
 
27
30
  Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
@@ -30,7 +33,14 @@ const RELEASE_DESC = `Release a held permit immediately after the heavy work fin
30
33
  export function buildServer(opts) {
31
34
  const client = opts.client;
32
35
  const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
33
- const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF });
36
+ // Drift notice (issue #38): one clear notice naming the exact command when
37
+ // this adapter copy is newer than the PATH-installed CLI. Notify only —
38
+ // never auto-install. Printed to stderr at init and embedded in the
39
+ // initialize instructions so any MCP client sees it exactly once.
40
+ const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
41
+ if (drift)
42
+ process.stderr.write(drift.message + "\n");
43
+ const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + (drift ? "\n\n" + drift.message : "") });
34
44
  const json = (v) => ({
35
45
  content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
36
46
  });
@@ -263,13 +273,15 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
263
273
  const jobs = new Map();
264
274
  server.registerTool("run_with_permit", {
265
275
  title: "Run heavy command under permit",
266
- description: "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns the command in its own process group, atomically associates it with your claimed permit, returns a job_id immediately (background execution — do NOT release the permit merely because this returned), and auto-releases the permit when the job exits (success, failure, or kill). Survives this tool call and your session: if this server dies, the daemon still watches the job's process group. Poll job_status for completion and output.",
276
+ description: "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns the command in its own process group, atomically associates it with your claimed permit, returns a job_id immediately (background execution — do NOT release the permit merely because this returned), and auto-releases the permit when the job exits (success, failure, or kill). Survives this tool call and your session: if this server dies, the daemon still watches the job's process group. Set wait:true to sleep through execution in this one call (bounded by wait_timeout_ms; returns still_running on timeout — re-call), otherwise sleep via wait_for_completion(job_id) and quick-check via job_status.",
267
277
  inputSchema: {
268
278
  permit_token: z.string().describe("Permit token from acquire/claim"),
269
279
  command: z.string().describe("Shell command to run (the heavy work you acquired the permit for — do not pass unrelated light commands)"),
270
280
  cwd: z.string().optional().describe("Working directory (default: this server's cwd)"),
281
+ wait: z.boolean().optional().describe("true = sleep through execution in this one call: wake promptly when the job finishes (bounded by wait_timeout_ms), returning exit code + output tail"),
282
+ wait_timeout_ms: z.number().int().optional().describe("Bounded sleep for wait:true (ms); default 600000. Returns still_running (a checkpoint) on timeout — call wait_for_completion again."),
271
283
  },
272
- }, async (input) => {
284
+ }, async (input, extra) => {
273
285
  const child = spawn(input.command, {
274
286
  shell: true,
275
287
  detached: true, // own process group => pgid === child.pid on Linux
@@ -277,6 +289,7 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
277
289
  env: process.env,
278
290
  stdio: ["ignore", "pipe", "pipe"],
279
291
  });
292
+ let resolveExit;
280
293
  const job = {
281
294
  id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
282
295
  child,
@@ -286,6 +299,9 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
286
299
  exited: false,
287
300
  exitCode: null,
288
301
  output: [],
302
+ exitPromise: new Promise((resolve) => {
303
+ resolveExit = resolve;
304
+ }),
289
305
  };
290
306
  // Watch for exit BEFORE associating (no awaits in between): a job that
291
307
  // exits instantly must not lose its exit event while we round-trip to
@@ -299,22 +315,29 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
299
315
  child.stderr?.on("data", push);
300
316
  child.on("exit", (code) => {
301
317
  job.exited = true;
318
+ job.exitedAt = Date.now();
302
319
  job.exitCode = code;
303
320
  // Auto-release on completion: success, failure, or termination.
304
- client
321
+ // releasePromise is assigned BEFORE resolveExit so a woken waiter
322
+ // sees the release round-trip in flight (see waitForLocalJob).
323
+ job.releasePromise = client
305
324
  .request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
306
- .catch(() => {
325
+ .then(() => {
326
+ job.permitReleased = true;
327
+ }, () => {
307
328
  /* already released/reclaimed — idempotent path */
308
329
  });
330
+ resolveExit(code);
309
331
  });
310
332
  // Atomic association; if the permit was reclaimed already, kill the job
311
333
  // before it can do meaningful work and report the failure.
312
334
  try {
313
- await client.request("start_job", {
335
+ const res = await client.request("start_job", {
314
336
  permitToken: input.permit_token,
315
337
  pid: child.pid,
316
338
  pgid: child.pid,
317
339
  });
340
+ job.requestId = res.entry?.requestId;
318
341
  }
319
342
  catch (err) {
320
343
  killProcessGroup(child.pid, "SIGKILL");
@@ -323,12 +346,31 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
323
346
  });
324
347
  }
325
348
  jobs.set(job.id, job);
349
+ const note = `Background job started under permit. Do NOT release the permit manually — it auto-releases when the job exits. Sleep through the execution with wait_for_completion(job_id); job_status(job_id) for a quick check; job_stop(job_id) to terminate.`;
350
+ if (!input.wait) {
351
+ return json({
352
+ job_id: job.id,
353
+ pid: child.pid,
354
+ pgid: child.pid,
355
+ state: "running",
356
+ note,
357
+ });
358
+ }
359
+ // Fire-and-wait: sleep honestly through execution in this one call.
360
+ const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: extra?.signal });
326
361
  return json({
327
362
  job_id: job.id,
328
363
  pid: child.pid,
329
364
  pgid: child.pid,
330
- state: "running",
331
- note: `Background job started under permit. Do NOT release the permit manually — it auto-releases when the job exits. Poll job_status(job_id) for completion; job_stop(job_id) to terminate.`,
365
+ command: job.command,
366
+ state: r.state,
367
+ exit_code: r.exitCode,
368
+ output_tail: r.outputTail,
369
+ duration_ms: r.durationMs,
370
+ permit_released: r.permitReleased,
371
+ waited_ms: r.waitedMs,
372
+ next_action: r.nextAction,
373
+ detail: r.detail,
332
374
  });
333
375
  });
334
376
  server.registerTool("job_status", {
@@ -356,6 +398,7 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
356
398
  if (!job)
357
399
  return json({ error: "unknown job_id" });
358
400
  if (!job.exited) {
401
+ job.stopRequested = true;
359
402
  killProcessGroup(job.child.pid, "SIGTERM");
360
403
  setTimeout(() => {
361
404
  if (!job.exited) {
@@ -365,6 +408,72 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
365
408
  }
366
409
  return json({ job_id: job.id, stopping: true });
367
410
  });
411
+ server.registerTool("wait_for_completion", {
412
+ title: "Sleep until the job finishes",
413
+ description: `Bounded blocking sleep for the EXECUTION phase — "sleep honestly until my heavy job finishes", replacing sleep-and-poll cycles. Two mechanisms, selected by identifier:
414
+ - job_id of a run_with_permit job (adapter-parented): resolves on the child's exact exit event — no polling, prompt wake, exact exit code + output tail;
415
+ - request_id of any daemon-tracked permit (attach_pid etc.): the daemon holds the wait and wakes it when its liveness sweep observes the process gone (worst-case promptness: one sweep interval, default 5s; the permit is released BEFORE you are woken).
416
+ Returns a STRUCTURED result on every path — never an error for a normal resolution:
417
+ - finished: exit_code, output_tail, duration_ms, permit_released — the work is done, the permit is (being) freed;
418
+ - still_running: timed out mid-execution — a checkpoint; call wait_for_completion again (long jobs = a handful of long sleeps, not a poll loop);
419
+ - timeout: this wait call was aborted/cancelled (harness tool timeout, AbortSignal) — the job is UNTOUCHED; call again;
420
+ - job_unknown: no such job/request, or the permit was reclaimed/expired meanwhile;
421
+ - stopped_by_request: the job/permit was stopped by an explicit admin action.
422
+ Input: job_id OR request_id (one of them), timeout_ms (default 600000, clamped to the machine's maxTurnWaitMs cap for daemon-tracked waits; 0 = check-only). The wait itself holds NO permit and NO capacity. A daemon restart mid-wait is handled transparently (tracked permits survive restarts via the journal); worst case it resolves with job_unknown + re-check instructions.`,
423
+ inputSchema: {
424
+ job_id: z.string().optional().describe("A run_with_permit job_id (adapter-parented; exact exit wake)"),
425
+ request_id: z.string().optional().describe("A daemon-tracked permit's request_id (attach_pid etc.; liveness-sweep wake)"),
426
+ timeout_ms: z.number().int().optional().describe("Bounded sleep (ms); default 600000. 0 = check-only. still_running on timeout — re-call."),
427
+ agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
428
+ },
429
+ }, async (input, extra) => {
430
+ if (!input.job_id && !input.request_id) {
431
+ return json({
432
+ state: "job_unknown",
433
+ waited_ms: 0,
434
+ next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
435
+ detail: "no identifier given",
436
+ });
437
+ }
438
+ if (input.job_id && jobs.has(input.job_id)) {
439
+ const job = jobs.get(input.job_id);
440
+ const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: extra?.signal });
441
+ return json({
442
+ state: r.state,
443
+ job_id: job.id,
444
+ request_id: job.requestId,
445
+ exit_code: r.exitCode,
446
+ output_tail: r.outputTail,
447
+ duration_ms: r.durationMs,
448
+ permit_released: r.permitReleased,
449
+ waited_ms: r.waitedMs,
450
+ started_at: r.startedAt,
451
+ next_action: r.nextAction,
452
+ detail: r.detail,
453
+ });
454
+ }
455
+ // Daemon-tracked permit (request_id), or a job_id from another/previous
456
+ // server process (unknown here -> falls through to job_unknown).
457
+ const requestId = input.request_id ?? input.job_id;
458
+ const r = await waitForTrackedJob({
459
+ client,
460
+ requestId,
461
+ timeoutMs: input.timeout_ms,
462
+ signal: extra?.signal,
463
+ });
464
+ return json({
465
+ state: r.state,
466
+ request_id: r.requestId ?? requestId,
467
+ exit_code: r.exitCode,
468
+ duration_ms: r.durationMs,
469
+ permit_released: r.permitReleased,
470
+ waited_ms: r.waitedMs,
471
+ effective_wait_ms: r.effectiveWaitMs,
472
+ started_at: r.startedAt,
473
+ next_action: r.nextAction,
474
+ detail: r.detail,
475
+ });
476
+ });
368
477
  // Automatic adapter heartbeat: while this server process lives, untracked
369
478
  // permits of this agent keep their lease; if the harness session dies, the
370
479
  // lease lapses and the daemon reclaims. The model never has to remember.
@@ -11,7 +11,7 @@
11
11
  * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
12
  * in the skill / docs/AGENT_GUIDE.md.
13
13
  */
14
- export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
14
+ export declare const POLICY_BRIEF = "# load-lock: machine-wide heavy-work budget\n\nMultiple coding agents share this machine. Before resource-intensive local work\n(builds, test suites, bulky containers, big compiles), get a permit from the\nshared, machine-wide counting semaphore. While queued, keep doing useful light\nwork (reading, editing, searching, planning); you will be offered capacity.\n\nRules:\n1. Heavy vs light: judge by expected CPU/memory/IO cost for the whole machine.\n A quick lint or single-file check is light. Full builds/tests are heavy.\n When unsure, call the status tool \u2014 it shows current machine pressure.\n2. Immediately before starting heavy work: acquire a permit\n (tool: load-lock acquire, CLI: load-lock acquire). Pass a short reason and\n a stable op_key for the pending operation.\n3. If you are queued: keep working on light tasks. When you receive an offer\n (in any load-lock tool result or a wakeup message), claim it promptly \u2014\n offers expire (default 30s) and capacity passes to the next waiter.\n Out of light work? Sleep honestly with wait_for_turn (MCP/plugin) or\n 'load-lock wait' (CLI) \u2014 a bounded sleep until your turn that\n auto-claims and holds no capacity.\n4. Start heavy work ONLY after acquire/claim succeeds. Never start heavy work\n on a \"queued\" result; never treat a notification as authorization.\n5. Hold the permit until the heavy process actually finishes \u2014 including when\n it runs in the background beyond this tool call or session. Optionally\n attach its pid (load-lock attach_pid) so a dead process auto-releases.\n Out of light work while it runs? Sleep honestly with wait_for_completion\n (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit\n with wait:true) or 'load-lock wait --job <request-id>' (CLI) \u2014 a bounded\n sleep that wakes promptly when the job finishes (exit code + output tail;\n the permit is freed before you are woken). On still_running it is a\n checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.\n6. Release immediately when done (success or failure), and cancel queued\n requests you no longer need.\n7. Capacity is a cooperative budget, not an enforced one. If the budget seems\n wrong for this machine, check load-lock inspect_machine (real cores/memory,\n including container limits) and adjust with configure (explain why). Don't\n assume N cores = N parallel heavy jobs.\n\nDetailed guide: the \"load-lock\" skill or `load-lock agent-guide`.";
15
15
  export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
16
16
  export declare function statusLine(st: {
17
17
  pools: {
@@ -36,6 +36,12 @@ Rules:
36
36
  5. Hold the permit until the heavy process actually finishes — including when
37
37
  it runs in the background beyond this tool call or session. Optionally
38
38
  attach its pid (load-lock attach_pid) so a dead process auto-releases.
39
+ Out of light work while it runs? Sleep honestly with wait_for_completion
40
+ (MCP/plugin: run_with_permit job_id or tracked request_id; or run_with_permit
41
+ with wait:true) or 'load-lock wait --job <request-id>' (CLI) — a bounded
42
+ sleep that wakes promptly when the job finishes (exit code + output tail;
43
+ the permit is freed before you are woken). On still_running it is a
44
+ checkpoint: call again. Do NOT guess sleeps or poll job_status in a loop.
39
45
  6. Release immediately when done (success or failure), and cancel queued
40
46
  requests you no longer need.
41
47
  7. Capacity is a cooperative budget, not an enforced one. If the budget seems
@@ -30,6 +30,7 @@ import { ProtocolError } from "./protocol.js";
30
30
  import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
31
31
  import { probeTransport } from "./transport.js";
32
32
  import { packageVersion } from "./version.js";
33
+ import { resolveDaemonEntry } from "./installs.js";
33
34
  export class LLClient {
34
35
  sock = null;
35
36
  nextId = 1;
@@ -230,7 +231,12 @@ function daemonMainPath() {
230
231
  if (process.env.LOAD_LOCK_DAEMON_BIN)
231
232
  return process.env.LOAD_LOCK_DAEMON_BIN;
232
233
  const here = dirname(fileURLToPath(import.meta.url)); // .../dist/shared
233
- return join(here, "..", "core", "daemon-main.js");
234
+ const own = join(here, "..", "core", "daemon-main.js");
235
+ // Issue #39: a harness (e.g. opencode's timestamped plugin cache) may
236
+ // prune this copy's directory while a session still executes it from
237
+ // memory — resolve to the PATH-installed CLI's daemon entry instead of
238
+ // spawning an ENOENT forever.
239
+ return resolveDaemonEntry(own);
234
240
  }
235
241
  /** Idempotently ensure a daemon is running and listening on `sockPath`. */
236
242
  export async function ensureDaemon(stateDir, sockPath) {
@@ -274,6 +280,10 @@ export async function ensureDaemon(stateDir, sockPath) {
274
280
  stdio: "ignore",
275
281
  env: { ...process.env, LOAD_LOCK_DAEMON_SPAWNED: "1" },
276
282
  });
283
+ // Issue #39: an unreadable/vanished entry must surface as the
284
+ // "did not come up" probe failure below, not crash the host process
285
+ // with an uncaught ENOENT 'error' event.
286
+ child.on("error", () => { });
277
287
  child.unref();
278
288
  }
279
289
  }
@@ -0,0 +1,21 @@
1
+ export interface CliDrift {
2
+ /** Version of this (adapter) copy — the newer one. */
3
+ ownVersion: string;
4
+ /** Version of the PATH-resolved `load-lock` CLI copy — the older one. */
5
+ cliVersion: string;
6
+ /** Package root of the PATH-resolved CLI copy. */
7
+ cliPath: string;
8
+ /** The one clear notice, with the exact command. */
9
+ message: string;
10
+ }
11
+ /** The notice text (exported for tests and doc-sync checks). */
12
+ export declare function driftMessage(ownVersion: string, cliVersion: string): string;
13
+ /**
14
+ * Compare `ownVersion` (this adapter copy) with the version of the
15
+ * `load-lock` binary that PATH resolves to, by reading that copy's
16
+ * package.json — never by spawning the old CLI, which may predate any
17
+ * introspection. Returns a drift notice when the PATH CLI is strictly
18
+ * older; null when it is the same or newer, absent, or unreadable.
19
+ * Best-effort: never throws.
20
+ */
21
+ export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CliDrift | null;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Adapter-init drift notice (issue #38): when a harness adapter copy NEWER
3
+ * than the PATH-installed `load-lock` CLI initializes — typically because
4
+ * opencode refreshed its private cache copy — surface ONE clear notice
5
+ * naming the exact command (`load-lock update`).
6
+ *
7
+ * Notify-first, NEVER auto-install: spawning a package manager from inside
8
+ * a harness plugin needs explicit opt-in; the default is to tell the
9
+ * agent/user, who can run `load-lock update` as an ordinary command or
10
+ * tool call.
11
+ *
12
+ * Honest limitation (documented in docs/OPERATIONS.md): a harness session
13
+ * that was already running keeps the OLD adapter code in memory until the
14
+ * harness restarts — this notice is emitted by the NEW copy at its init,
15
+ * which is the best it can do for sessions started before the refresh.
16
+ *
17
+ * Pure and synchronous (PATH scan + package.json reads only) so both the
18
+ * OpenCode plugin setup and the MCP server startup can call it inline.
19
+ */
20
+ import { existsSync, realpathSync, statSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { isWindows } from "./transport.js";
23
+ import { enclosingPackage, PKG_NAME } from "./pkginfo.js";
24
+ import { compareVersions } from "./version.js";
25
+ /** The notice text (exported for tests and doc-sync checks). */
26
+ export function driftMessage(ownVersion, cliVersion) {
27
+ return (`load-lock: this load-lock adapter copy (v${ownVersion}) is newer than the installed CLI (v${cliVersion}). ` +
28
+ `Run \`load-lock update\` to update the CLI on PATH and restart the daemon. ` +
29
+ `(Notify only — nothing is auto-installed; if your CLI predates the \`update\` command, run ` +
30
+ `\`npm i -g ${PKG_NAME}@latest\` instead.)`);
31
+ }
32
+ /**
33
+ * Compare `ownVersion` (this adapter copy) with the version of the
34
+ * `load-lock` binary that PATH resolves to, by reading that copy's
35
+ * package.json — never by spawning the old CLI, which may predate any
36
+ * introspection. Returns a drift notice when the PATH CLI is strictly
37
+ * older; null when it is the same or newer, absent, or unreadable.
38
+ * Best-effort: never throws.
39
+ */
40
+ export function detectCliDrift(ownVersion, env = process.env, platform = process.platform) {
41
+ try {
42
+ const sep = isWindows(platform) ? ";" : ":";
43
+ const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
44
+ const dirs = (env.PATH ?? "").split(sep).filter(Boolean);
45
+ for (const dir of dirs) {
46
+ for (const bin of binNames) {
47
+ const binPath = join(dir, bin);
48
+ if (!existsSync(binPath) || !statSync(binPath).isFile())
49
+ continue;
50
+ // Resolve through symlinks/shims to the copy that owns the bin.
51
+ let realBin;
52
+ try {
53
+ realBin = realpathSync(binPath);
54
+ }
55
+ catch {
56
+ realBin = binPath;
57
+ }
58
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
59
+ if (!found || found.version === null)
60
+ return null; // first PATH hit wins; cannot compare honestly
61
+ if (compareVersions(found.version, ownVersion) < 0) {
62
+ return {
63
+ ownVersion,
64
+ cliVersion: found.version,
65
+ cliPath: found.path,
66
+ message: driftMessage(ownVersion, found.version),
67
+ };
68
+ }
69
+ return null; // PATH CLI is same/newer — no drift
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ /* best-effort: unreadable PATH entries are skipped, never fatal */
75
+ }
76
+ return null; // no load-lock on PATH — nothing to compare against
77
+ }
@@ -0,0 +1,39 @@
1
+ /** All `load-lock` binaries found on PATH, in PATH order (files only). */
2
+ export declare function pathBinCandidates(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
3
+ export interface PathInstall {
4
+ /** Package root of the install (the directory containing package.json). */
5
+ path: string;
6
+ /** Version from that package.json, or null when unreadable. */
7
+ version: string | null;
8
+ }
9
+ /**
10
+ * The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
11
+ * resolved (npm/bun global layouts symlink the bin into the install), and a
12
+ * plain shim script falls back to the PATH directory itself.
13
+ */
14
+ export declare function pathInstall(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): PathInstall | null;
15
+ export interface InstallDrift {
16
+ /** Version of THIS running copy (src/shared/version.ts, cached). */
17
+ ownVersion: string;
18
+ /** Version of the PATH-resolved CLI install, or null when unresolvable. */
19
+ pathVersion: string | null;
20
+ /** True when the PATH install is strictly newer than this running copy. */
21
+ stale: boolean;
22
+ }
23
+ /**
24
+ * In-memory staleness probe (issue #39): a harness that loads plugin code
25
+ * once at startup (opencode never hot-reloads plugins) keeps executing the
26
+ * old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
27
+ * that: the running copy vs. what the PATH `load-lock` resolves to.
28
+ */
29
+ export declare function detectInstallDrift(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstallDrift;
30
+ /**
31
+ * Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
32
+ * copy's own dist/core/daemon-main.js. When that file has vanished — a
33
+ * harness may prune its old plugin-cache timestamped dir while a session
34
+ * still runs that copy in memory — spawn from the PATH-installed CLI's copy
35
+ * instead, which is what the user most recently installed. If nothing on
36
+ * PATH resolves either, the own path is returned unchanged so the spawn
37
+ * fails with the original error rather than a misleading one.
38
+ */
39
+ export declare function resolveDaemonEntry(ownEntry: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared install-location helpers (issue #39): resolving the PATH-installed
3
+ * copy of the CLI, detecting version drift between the running copy and that
4
+ * install, and finding a usable daemon entry when the running copy's own
5
+ * entry has vanished (e.g. a harness pruned an old plugin-cache copy while a
6
+ * session still executes it from memory).
7
+ *
8
+ * `enclosingPackage` / `PKG_NAME` live in `./pkginfo.js` (shipped by #38's
9
+ * adapter-init drift notice) and are reused here; this module adds the
10
+ * PATH-install scan and the two #39-specific probes on top of them:
11
+ * `detectInstallDrift` (running copy OLDER than the PATH CLI — the mirror
12
+ * image of pkginfo-neighbor `drift.ts`'s `detectCliDrift`, which flags the
13
+ * PATH CLI being older) and `resolveDaemonEntry` (spawn fallback when this
14
+ * copy's own entry has vanished).
15
+ *
16
+ * Everything here is best-effort and never throws: missing paths, unreadable
17
+ * files, and unexpected layouts yield null / false / the input unchanged.
18
+ */
19
+ import { existsSync, realpathSync, statSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { isWindows } from "./transport.js";
22
+ import { compareVersions, packageVersion } from "./version.js";
23
+ import { enclosingPackage } from "./pkginfo.js";
24
+ /** All `load-lock` binaries found on PATH, in PATH order (files only). */
25
+ export function pathBinCandidates(platform = process.platform, env = process.env) {
26
+ const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
27
+ const sep = isWindows(platform) ? ";" : ":";
28
+ const out = [];
29
+ for (const dir of (env.PATH ?? "").split(sep).filter(Boolean)) {
30
+ for (const bin of binNames) {
31
+ try {
32
+ const binPath = join(dir, bin);
33
+ if (statSync(binPath).isFile())
34
+ out.push(binPath);
35
+ }
36
+ catch {
37
+ /* unreadable PATH entry — skip */
38
+ }
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
45
+ * resolved (npm/bun global layouts symlink the bin into the install), and a
46
+ * plain shim script falls back to the PATH directory itself.
47
+ */
48
+ export function pathInstall(platform = process.platform, env = process.env) {
49
+ for (const binPath of pathBinCandidates(platform, env)) {
50
+ let realBin = binPath;
51
+ try {
52
+ realBin = realpathSync(binPath);
53
+ }
54
+ catch {
55
+ /* keep the raw path */
56
+ }
57
+ // A shim/wrapper script is not itself the copy — resolve through its
58
+ // enclosing package, falling back to the PATH dir (same logic the
59
+ // doctor's multi-copy scan uses).
60
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dirname(binPath));
61
+ if (found)
62
+ return { path: found.path, version: found.version };
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * In-memory staleness probe (issue #39): a harness that loads plugin code
68
+ * once at startup (opencode never hot-reloads plugins) keeps executing the
69
+ * old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
70
+ * that: the running copy vs. what the PATH `load-lock` resolves to.
71
+ */
72
+ export function detectInstallDrift(platform = process.platform, env = process.env) {
73
+ const ownVersion = packageVersion();
74
+ const inst = pathInstall(platform, env);
75
+ const pathVersion = inst?.version ?? null;
76
+ return {
77
+ ownVersion,
78
+ pathVersion,
79
+ stale: pathVersion != null && compareVersions(pathVersion, ownVersion) > 0,
80
+ };
81
+ }
82
+ /**
83
+ * Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
84
+ * copy's own dist/core/daemon-main.js. When that file has vanished — a
85
+ * harness may prune its old plugin-cache timestamped dir while a session
86
+ * still runs that copy in memory — spawn from the PATH-installed CLI's copy
87
+ * instead, which is what the user most recently installed. If nothing on
88
+ * PATH resolves either, the own path is returned unchanged so the spawn
89
+ * fails with the original error rather than a misleading one.
90
+ */
91
+ export function resolveDaemonEntry(ownEntry, platform = process.platform, env = process.env) {
92
+ if (existsSync(ownEntry))
93
+ return ownEntry;
94
+ const inst = pathInstall(platform, env);
95
+ if (inst) {
96
+ const candidate = join(inst.path, "dist", "core", "daemon-main.js");
97
+ if (existsSync(candidate))
98
+ return candidate;
99
+ }
100
+ return ownEntry;
101
+ }
@@ -0,0 +1,16 @@
1
+ /** Package name of this package (used to find enclosing package roots). */
2
+ export declare const PKG_NAME = "@aron98/load-lock";
3
+ export interface PkgRootInfo {
4
+ /** Package root (the directory holding the matching package.json). */
5
+ path: string;
6
+ /** Version string, or null when the package.json has no/invalid version. */
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): PkgRootInfo | null;
15
+ /** Cache root the way opencode resolves it (its npm cache lives under it). */
16
+ export declare function opencodeCacheRoot(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Package-identity plumbing shared by copy detection (cli/copies.ts), the
3
+ * update command (cli/update.ts), and the adapter drift notice
4
+ * (shared/drift.ts): the package name and the "which package root
5
+ * contains this file" climb.
6
+ *
7
+ * Pure functions of their arguments (no process.env / import.meta at call
8
+ * time beyond what callers pass in) so tests can plant layouts.
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ /** Package name of this package (used to find enclosing package roots). */
14
+ export const PKG_NAME = "@aron98/load-lock";
15
+ /**
16
+ * Find the package.json of the @aron98/load-lock copy that contains the
17
+ * given file, by climbing parent directories. Best-effort: returns null if
18
+ * no enclosing package.json matches (or is readable).
19
+ */
20
+ export function enclosingPackage(startDir, maxDepth = 12) {
21
+ let dir = startDir;
22
+ for (let i = 0; i < maxDepth; i++) {
23
+ const pkgPath = join(dir, "package.json");
24
+ try {
25
+ if (existsSync(pkgPath)) {
26
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
27
+ if (pkg?.name === PKG_NAME) {
28
+ return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
29
+ }
30
+ // A foreign package.json terminates the climb (e.g. a PATH bin
31
+ // inside some other tool's package).
32
+ return null;
33
+ }
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ const parent = dirname(dir);
39
+ if (parent === dir)
40
+ return null;
41
+ dir = parent;
42
+ }
43
+ return null;
44
+ }
45
+ /** Cache root the way opencode resolves it (its npm cache lives under it). */
46
+ export function opencodeCacheRoot(platform, env) {
47
+ if (env.XDG_CACHE_HOME)
48
+ return env.XDG_CACHE_HOME;
49
+ if (platform === "win32")
50
+ return env.LOCALAPPDATA ?? null;
51
+ if (platform === "darwin")
52
+ return join(env.HOME ?? homedir(), "Library", "Caches");
53
+ return join(env.HOME ?? homedir(), ".cache");
54
+ }
@@ -130,6 +130,8 @@ export interface EntrySnapshot {
130
130
  leaseDeadline?: number;
131
131
  lastSeen?: number;
132
132
  heldFor?: number;
133
+ /** When execution started (start_job / mark_started), epoch ms. Absent before that. */
134
+ startedAt?: number;
133
135
  }
134
136
  /** Owner-scoped snapshot: includes tokens. Never returned to non-owners. */
135
137
  export interface OwnerEntrySnapshot extends EntrySnapshot {
@@ -394,6 +396,36 @@ export interface WaitForTurnResult {
394
396
  nextAction: string;
395
397
  detail?: string;
396
398
  }
399
+ export type WaitForCompletionState = "finished" | "still_running" | "timeout" | "job_unknown" | "stopped_by_request";
400
+ export interface WaitForCompletionParams {
401
+ /** Request id whose tracked job to wait for. */
402
+ requestId: string;
403
+ /** Bounded wait budget for this call, ms. 0 = check-only (immediate return). */
404
+ timeoutMs?: number;
405
+ }
406
+ export interface WaitForCompletionResult {
407
+ state: WaitForCompletionState;
408
+ /** The job id (adapter-parented run_with_permit job). */
409
+ jobId?: string;
410
+ /** The request/permit id whose job was waited on. */
411
+ requestId?: string;
412
+ /** Adapter-parented jobs only: the child's exit code. */
413
+ exitCode?: number | null;
414
+ /** Adapter-parented jobs only: last lines of combined output. */
415
+ outputTail?: string;
416
+ /** Wall time from execution start to job end (ms). */
417
+ durationMs?: number;
418
+ /** True when the daemon confirms the permit is no longer held. */
419
+ permitReleased?: boolean;
420
+ /** How long the wait actually ran before resolving (ms). */
421
+ waitedMs: number;
422
+ /** The clamped budget actually applied, when it differed from the requested one. */
423
+ effectiveWaitMs?: number;
424
+ /** When execution started, when known (epoch ms). */
425
+ startedAt?: number;
426
+ nextAction: string;
427
+ detail?: string;
428
+ }
397
429
  /** Recovery diagnostics: held permits with holder + job information. */
398
430
  export interface HolderDiag {
399
431
  entry: EntrySnapshot;