@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
@@ -0,0 +1,57 @@
1
+ export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "npx-cache" | "repo-checkout" | "unknown";
2
+ export interface SelfLocation {
3
+ kind: SelfLocationKind;
4
+ /** Package root of this copy. */
5
+ path: string;
6
+ /** Explanation shown in reports (part of the refusal reason when refusing). */
7
+ detail: string;
8
+ }
9
+ /** Inputs classifySelf needs; callers resolve them (tests plant values). */
10
+ export interface ClassifyCtx {
11
+ home: string;
12
+ /** bun global node_modules root; default <home>/.bun/install/global/node_modules. */
13
+ bunGlobalRoot?: string;
14
+ /** `npm root -g` result; null when npm could not answer. */
15
+ npmGlobalRoot: string | null;
16
+ /** opencode cache root (XDG_CACHE_HOME | platform default); null when unknown. */
17
+ cacheRoot: string | null;
18
+ /** npx cache root (<npm cache>/_npx); null/absent when unknown. */
19
+ npxRoot?: string | null;
20
+ }
21
+ /**
22
+ * Classify where this copy of load-lock lives, BEFORE touching anything:
23
+ * - bun global (<home>/.bun/install/global/node_modules/...) → update via bun
24
+ * - npm global (under `npm root -g`) → update via npm
25
+ * - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
26
+ * manages this copy and refreshes it on its own schedule
27
+ * - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
28
+ * via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
29
+ * - repo checkout (nearest .git found by walking up, like the omac
30
+ * read-grant logic) → refuse with a pointer to the git workflow
31
+ * - anything else → unknown → refuse honestly rather than guessing
32
+ */
33
+ export declare function classifySelf(pkgRoot: string, ctx: ClassifyCtx): SelfLocation;
34
+ export type UpdatePlan = {
35
+ action: "install";
36
+ argv: string[];
37
+ detail: string;
38
+ } | {
39
+ action: "refuse";
40
+ reason: string;
41
+ };
42
+ /** What to run for the detected location — or why we refuse to guess. */
43
+ export declare function planUpdate(loc: SelfLocation): UpdatePlan;
44
+ export interface UpdateOptions {
45
+ /** --check: report only, never install or restart. */
46
+ check: boolean;
47
+ /** --json: machine-readable output (house style: humans get prose). */
48
+ json: boolean;
49
+ }
50
+ /**
51
+ * The `load-lock update` command. Prints a structured report (JSON with
52
+ * --json, prose otherwise) and returns the exit code:
53
+ * 0 up-to-date (check) or applied successfully
54
+ * 1 update available (--check; includes "cannot self-apply" refusals)
55
+ * 70 failure or refusal in apply mode
56
+ */
57
+ export declare function updateCommand(opts: UpdateOptions): Promise<number>;
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `load-lock update` (issue #38): check the npm registry for a newer
3
+ * version (npm is the only distribution channel), classify where THIS copy
4
+ * lives before touching anything, and apply the update via the detected
5
+ * package manager — or refuse honestly (repo checkouts, opencode-managed
6
+ * cache copies, npx cache copies, unknown locations) rather than guessing. After a
7
+ * successful apply, `service restart` moves the daemon onto the new code
8
+ * immediately; the v1.3.0 version handshake stays as the safety net for
9
+ * daemons nobody explicitly restarts.
10
+ *
11
+ * Classification and planning are pure functions of their arguments
12
+ * (planted-layout testable, like shared/transport.ts); the registry and
13
+ * package-manager spawns resolve `npm` through PATH so tests stub them
14
+ * with a fake `npm` on PATH — the real registry is never contacted in
15
+ * tests and nothing is ever installed by them.
16
+ *
17
+ * Exit codes: 0 up-to-date / applied; 1 update available (`--check`,
18
+ * including "an update exists but this copy cannot self-apply" refusals);
19
+ * 70 failure (registry unreachable, package manager failed, refused in
20
+ * apply mode, or restart failed after an applied update).
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { dirname, join, sep } from "node:path";
25
+ import { homedir } from "node:os";
26
+ import { compareVersions, packageVersion } from "../shared/version.js";
27
+ import { npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
28
+ import { thisCopy } from "./copies.js";
29
+ import { serviceRestart, ServiceError } from "./service.js";
30
+ /** Path containment with separator awareness ("a" contains "a" and "a/b"). */
31
+ function isUnder(dir, root) {
32
+ if (dir === root)
33
+ return true;
34
+ return dir.startsWith(root.endsWith(sep) ? root : root + sep);
35
+ }
36
+ /**
37
+ * Classify where this copy of load-lock lives, BEFORE touching anything:
38
+ * - bun global (<home>/.bun/install/global/node_modules/...) → update via bun
39
+ * - npm global (under `npm root -g`) → update via npm
40
+ * - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
41
+ * manages this copy and refreshes it on its own schedule
42
+ * - npx cache copy (<npm cache>/_npx/...) → refuse: whatever launches it
43
+ * via `npx @aron98/load-lock@<pin>` (skill / ACE / MCP entry) owns the pin
44
+ * - repo checkout (nearest .git found by walking up, like the omac
45
+ * read-grant logic) → refuse with a pointer to the git workflow
46
+ * - anything else → unknown → refuse honestly rather than guessing
47
+ */
48
+ export function classifySelf(pkgRoot, ctx) {
49
+ const bunRoot = ctx.bunGlobalRoot ?? join(ctx.home, ".bun", "install", "global", "node_modules");
50
+ if (isUnder(pkgRoot, bunRoot)) {
51
+ return { kind: "bun-global", path: pkgRoot, detail: `bun global install under ${bunRoot}` };
52
+ }
53
+ if (ctx.npmGlobalRoot && isUnder(pkgRoot, ctx.npmGlobalRoot)) {
54
+ return { kind: "npm-global", path: pkgRoot, detail: `npm global install under ${ctx.npmGlobalRoot}` };
55
+ }
56
+ if (ctx.cacheRoot && isUnder(pkgRoot, join(ctx.cacheRoot, "opencode", "npm"))) {
57
+ return { kind: "opencode-cache", path: pkgRoot, detail: `opencode-managed cache copy under ${ctx.cacheRoot}` };
58
+ }
59
+ if (ctx.npxRoot && isUnder(pkgRoot, ctx.npxRoot)) {
60
+ return { kind: "npx-cache", path: pkgRoot, detail: `npx cache copy under ${ctx.npxRoot}` };
61
+ }
62
+ for (let dir = pkgRoot;;) {
63
+ if (existsSync(join(dir, ".git"))) {
64
+ return { kind: "repo-checkout", path: pkgRoot, detail: `git checkout (.git at ${dir})` };
65
+ }
66
+ const parent = dirname(dir);
67
+ if (parent === dir)
68
+ break;
69
+ dir = parent;
70
+ }
71
+ return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode/npx cache, or git checkout location" };
72
+ }
73
+ /** What to run for the detected location — or why we refuse to guess. */
74
+ export function planUpdate(loc) {
75
+ switch (loc.kind) {
76
+ case "bun-global":
77
+ return { action: "install", argv: ["bun", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
78
+ case "npm-global":
79
+ return { action: "install", argv: ["npm", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
80
+ case "opencode-cache":
81
+ return {
82
+ action: "refuse",
83
+ reason: `this copy lives in opencode's npm cache (${loc.path}); opencode manages it and refreshes it on its own schedule — ` +
84
+ `update the PATH-installed CLI instead (npm i -g ${PKG_NAME}@latest)`,
85
+ };
86
+ case "npx-cache":
87
+ return {
88
+ action: "refuse",
89
+ reason: `this copy lives in the npx cache (${loc.path}); it is whatever version the launching npx command pins — ` +
90
+ `bump the pinned version (the \`npx -y ${PKG_NAME}@<version>\` in your MCP/skill config, or the ACE artifact) ` +
91
+ `to move it, and update the PATH-installed CLI separately (npm i -g ${PKG_NAME}@latest)`,
92
+ };
93
+ case "repo-checkout":
94
+ return {
95
+ action: "refuse",
96
+ reason: `this copy is a git checkout (${loc.path}) — update it via your git workflow (e.g. git pull), not a package manager`,
97
+ };
98
+ case "unknown":
99
+ return {
100
+ action: "refuse",
101
+ reason: `cannot tell how this copy (${loc.path}) was installed — refusing to guess; ` +
102
+ `if it is a global install, run npm i -g ${PKG_NAME}@latest (or the bun equivalent) by hand`,
103
+ };
104
+ }
105
+ }
106
+ /** One npm invocation, bounded; resolves null on timeout/spawn failure. */
107
+ function npmOutput(args, timeoutMs) {
108
+ return new Promise((resolve) => {
109
+ let stdout = "";
110
+ let stderr = "";
111
+ let child;
112
+ try {
113
+ child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
114
+ }
115
+ catch {
116
+ resolve(null);
117
+ return;
118
+ }
119
+ const timer = setTimeout(() => {
120
+ child.kill("SIGKILL");
121
+ resolve(null);
122
+ }, timeoutMs);
123
+ timer.unref?.();
124
+ child.stdout?.on("data", (b) => (stdout += b));
125
+ child.stderr?.on("data", (b) => (stderr += b));
126
+ child.once("error", () => {
127
+ clearTimeout(timer);
128
+ resolve(null);
129
+ });
130
+ child.once("exit", (code) => {
131
+ clearTimeout(timer);
132
+ resolve({ code, stdout, stderr });
133
+ });
134
+ });
135
+ }
136
+ /** Latest published version from the npm registry, or null on failure. */
137
+ async function viewLatest(timeoutMs) {
138
+ const r = await npmOutput(["view", PKG_NAME, "version"], timeoutMs);
139
+ if (!r || r.code !== 0)
140
+ return null;
141
+ // `npm view <pkg> version` prints the bare version (possibly quoted).
142
+ const line = r.stdout.trim().split("\n").pop() ?? "";
143
+ const v = line.replace(/^"|"$/g, "").trim();
144
+ return /^\d+(\.\d+)*(-.*)?$/.test(v) ? v : null;
145
+ }
146
+ /** `npm root -g` (the global node_modules dir), or null on failure. */
147
+ async function npmGlobalRoot(timeoutMs) {
148
+ const r = await npmOutput(["root", "-g"], timeoutMs);
149
+ if (!r || r.code !== 0)
150
+ return null;
151
+ const line = r.stdout.trim().split("\n").pop() ?? "";
152
+ return line || null;
153
+ }
154
+ function fail(msg, code = 70) {
155
+ process.stderr.write(`load-lock: ${msg}\n`);
156
+ process.exit(code);
157
+ }
158
+ /**
159
+ * The `load-lock update` command. Prints a structured report (JSON with
160
+ * --json, prose otherwise) and returns the exit code:
161
+ * 0 up-to-date (check) or applied successfully
162
+ * 1 update available (--check; includes "cannot self-apply" refusals)
163
+ * 70 failure or refusal in apply mode
164
+ */
165
+ export async function updateCommand(opts) {
166
+ const timeoutMs = 30_000;
167
+ const out = (v, human) => {
168
+ if (opts.json)
169
+ process.stdout.write(JSON.stringify(v, null, 2) + "\n");
170
+ else
171
+ process.stdout.write(human() + "\n");
172
+ };
173
+ const locationReport = (loc) => ({ kind: loc.kind, path: loc.path });
174
+ // 1. Classify self BEFORE touching anything (local only, no registry).
175
+ const self = thisCopy();
176
+ const own = packageVersion();
177
+ if (!self) {
178
+ fail(`cannot resolve this copy's package root — run \`load-lock doctor\``);
179
+ }
180
+ const root = await npmGlobalRoot(timeoutMs);
181
+ const loc = classifySelf(self.path, {
182
+ home: process.env.HOME ?? homedir(),
183
+ npmGlobalRoot: root,
184
+ cacheRoot: opencodeCacheRoot(process.platform, process.env),
185
+ npxRoot: npxCacheRoot(process.platform, process.env),
186
+ });
187
+ // 2. Check the registry (npm is the only distribution channel).
188
+ const latest = await viewLatest(timeoutMs);
189
+ if (latest === null) {
190
+ fail(`could not determine the latest published version of ${PKG_NAME} (npm view failed — is the registry reachable?)`);
191
+ }
192
+ // 3. Up to date (or this copy is ahead of the registry — nothing to do).
193
+ if (compareVersions(latest, own) <= 0) {
194
+ const ahead = compareVersions(latest, own) < 0;
195
+ const report = {
196
+ state: "up-to-date",
197
+ current_version: own,
198
+ latest_version: latest,
199
+ location: locationReport(loc),
200
+ ...(ahead ? { note: `this copy (v${own}) is newer than the registry latest (v${latest}) — nothing to do` } : {}),
201
+ };
202
+ out(report, () => `load-lock: up to date (v${own}; registry latest v${latest}; location: ${loc.kind})` +
203
+ (ahead ? ` — this copy is ahead of the registry` : ""));
204
+ return 0;
205
+ }
206
+ // 4. An update exists: plan the apply for the classified location.
207
+ const plan = planUpdate(loc);
208
+ if (plan.action === "refuse") {
209
+ const report = {
210
+ state: "refused",
211
+ current_version: own,
212
+ latest_version: latest,
213
+ location: locationReport(loc),
214
+ reason: plan.reason,
215
+ next_action: "resolve the location issue, then run `load-lock update` again (see docs/OPERATIONS.md)",
216
+ };
217
+ out(report, () => `load-lock: update to v${latest} available, but refused: ${plan.reason}`);
218
+ if (opts.check)
219
+ return 1; // an update exists — this copy just cannot self-apply it
220
+ process.stderr.write(`load-lock: update refused — ${plan.reason}\n`);
221
+ return 70;
222
+ }
223
+ // 5. --check: report what WOULD happen; touch nothing.
224
+ if (opts.check) {
225
+ const report = {
226
+ state: "update-available",
227
+ current_version: own,
228
+ latest_version: latest,
229
+ location: locationReport(loc),
230
+ command: plan.argv,
231
+ next_action: "run `load-lock update` to apply it; the service restarts automatically afterwards",
232
+ };
233
+ out(report, () => `load-lock: update available: v${own} -> v${latest} (location: ${loc.kind}; would run: ${plan.argv.join(" ")})`);
234
+ return 1;
235
+ }
236
+ // 6. Apply: run the detected package manager (output shown live).
237
+ process.stdout.write(opts.json
238
+ ? JSON.stringify({ state: "applying", current_version: own, latest_version: latest, command: plan.argv }, null, 2) + "\n"
239
+ : `load-lock: updating v${own} -> v${latest} via ${plan.argv.join(" ")}\n`);
240
+ const code = await new Promise((resolve) => {
241
+ let child;
242
+ try {
243
+ child = spawn(plan.argv[0], plan.argv.slice(1), { stdio: "inherit" });
244
+ }
245
+ catch (e) {
246
+ fail(`cannot run ${plan.argv[0]}: ${e.message}`);
247
+ }
248
+ child.once("error", (e) => fail(`cannot run ${plan.argv[0]}: ${e.message}`));
249
+ child.once("exit", (c) => resolve(c));
250
+ });
251
+ if (code !== 0) {
252
+ fail(`update not applied: ${plan.argv.join(" ")} exited with code ${code} — the daemon keeps running the current code`);
253
+ }
254
+ // 7. Restart the service so the daemon moves onto the new code now
255
+ // (the version handshake remains the safety net, not the primary means).
256
+ try {
257
+ const r = await serviceRestart();
258
+ const report = {
259
+ state: "updated",
260
+ from: own,
261
+ to: latest,
262
+ location: locationReport(loc),
263
+ command: plan.argv,
264
+ restarted: true,
265
+ restart: { stop: r.stop, start: r.start },
266
+ };
267
+ out(report, () => `load-lock: updated v${own} -> v${latest}; ${r.stop}; ${r.start}`);
268
+ return 0;
269
+ }
270
+ catch (e) {
271
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
272
+ const report = {
273
+ state: "updated",
274
+ from: own,
275
+ to: latest,
276
+ location: locationReport(loc),
277
+ command: plan.argv,
278
+ restarted: false,
279
+ restart_error: reason,
280
+ next_action: "the update IS applied; the daemon still runs old code — the version handshake will self-restart it on the " +
281
+ "next client contact, or run `load-lock service restart` manually (see docs/OPERATIONS.md)",
282
+ };
283
+ out(report, () => `load-lock: updated v${own} -> v${latest}, but the service restart failed: ${reason}`);
284
+ process.stderr.write(`load-lock: ${reason}\n`);
285
+ return 70;
286
+ }
287
+ }
@@ -29,6 +29,9 @@ export declare class Daemon {
29
29
  private waiters;
30
30
  /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
31
31
  private completionWaiters;
32
+ /** hook_once keys (issue #54): in-memory only, never persisted. A key is
33
+ * live until expiresAt, or — when held — until its socket closes. */
34
+ private hookKeys;
32
35
  private sentEventId;
33
36
  private stateDirPath;
34
37
  private socketPath;
@@ -36,6 +39,10 @@ export declare class Daemon {
36
39
  private expiryTimer;
37
40
  private restartTimer;
38
41
  private stopped;
42
+ /** Known runtime copies (issue #52), mirrored to runtimes.json. */
43
+ private runtimes;
44
+ /** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
45
+ private runtimeHints;
39
46
  constructor(opts?: {
40
47
  stateDir?: string;
41
48
  socket?: string;
@@ -53,6 +60,13 @@ export declare class Daemon {
53
60
  private onConnection;
54
61
  private send;
55
62
  private handleLine;
63
+ /**
64
+ * Known runtime copies (issue #52): remember a copy seen via the
65
+ * handshake. Deduped by real path; the version is read from the copy's
66
+ * own package.json. Disk writes only on a new copy, a version change, or
67
+ * a stale lastSeen — never per request. Best-effort: never throws.
68
+ */
69
+ private recordRuntime;
56
70
  /**
57
71
  * Self-restart handshake (issue #34): if a client identifies a NEWER
58
72
  * package version than this daemon runs, schedule a respawn so the daemon
@@ -65,9 +79,12 @@ export declare class Daemon {
65
79
  /**
66
80
  * Drain + respawn onto the updated on-disk code. Persists state and the
67
81
  * journal FIRST, then stops (destroying all client connections — they
68
- * reconnect to the fresh daemon), then spawns the replacement with the
69
- * exact same argv so a daemon started via the old copy runs the new copy's
70
- * code, and exits.
82
+ * reconnect to the fresh daemon), then spawns the replacement and exits.
83
+ *
84
+ * The replacement runs the newer client's copy when its package root
85
+ * (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
86
+ * reruns the exact same argv, which picks up an in-place update of the
87
+ * daemon's own copy (e.g. `npm i -g`).
71
88
  */
72
89
  private selfRestart;
73
90
  /** Returns [result, agentIdForPendingAttachment]. */
@@ -140,3 +157,12 @@ export declare function isProcessAlive(pid: number, expectedStartTime?: string,
140
157
  export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
141
158
  /** Read the Linux start time of a pid (pid-reuse-safe association). */
142
159
  export declare function processStartTime(pid: number): string | undefined;
160
+ /**
161
+ * Daemon entry point of the newer client's copy (issue #53), or null to
162
+ * fall back to the same-argv respawn. The hint must name a load-lock
163
+ * package root whose on-disk version equals the version the client
164
+ * claimed, with a dist/core/daemon-main.js. The hint arrives over the
165
+ * same-user socket, so following it grants no privilege the client did
166
+ * not already have (it could run that file itself).
167
+ */
168
+ export declare function restartEntry(clientRoot: string | undefined, clientVersion: string): string | null;
@@ -25,8 +25,10 @@ import { Machine } from "./state.js";
25
25
  import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
26
26
  import { inspectMachine } from "./sysinfo.js";
27
27
  import { ProtocolError } from "../shared/protocol.js";
28
+ import { HOOK_ONCE_DEFAULT_TTL_MS, HOOK_ONCE_MAX_TTL_MS } from "../shared/protocol.js";
28
29
  import { isWindows, probeTransport } from "../shared/transport.js";
29
- import { packageVersion, compareVersions } from "../shared/version.js";
30
+ import { packageVersion, packageRoot, compareVersions } from "../shared/version.js";
31
+ import { inspectPackageRoot, loadRuntimes, mergeRuntime, saveRuntimes } from "../shared/runtimes.js";
30
32
  /** Package version of the copy of load-lock this daemon process runs from. */
31
33
  const DAEMON_VERSION = packageVersion();
32
34
  /**
@@ -35,6 +37,8 @@ const DAEMON_VERSION = packageVersion();
35
37
  * only one respawn per version bump.
36
38
  */
37
39
  const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
40
+ /** Re-persist a known copy's lastSeen at most this often (issue #52). */
41
+ const RUNTIME_TOUCH_MS = 60_000;
38
42
  export class Daemon {
39
43
  machine;
40
44
  server = null;
@@ -46,6 +50,9 @@ export class Daemon {
46
50
  waiters = new Map();
47
51
  /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
48
52
  completionWaiters = new Map();
53
+ /** hook_once keys (issue #54): in-memory only, never persisted. A key is
54
+ * live until expiresAt, or — when held — until its socket closes. */
55
+ hookKeys = new Map();
49
56
  sentEventId;
50
57
  stateDirPath;
51
58
  socketPath;
@@ -53,6 +60,10 @@ export class Daemon {
53
60
  expiryTimer = null;
54
61
  restartTimer = null;
55
62
  stopped = false;
63
+ /** Known runtime copies (issue #52), mirrored to runtimes.json. */
64
+ runtimes = [];
65
+ /** clientRoot hints already resolved this run: raw hint -> real path (null = invalid). */
66
+ runtimeHints = new Map();
56
67
  constructor(opts = {}) {
57
68
  this.stateDirPath = defaultStateDir(opts.stateDir);
58
69
  this.socketPath = defaultSocket(opts.socket);
@@ -81,7 +92,17 @@ export class Daemon {
81
92
  this.server.on("error", reject);
82
93
  this.server.listen(this.socketPath, () => resolve());
83
94
  });
84
- writeFileSync(join(this.stateDirPath, "daemon.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now(), socket: this.socketPath }, null, 2) + "\n");
95
+ const own = inspectPackageRoot(packageRoot());
96
+ const info = {
97
+ pid: process.pid,
98
+ startedAt: Date.now(),
99
+ socket: this.socketPath,
100
+ packageRoot: own?.path ?? packageRoot(),
101
+ version: DAEMON_VERSION,
102
+ };
103
+ writeFileSync(join(this.stateDirPath, "daemon.json"), JSON.stringify(info, null, 2) + "\n");
104
+ this.runtimes = loadRuntimes(this.stateDirPath);
105
+ this.recordRuntime(packageRoot());
85
106
  process.once("SIGTERM", () => this.stop());
86
107
  process.once("SIGINT", () => this.stop());
87
108
  this.sweepTimer = setInterval(() => this.sweep(), 5_000);
@@ -187,6 +208,9 @@ export class Daemon {
187
208
  for (const w of [...this.watchers])
188
209
  if (w.socket === sock)
189
210
  this.watchers.delete(w);
211
+ for (const [k, v] of this.hookKeys)
212
+ if (v.sock === sock)
213
+ this.hookKeys.delete(k);
190
214
  });
191
215
  }
192
216
  send(sock, obj) {
@@ -204,6 +228,8 @@ export class Daemon {
204
228
  }
205
229
  // Version handshake: remember the client's version for skew detection.
206
230
  const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
231
+ if (typeof msg.clientRoot === "string")
232
+ this.recordRuntime(msg.clientRoot);
207
233
  let result;
208
234
  let agentId;
209
235
  try {
@@ -222,7 +248,7 @@ export class Daemon {
222
248
  }
223
249
  // Respond first, then consider a self-restart — even on errors, a
224
250
  // newer client means this daemon is running stale on-disk code.
225
- this.maybeScheduleSelfRestart(clientVersion);
251
+ this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
226
252
  return;
227
253
  }
228
254
  // Tool-boundary delivery: surface this agent's outstanding offers/queue
@@ -231,9 +257,41 @@ export class Daemon {
231
257
  result.pending = this.machine.pending(agentId);
232
258
  }
233
259
  this.send(sock, { id: msg.id, ok: true, result });
234
- this.maybeScheduleSelfRestart(clientVersion);
260
+ this.maybeScheduleSelfRestart(clientVersion, msg.clientRoot);
235
261
  }
236
262
  // -------------------------------------------------------- version handshake
263
+ /**
264
+ * Known runtime copies (issue #52): remember a copy seen via the
265
+ * handshake. Deduped by real path; the version is read from the copy's
266
+ * own package.json. Disk writes only on a new copy, a version change, or
267
+ * a stale lastSeen — never per request. Best-effort: never throws.
268
+ */
269
+ recordRuntime(root) {
270
+ try {
271
+ let real = this.runtimeHints.get(root);
272
+ const now = Date.now();
273
+ if (real === undefined) {
274
+ real = inspectPackageRoot(root)?.path ?? null;
275
+ if (this.runtimeHints.size > 256)
276
+ this.runtimeHints.clear();
277
+ this.runtimeHints.set(root, real);
278
+ }
279
+ if (real === null)
280
+ return;
281
+ const known = this.runtimes.find((c) => c.path === real);
282
+ if (known && now - known.lastSeen < RUNTIME_TOUCH_MS)
283
+ return;
284
+ const merged = mergeRuntime(this.runtimes, real, now);
285
+ if (!merged)
286
+ return;
287
+ // Drop copies whose path disappeared since they were last seen.
288
+ this.runtimes = merged.filter((c) => c.path === real || existsSync(join(c.path, "package.json")));
289
+ saveRuntimes(this.stateDirPath, this.runtimes);
290
+ }
291
+ catch {
292
+ /* bookkeeping only */
293
+ }
294
+ }
237
295
  /**
238
296
  * Self-restart handshake (issue #34): if a client identifies a NEWER
239
297
  * package version than this daemon runs, schedule a respawn so the daemon
@@ -242,7 +300,7 @@ export class Daemon {
242
300
  * entirely daemon-side and best-effort: pre-handshake clients that send
243
301
  * no version never trigger a restart.
244
302
  */
245
- maybeScheduleSelfRestart(clientVersion) {
303
+ maybeScheduleSelfRestart(clientVersion, clientRoot) {
246
304
  if (this.stopped || this.restartTimer || !clientVersion)
247
305
  return;
248
306
  if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
@@ -252,18 +310,21 @@ export class Daemon {
252
310
  return; // already tried
253
311
  this.restartTimer = setTimeout(() => {
254
312
  this.restartTimer = null;
255
- this.selfRestart(clientVersion);
313
+ this.selfRestart(clientVersion, typeof clientRoot === "string" ? clientRoot : undefined);
256
314
  }, 250);
257
315
  this.restartTimer.unref?.();
258
316
  }
259
317
  /**
260
318
  * Drain + respawn onto the updated on-disk code. Persists state and the
261
319
  * journal FIRST, then stops (destroying all client connections — they
262
- * reconnect to the fresh daemon), then spawns the replacement with the
263
- * exact same argv so a daemon started via the old copy runs the new copy's
264
- * code, and exits.
320
+ * reconnect to the fresh daemon), then spawns the replacement and exits.
321
+ *
322
+ * The replacement runs the newer client's copy when its package root
323
+ * (clientRoot, issue #53) checks out — see restartEntry(). Otherwise it
324
+ * reruns the exact same argv, which picks up an in-place update of the
325
+ * daemon's own copy (e.g. `npm i -g`).
265
326
  */
266
- selfRestart(clientVersion) {
327
+ selfRestart(clientVersion, clientRoot) {
267
328
  if (this.stopped)
268
329
  return;
269
330
  // Best-effort persistence; a failed save must not block the restart.
@@ -276,7 +337,9 @@ export class Daemon {
276
337
  }
277
338
  this.stop();
278
339
  try {
279
- const child = spawn(process.execPath, process.argv.slice(1), {
340
+ const entry = restartEntry(clientRoot, clientVersion);
341
+ const args = entry ? [entry, "--state-dir", this.stateDirPath, "--socket", this.socketPath] : process.argv.slice(1);
342
+ const child = spawn(process.execPath, args, {
280
343
  detached: true,
281
344
  stdio: "ignore",
282
345
  cwd: process.cwd(),
@@ -406,6 +469,26 @@ export class Daemon {
406
469
  const cwd = String(p.cwd);
407
470
  return [{ offers: m.allOffers().filter((e) => e.cwd === cwd) }, undefined];
408
471
  }
472
+ case "hook_once": {
473
+ // Duplicate hook wiring (loose + plugin, two plugins, ...) fires the
474
+ // same hook several times; the first caller per key acts, the rest
475
+ // stay silent. Keys are in-memory only and expire on their own
476
+ // (ttlMs) or, with hold, live exactly as long as this connection.
477
+ const params = p;
478
+ if (!params.harness || !params.sessionId || !params.event) {
479
+ throw new ProtocolError("invalid", "hook_once: harness, sessionId and event are required");
480
+ }
481
+ const now = Date.now();
482
+ for (const [k, v] of this.hookKeys)
483
+ if (!v.sock && v.expiresAt <= now)
484
+ this.hookKeys.delete(k);
485
+ const key = JSON.stringify([params.harness, params.sessionId, params.event, params.occurrence ?? ""]);
486
+ if (this.hookKeys.has(key))
487
+ return [{ first: false }, undefined];
488
+ const ttl = Math.min(Math.max(Number(params.ttlMs ?? HOOK_ONCE_DEFAULT_TTL_MS) || HOOK_ONCE_DEFAULT_TTL_MS, 1_000), HOOK_ONCE_MAX_TTL_MS);
489
+ this.hookKeys.set(key, params.hold ? { expiresAt: Infinity, sock } : { expiresAt: now + ttl });
490
+ return [{ first: true }, undefined];
491
+ }
409
492
  case "admin_list":
410
493
  return [{ holders: m.diagnose((e) => jobAlive(e.pid, e.pgid, e.pidStartTime)), latestEventId: m.latestEventId() }, undefined];
411
494
  case "admin_release": {
@@ -863,3 +946,20 @@ export function processStartTime(pid) {
863
946
  return undefined;
864
947
  }
865
948
  }
949
+ /**
950
+ * Daemon entry point of the newer client's copy (issue #53), or null to
951
+ * fall back to the same-argv respawn. The hint must name a load-lock
952
+ * package root whose on-disk version equals the version the client
953
+ * claimed, with a dist/core/daemon-main.js. The hint arrives over the
954
+ * same-user socket, so following it grants no privilege the client did
955
+ * not already have (it could run that file itself).
956
+ */
957
+ export function restartEntry(clientRoot, clientVersion) {
958
+ if (!clientRoot)
959
+ return null;
960
+ const found = inspectPackageRoot(clientRoot);
961
+ if (!found || found.version !== clientVersion)
962
+ return null;
963
+ const entry = join(found.path, "dist", "core", "daemon-main.js");
964
+ return existsSync(entry) ? entry : null;
965
+ }
@@ -1,9 +1,12 @@
1
1
  import { MachineConfig, ConfigHistoryEntry, JournalEntry } from "../shared/protocol.js";
2
2
  export declare function stateDir(override?: string): string;
3
3
  export declare function socketPath(override?: string): string;
4
+ /** Format version this code writes into config.json and journal entries. */
5
+ export declare const STATE_FORMAT_VERSION = 1;
4
6
  export declare function loadState(dir: string): {
5
7
  config: MachineConfig;
6
8
  history: ConfigHistoryEntry[];
9
+ formatVersion: number;
7
10
  };
8
11
  export declare function saveState(dir: string, config: MachineConfig, history: ConfigHistoryEntry[]): void;
9
12
  export declare function journalPath(dir: string): string;