@aron98/load-lock 1.5.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 (66) hide show
  1. package/README.md +61 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +157 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +76 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. package/skills/load-lock/SKILL.md +62 -0
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * load-lock MCP server (stdio) — the universal adapter for harnesses that
3
3
  * speak MCP (Claude Code, Codex CLI, Gemini CLI, Copilot CLI, Cline, Roo,
4
- * Goose, ...). Each harness session spawns one MCP server process; the
5
- * process-scoped agent identity routes offers back through tool results and
6
- * watch pushes.
4
+ * Goose, ...). Each harness session spawns one MCP server process per
5
+ * registration; the session-derived agent identity (issue #50, see
6
+ * shared/identity.ts) routes offers back through tool results and watch
7
+ * pushes, and is shared by every registration in the same session.
7
8
  *
8
9
  * Notification honesty (see docs/COMPATIBILITY.md): MCP transports can
9
10
  * surface server-initiated *messages*, but most harnesses do not inject them
@@ -19,12 +20,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
20
  import { z } from "zod";
20
21
  import { spawn } from "node:child_process";
21
22
  import { LLClient } from "../shared/client.js";
22
- import { POLICY_BRIEF } from "../policy/text.js";
23
+ import { POLICY_BRIEF, SETUP_HINT } from "../policy/text.js";
23
24
  import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
24
25
  import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
25
26
  import { killProcessGroup } from "../shared/transport.js";
26
27
  import { detectCliDrift } from "../shared/drift.js";
27
28
  import { packageVersion } from "../shared/version.js";
29
+ import { sessionIdentity } from "../shared/identity.js";
30
+ import { agentGuideText } from "../shared/agent-guide.js";
28
31
  const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
29
32
 
30
33
  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.`;
@@ -32,7 +35,11 @@ const CLAIM_DESC = `Claim an offered permit within its claim window (offers expi
32
35
  const RELEASE_DESC = `Release a held permit immediately after the heavy work finishes — success, failure, or termination (idempotent: releasing twice is fine). A permit survives the tool call and session that acquired it; automatic cleanup releases it when a tracked job exits or, for untracked permits, when your adapter stops heartbeating — but do not rely on that: release as soon as the work is done.`;
33
36
  export function buildServer(opts) {
34
37
  const client = opts.client;
35
- const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
38
+ // Session-derived (issue #50): two registrations in one harness session
39
+ // (plugin-bundled + standalone) resolve to the same id; the per-process
40
+ // random id remains the fallback when no session can be found.
41
+ const defaultAgent = opts.agentId ??
42
+ sessionIdentity({ fallback: "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8) }).id;
36
43
  // Drift notice (issue #38): one clear notice naming the exact command when
37
44
  // this adapter copy is newer than the PATH-installed CLI. Notify only —
38
45
  // never auto-install. Printed to stderr at init and embedded in the
@@ -40,7 +47,7 @@ export function buildServer(opts) {
40
47
  const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
41
48
  if (drift)
42
49
  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 : "") });
50
+ const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + "\n\n" + SETUP_HINT + (drift ? "\n\n" + drift.message : "") });
44
51
  const json = (v) => ({
45
52
  content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
46
53
  });
@@ -214,6 +221,20 @@ Input: request_id (default: your oldest offered, then front-most queued, then ol
214
221
  description: "Current config: pools/capacity, offer timeout, heavy-work guidance, and change history with rationale.",
215
222
  inputSchema: {},
216
223
  }, async () => json(await client.request("get_config", {})));
