@aron98/load-lock 1.4.0 → 1.6.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 (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -4,12 +4,17 @@
4
4
  *
5
5
  * - OpenCode plugin: injected via session "context" hook (every model call,
6
6
  * compaction-proof) + registered skill for detail.
7
- * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork).
7
+ * - Claude Code: SessionStart hook stdout (matcher startup|resume|clear|compact|fork),
8
+ * from the loose install or the load-lock-plugin Claude Code plugin.
8
9
  * - Codex CLI: SessionStart/SubagentStart hook stdout (needs /hooks trust).
9
10
  * - MCP server: `instructions` field on every harness that surfaces it.
11
+ * - Skill only (ladder rung 0, docs/OPERATIONS.md): no brief is injected;
12
+ * skills/load-lock/SKILL.md carries its own version of these rules plus the
13
+ * MCP -> PATH -> pinned-npx bootstrap (docs/AGENT_GUIDE.md section 0).
10
14
  *
11
15
  * Deliberately SHORT (spec: no noisy reminders). The detailed playbook lives
12
- * in the skill / docs/AGENT_GUIDE.md.
16
+ * in docs/AGENT_GUIDE.md, served by the MCP agent_guide tool, the skill and
17
+ * `load-lock agent-guide`.
13
18
  */
14
19
  export const POLICY_BRIEF = `# load-lock: machine-wide heavy-work budget
15
20
 
@@ -49,7 +54,13 @@ Rules:
49
54
  including container limits) and adjust with configure (explain why). Don't
50
55
  assume N cores = N parallel heavy jobs.
51
56
 
52
- Detailed guide: the "load-lock" skill or \`load-lock agent-guide\`.`;
57
+ Detailed guide: the load-lock agent_guide tool (MCP/plugin), else the
58
+ "load-lock" skill or \`load-lock agent-guide\` (CLI) where installed.`;
59
+ /**
60
+ * The one upgrade hint every rung carries (#47 ladder, #59): appended after
61
+ * the brief in the MCP instructions and the session-start hook output.
62
+ */
63
+ export const SETUP_HINT = "Wiring: `load-lock setup` lists this machine's load-lock integrations per harness; `load-lock setup --yes` converges them to the recommended layout (hooks for idle wakeups, policy that survives compaction).";
53
64
  export function policyWithContext(statusSummary, configSummary) {
54
65
  let s = POLICY_BRIEF + "\n\n";
55
66
  s += statusSummary;
@@ -0,0 +1,3 @@
1
+ export declare function agentGuidePath(): string;
2
+ /** The guide text; throws when this copy ships without docs/. */
3
+ export declare function agentGuideText(): string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The full agent guide: docs/AGENT_GUIDE.md, shipped next to dist/ in every
3
+ * copy of the package (the file `load-lock agent-guide` prints). The MCP
4
+ * `agent_guide` tool (issue #61) returns the same file, so a plugin-only
5
+ * install (no skill, no CLI on PATH) can still reach the detailed guide.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { packageRoot } from "./version.js";
10
+ export function agentGuidePath() {
11
+ return join(packageRoot(), "docs", "AGENT_GUIDE.md");
12
+ }
13
+ /** The guide text; throws when this copy ships without docs/. */
14
+ export function agentGuideText() {
15
+ return readFileSync(agentGuidePath(), "utf8");
16
+ }
@@ -29,7 +29,8 @@ import { setTimeout as delay } from "node:timers/promises";
29
29
  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
- import { packageVersion } from "./version.js";
32
+ import { packageVersion, packageRoot } from "./version.js";
33
+ import { resolveDaemonEntry } from "./installs.js";
33
34
  export class LLClient {
34
35
  sock = null;
35
36
  nextId = 1;
@@ -172,8 +173,10 @@ export class LLClient {
172
173
  });
173
174
  const sock = this.ensureSock();
174
175
  // clientVersion: lets the daemon detect version skew and self-restart
175
- // onto newer on-disk code (pre-handshake daemons ignore the field).
176
- sock.write(JSON.stringify({ id, method, params, clientVersion: packageVersion() }) + "\n");
176
+ // onto newer on-disk code (pre-handshake daemons ignore the field);
177
+ // clientRoot: where that code lives, so the daemon can record known
178
+ // runtime copies (issue #52; older daemons ignore it too).
179
+ sock.write(JSON.stringify({ id, method, params, clientVersion: packageVersion(), clientRoot: packageRoot() }) + "\n");
177
180
  });
