@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.
Files changed (66) hide show
  1. package/README.md +62 -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 +206 -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 +93 -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,4 +1,4 @@
1
- export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "repo-checkout" | "unknown";
1
+ export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-cache" | "npx-cache" | "repo-checkout" | "unknown";
2
2
  export interface SelfLocation {
3
3
  kind: SelfLocationKind;
4
4
  /** Package root of this copy. */
@@ -15,6 +15,8 @@ export interface ClassifyCtx {
15
15
  npmGlobalRoot: string | null;
16
16
  /** opencode cache root (XDG_CACHE_HOME | platform default); null when unknown. */
17
17
  cacheRoot: string | null;
18
+ /** npx cache root (<npm cache>/_npx); null/absent when unknown. */
19
+ npxRoot?: string | null;
18
20
  }
19
21
  /**
20
22
  * Classify where this copy of load-lock lives, BEFORE touching anything:
@@ -22,6 +24,8 @@ export interface ClassifyCtx {
22
24
  * - npm global (under `npm root -g`) → update via npm
23
25
  * - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
24
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
25
29
  * - repo checkout (nearest .git found by walking up, like the omac
26
30
  * read-grant logic) → refuse with a pointer to the git workflow
27
31
  * - anything else → unknown → refuse honestly rather than guessing
@@ -3,7 +3,7 @@
3
3
  * version (npm is the only distribution channel), classify where THIS copy
4
4
  * lives before touching anything, and apply the update via the detected
5
5
  * package manager — or refuse honestly (repo checkouts, opencode-managed
6
- * cache copies, unknown locations) rather than guessing. After a
6
+ * cache copies, npx cache copies, unknown locations) rather than guessing. After a
7
7
  * successful apply, `service restart` moves the daemon onto the new code
8
8
  * immediately; the v1.3.0 version handshake stays as the safety net for
9
9
  * daemons nobody explicitly restarts.
@@ -24,7 +24,7 @@ import { existsSync } from "node:fs";
24
24
  import { dirname, join, sep } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { compareVersions, packageVersion } from "../shared/version.js";
27
- import { opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
27
+ import { npxCacheRoot, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
28
28
  import { thisCopy } from "./copies.js";
29
29
  import { serviceRestart, ServiceError } from "./service.js";
30
30
  /** Path containment with separator awareness ("a" contains "a" and "a/b"). */
@@ -39,6 +39,8 @@ function isUnder(dir, root) {
39
39
  * - npm global (under `npm root -g`) → update via npm
40
40
  * - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
41
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
42
44
  * - repo checkout (nearest .git found by walking up, like the omac
43
45
  * read-grant logic) → refuse with a pointer to the git workflow
44
46
  * - anything else → unknown → refuse honestly rather than guessing
@@ -54,6 +56,9 @@ export function classifySelf(pkgRoot, ctx) {
54
56
  if (ctx.cacheRoot && isUnder(pkgRoot, join(ctx.cacheRoot, "opencode", "npm"))) {
55
57
  return { kind: "opencode-cache", path: pkgRoot, detail: `opencode-managed cache copy under ${ctx.cacheRoot}` };
56
58
  }
59
+ if (ctx.npxRoot && isUnder(pkgRoot, ctx.npxRoot)) {
60
+ return { kind: "npx-cache", path: pkgRoot, detail: `npx cache copy under ${ctx.npxRoot}` };
61
+ }
57
62
  for (let dir = pkgRoot;;) {
58
63
  if (existsSync(join(dir, ".git"))) {
59
64
  return { kind: "repo-checkout", path: pkgRoot, detail: `git checkout (.git at ${dir})` };
@@ -63,7 +68,7 @@ export function classifySelf(pkgRoot, ctx) {
63
68
  break;
64
69
  dir = parent;
65
70
  }
66
- return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode cache, or git checkout location" };
71
+ return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode/npx cache, or git checkout location" };
67
72
  }
68
73
  /** What to run for the detected location — or why we refuse to guess. */
69
74
  export function planUpdate(loc) {
@@ -78,6 +83,13 @@ export function planUpdate(loc) {
78
83
  reason: `this copy lives in opencode's npm cache (${loc.path}); opencode manages it and refreshes it on its own schedule — ` +
79
84
  `update the PATH-installed CLI instead (npm i -g ${PKG_NAME}@latest)`,
80
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
+ };
81
93
  case "repo-checkout":
82
94
  return {
83
95
  action: "refuse",
@@ -170,6 +182,7 @@ export async function updateCommand(opts) {
170
182
  home: process.env.HOME ?? homedir(),
171
183
  npmGlobalRoot: root,
172
184
  cacheRoot: opencodeCacheRoot(process.platform, process.env),
185
+ npxRoot: npxCacheRoot(process.platform, process.env),
173
186
  });
174
187
  // 2. Check the registry (npm is the only distribution channel).
175
188
  const latest = await viewLatest(timeoutMs);
@@ -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;
@@ -19,35 +19,73 @@ export function stateDir(override) {
19
19
  export function socketPath(override) {
20
20
  return resolveSocketPath(process.platform, process.env, override);
21
21
  }
22
+ // --------------------------------------------------------------------------
23
+ // Format version + compatibility (issue #51). Several runtime copies may share
24
+ // one state dir, so an OLDER daemon can load files written by a NEWER one
25
+ // (downgrade) and vice versa. The promise (docs/ARCHITECTURE.md):
26
+ // - format changes are additive; readers ignore what they don't understand;
27
+ // - unknown fields survive a rewrite (config.json: top level, inside
28
+ // `config`, inside pools; journal: per entry while the entry is live);
29
+ // - unknown journal entry kinds are skipped (reported), never fatal;
30
+ // - an incompatible change would use a NEW file name, never reinterpret
31
+ // these files.
32
+ // Files written before versioning carry no formatVersion (=> 0).
33
+ /** Format version this code writes into config.json and journal entries. */
34
+ export const STATE_FORMAT_VERSION = 1;
35
+ const KNOWN_STATE_KEYS = new Set(["formatVersion", "config", "history"]);
36
+ function isObject(v) {
37
+ return typeof v === "object" && v !== null && !Array.isArray(v);
38
+ }
22
39
  export function loadState(dir) {
23
40
  const path = join(dir, "config.json");
24
41
  if (!existsSync(path))
25
- return { config: defaultConfig(), history: [] };
42
+ return { config: defaultConfig(), history: [], formatVersion: STATE_FORMAT_VERSION };
26
43
  const raw = JSON.parse(readFileSync(path, "utf8"));
27
- const config = { ...defaultConfig(), ...raw.config };
44
+ // Unknown fields inside `config` ride along in memory and are written back
45
+ // by saveState (configure() mutates in place).
46
+ const config = { ...defaultConfig(), ...(isObject(raw?.config) ? raw.config : {}) };
28
47
  // Defensive: always at least the default pool.
29
- if (!config.pools || Object.keys(config.pools).length === 0)
48
+ if (!isObject(config.pools) || Object.keys(config.pools).length === 0)
30
49
  config.pools = { heavy: { capacity: 1 } };
31
- return { config, history: Array.isArray(raw.history) ? raw.history : [] };
50
+ const formatVersion = typeof raw?.formatVersion === "number" ? raw.formatVersion : 0;
51
+ return { config, history: Array.isArray(raw?.history) ? raw.history : [], formatVersion };
32
52
  }
33
53
  export function saveState(dir, config, history) {
34
54
  mkdirSync(dir, { recursive: true });
35
55
  const path = join(dir, "config.json");
56
+ // Preserve top-level fields written by a newer format (never rewrite
57
+ // destructively). Best-effort: an unreadable file has nothing to keep.
58
+ const extra = {};
59
+ try {
60
+ const prev = JSON.parse(readFileSync(path, "utf8"));
61
+ if (isObject(prev))
62
+ for (const [k, v] of Object.entries(prev))
63
+ if (!KNOWN_STATE_KEYS.has(k))
64
+ extra[k] = v;
65
+ }
66
+ catch {
67
+ /* missing or corrupt: nothing to preserve */
68
+ }
36
69
  const tmp = join(dirname(path), ".config.json.tmp-" + process.pid);
37
- writeFileSync(tmp, JSON.stringify({ config, history }, null, 2) + "\n", { mode: 0o644 });
70
+ writeFileSync(tmp, JSON.stringify({ formatVersion: STATE_FORMAT_VERSION, ...extra, config, history }, null, 2) + "\n", { mode: 0o644 });
38
71
  renameSync(tmp, path); // atomic on POSIX
39
72
  }
40
73
  // --------------------------------------------------------------------------
41
- // Journal: held permits, persisted so a daemon restart can conservatively
42
- // reconcile running ownership (tracked jobs keep their permit; confirmed-dead
43
- // jobs release; queued/offered requests are NOT journaled — agents re-acquire).
74
+ // Journal: held permits AND queued/offered requests, persisted so a daemon
75
+ // restart can conservatively reconcile running ownership (tracked jobs keep
76
+ // their permit; confirmed-dead jobs release) and re-queue waiters FIFO with
77
+ // the same requestIds (offered entries come back as queued; issue #34).
44
78
  export function journalPath(dir) {
45
79
  return join(dir, "journal.json");
46
80
  }
81
+ // The journal stays a bare JSON array (what every released daemon reads); the
82
+ // format version is stamped per entry. An object form `{ entries: [...] }` is
83
+ // accepted too, so a future format may wrap the array without breaking us.
47
84
  export function loadJournal(dir) {
48
85
  try {
49
86
  const raw = JSON.parse(readFileSync(journalPath(dir), "utf8"));
50
- return Array.isArray(raw) ? raw : [];
87
+ const list = Array.isArray(raw) ? raw : isObject(raw) && Array.isArray(raw.entries) ? raw.entries : [];
88
+ return list.filter(isObject);
51
89
  }
52
90
  catch {
53
91
  return [];
@@ -56,6 +94,7 @@ export function loadJournal(dir) {
56
94
  export function saveJournal(dir, entries) {
57
95
  const path = journalPath(dir);
58
96
  const tmp = join(dirname(path), ".journal.json.tmp-" + process.pid);
59
- writeFileSync(tmp, JSON.stringify(entries, null, 2) + "\n", { mode: 0o644 });
97
+ const stamped = entries.map((e) => ({ formatVersion: STATE_FORMAT_VERSION, ...e }));
98
+ writeFileSync(tmp, JSON.stringify(stamped, null, 2) + "\n", { mode: 0o644 });
60
99
  renameSync(tmp, path);
61
100
  }
@@ -56,6 +56,8 @@ interface Entry {
56
56
  leaseDeadline?: number;
57
57
  lastSeen?: number;
58
58
  enqueuedAt?: number;
59
+ /** Journal fields this version doesn't know (written by a newer daemon); written back as-is (issue #51). */
60
+ journalExtra?: Record<string, unknown>;
59
61
  }
60
62
  export declare class Machine {
61
63
  config: MachineConfig;
@@ -197,6 +199,12 @@ export declare class Machine {
197
199
  * alive => keep (same token, so a late explicit release still works).
198
200
  * - claimed: keep the start deadline (reclaimed by tick if already past).
199
201
  * - untracked: keep, with a FRESH lease window from restore time.
202
+ * - forward compatibility (issue #51): entries of an unknown `state`
203
+ * (written by a newer daemon) or missing required fields are skipped
204
+ * and reported, never fatal; a held entry with an unknown `phase` is
205
+ * kept as untracked with a fresh lease (capacity stays accounted, but
206
+ * the lease reclaims it if nobody heartbeats). Unknown fields are kept
207
+ * and written back to the journal while the entry lives.
200
208
  */
201
209
  restore(journal: JournalEntry[], liveness: (e: {
202
210
  pid?: number;
@@ -206,6 +214,7 @@ export declare class Machine {
206
214
  kept: number;
207
215
  releasedDead: number;
208
216
  queued: number;
217
+ skipped: number;
209
218
  };
210
219
  }
211
220
  export {};
@@ -48,6 +48,21 @@ export function defaultConfig() {
48
48
  guidance: {},
49
49
  };
50
50
  }
51
+ /** Journal entry fields this version understands; everything else is carried through (issue #51). */
52
+ const KNOWN_JOURNAL_KEYS = new Set([
53
+ "formatVersion", "requestId", "pool", "agentId", "agentLabel", "cwd", "opKey", "reason", "weight",
54
+ "permitToken", "phase", "pid", "pgid", "pidStartTime", "claimedAt", "startedAt", "leaseDeadline", "state", "enqueuedAt",
55
+ ]);
56
+ const KNOWN_PHASES = new Set(["claimed", "running_tracked", "running_untracked"]);
57
+ function journalExtra(j) {
58
+ let out;
59
+ for (const [k, v] of Object.entries(j)) {
60
+ if (KNOWN_JOURNAL_KEYS.has(k))
61
+ continue;
62
+ (out ??= {})[k] = v;
63
+ }
64
+ return out;
65
+ }
51
66
  const rid = () => "req_" + randomBytes(12).toString("hex");
52
67
  const tok = (p) => p + "_" + randomBytes(16).toString("hex");
53
68
  export class Machine {
@@ -793,8 +808,9 @@ export class Machine {
793
808
  }
794
809
  this.config.version += 1;
795
810
  if (c.pools) {
811
+ // Spread the previous pool config so fields from a newer format survive (issue #51).
796
812
  for (const [name, pc] of Object.entries(c.pools))
797
- this.config.pools[name] = { capacity: pc.capacity };
813
+ this.config.pools[name] = { ...this.config.pools[name], capacity: pc.capacity };
798
814
  }
799
815
  if (c.offerTimeoutMs !== undefined)
800
816
  this.config.offerTimeoutMs = c.offerTimeoutMs;
@@ -853,6 +869,7 @@ export class Machine {
853
869
  if (e.state !== "held")
854
870
  continue;
855
871
  out.push({
872
+ ...e.journalExtra,
856
873
  requestId: e.id,
857
874
  pool: e.pool,
858
875
  agentId: e.agentId,
@@ -876,6 +893,7 @@ export class Machine {
876
893
  if (e.state !== "queued" && e.state !== "offered")
877
894
  continue;
878
895
  out.push({
896
+ ...e.journalExtra,
879
897
  requestId: e.id,
880
898
  pool: e.pool,
881
899
  agentId: e.agentId,
@@ -901,12 +919,27 @@ export class Machine {
901
919
  * alive => keep (same token, so a late explicit release still works).
902
920
  * - claimed: keep the start deadline (reclaimed by tick if already past).
903
921
  * - untracked: keep, with a FRESH lease window from restore time.
922
+ * - forward compatibility (issue #51): entries of an unknown `state`
923
+ * (written by a newer daemon) or missing required fields are skipped
924
+ * and reported, never fatal; a held entry with an unknown `phase` is
925
+ * kept as untracked with a fresh lease (capacity stays accounted, but
926
+ * the lease reclaims it if nobody heartbeats). Unknown fields are kept
927
+ * and written back to the journal while the entry lives.
904
928
  */
905
929
  restore(journal, liveness) {
906
930
  let kept = 0;
907
931
  let releasedDead = 0;
932
+ let skipped = 0;
908
933
  const queuedEntries = [];
909
934
  for (const j of journal) {
935
+ const st = j.state;
936
+ if ((st !== undefined && st !== "held" && st !== "queued") ||
937
+ typeof j.requestId !== "string" ||
938
+ typeof j.pool !== "string" ||
939
+ typeof j.agentId !== "string") {
940
+ skipped++;
941
+ continue;
942
+ }
910
943
  if (j.state === "queued") {
911
944
  queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
912
945
  continue;
@@ -929,7 +962,10 @@ export class Machine {
929
962
  pidStartTime: j.pidStartTime,
930
963
  startedAt: j.startedAt,
931
964
  lastSeen: this.now(),
965
+ journalExtra: journalExtra(j),
932
966
  };
967
+ if (!KNOWN_PHASES.has(e.phase))
968
+ e.phase = "running_untracked";
933
969
  if (e.phase === "claimed") {
934
970
  e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
935
971
  }
@@ -974,6 +1010,7 @@ export class Machine {
974
1010
  state: "queued",
975
1011
  since: this.now(),
976
1012
  enqueuedAt: j.enqueuedAt ?? j.claimedAt,
1013
+ journalExtra: journalExtra(j),
977
1014
  };
978
1015
  this.entries.set(e.id, e);
979
1016
  this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
@@ -988,8 +1025,9 @@ export class Machine {
988
1025
  this.pushEvent({
989
1026
  type: "daemon_recovered",
990
1027
  targetAgent: "*",
991
- detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)`,
1028
+ detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)` +
1029
+ (skipped ? `; skipped ${skipped} unrecognized journal entr${skipped === 1 ? "y" : "ies"} (newer format?)` : ""),
992
1030
  });
993
- return { kept, releasedDead, queued };
1031
+ return { kept, releasedDead, queued, skipped };
994
1032
  }
995
1033
  }
@@ -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