@aron98/load-lock 1.2.1 → 1.3.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.
@@ -20,11 +20,21 @@ import { createServer } from "node:net";
20
20
  import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
21
21
  import { readdirSync } from "node:fs";
22
22
  import { join } from "node:path";
23
+ import { spawn } from "node:child_process";
23
24
  import { Machine } from "./state.js";
24
25
  import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
25
26
  import { inspectMachine } from "./sysinfo.js";
26
27
  import { ProtocolError } from "../shared/protocol.js";
27
28
  import { isWindows, probeTransport } from "../shared/transport.js";
29
+ import { packageVersion, compareVersions } from "../shared/version.js";
30
+ /** Package version of the copy of load-lock this daemon process runs from. */
31
+ const DAEMON_VERSION = packageVersion();
32
+ /**
33
+ * Env marker set on the respawned daemon: the version this process already
34
+ * restarted for. Prevents restart loops when a newer client pings twice —
35
+ * only one respawn per version bump.
36
+ */
37
+ const RESTARTED_FOR_ENV = "LOAD_LOCK_RESTARTED_FOR";
28
38
  export class Daemon {
29
39
  machine;
30
40
  server = null;
@@ -39,6 +49,7 @@ export class Daemon {
39
49
  socketPath;
40
50
  sweepTimer = null;
41
51
  expiryTimer = null;
52
+ restartTimer = null;
42
53
  stopped = false;
43
54
  constructor(opts = {}) {
44
55
  this.stateDirPath = defaultStateDir(opts.stateDir);
@@ -108,6 +119,8 @@ export class Daemon {
108
119
  clearInterval(this.sweepTimer);
109
120
  if (this.expiryTimer)
110
121
  clearTimeout(this.expiryTimer);
122
+ if (this.restartTimer)
123
+ clearTimeout(this.restartTimer);
111
124
  for (const w of this.watchers)
112
125
  w.socket.destroy();
113
126
  this.watchers.clear();
@@ -171,6 +184,8 @@ export class Daemon {
171
184
  this.send(sock, { id: null, ok: false, error: { code: "invalid", message: "malformed JSON line" } });
172
185
  return;
173
186
  }
187
+ // Version handshake: remember the client's version for skew detection.
188
+ const clientVersion = typeof msg.clientVersion === "string" ? msg.clientVersion : undefined;
174
189
  let result;
175
190
  let agentId;
176
191
  try {
@@ -187,6 +202,9 @@ export class Daemon {
187
202
  else {
188
203
  this.send(sock, { id: msg.id, ok: false, error: { code: "internal", message: String(err?.message ?? err) } });
189
204
  }
205
+ // Respond first, then consider a self-restart — even on errors, a
206
+ // newer client means this daemon is running stale on-disk code.
207
+ this.maybeScheduleSelfRestart(clientVersion);
190
208
  return;
191
209
  }
192
210
  // Tool-boundary delivery: surface this agent's outstanding offers/queue
@@ -195,6 +213,63 @@ export class Daemon {
195
213
  result.pending = this.machine.pending(agentId);
196
214
  }
197
215
  this.send(sock, { id: msg.id, ok: true, result });
216
+ this.maybeScheduleSelfRestart(clientVersion);
217
+ }
218
+ // -------------------------------------------------------- version handshake
219
+ /**
220
+ * Self-restart handshake (issue #34): if a client identifies a NEWER
221
+ * package version than this daemon runs, schedule a respawn so the daemon
222
+ * picks up the updated on-disk code (e.g. after `npm i -g
223
+ * @aron98/load-lock` while the daemon kept running). Skew detection is
224
+ * entirely daemon-side and best-effort: pre-handshake clients that send
225
+ * no version never trigger a restart.
226
+ */
227
+ maybeScheduleSelfRestart(clientVersion) {
228
+ if (this.stopped || this.restartTimer || !clientVersion)
229
+ return;
230
+ if (compareVersions(clientVersion, DAEMON_VERSION) <= 0)
231
+ return;
232
+ const restartedFor = process.env[RESTARTED_FOR_ENV];
233
+ if (restartedFor && compareVersions(clientVersion, restartedFor) <= 0)
234
+ return; // already tried
235
+ this.restartTimer = setTimeout(() => {
236
+ this.restartTimer = null;
237
+ this.selfRestart(clientVersion);
238
+ }, 250);
239
+ this.restartTimer.unref?.();
240
+ }
241
+ /**
242
+ * Drain + respawn onto the updated on-disk code. Persists state and the
243
+ * journal FIRST, then stops (destroying all client connections — they
244
+ * reconnect to the fresh daemon), then spawns the replacement with the
245
+ * exact same argv so a daemon started via the old copy runs the new copy's
246
+ * code, and exits.
247
+ */
248
+ selfRestart(clientVersion) {
249
+ if (this.stopped)
250
+ return;
251
+ // Best-effort persistence; a failed save must not block the restart.
252
+ try {
253
+ saveState(this.stateDirPath, this.machine.config, this.machine.history);
254
+ saveJournal(this.stateDirPath, this.machine.journalEntries());
255
+ }
256
+ catch {
257
+ /* journal is best-effort */
258
+ }
259
+ this.stop();
260
+ try {
261
+ const child = spawn(process.execPath, process.argv.slice(1), {
262
+ detached: true,
263
+ stdio: "ignore",
264
+ cwd: process.cwd(),
265
+ env: { ...process.env, [RESTARTED_FOR_ENV]: clientVersion },
266
+ });
267
+ child.unref();
268
+ }
269
+ catch {
270
+ /* if the respawn fails the next client contact re-triggers it */
271
+ }
272
+ process.exit(0);
198
273
  }
199
274
  // --------------------------------------------------------------- dispatch
200
275
  /** Returns [result, agentIdForPendingAttachment]. */
@@ -202,7 +277,10 @@ export class Daemon {
202
277
  const m = this.machine;
203
278
  switch (method) {
204
279
  case "hello":
205
- return [{ protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version }, undefined];
280
+ return [
281
+ { protocolVersion: 1, pid: process.pid, startedAt: m.startedAt, configVersion: m.config.version, daemonVersion: DAEMON_VERSION },
282
+ undefined,
283
+ ];
206
284
  case "acquire": {
207
285
  const params = p;
208
286
  let res = m.acquire(params);
@@ -265,8 +343,11 @@ export class Daemon {
265
343
  const res = m.pending(String(p.agentId));
266
344
  return [res, String(p.agentId)];
267
345
  }
268
- case "status":
269
- return [m.status(p.pool ? String(p.pool) : undefined), undefined];
346
+ case "status": {
347
+ const res = m.status(p.pool ? String(p.pool) : undefined);
348
+ res.daemon.daemonVersion = DAEMON_VERSION;
349
+ return [res, undefined];
350
+ }
270
351
  case "get_config":
271
352
  return [m.get_config(), undefined];
272
353
  case "configure": {
@@ -3,9 +3,11 @@
3
3
  * State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
4
4
  * Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
5
5
  *
6
- * Only *configuration* persists across daemon restarts. Permits and queue
7
- * are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
8
- * tokens are daemon-instance-scoped, and agents re-acquire after a restart.
6
+ * Configuration persists across daemon restarts, plus the journal
7
+ * (journal.json): held permits AND queued/offered requests. Held permits
8
+ * are restored conservatively (tracked jobs re-checked for liveness, same
9
+ * permit tokens so late releases stay idempotent); queued requests are
10
+ * re-queued FIFO with the same requestIds (see docs/ARCHITECTURE.md).
9
11
  */
10
12
  import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
11
13
  import { dirname, join } from "node:path";
@@ -180,16 +180,18 @@ export declare class Machine {
180
180
  get_config(): GetConfigResult;
181
181
  configure(p: ConfigureParams): ConfigureResult;
182
182
  history: ConfigHistoryEntry[];
183
- /** Held permits to persist for conservative restart reconciliation. */
183
+ /** Held permits (first) and queued/offered requests to persist across restarts. */
184
184
  journalEntries(): JournalEntry[];
185
185
  /**
186
- * Restore held permits after a daemon restart (conservative reconciliation):
186
+ * Restore state after a daemon restart (conservative reconciliation):
187
+ * - queued (incl. previously offered): re-queued FIFO by original enqueue
188
+ * time, same requestIds so idempotent re-acquire still works; pools no
189
+ * longer in the config are dropped. Offered entries restart as queued
190
+ * and get a fresh offer (old offer tokens are invalid after restart).
187
191
  * - tracked: re-check liveness via the supplied probe; dead => release,
188
192
  * alive => keep (same token, so a late explicit release still works).
189
193
  * - claimed: keep the start deadline (reclaimed by tick if already past).
190
194
  * - untracked: keep, with a FRESH lease window from restore time.
191
- * Queued/offered requests are deliberately NOT restored — queued agents
192
- * re-acquire (documented restart behavior).
193
195
  */
194
196
  restore(journal: JournalEntry[], liveness: (e: {
195
197
  pid?: number;
@@ -198,6 +200,7 @@ export declare class Machine {
198
200
  }) => boolean | null): {
199
201
  kept: number;
200
202
  releasedDead: number;
203
+ queued: number;
201
204
  };
202
205
  }
203
206
  export {};
@@ -830,7 +830,7 @@ export class Machine {
830
830
  }
831
831
  history = [];
832
832
  // ----------------------------------------------------------------- journal
833
- /** Held permits to persist for conservative restart reconciliation. */
833
+ /** Held permits (first) and queued/offered requests to persist across restarts. */
834
834
  journalEntries() {
835
835
  const out = [];
836
836
  for (const e of this.entries.values()) {
@@ -845,6 +845,7 @@ export class Machine {
845
845
  opKey: e.opKey,
846
846
  reason: e.reason,
847
847
  weight: e.weight,
848
+ state: "held",
848
849
  permitToken: e.permitToken,
849
850
  phase: e.phase ?? "claimed",
850
851
  pid: e.pid,
@@ -855,21 +856,45 @@ export class Machine {
855
856
  leaseDeadline: e.leaseDeadline,
856
857
  });
857
858
  }
859
+ for (const e of this.entries.values()) {
860
+ if (e.state !== "queued" && e.state !== "offered")
861
+ continue;
862
+ out.push({
863
+ requestId: e.id,
864
+ pool: e.pool,
865
+ agentId: e.agentId,
866
+ agentLabel: e.agentLabel,
867
+ cwd: e.cwd,
868
+ opKey: e.opKey,
869
+ reason: e.reason,
870
+ weight: e.weight,
871
+ state: "queued",
872
+ enqueuedAt: e.enqueuedAt ?? e.since,
873
+ claimedAt: e.since,
874
+ });
875
+ }
858
876
  return out;
859
877
  }
860
878
  /**
861
- * Restore held permits after a daemon restart (conservative reconciliation):
879
+ * Restore state after a daemon restart (conservative reconciliation):
880
+ * - queued (incl. previously offered): re-queued FIFO by original enqueue
881
+ * time, same requestIds so idempotent re-acquire still works; pools no
882
+ * longer in the config are dropped. Offered entries restart as queued
883
+ * and get a fresh offer (old offer tokens are invalid after restart).
862
884
  * - tracked: re-check liveness via the supplied probe; dead => release,
863
885
  * alive => keep (same token, so a late explicit release still works).
864
886
  * - claimed: keep the start deadline (reclaimed by tick if already past).
865
887
  * - untracked: keep, with a FRESH lease window from restore time.
866
- * Queued/offered requests are deliberately NOT restored — queued agents
867
- * re-acquire (documented restart behavior).
868
888
  */
869
889
  restore(journal, liveness) {
870
890
  let kept = 0;
871
891
  let releasedDead = 0;
892
+ const queuedEntries = [];
872
893
  for (const j of journal) {
894
+ if (j.state === "queued") {
895
+ queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
896
+ continue;
897
+ }
873
898
  const e = {
874
899
  id: j.requestId,
875
900
  pool: j.pool,
@@ -882,20 +907,20 @@ export class Machine {
882
907
  state: "held",
883
908
  since: j.claimedAt,
884
909
  permitToken: j.permitToken,
885
- phase: j.phase,
910
+ phase: j.phase ?? "claimed",
886
911
  pid: j.pid,
887
912
  pgid: j.pgid,
888
913
  pidStartTime: j.pidStartTime,
889
914
  startedAt: j.startedAt,
890
915
  lastSeen: this.now(),
891
916
  };
892
- if (j.phase === "claimed") {
917
+ if (e.phase === "claimed") {
893
918
  e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
894
919
  }
895
- else if (j.phase === "running_untracked") {
920
+ else if (e.phase === "running_untracked") {
896
921
  e.leaseDeadline = this.now() + this.config.untrackedLeaseMs; // fresh window
897
922
  }
898
- else if (j.phase === "running_tracked") {
923
+ else if (e.phase === "running_tracked") {
899
924
  const alive = liveness(e);
900
925
  if (alive === false) {
901
926
  // Confirmed dead while we were down: release, but keep a terminal
@@ -915,11 +940,40 @@ export class Machine {
915
940
  this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
916
941
  kept++;
917
942
  }
943
+ // Re-queue queued requests, oldest first, preserving FIFO fairness.
944
+ queuedEntries.sort((a, b) => a.at - b.at);
945
+ let queued = 0;
946
+ for (const { j } of queuedEntries) {
947
+ if (!this.config.pools[j.pool])
948
+ continue; // pool removed while down
949
+ const e = {
950
+ id: j.requestId,
951
+ pool: j.pool,
952
+ agentId: j.agentId,
953
+ agentLabel: j.agentLabel,
954
+ cwd: j.cwd,
955
+ opKey: j.opKey,
956
+ reason: j.reason,
957
+ weight: j.weight,
958
+ state: "queued",
959
+ since: this.now(),
960
+ enqueuedAt: j.enqueuedAt ?? j.claimedAt,
961
+ };
962
+ this.entries.set(e.id, e);
963
+ this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
964
+ if (!this.queues.has(e.pool))
965
+ this.queues.set(e.pool, []);
966
+ this.queues.get(e.pool).push(e.id);
967
+ queued++;
968
+ }
969
+ // Admit newly-eligible waiters (capacity freed while we were down).
970
+ for (const name of this.poolNames())
971
+ this.admit(name);
918
972
  this.pushEvent({
919
973
  type: "daemon_recovered",
920
974
  targetAgent: "*",
921
- detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released)`,
975
+ detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)`,
922
976
  });
923
- return { kept, releasedDead };
977
+ return { kept, releasedDead, queued };
924
978
  }
925
979
  }
@@ -59,13 +59,17 @@ export function renderFrame(view, width = 100, height = 40) {
59
59
  const syncAge = view.lastSyncAt ? fmtDuration(Math.max(0, view.now - view.lastSyncAt)) : "never";
60
60
  push(`load-lock dashboard — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
61
61
  if (view.daemon) {
62
- push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion} · events through #${view.latestEventId}`);
62
+ push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion}` +
63
+ (view.daemon.daemonVersion ? ` · daemon v${view.daemon.daemonVersion}` : "") +
64
+ ` · events through #${view.latestEventId}`);
63
65
  }
64
66
  else {
65
67
  push(`daemon: unreachable — it auto-spawns on the next load-lock command`);
66
68
  }
67
69
  if (view.daemonRestartNote)
68
70
  push(`note: ${view.daemonRestartNote}`);
71
+ if (view.versionSkewNote)
72
+ push(`note: ${view.versionSkewNote}`);
69
73
  if (view.machine) {
70
74
  const m = view.machine;
71
75
  const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
@@ -105,6 +105,8 @@ export interface DaemonView {
105
105
  uptimeMs: number;
106
106
  protocolVersion: number;
107
107
  configVersion: number;
108
+ /** Package version of the daemon's code (pre-handshake daemons omit it). */
109
+ daemonVersion?: string;
108
110
  }
109
111
  /** The complete render-ready snapshot of the machine-wide budget. */
110
112
  export interface DashboardView {
@@ -112,6 +114,8 @@ export interface DashboardView {
112
114
  state: FeedState;
113
115
  /** False right after a daemon restart was detected (history was reset). */
114
116
  daemonRestartNote?: string;
117
+ /** Set when the running daemon is older than the installed copy. */
118
+ versionSkewNote?: string;
115
119
  daemon: DaemonView | null;
116
120
  pools: PoolView[];
117
121
  events: EventLine[];
@@ -1,3 +1,4 @@
1
+ import { packageVersion, compareVersions } from "../shared/version.js";
1
2
  // ---------------------------------------------------------------- formatters
2
3
  /** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
3
4
  export function fmtDuration(ms) {
@@ -189,6 +190,9 @@ export function buildDashboardView(inps) {
189
190
  now,
190
191
  state: inps.state,
191
192
  daemonRestartNote: inps.daemonRestartNote,
193
+ versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
194
+ ? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
195
+ : undefined,
192
196
  daemon: d
193
197
  ? {
194
198
  pid: d.pid,
@@ -196,6 +200,7 @@ export function buildDashboardView(inps) {
196
200
  uptimeMs: now - d.startedAt,
197
201
  protocolVersion: d.protocolVersion,
198
202
  configVersion: inps.status?.config.version ?? 0,
203
+ daemonVersion: d.daemonVersion,
199
204
  }
200
205
  : null,
201
206
  pools,
@@ -29,6 +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
33
  export class LLClient {
33
34
  sock = null;
34
35
  nextId = 1;
@@ -170,7 +171,9 @@ export class LLClient {
170
171
  },
171
172
  });
172
173
  const sock = this.ensureSock();
173
- sock.write(JSON.stringify({ id, method, params }) + "\n");
174
+ // clientVersion: lets the daemon detect version skew and self-restart
175
+ // onto newer on-disk code (pre-handshake daemons ignore the field).
176
+ sock.write(JSON.stringify({ id, method, params, clientVersion: packageVersion() }) + "\n");
174
177
  });
175
178
  }
176
179
  /** Subscribe to daemon events targeted at agentId (or "*" for all). */
@@ -6,6 +6,11 @@
6
6
  * receive unsolicited event lines.
7
7
  *
8
8
  * Request: {"id": <number>, "method": "<name>", "params": {...}}
9
+ * Newer clients also stamp the envelope with their package
10
+ * version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
11
+ * the extra field (forward-compatible); newer daemons use it
12
+ * for the update handshake: a client strictly newer than the
13
+ * running daemon triggers a daemon self-restart (issue #34).
9
14
  * Response: {"id": <number>, "ok": true, "result": {...}}
10
15
  * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
11
16
  * Watch push: {"event": true, ...event fields...}
@@ -140,14 +145,40 @@ export interface JournalEntry {
140
145
  opKey: string;
141
146
  reason: string;
142
147
  weight: number;
143
- permitToken: string;
144
- phase: PermitPhase;
148
+ /** Held permits only; queued entries carry no token. */
149
+ permitToken?: string;
150
+ /** Held permits only. */
151
+ phase?: PermitPhase;
145
152
  pid?: number;
146
153
  pgid?: number;
147
154
  pidStartTime?: string;
155
+ /** When the permit was claimed (held) / the request was journaled. */
148
156
  claimedAt: number;
149
157
  startedAt?: number;
150
158
  leaseDeadline?: number;
159
+ /**
160
+ * Journaled lifecycle state (issue #34): "held" (permit — liveness is
161
+ * reconciled on restore) or "queued" (FIFO position preserved across
162
+ * restarts/self-restarts; offered entries are journaled as queued and
163
+ * re-offered with a fresh token after the restart). Absent in journals
164
+ * written before the update story => "held" (the original format).
165
+ */
166
+ state?: "held" | "queued";
167
+ /** Queue insertion time (queued only) — restores FIFO order. */
168
+ enqueuedAt?: number;
169
+ }
170
+ /**
171
+ * Result of `hello`. `daemonVersion` is the running daemon's package
172
+ * version. OPTIONAL in responses from daemons older than the handshake —
173
+ * clients must treat a missing value as "unknown (old daemon)" and warn
174
+ * instead of failing.
175
+ */
176
+ export interface HelloResult {
177
+ protocolVersion: number;
178
+ pid: number;
179
+ startedAt: number;
180
+ configVersion: number;
181
+ daemonVersion?: string;
151
182
  }
152
183
  export interface PoolStatus {
153
184
  pool: string;
@@ -163,6 +194,13 @@ export interface StatusResult {
163
194
  pid: number;
164
195
  startedAt: number;
165
196
  protocolVersion: number;
197
+ /**
198
+ * Running daemon's package version (update handshake, issue #34).
199
+ * Optional because daemons older than the handshake never send it —
200
+ * clients must treat a missing value as "unknown, old daemon" and
201
+ * warn rather than fail.
202
+ */
203
+ daemonVersion?: string;
166
204
  };
167
205
  config: {
168
206
  version: number;
@@ -6,6 +6,11 @@
6
6
  * receive unsolicited event lines.
7
7
  *
8
8
  * Request: {"id": <number>, "method": "<name>", "params": {...}}
9
+ * Newer clients also stamp the envelope with their package
10
+ * version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
11
+ * the extra field (forward-compatible); newer daemons use it
12
+ * for the update handshake: a client strictly newer than the
13
+ * running daemon triggers a daemon self-restart (issue #34).
9
14
  * Response: {"id": <number>, "ok": true, "result": {...}}
10
15
  * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
11
16
  * Watch push: {"event": true, ...event fields...}
@@ -0,0 +1,10 @@
1
+ /** Version of THIS copy of load-lock (the on-disk install running now). */
2
+ export declare function packageVersion(): string;
3
+ /**
4
+ * Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
5
+ * segments compared left to right, missing segments as 0, non-numeric
6
+ * prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
7
+ * Unparseable input never throws — it compares as 0.0.0, so the daemon
8
+ * never acts (restarts) on a garbage version.
9
+ */
10
+ export declare function compareVersions(a: string, b: string): number;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Package-version plumbing for the update story (issue #34).
3
+ *
4
+ * Every load-lock process — daemon, CLI, MCP server, plugin — learns its
5
+ * own on-disk package version by reading the package.json that ships next
6
+ * to its compiled dist/ tree. That version takes part in the client→daemon
7
+ * handshake: clients stamp every request envelope with it
8
+ * (`clientVersion`), the daemon stamps every hello with it
9
+ * (`daemonVersion`), and a strictly-newer client triggers a daemon
10
+ * self-restart (see core/daemon.ts).
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ let cached;
16
+ /** Version of THIS copy of load-lock (the on-disk install running now). */
17
+ export function packageVersion() {
18
+ if (cached === undefined) {
19
+ try {
20
+ // dist/shared/version.js → <package root>/package.json — resolves the
21
+ // same way in the repo checkout, a global CLI install, and an
22
+ // opencode cache copy. npm always ships package.json with the tarball.
23
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
24
+ cached = String(JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0");
25
+ }
26
+ catch {
27
+ cached = "0.0.0";
28
+ }
29
+ }
30
+ return cached;
31
+ }
32
+ /**
33
+ * Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
34
+ * segments compared left to right, missing segments as 0, non-numeric
35
+ * prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
36
+ * Unparseable input never throws — it compares as 0.0.0, so the daemon
37
+ * never acts (restarts) on a garbage version.
38
+ */
39
+ export function compareVersions(a, b) {
40
+ const pa = parse(a);
41
+ const pb = parse(b);
42
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
43
+ const x = pa[i] ?? 0;
44
+ const y = pb[i] ?? 0;
45
+ if (x !== y)
46
+ return x < y ? -1 : 1;
47
+ }
48
+ return 0;
49
+ }
50
+ function parse(v) {
51
+ return v
52
+ .split(".")
53
+ .map((seg) => {
54
+ const m = /^(\d+)/.exec(seg.trim());
55
+ return m ? Number(m[1]) : 0;
56
+ });
57
+ }
@@ -0,0 +1 @@
1
+ export {};