178
181
  }
179
182
  /** Subscribe to daemon events targeted at agentId (or "*" for all). */
@@ -230,7 +233,12 @@ function daemonMainPath() {
230
233
  if (process.env.LOAD_LOCK_DAEMON_BIN)
231
234
  return process.env.LOAD_LOCK_DAEMON_BIN;
232
235
  const here = dirname(fileURLToPath(import.meta.url)); // .../dist/shared
233
- return join(here, "..", "core", "daemon-main.js");
236
+ const own = join(here, "..", "core", "daemon-main.js");
237
+ // Issue #39: a harness (e.g. opencode's timestamped plugin cache) may
238
+ // prune this copy's directory while a session still executes it from
239
+ // memory — resolve to the PATH-installed CLI's daemon entry instead of
240
+ // spawning an ENOENT forever.
241
+ return resolveDaemonEntry(own);
234
242
  }
235
243
  /** Idempotently ensure a daemon is running and listening on `sockPath`. */
236
244
  export async function ensureDaemon(stateDir, sockPath) {
@@ -274,6 +282,10 @@ export async function ensureDaemon(stateDir, sockPath) {
274
282
  stdio: "ignore",
275
283
  env: { ...process.env, LOAD_LOCK_DAEMON_SPAWNED: "1" },
276
284
  });
285
+ // Issue #39: an unreadable/vanished entry must surface as the
286
+ // "did not come up" probe failure below, not crash the host process
287
+ // with an uncaught ENOENT 'error' event.
288
+ child.on("error", () => { });
277
289
  child.unref();
278
290
  }
279
291
  }
