@aron98/load-lock 1.5.0 → 1.6.1
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.
- package/README.md +62 -14
- package/dist/cli/copies.d.ts +3 -2
- package/dist/cli/copies.js +32 -3
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +67 -13
- package/dist/cli/main.js +62 -5
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +5 -1
- package/dist/cli/update.js +16 -3
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/mcp/server.d.ts +4 -3
- package/dist/mcp/server.js +29 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +5 -3
- package/dist/shared/drift.d.ts +4 -2
- package/dist/shared/drift.js +24 -6
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/pkginfo.d.ts +9 -0
- package/dist/shared/pkginfo.js +16 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +206 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +93 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.js +71 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +36 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +142 -13
- package/docs/OPERATIONS.md +292 -14
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
* watch
|
|
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
|
-
|
|
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:
|
|
50
|
+
const server = new McpServer({ name: opts.serverName ?? "load-lock", version: packageVersion() }, { 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 */
|
package/dist/policy/text.d.ts
CHANGED
|
@@ -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
|
|
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: {
|
package/dist/policy/text.js
CHANGED
|
@@ -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
|
|
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
|
|
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,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
|
+
}
|
package/dist/shared/client.js
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/dist/shared/drift.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/drift.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/shared/pkginfo.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/pkginfo.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/shared/protocol.js
CHANGED
|
@@ -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;
|