224
+ // Issue #61: a plugin-only install may have neither the skill nor the CLI
225
+ // on PATH, so the policy brief's pointer to the detailed guide lands here.
226
+ server.registerTool("agent_guide", {
227
+ title: "Full agent guide",
228
+ description: "The detailed load-lock playbook (same text as `load-lock agent-guide`): heavy-vs-light heuristics, offer claiming, background jobs, configuration and recovery. Read it when the short policy is not enough. No daemon call.",
229
+ inputSchema: {},
230
+ }, async () => {
231
+ try {
232
+ return { content: [{ type: "text", text: agentGuideText() }] };
233
+ }
234
+ catch {
235
+ return { content: [{ type: "text", text: POLICY_BRIEF }] };
236
+ }
237
+ });
217
238
  server.registerTool("configure", {
218
239
  title: "Update config (CAS)",
219
240
  description: "Compare-and-swap config update so concurrent agents never silently overwrite each other. Read get_config, pass its version as expected_version, describe changes and a reason. Capacity decreases let current holders finish; increases admit queued requests immediately.",
@@ -477,6 +498,8 @@ Input: job_id OR request_id (one of them), timeout_ms (default 600000, clamped t
477
498
  // Automatic adapter heartbeat: while this server process lives, untracked
478
499
  // permits of this agent keep their lease; if the harness session dies, the
479
500
  // lease lapses and the daemon reclaims. The model never has to remember.
501
+ // Servers sharing a session identity each renew the same lease; heartbeats
502
+ // are idempotent, so that is harmless.
480
503
  const heartbeat = setInterval(() => {
481
504
  void client.request("heartbeat", { agentId: defaultAgent }).catch(() => {
482
505
  /* daemon unreachable; next tool call reconnects */
@@ -4,14 +4,24 @@
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
- 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`.";
19
+ 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 agent_guide tool (MCP/plugin), else the\n\"load-lock\" skill or `load-lock agent-guide` (CLI) where installed.";
20
+ /**
21
+ * The one upgrade hint every rung carries (#47 ladder, #59): appended after
22
+ * the brief in the MCP instructions and the session-start hook output.
23
+ */
24
+ export declare 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).";
15
25
  export declare function policyWithContext(statusSummary: string, configSummary?: string): string;
16
26
  export declare function statusLine(st: {
17
27
  pools: {
@@ -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,7 @@ 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
33
  import { resolveDaemonEntry } from "./installs.js";
34
34
  export class LLClient {
35
35
  sock = null;
@@ -173,8 +173,10 @@ export class LLClient {
173
173
  });
174
174
  const sock = this.ensureSock();
175
175
  // clientVersion: lets the daemon detect version skew and self-restart
176
- // onto newer on-disk code (pre-handshake daemons ignore the field).
177
- 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");
178
180
  });
179
181
  }
180
182
  /** Subscribe to daemon events targeted at agentId (or "*" for all). */
@@ -5,11 +5,13 @@ export interface CliDrift {
5
5
  cliVersion: string;
6
6
  /** Package root of the PATH-resolved CLI copy. */
7
7
  cliPath: string;
8
+ /** Where this (adapter) copy lives: the npx cache, or anywhere else. */
9
+ ownSource: "npx-cache" | "adapter";
8
10
  /** The one clear notice, with the exact command. */
9
11
  message: string;
10
12
  }
11
13
  /** The notice text (exported for tests and doc-sync checks). */
12
- export declare function driftMessage(ownVersion: string, cliVersion: string): string;
14
+ export declare function driftMessage(ownVersion: string, cliVersion: string, ownSource?: CliDrift["ownSource"]): string;
13
15
  /**
14
16
  * Compare `ownVersion` (this adapter copy) with the version of the
15
17
  * `load-lock` binary that PATH resolves to, by reading that copy's
@@ -18,4 +20,4 @@ export declare function driftMessage(ownVersion: string, cliVersion: string): st
18
20
  * older; null when it is the same or newer, absent, or unreadable.
19
21
  * Best-effort: never throws.
20
22
  */
21
- export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CliDrift | null;
23
+ export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, ownPath?: string | null): CliDrift | null;
@@ -14,21 +14,36 @@
14
14
  * harness restarts — this notice is emitted by the NEW copy at its init,
15
15
  * which is the best it can do for sessions started before the refresh.
16
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
+ *
17
21
  * Pure and synchronous (PATH scan + package.json reads only) so both the
18
22
  * OpenCode plugin setup and the MCP server startup can call it inline.
19
23
  */
20
24
  import { existsSync, realpathSync, statSync } from "node:fs";
21
- import { dirname, join } from "node:path";
25
+ import { dirname, join, sep as pathSep } from "node:path";
26
+ import { fileURLToPath } from "node:url";
22
27
  import { isWindows } from "./transport.js";
23
- import { enclosingPackage, PKG_NAME } from "./pkginfo.js";
28
+ import { enclosingPackage, npxCacheRoot, PKG_NAME } from "./pkginfo.js";
24
29
  import { compareVersions } from "./version.js";
25
30
  /** 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}). ` +
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}). ` +
28
34
  `Run \`load-lock update\` to update the CLI on PATH and restart the daemon. ` +
29
35
  `(Notify only — nothing is auto-installed; if your CLI predates the \`update\` command, run ` +
30
36
  `\`npm i -g ${PKG_NAME}@latest\` instead.)`);
31
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
+ }
32
47
  /**
33
48
  * Compare `ownVersion` (this adapter copy) with the version of the
34
49
  * `load-lock` binary that PATH resolves to, by reading that copy's
@@ -37,8 +52,10 @@ export function driftMessage(ownVersion, cliVersion) {
37
52
  * older; null when it is the same or newer, absent, or unreadable.
38
53
  * Best-effort: never throws.
39
54
  */
40
- export function detectCliDrift(ownVersion, env = process.env, platform = process.platform) {
55
+ export function detectCliDrift(ownVersion, env = process.env, platform = process.platform, ownPath = ownPackageRoot()) {
41
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";
42
59
  const sep = isWindows(platform) ? ";" : ":";
43
60
  const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
44
61
  const dirs = (env.PATH ?? "").split(sep).filter(Boolean);
@@ -63,7 +80,8 @@ export function detectCliDrift(ownVersion, env = process.env, platform = process
63
80
  ownVersion,
64
81
  cliVersion: found.version,
65
82
  cliPath: found.path,
66
- message: driftMessage(ownVersion, found.version),
83
+ ownSource,
84
+ message: driftMessage(ownVersion, found.version, ownSource),
67
85
  };
68
86
  }
69
87
  return null; // PATH CLI is same/newer — no drift
@@ -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
+ }
@@ -14,3 +14,12 @@ export interface PkgRootInfo {
14
14
  export declare function enclosingPackage(startDir: string, maxDepth?: number): PkgRootInfo | null;
15
15
  /** Cache root the way opencode resolves it (its npm cache lives under it). */
16
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;
@@ -52,3 +52,19 @@ export function opencodeCacheRoot(platform, env) {
52
52
  return join(env.HOME ?? homedir(), "Library", "Caches");
53
53
  return join(env.HOME ?? homedir(), ".cache");
54
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
+ }
@@ -11,6 +11,9 @@
11
11
  * the extra field (forward-compatible); newer daemons use it
12
12
  * for the update handshake: a client strictly newer than the
13
13
  * running daemon triggers a daemon self-restart (issue #34).
14
+ * Since #52 the envelope also carries "clientRoot" (the client
15
+ * copy's package root); the daemon records it as a known
16
+ * runtime copy (runtimes.json). Also ignored by older daemons.
14
17
  * Response: {"id": <number>, "ok": true, "result": {...}}
15
18
  * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
16
19
  * Watch push: {"event": true, ...event fields...}
@@ -139,6 +142,8 @@ export interface OwnerEntrySnapshot extends EntrySnapshot {
139
142
  permitToken?: string;
140
143
  }
141
144
  export interface JournalEntry {
145
+ /** Persisted format version of the writer (issue #51); absent before versioning. */
146
+ formatVersion?: number;
142
147
  requestId: string;
143
148
  pool: string;
144
149
  agentId: string;
@@ -438,3 +443,20 @@ export interface HolderDiag {
438
443
  note: string;
439
444
  };
440
445
  }
446
+ export declare const HOOK_ONCE_DEFAULT_TTL_MS = 60000;
447
+ export declare const HOOK_ONCE_MAX_TTL_MS: number;
448
+ export interface HookOnceParams {
449
+ harness: string;
450
+ sessionId: string;
451
+ event: string;
452
+ /** Distinguishes legitimate repeats within one session (e.g. SessionStart source, offer token). */
453
+ occurrence?: string;
454
+ /** Key lifetime, ms (clamped to [1s, HOOK_ONCE_MAX_TTL_MS]); ignored with hold. */
455
+ ttlMs?: number;
456
+ /** Hold the key until this connection closes instead of a TTL. */
457
+ hold?: boolean;
458
+ }
459
+ export interface HookOnceResult {
460
+ /** True when the caller is the first for this key and should act. */
461
+ first: boolean;
462
+ }
@@ -11,6 +11,9 @@
11
11
  * the extra field (forward-compatible); newer daemons use it
12
12
  * for the update handshake: a client strictly newer than the
13
13
  * running daemon triggers a daemon self-restart (issue #34).
14
+ * Since #52 the envelope also carries "clientRoot" (the client
15
+ * copy's package root); the daemon records it as a known
16
+ * runtime copy (runtimes.json). Also ignored by older daemons.
14
17
  * Response: {"id": <number>, "ok": true, "result": {...}}
15
18
  * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
16
19
  * Watch push: {"event": true, ...event fields...}
@@ -25,3 +28,15 @@ export class ProtocolError extends Error {
25
28
  this.details = details;
26
29
  }
27
30
  }
31
+ // ---------------------------------------------------------------------------
32
+ // hook_once (issue #54): daemon-side dedupe for duplicate hook wiring.
33
+ // Loose hooks + plugin hooks (or two plugins) fire the same harness hook
34
+ // more than once. Each hook handler first claims the key
35
+ // (harness, sessionId, event, occurrence); only the first caller acts, the
36
+ // rest exit 0 silently. Keys are in-memory only (never persisted, gone on
37
+ // daemon restart) and expire after ttlMs — or, with hold, live exactly as
38
+ // long as the calling connection (long-running await-offer watchers).
39
+ // Daemons without the method answer `unknown method`; callers then act as
40
+ // before (no dedupe).
41
+ export const HOOK_ONCE_DEFAULT_TTL_MS = 60_000;
42
+ export const HOOK_ONCE_MAX_TTL_MS = 10 * 60_000;
@@ -0,0 +1,41 @@
1
+ export interface RuntimeCopy {
2
+ /** Real path of the copy's package root. */
3
+ path: string;
4
+ /** Version read from the copy's package.json (not from the client). */
5
+ version: string;
6
+ /** Epoch ms of the last handshake from this copy. */
7
+ lastSeen: number;
8
+ }
9
+ /** daemon.json contents (fields beyond pid/startedAt/socket since #52). */
10
+ export interface DaemonInfo {
11
+ pid: number;
12
+ startedAt: number;
13
+ socket: string;
14
+ /** Package root the daemon runs from (since #52). */
15
+ packageRoot?: string;
16
+ /** Package version the daemon runs (since #52). */
17
+ version?: string;
18
+ }
19
+ /** Cap on remembered copies; the least recently seen are dropped first. */
20
+ export declare const MAX_RUNTIMES = 32;
21
+ export declare function runtimesPath(stateDir: string): string;
22
+ /**
23
+ * Validate a package root: it must hold a package.json naming
24
+ * @aron98/load-lock with a string version. Returns the real path and the
25
+ * on-disk version, or null.
26
+ */
27
+ export declare function inspectPackageRoot(root: string): {
28
+ path: string;
29
+ version: string;
30
+ } | null;
31
+ /** Known copies from the state dir, dropping entries whose path is gone. */
32
+ export declare function loadRuntimes(stateDir: string): RuntimeCopy[];
33
+ export declare function saveRuntimes(stateDir: string, copies: RuntimeCopy[]): void;
34
+ /**
35
+ * Merge one sighting into the list (dedupe by real path, newest first,
36
+ * capped). Returns the new list, or null when the root is not a valid
37
+ * load-lock copy.
38
+ */
39
+ export declare function mergeRuntime(copies: RuntimeCopy[], root: string, now: number): RuntimeCopy[] | null;
40
+ /** daemon.json contents, or null when missing/unreadable. */
41
+ export declare function readDaemonInfo(stateDir: string): DaemonInfo | null;