@aron98/load-lock 1.3.0 → 1.5.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 (47) hide show
  1. package/dist/adapters/opencode/plugin.d.ts +2 -1
  2. package/dist/adapters/opencode/plugin.js +100 -6
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +1 -9
  5. package/dist/cli/copies.js +2 -43
  6. package/dist/cli/install.js +1 -1
  7. package/dist/cli/main.js +82 -217
  8. package/dist/cli/service.d.ts +56 -0
  9. package/dist/cli/service.js +261 -0
  10. package/dist/cli/update.d.ts +53 -0
  11. package/dist/cli/update.js +274 -0
  12. package/dist/core/daemon.d.ts +24 -0
  13. package/dist/core/daemon.js +221 -0
  14. package/dist/core/state.d.ts +5 -0
  15. package/dist/core/state.js +16 -0
  16. package/dist/dashboard/feed.d.ts +23 -0
  17. package/dist/dashboard/feed.js +64 -2
  18. package/dist/dashboard/frame.js +2 -0
  19. package/dist/dashboard/view.d.ts +11 -0
  20. package/dist/dashboard/view.js +13 -0
  21. package/dist/mcp/server.d.ts +7 -0
  22. package/dist/mcp/server.js +117 -8
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +6 -0
  25. package/dist/shared/client.js +11 -1
  26. package/dist/shared/drift.d.ts +21 -0
  27. package/dist/shared/drift.js +77 -0
  28. package/dist/shared/installs.d.ts +39 -0
  29. package/dist/shared/installs.js +101 -0
  30. package/dist/shared/pkginfo.d.ts +16 -0
  31. package/dist/shared/pkginfo.js +54 -0
  32. package/dist/shared/protocol.d.ts +32 -0
  33. package/dist/shared/wait-completion.d.ts +67 -0
  34. package/dist/shared/wait-completion.js +505 -0
  35. package/dist/test/mcp.test.js +115 -1
  36. package/dist/test/reconnect.test.d.ts +1 -0
  37. package/dist/test/reconnect.test.js +389 -0
  38. package/dist/test/update-command.test.d.ts +1 -0
  39. package/dist/test/update-command.test.js +608 -0
  40. package/dist/test/wait-completion.test.d.ts +1 -0
  41. package/dist/test/wait-completion.test.js +418 -0
  42. package/docs/ADAPTERS.md +3 -2
  43. package/docs/AGENT_GUIDE.md +61 -3
  44. package/docs/ARCHITECTURE.md +15 -0
  45. package/docs/COMPATIBILITY.md +16 -2
  46. package/docs/OPERATIONS.md +118 -9
  47. package/package.json +1 -1
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `load-lock service start|stop|restart|status` (issue #38): daemon
3
+ * lifecycle, extracted from main.ts so `service restart` and
4
+ * `load-lock update` (which restarts after applying) compose the same
5
+ * hardened stop + honest start paths instead of duplicating them.
6
+ *
7
+ * - stop (hardened): SIGTERM → bounded wait → SIGKILL → verify the
8
+ * socket is actually free; crash leftovers cleaned either way.
9
+ * - start (honest): auto-spawn via connect, hello confirmation;
10
+ * refusals name the conflicting pid.
11
+ * - restart: stop then start, reporting both phases; any failure names
12
+ * the phase and reason.
13
+ *
14
+ * Failures throw ServiceError (message + exit code); callers decide how
15
+ * to exit so composition can annotate ("restart failed in the STOP
16
+ * phase: ...").
17
+ */
18
+ import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { connect } from "node:net";
21
+ import { setTimeout as delay } from "node:timers/promises";
22
+ import { withClient } from "../shared/client.js";
23
+ import { isWindows, probeTransport } from "../shared/transport.js";
24
+ import { compareVersions, packageVersion } from "../shared/version.js";
25
+ import { stateDir, socketPath } from "../core/persist.js";
26
+ /** A service-phase failure with the exit code the CLI should use. */
27
+ export class ServiceError extends Error {
28
+ exitCode;
29
+ constructor(message, exitCode = 70) {
30
+ super(message);
31
+ this.exitCode = exitCode;
32
+ }
33
+ }
34
+ /** daemon.json contents, or null when missing/unreadable. */
35
+ export function readDaemonInfo(dir) {
36
+ try {
37
+ return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Version-less hello over a raw socket connection: deliberately does NOT
45
+ * send clientVersion, so asking a daemon for its identity never orders it
46
+ * to self-restart (used by `service stop` and post-restart confirmation).
47
+ * Best-effort: resolves null on any failure.
48
+ */
49
+ export function rawHello(sockPath, timeoutMs = 2000) {
50
+ return new Promise((resolve) => {
51
+ let settled = false;
52
+ let buf = "";
53
+ const done = (v) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ try {
58
+ sock.destroy();
59
+ }
60
+ catch {
61
+ /* ignore */
62
+ }
63
+ resolve(v);
64
+ };
65
+ const sock = connect(sockPath, () => {
66
+ sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
67
+ });
68
+ sock.on("data", (chunk) => {
69
+ buf += chunk.toString("utf8");
70
+ const idx = buf.indexOf("\n");
71
+ if (idx === -1)
72
+ return;
73
+ try {
74
+ const msg = JSON.parse(buf.slice(0, idx));
75
+ done(msg.ok ? msg.result : null);
76
+ }
77
+ catch {
78
+ done(null);
79
+ }
80
+ });
81
+ sock.on("error", () => done(null));
82
+ const timer = setTimeout(() => done(null), timeoutMs);
83
+ timer.unref?.();
84
+ });
85
+ }
86
+ /** Poll the socket until nothing answers there (bounded). */
87
+ async function waitSocketFree(sockPath, timeoutMs) {
88
+ const deadline = Date.now() + Math.max(0, timeoutMs);
89
+ for (;;) {
90
+ if (!(await probeTransport(sockPath)))
91
+ return true;
92
+ if (Date.now() >= deadline)
93
+ return false;
94
+ await delay(100);
95
+ }
96
+ }
97
+ /**
98
+ * After ordering a self-restart, wait for the daemon to come back running
99
+ * at least `installed` (the version of this CLI copy). Resolves its hello
100
+ * result, or null if it did not come back (or an older/pre-handshake
101
+ * daemon answered again — e.g. the daemon's copy is not actually updated).
102
+ */
103
+ export async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
104
+ const deadline = Date.now() + Math.max(0, timeoutMs);
105
+ for (;;) {
106
+ const h = await rawHello(sockPath, 1500);
107
+ if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
108
+ return h;
109
+ if (Date.now() >= deadline)
110
+ return null;
111
+ await delay(250);
112
+ }
113
+ }
114
+ /**
115
+ * Hardened stop: signal the daemon, wait bounded for its socket to go
116
+ * silent, escalate to SIGKILL, and only report success once the endpoint
117
+ * is verifiably free. A leftover silent socket file or daemon.json (crash
118
+ * leftovers) is cleaned up either way. Returns the report line; throws
119
+ * ServiceError on failure.
120
+ */
121
+ export async function serviceStop(timeoutMs) {
122
+ const dir = stateDir();
123
+ const sock = socketPath();
124
+ const info = readDaemonInfo(dir);
125
+ const answering = await probeTransport(sock);
126
+ if (!answering) {
127
+ // Nothing is listening: no daemon to stop. Clean crash leftovers.
128
+ try {
129
+ if (!isWindows() && existsSync(sock))
130
+ unlinkSync(sock);
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ try {
136
+ rmSync(join(dir, "daemon.json"), { force: true });
137
+ }
138
+ catch {
139
+ /* ignore */
140
+ }
141
+ return "no running daemon found (stale files cleaned)";
142
+ }
143
+ // Identify the daemon: prefer daemon.json, else ask it directly
144
+ // (version-less hello — stopping must not order a self-restart).
145
+ let pid = typeof info?.pid === "number" ? info.pid : undefined;
146
+ if (!pid) {
147
+ const hello = await rawHello(sock, 1500);
148
+ if (hello && typeof hello.pid === "number")
149
+ pid = hello.pid;
150
+ }
151
+ if (!pid) {
152
+ throw new ServiceError(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
153
+ `— cannot stop safely; run \`load-lock doctor\``);
154
+ }
155
+ let how = "SIGTERM";
156
+ let stopped = false;
157
+ try {
158
+ process.kill(pid, "SIGTERM");
159
+ }
160
+ catch (e) {
161
+ if (e?.code === "ESRCH")
162
+ stopped = true; // already gone
163
+ else
164
+ throw new ServiceError(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`);
165
+ }
166
+ if (!stopped)
167
+ stopped = await waitSocketFree(sock, timeoutMs);
168
+ if (!stopped) {
169
+ how = "SIGKILL";
170
+ try {
171
+ process.kill(pid, "SIGKILL");
172
+ }
173
+ catch {
174
+ /* already dead */
175
+ }
176
+ stopped = await waitSocketFree(sock, timeoutMs);
177
+ }
178
+ if (!stopped) {
179
+ throw new ServiceError(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``);
180
+ }
181
+ try {
182
+ if (!isWindows() && existsSync(sock))
183
+ unlinkSync(sock);
184
+ }
185
+ catch {
186
+ /* ignore */
187
+ }
188
+ try {
189
+ rmSync(join(dir, "daemon.json"), { force: true });
190
+ }
191
+ catch {
192
+ /* ignore */
193
+ }
194
+ return `daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}`;
195
+ }
196
+ /**
197
+ * Honest start: connecting auto-spawns a detached daemon (see
198
+ * shared/client.ts); hello confirms it is up. Refusals name the
199
+ * conflicting pid. Returns the report line; throws ServiceError on
200
+ * failure.
201
+ */
202
+ export async function serviceStart(timeoutMs) {
203
+ const sock = socketPath();
204
+ const installed = packageVersion();
205
+ try {
206
+ const r = (await withClient((c) => c.request("hello", {}, timeoutMs)));
207
+ const dv = typeof r.daemonVersion === "string" ? r.daemonVersion : undefined;
208
+ if (dv && compareVersions(dv, installed) < 0) {
209
+ // The handshake just ordered this old daemon to self-restart;
210
+ // confirm it comes back on updated code.
211
+ const up = await awaitDaemonUpgraded(sock, 8000, installed);
212
+ if (up) {
213
+ return `daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}`;
214
+ }
215
+ return (`daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
216
+ `self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)`);
217
+ }
218
+ return `daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}`;
219
+ }
220
+ catch (e) {
221
+ if (await probeTransport(sock)) {
222
+ // Something owns the endpoint. Name it.
223
+ const hello = await rawHello(sock, 1500);
224
+ if (hello && typeof hello.pid === "number") {
225
+ throw new ServiceError(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
226
+ `if it is stale, run \`load-lock service stop\` first`);
227
+ }
228
+ const info = readDaemonInfo(stateDir());
229
+ if (info && typeof info.pid === "number") {
230
+ throw new ServiceError(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
231
+ `run \`load-lock service stop\``);
232
+ }
233
+ throw new ServiceError(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``);
234
+ }
235
+ throw e;
236
+ }
237
+ }
238
+ /**
239
+ * `service restart` (issue #38): hardened stop, then honest start — both
240
+ * phases reported; a failure names the phase and reason. Stopping when
241
+ * nothing runs is fine: the start phase simply spawns a fresh daemon.
242
+ */
243
+ export async function serviceRestart(stopTimeoutMs = 5000, startTimeoutMs = 10_000) {
244
+ let stop;
245
+ try {
246
+ stop = await serviceStop(stopTimeoutMs);
247
+ }
248
+ catch (e) {
249
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
250
+ throw new ServiceError(`restart failed in the STOP phase: ${reason}`, e instanceof ServiceError ? e.exitCode : 70);
251
+ }
252
+ let start;
253
+ try {
254
+ start = await serviceStart(startTimeoutMs);
255
+ }
256
+ catch (e) {
257
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
258
+ throw new ServiceError(`restart failed in the START phase (stop phase succeeded: ${stop}) — ${reason}`, e instanceof ServiceError ? e.exitCode : 70);
259
+ }
260
+ return { stop, start };
261
+ }
@@ -0,0 +1,53 @@
1
+ export type SelfLocationKind = "bun-global" | "npm-global" | "opencode-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
+ }
19
+ /**
20
+ * Classify where this copy of load-lock lives, BEFORE touching anything:
21
+ * - bun global (<home>/.bun/install/global/node_modules/...) → update via bun
22
+ * - npm global (under `npm root -g`) → update via npm
23
+ * - opencode cache copy (<cache>/opencode/npm/...) → refuse: opencode
24
+ * manages this copy and refreshes it on its own schedule
25
+ * - repo checkout (nearest .git found by walking up, like the omac
26
+ * read-grant logic) → refuse with a pointer to the git workflow
27
+ * - anything else → unknown → refuse honestly rather than guessing
28
+ */
29
+ export declare function classifySelf(pkgRoot: string, ctx: ClassifyCtx): SelfLocation;
30
+ export type UpdatePlan = {
31
+ action: "install";
32
+ argv: string[];
33
+ detail: string;
34
+ } | {
35
+ action: "refuse";
36
+ reason: string;
37
+ };
38
+ /** What to run for the detected location — or why we refuse to guess. */
39
+ export declare function planUpdate(loc: SelfLocation): UpdatePlan;
40
+ export interface UpdateOptions {
41
+ /** --check: report only, never install or restart. */
42
+ check: boolean;
43
+ /** --json: machine-readable output (house style: humans get prose). */
44
+ json: boolean;
45
+ }
46
+ /**
47
+ * The `load-lock update` command. Prints a structured report (JSON with
48
+ * --json, prose otherwise) and returns the exit code:
49
+ * 0 up-to-date (check) or applied successfully
50
+ * 1 update available (--check; includes "cannot self-apply" refusals)
51
+ * 70 failure or refusal in apply mode
52
+ */
53
+ export declare function updateCommand(opts: UpdateOptions): Promise<number>;
@@ -0,0 +1,274 @@
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, 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 { 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
+ * - repo checkout (nearest .git found by walking up, like the omac
43
+ * read-grant logic) → refuse with a pointer to the git workflow
44
+ * - anything else → unknown → refuse honestly rather than guessing
45
+ */
46
+ export function classifySelf(pkgRoot, ctx) {
47
+ const bunRoot = ctx.bunGlobalRoot ?? join(ctx.home, ".bun", "install", "global", "node_modules");
48
+ if (isUnder(pkgRoot, bunRoot)) {
49
+ return { kind: "bun-global", path: pkgRoot, detail: `bun global install under ${bunRoot}` };
50
+ }
51
+ if (ctx.npmGlobalRoot && isUnder(pkgRoot, ctx.npmGlobalRoot)) {
52
+ return { kind: "npm-global", path: pkgRoot, detail: `npm global install under ${ctx.npmGlobalRoot}` };
53
+ }
54
+ if (ctx.cacheRoot && isUnder(pkgRoot, join(ctx.cacheRoot, "opencode", "npm"))) {
55
+ return { kind: "opencode-cache", path: pkgRoot, detail: `opencode-managed cache copy under ${ctx.cacheRoot}` };
56
+ }
57
+ for (let dir = pkgRoot;;) {
58
+ if (existsSync(join(dir, ".git"))) {
59
+ return { kind: "repo-checkout", path: pkgRoot, detail: `git checkout (.git at ${dir})` };
60
+ }
61
+ const parent = dirname(dir);
62
+ if (parent === dir)
63
+ break;
64
+ dir = parent;
65
+ }
66
+ return { kind: "unknown", path: pkgRoot, detail: "not a recognized bun/npm global, opencode cache, or git checkout location" };
67
+ }
68
+ /** What to run for the detected location — or why we refuse to guess. */
69
+ export function planUpdate(loc) {
70
+ switch (loc.kind) {
71
+ case "bun-global":
72
+ return { action: "install", argv: ["bun", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
73
+ case "npm-global":
74
+ return { action: "install", argv: ["npm", "i", "-g", `${PKG_NAME}@latest`], detail: loc.detail };
75
+ case "opencode-cache":
76
+ return {
77
+ action: "refuse",
78
+ reason: `this copy lives in opencode's npm cache (${loc.path}); opencode manages it and refreshes it on its own schedule — ` +
79
+ `update the PATH-installed CLI instead (npm i -g ${PKG_NAME}@latest)`,
80
+ };
81
+ case "repo-checkout":
82
+ return {
83
+ action: "refuse",
84
+ reason: `this copy is a git checkout (${loc.path}) — update it via your git workflow (e.g. git pull), not a package manager`,
85
+ };
86
+ case "unknown":
87
+ return {
88
+ action: "refuse",
89
+ reason: `cannot tell how this copy (${loc.path}) was installed — refusing to guess; ` +
90
+ `if it is a global install, run npm i -g ${PKG_NAME}@latest (or the bun equivalent) by hand`,
91
+ };
92
+ }
93
+ }
94
+ /** One npm invocation, bounded; resolves null on timeout/spawn failure. */
95
+ function npmOutput(args, timeoutMs) {
96
+ return new Promise((resolve) => {
97
+ let stdout = "";
98
+ let stderr = "";
99
+ let child;
100
+ try {
101
+ child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
102
+ }
103
+ catch {
104
+ resolve(null);
105
+ return;
106
+ }
107
+ const timer = setTimeout(() => {
108
+ child.kill("SIGKILL");
109
+ resolve(null);
110
+ }, timeoutMs);
111
+ timer.unref?.();
112
+ child.stdout?.on("data", (b) => (stdout += b));
113
+ child.stderr?.on("data", (b) => (stderr += b));
114
+ child.once("error", () => {
115
+ clearTimeout(timer);
116
+ resolve(null);
117
+ });
118
+ child.once("exit", (code) => {
119
+ clearTimeout(timer);
120
+ resolve({ code, stdout, stderr });
121
+ });
122
+ });
123
+ }
124
+ /** Latest published version from the npm registry, or null on failure. */
125
+ async function viewLatest(timeoutMs) {
126
+ const r = await npmOutput(["view", PKG_NAME, "version"], timeoutMs);
127
+ if (!r || r.code !== 0)
128
+ return null;
129
+ // `npm view <pkg> version` prints the bare version (possibly quoted).
130
+ const line = r.stdout.trim().split("\n").pop() ?? "";
131
+ const v = line.replace(/^"|"$/g, "").trim();
132
+ return /^\d+(\.\d+)*(-.*)?$/.test(v) ? v : null;
133
+ }
134
+ /** `npm root -g` (the global node_modules dir), or null on failure. */
135
+ async function npmGlobalRoot(timeoutMs) {
136
+ const r = await npmOutput(["root", "-g"], timeoutMs);
137
+ if (!r || r.code !== 0)
138
+ return null;
139
+ const line = r.stdout.trim().split("\n").pop() ?? "";
140
+ return line || null;
141
+ }
142
+ function fail(msg, code = 70) {
143
+ process.stderr.write(`load-lock: ${msg}\n`);
144
+ process.exit(code);
145
+ }
146
+ /**
147
+ * The `load-lock update` command. Prints a structured report (JSON with
148
+ * --json, prose otherwise) and returns the exit code:
149
+ * 0 up-to-date (check) or applied successfully
150
+ * 1 update available (--check; includes "cannot self-apply" refusals)
151
+ * 70 failure or refusal in apply mode
152
+ */
153
+ export async function updateCommand(opts) {
154
+ const timeoutMs = 30_000;
155
+ const out = (v, human) => {
156
+ if (opts.json)
157
+ process.stdout.write(JSON.stringify(v, null, 2) + "\n");
158
+ else
159
+ process.stdout.write(human() + "\n");
160
+ };
161
+ const locationReport = (loc) => ({ kind: loc.kind, path: loc.path });
162
+ // 1. Classify self BEFORE touching anything (local only, no registry).
163
+ const self = thisCopy();
164
+ const own = packageVersion();
165
+ if (!self) {
166
+ fail(`cannot resolve this copy's package root — run \`load-lock doctor\``);
167
+ }
168
+ const root = await npmGlobalRoot(timeoutMs);
169
+ const loc = classifySelf(self.path, {
170
+ home: process.env.HOME ?? homedir(),
171
+ npmGlobalRoot: root,
172
+ cacheRoot: opencodeCacheRoot(process.platform, process.env),
173
+ });
174
+ // 2. Check the registry (npm is the only distribution channel).
175
+ const latest = await viewLatest(timeoutMs);
176
+ if (latest === null) {
177
+ fail(`could not determine the latest published version of ${PKG_NAME} (npm view failed — is the registry reachable?)`);
178
+ }
179
+ // 3. Up to date (or this copy is ahead of the registry — nothing to do).
180
+ if (compareVersions(latest, own) <= 0) {
181
+ const ahead = compareVersions(latest, own) < 0;
182
+ const report = {
183
+ state: "up-to-date",
184
+ current_version: own,
185
+ latest_version: latest,
186
+ location: locationReport(loc),
187
+ ...(ahead ? { note: `this copy (v${own}) is newer than the registry latest (v${latest}) — nothing to do` } : {}),
188
+ };
189
+ out(report, () => `load-lock: up to date (v${own}; registry latest v${latest}; location: ${loc.kind})` +
190
+ (ahead ? ` — this copy is ahead of the registry` : ""));
191
+ return 0;
192
+ }
193
+ // 4. An update exists: plan the apply for the classified location.
194
+ const plan = planUpdate(loc);
195
+ if (plan.action === "refuse") {
196
+ const report = {
197
+ state: "refused",
198
+ current_version: own,
199
+ latest_version: latest,
200
+ location: locationReport(loc),
201
+ reason: plan.reason,
202
+ next_action: "resolve the location issue, then run `load-lock update` again (see docs/OPERATIONS.md)",
203
+ };
204
+ out(report, () => `load-lock: update to v${latest} available, but refused: ${plan.reason}`);
205
+ if (opts.check)
206
+ return 1; // an update exists — this copy just cannot self-apply it
207
+ process.stderr.write(`load-lock: update refused — ${plan.reason}\n`);
208
+ return 70;
209
+ }
210
+ // 5. --check: report what WOULD happen; touch nothing.
211
+ if (opts.check) {
212
+ const report = {
213
+ state: "update-available",
214
+ current_version: own,
215
+ latest_version: latest,
216
+ location: locationReport(loc),
217
+ command: plan.argv,
218
+ next_action: "run `load-lock update` to apply it; the service restarts automatically afterwards",
219
+ };
220
+ out(report, () => `load-lock: update available: v${own} -> v${latest} (location: ${loc.kind}; would run: ${plan.argv.join(" ")})`);
221
+ return 1;
222
+ }
223
+ // 6. Apply: run the detected package manager (output shown live).
224
+ process.stdout.write(opts.json
225
+ ? JSON.stringify({ state: "applying", current_version: own, latest_version: latest, command: plan.argv }, null, 2) + "\n"
226
+ : `load-lock: updating v${own} -> v${latest} via ${plan.argv.join(" ")}\n`);
227
+ const code = await new Promise((resolve) => {
228
+ let child;
229
+ try {
230
+ child = spawn(plan.argv[0], plan.argv.slice(1), { stdio: "inherit" });
231
+ }
232
+ catch (e) {
233
+ fail(`cannot run ${plan.argv[0]}: ${e.message}`);
234
+ }
235
+ child.once("error", (e) => fail(`cannot run ${plan.argv[0]}: ${e.message}`));
236
+ child.once("exit", (c) => resolve(c));
237
+ });
238
+ if (code !== 0) {
239
+ fail(`update not applied: ${plan.argv.join(" ")} exited with code ${code} — the daemon keeps running the current code`);
240
+ }
241
+ // 7. Restart the service so the daemon moves onto the new code now
242
+ // (the version handshake remains the safety net, not the primary means).
243
+ try {
244
+ const r = await serviceRestart();
245
+ const report = {
246
+ state: "updated",
247
+ from: own,
248
+ to: latest,
249
+ location: locationReport(loc),
250
+ command: plan.argv,
251
+ restarted: true,
252
+ restart: { stop: r.stop, start: r.start },
253
+ };
254
+ out(report, () => `load-lock: updated v${own} -> v${latest}; ${r.stop}; ${r.start}`);
255
+ return 0;
256
+ }
257
+ catch (e) {
258
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
259
+ const report = {
260
+ state: "updated",
261
+ from: own,
262
+ to: latest,
263
+ location: locationReport(loc),
264
+ command: plan.argv,
265
+ restarted: false,
266
+ restart_error: reason,
267
+ next_action: "the update IS applied; the daemon still runs old code — the version handshake will self-restart it on the " +
268
+ "next client contact, or run `load-lock service restart` manually (see docs/OPERATIONS.md)",
269
+ };
270
+ out(report, () => `load-lock: updated v${own} -> v${latest}, but the service restart failed: ${reason}`);
271
+ process.stderr.write(`load-lock: ${reason}\n`);
272
+ return 70;
273
+ }
274
+ }
@@ -27,6 +27,8 @@ export declare class Daemon {
27
27
  * restart via the event cursor) and the process can exit promptly. */
28
28
  private conns;
29
29
  private waiters;
30
+ /** wait_for_completion calls blocked on tracked jobs; multiple waiters per job are fine. */
31
+ private completionWaiters;
30
32
  private sentEventId;
31
33
  private stateDirPath;
32
34
  private socketPath;
@@ -77,6 +79,28 @@ export declare class Daemon {
77
79
  * push notifications — no model-authored busy-polling required.
78
80
  */
79
81
  private waitForAdmission;
82
+ /**
83
+ * Bounded blocking wait for a daemon-tracked job's completion. Mirrors
84
+ * waitForAdmission: the call is held server-side and woken by
85
+ * resolveCompletionWaiters() when the job is observed done — which always
86
+ * happens AFTER the permit has been released (finish() frees capacity
87
+ * before the event is emitted, and waiters are resolved after that).
88
+ * timeoutMs 0 is check-only. Never blocks unbounded.
89
+ */
90
+ private waitForJobCompletion;
91
+ /**
92
+ * Classify a request whose entry is no longer held (released, reclaimed,
93
+ * cancelled, or never started) into a structured completion result, using
94
+ * the most recent event for the request.
95
+ */
96
+ private classifyEntryCompletion;
97
+ /**
98
+ * Wake blocked wait_for_completion calls whose tracked job has ended.
99
+ * Called after every machine transition (afterOp, sweep, expiry timer) —
100
+ * always AFTER finish() has freed capacity and after the event flush, so
101
+ * a woken agent observes the pool as free. Entries still held are skipped.
102
+ */
103
+ private resolveCompletionWaiters;
80
104
  /** Runs after every dispatch: sweep deadlines, auto-claim for waiters, flush events, journal. */
81
105
  private afterOp;
82
106
  private armExpiryTimer;