@@ -0,0 +1,23 @@
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
+ /** Where this (adapter) copy lives: the npx cache, or anywhere else. */
9
+ ownSource: "npx-cache" | "adapter";
10
+ /** The one clear notice, with the exact command. */
11
+ message: string;
12
+ }
13
+ /** The notice text (exported for tests and doc-sync checks). */
14
+ export declare function driftMessage(ownVersion: string, cliVersion: string, ownSource?: CliDrift["ownSource"]): string;
15
+ /**
16
+ * Compare `ownVersion` (this adapter copy) with the version of the
17
+ * `load-lock` binary that PATH resolves to, by reading that copy's
18
+ * package.json — never by spawning the old CLI, which may predate any
19
+ * introspection. Returns a drift notice when the PATH CLI is strictly
20
+ * older; null when it is the same or newer, absent, or unreadable.
21
+ * Best-effort: never throws.
22
+ */
23
+ export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, ownPath?: string | null): CliDrift | null;
@@ -0,0 +1,95 @@
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
+ * npx-vs-PATH skew (issue #57): an MCP server launched via a pinned
18
+ * `npx -y @aron98/load-lock@<pin>` runs from the npx cache; when that copy
19
+ * is newer than the PATH CLI the notice names it as the npx copy.
20
+ *
21
+ * Pure and synchronous (PATH scan + package.json reads only) so both the
22
+ * OpenCode plugin setup and the MCP server startup can call it inline.
23
+ */
24
+ import { existsSync, realpathSync, statSync } from "node:fs";
25
+ import { dirname, join, sep as pathSep } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { isWindows } from "./transport.js";
28
+ import { enclosingPackage, npxCacheRoot, PKG_NAME } from "./pkginfo.js";
29
+ import { compareVersions } from "./version.js";
30
+ /** The notice text (exported for tests and doc-sync checks). */
31
+ export function driftMessage(ownVersion, cliVersion, ownSource = "adapter") {
32
+ const own = ownSource === "npx-cache" ? "npx-cache copy (launched via npx)" : "adapter copy";
33
+ return (`load-lock: this load-lock ${own} (v${ownVersion}) is newer than the installed CLI (v${cliVersion}). ` +
34
+ `Run \`load-lock update\` to update the CLI on PATH and restart the daemon. ` +
35
+ `(Notify only — nothing is auto-installed; if your CLI predates the \`update\` command, run ` +
36
+ `\`npm i -g ${PKG_NAME}@latest\` instead.)`);
37
+ }
38
+ /** Package root of the copy this module runs from, or null. */
39
+ function ownPackageRoot() {
40
+ try {
41
+ return enclosingPackage(dirname(dirname(fileURLToPath(import.meta.url))))?.path ?? null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Compare `ownVersion` (this adapter copy) with the version of the
49
+ * `load-lock` binary that PATH resolves to, by reading that copy's
50
+ * package.json — never by spawning the old CLI, which may predate any
51
+ * introspection. Returns a drift notice when the PATH CLI is strictly
52
+ * older; null when it is the same or newer, absent, or unreadable.
53
+ * Best-effort: never throws.
54
+ */
55
+ export function detectCliDrift(ownVersion, env = process.env, platform = process.platform, ownPath = ownPackageRoot()) {
56
+ try {
57
+ const npxRoot = npxCacheRoot(platform, env);
58
+ const ownSource = ownPath && npxRoot && (ownPath + pathSep).startsWith(npxRoot.endsWith(pathSep) ? npxRoot : npxRoot + pathSep) ? "npx-cache" : "adapter";
59
+ const sep = isWindows(platform) ? ";" : ":";
60
+ const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
61
+ const dirs = (env.PATH ?? "").split(sep).filter(Boolean);
62
+ for (const dir of dirs) {
63
+ for (const bin of binNames) {
64
+ const binPath = join(dir, bin);
65
+ if (!existsSync(binPath) || !statSync(binPath).isFile())
66
+ continue;
67
+ // Resolve through symlinks/shims to the copy that owns the bin.
68
+ let realBin;
69
+ try {
70
+ realBin = realpathSync(binPath);
71
+ }
72
+ catch {
73
+ realBin = binPath;
74
+ }
75
+ const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
76
+ if (!found || found.version === null)
77
+ return null; // first PATH hit wins; cannot compare honestly
78
+ if (compareVersions(found.version, ownVersion) < 0) {
79
+ return {
80
+ ownVersion,
81
+ cliVersion: found.version,
82
+ cliPath: found.path,
83
+ ownSource,
84
+ message: driftMessage(ownVersion, found.version, ownSource),
85
+ };
86
+ }
87
+ return null; // PATH CLI is same/newer — no drift
88
+ }
89
+ }
90
+ }
91
+ catch {
92
+ /* best-effort: unreadable PATH entries are skipped, never fatal */
93
+ }
94
+ return null; // no load-lock on PATH — nothing to compare against
95
+ }
@@ -0,0 +1,51 @@
1
+ export type IdentitySource = "override" | "env" | "harness-env" | "ancestor" | "fallback";
2
+ export interface SessionIdentity {
3
+ /** The agentId sent to the daemon. */
4
+ id: string;
5
+ /** Short human-readable label for holder lists (defaults to the id). */
6
+ label: string;
7
+ source: IdentitySource;
8
+ }
9
+ /**
10
+ * Harness session env vars exported to child processes (verified only; a
11
+ * harness that is not listed falls through to the ancestor walk).
12
+ *
13
+ * - Claude Code (2.1.x): `CLAUDE_CODE_SESSION_ID` in tool/hook/MCP children;
14
+ * the same value hooks receive on stdin as `session_id`.
15
+ * - OpenCode (2.x): the bash tool exports `OPENCODE_SESSION_ID` = the
16
+ * session's `sessionID`, which is exactly the agentId the native plugin
17
+ * uses — so CLI calls from an OpenCode shell share the plugin's identity
18
+ * (hence no prefix).
19
+ */
20
+ export declare const HARNESS_SESSION_ENV: {
21
+ env: string;
22
+ harness: string;
23
+ id: (sid: string) => string;
24
+ }[];
25
+ export interface ResolveOptions {
26
+ /** Explicit id (e.g. `--agent` / `agent_id`); wins over everything. */
27
+ override?: string;
28
+ /** Used when nothing session-scoped is found. */
29
+ fallback: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ /** First process to inspect (default: the caller's parent). */
32
+ startPid?: number;
33
+ /** /proc root (tests plant a fake tree). */
34
+ procRoot?: string;
35
+ platform?: NodeJS.Platform;
36
+ }
37
+ export declare function sessionIdentity(opts: ResolveOptions): SessionIdentity;
38
+ export interface ProcInfo {
39
+ pid: number;
40
+ ppid: number;
41
+ comm: string;
42
+ /** /proc/<pid>/stat field 22 (clock ticks since boot). */
43
+ startTime: string;
44
+ }
45
+ export declare function readProc(pid: number, procRoot?: string): ProcInfo | null;
46
+ /**
47
+ * First ancestor (starting at `startPid`, inclusive) that is not a shell or
48
+ * launcher wrapper. null when the walk reaches init (pid <= 1) or /proc is
49
+ * unreadable — an orphaned caller has no harness to share an identity with.
50
+ */
51
+ export declare function harnessAncestor(startPid: number, procRoot?: string): ProcInfo | null;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Session-derived agent identity (issue #49, umbrella #47 item 1).
3
+ *
4
+ * The daemon routes offers, `pending` and op-key duplicate suppression by
5
+ * agentId, so the id must be "one per harness session": the same for every
6
+ * call a session makes (whichever wiring — CLI, MCP server, hook — made it),
7
+ * and different between sessions. Resolution order:
8
+ *
9
+ * 1. explicit override: `--agent` / `agent_id` (callers pass it as
10
+ * `override`) or `LOAD_LOCK_AGENT`;
11
+ * 2. a harness session env var, where the harness exports one to the
12
+ * processes it spawns (see HARNESS_SESSION_ENV);
13
+ * 3. the stable harness ancestor process: walk up from the caller's
14
+ * parent past shells and launcher wrappers (npm/npx, env, ...) to the
15
+ * first real ancestor, identified by pid + /proc start time (pid-reuse
16
+ * safe). Linux only; elsewhere this step is skipped;
17
+ * 4. the caller's fallback (the CLI's legacy `cli:$USER@$HOSTNAME`).
18
+ *
19
+ * Pure apart from reading /proc; every input is injectable for tests.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { basename, join } from "node:path";
23
+ /**
24
+ * Harness session env vars exported to child processes (verified only; a
25
+ * harness that is not listed falls through to the ancestor walk).
26
+ *
27
+ * - Claude Code (2.1.x): `CLAUDE_CODE_SESSION_ID` in tool/hook/MCP children;
28
+ * the same value hooks receive on stdin as `session_id`.
29
+ * - OpenCode (2.x): the bash tool exports `OPENCODE_SESSION_ID` = the
30
+ * session's `sessionID`, which is exactly the agentId the native plugin
31
+ * uses — so CLI calls from an OpenCode shell share the plugin's identity
32
+ * (hence no prefix).
33
+ */
34
+ export const HARNESS_SESSION_ENV = [
35
+ { env: "CLAUDE_CODE_SESSION_ID", harness: "claude-code", id: (sid) => "claude-code:" + sid },
36
+ { env: "OPENCODE_SESSION_ID", harness: "opencode", id: (sid) => sid },
37
+ ];
38
+ /** Interpreters/shells that sit between a harness and the CLI per tool call. */
39
+ const SHELLS = new Set([
40
+ "sh", "bash", "dash", "zsh", "ksh", "mksh", "fish", "tcsh", "csh", "nu", "pwsh", "busybox",
41
+ "env", "timeout", "nohup", "setsid", "stdbuf", "nice", "xargs",
42
+ ]);
43
+ /** Launcher scripts recognized in argv[0..1] (node/bun running npm/npx, nested load-lock, plugin launcher). */
44
+ const WRAPPER_SCRIPTS = new Set([
45
+ "npm", "npx", "npm-cli.js", "npx-cli.js", "pnpm", "pnpx", "yarn", "bunx", "load-lock", "load-lock.mjs",
46
+ ]);
47
+ export function sessionIdentity(opts) {
48
+ const env = opts.env ?? process.env;
49
+ const override = opts.override?.trim();
50
+ if (override)
51
+ return { id: override, label: override, source: "override" };
52
+ const envAgent = env.LOAD_LOCK_AGENT?.trim();
53
+ if (envAgent)
54
+ return { id: envAgent, label: envAgent, source: "env" };
55
+ for (const h of HARNESS_SESSION_ENV) {
56
+ const sid = env[h.env]?.trim();
57
+ if (sid)
58
+ return { id: h.id(sid), label: `${h.harness}:${sid.slice(0, 8)}`, source: "harness-env" };
59
+ }
60
+ const platform = opts.platform ?? process.platform;
61
+ if (platform === "linux") {
62
+ const anc = harnessAncestor(opts.startPid ?? process.ppid, opts.procRoot ?? "/proc");
63
+ if (anc) {
64
+ return { id: `proc:${anc.pid}:${anc.startTime}`, label: `${anc.comm}:${anc.pid}`, source: "ancestor" };
65
+ }
66
+ }
67
+ return { id: opts.fallback, label: opts.fallback, source: "fallback" };
68
+ }
69
+ export function readProc(pid, procRoot = "/proc") {
70
+ try {
71
+ const stat = readFileSync(join(procRoot, String(pid), "stat"), "utf8");
72
+ // comm is parenthesized and may itself contain spaces or parens.
73
+ const open = stat.indexOf("(");
74
+ const close = stat.lastIndexOf(")");
75
+ if (open < 0 || close < open)
76
+ return null;
77
+ const rest = stat.slice(close + 2).split(" ");
78
+ const ppid = Number(rest[1]);
79
+ const startTime = rest[19];
80
+ if (!Number.isInteger(ppid) || !startTime)
81
+ return null;
82
+ return { pid, ppid, comm: stat.slice(open + 1, close), startTime };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function isWrapper(p, procRoot) {
89
+ if (SHELLS.has(p.comm))
90
+ return true;
91
+ let argv = [];
92
+ try {
93
+ argv = readFileSync(join(procRoot, String(p.pid), "cmdline"), "utf8").split("\0");
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ return argv.slice(0, 2).some((a) => a && WRAPPER_SCRIPTS.has(basename(a)));
99
+ }
100
+ /**
101
+ * First ancestor (starting at `startPid`, inclusive) that is not a shell or
102
+ * launcher wrapper. null when the walk reaches init (pid <= 1) or /proc is
103
+ * unreadable — an orphaned caller has no harness to share an identity with.
104
+ */
105
+ export function harnessAncestor(startPid, procRoot = "/proc") {
106
+ let pid = startPid;
107
+ for (let depth = 0; depth < 32 && pid > 1; depth++) {
108
+ const p = readProc(pid, procRoot);
109
+ if (!p)
110
+ return null;
111
+ if (!isWrapper(p, procRoot))
112
+ return p;
113
+ pid = p.ppid;
114
+ }
115
+ return null;
116
+ }
@@ -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,25 @@
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;
17
+ /**
18
+ * Root of npx's install cache (`<npm cache>/_npx`), where `npx -y
19
+ * @aron98/load-lock@<pin>` (skill / ACE / pinned MCP entries) installs its
20
+ * copies as `<root>/<hash>/node_modules/@aron98/load-lock`. The npm cache
21
+ * dir follows npm's own resolution: `npm_config_cache` (set by npm/npx for
22
+ * the processes they launch, or by the user), else `%LocalAppData%\npm-cache`
23
+ * on Windows, else `~/.npm`.
24
+ */
25
+ export declare function npxCacheRoot(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | null;
@@ -0,0 +1,70 @@
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
+ }
55
+ /**
56
+ * Root of npx's install cache (`<npm cache>/_npx`), where `npx -y
57
+ * @aron98/load-lock@<pin>` (skill / ACE / pinned MCP entries) installs its
58
+ * copies as `<root>/<hash>/node_modules/@aron98/load-lock`. The npm cache
59
+ * dir follows npm's own resolution: `npm_config_cache` (set by npm/npx for
60
+ * the processes they launch, or by the user), else `%LocalAppData%\npm-cache`
61
+ * on Windows, else `~/.npm`.
62
+ */
63
+ export function npxCacheRoot(platform, env) {
64
+ const configured = env.npm_config_cache || env.NPM_CONFIG_CACHE;
65
+ if (configured)
66
+ return join(configured, "_npx");
67
+ if (platform === "win32")
68
+ return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "npm-cache", "_npx") : null;
69
+ return join(env.HOME ?? homedir(), ".npm", "_npx");
70
+ }