@aron98/load-lock 1.2.1 → 1.4.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.
- package/dist/adapters/opencode/plugin.d.ts +2 -1
- package/dist/adapters/opencode/plugin.js +85 -5
- package/dist/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/main.js +298 -25
- package/dist/core/daemon.d.ts +42 -0
- package/dist/core/daemon.js +305 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +12 -4
- package/dist/core/state.js +80 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/mcp/server.js +107 -7
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +72 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/shared/wait-completion.d.ts +67 -0
- package/dist/shared/wait-completion.js +505 -0
- package/dist/test/mcp.test.js +115 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/dist/test/wait-completion.test.d.ts +1 -0
- package/dist/test/wait-completion.test.js +418 -0
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +49 -3
- package/docs/ARCHITECTURE.md +25 -6
- package/docs/COMPATIBILITY.md +32 -0
- package/docs/OPERATIONS.md +54 -6
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
5
|
* Provides:
|
|
6
6
|
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
-
* - run_with_permit / job_status / job_stop
|
|
7
|
+
* - run_with_permit / job_status / job_stop / wait_for_completion:
|
|
8
|
+
* full execution lifecycle incl. honest execution-phase sleeping
|
|
8
9
|
* - automatic policy injection on every model call via the session
|
|
9
10
|
* "context" hook (compaction-proof by construction)
|
|
10
11
|
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
5
|
* Provides:
|
|
6
6
|
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
-
* - run_with_permit / job_status / job_stop
|
|
7
|
+
* - run_with_permit / job_status / job_stop / wait_for_completion:
|
|
8
|
+
* full execution lifecycle incl. honest execution-phase sleeping
|
|
8
9
|
* - automatic policy injection on every model call via the session
|
|
9
10
|
* "context" hook (compaction-proof by construction)
|
|
10
11
|
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
@@ -21,6 +22,7 @@ import path from "node:path";
|
|
|
21
22
|
import { LLClient } from "../../shared/client.js";
|
|
22
23
|
import { POLICY_BRIEF } from "../../policy/text.js";
|
|
23
24
|
import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
|
|
25
|
+
import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
|
|
24
26
|
import { killProcessGroup } from "../../shared/transport.js";
|
|
25
27
|
const client = new LLClient();
|
|
26
28
|
const knownSessions = new Set();
|
|
@@ -156,10 +158,12 @@ export default Plugin.define({
|
|
|
156
158
|
reason: input.reason,
|
|
157
159
|
author: "opencode:" + context.sessionID,
|
|
158
160
|
}));
|
|
159
|
-
add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs.
|
|
161
|
+
add("run_with_permit", "Run the heavy command you hold a permit for, with full lifecycle ownership: spawns it in its own process group, atomically associates it with the permit, returns a job_id immediately (background), and auto-releases the permit when the job exits. Do NOT release manually for these jobs. Set wait:true to sleep through execution in this one call; otherwise sleep via wait_for_completion(job_id), quick-check via job_status.", {
|
|
160
162
|
permit_token: { type: "string" },
|
|
161
163
|
command: { type: "string", description: "The heavy shell command (not unrelated light commands)" },
|
|
162
164
|
cwd: { type: "string" },
|
|
165
|
+
wait: { type: "boolean", description: "true = sleep through execution in this one call; wake promptly when the job finishes (bounded by wait_timeout_ms)" },
|
|
166
|
+
wait_timeout_ms: { type: "number", description: "Bounded sleep for wait:true (ms, default 600000); still_running checkpoint on timeout" },
|
|
163
167
|
}, async (input, context) => {
|
|
164
168
|
const child = spawn(input.command, {
|
|
165
169
|
shell: true,
|
|
@@ -168,15 +172,20 @@ export default Plugin.define({
|
|
|
168
172
|
env: process.env,
|
|
169
173
|
stdio: ["ignore", "pipe", "pipe"],
|
|
170
174
|
});
|
|
175
|
+
let resolveExit;
|
|
171
176
|
const job = {
|
|
172
177
|
id: "job_" + child.pid + "_" + Math.random().toString(36).slice(2, 8),
|
|
173
178
|
child,
|
|
174
179
|
permitToken: input.permit_token,
|
|
175
180
|
command: input.command,
|
|
176
181
|
sessionID: context.sessionID,
|
|
182
|
+
startedAt: Date.now(),
|
|
177
183
|
exited: false,
|
|
178
184
|
exitCode: null,
|
|
179
185
|
output: [],
|
|
186
|
+
exitPromise: new Promise((resolve) => {
|
|
187
|
+
resolveExit = resolve;
|
|
188
|
+
}),
|
|
180
189
|
};
|
|
181
190
|
const push = (b) => {
|
|
182
191
|
job.output.push(b.toString("utf8"));
|
|
@@ -190,18 +199,43 @@ export default Plugin.define({
|
|
|
190
199
|
// round-trip to the daemon, or the permit would never auto-release.
|
|
191
200
|
child.on("exit", (code) => {
|
|
192
201
|
job.exited = true;
|
|
202
|
+
job.exitedAt = Date.now();
|
|
193
203
|
job.exitCode = code;
|
|
194
|
-
|
|
204
|
+
// releasePromise is assigned BEFORE resolveExit so a woken waiter
|
|
205
|
+
// sees the release round-trip in flight (see waitForLocalJob).
|
|
206
|
+
job.releasePromise = client
|
|
207
|
+
.request("release", { permitToken: job.permitToken, outcome: code === 0 ? "success" : "failure" })
|
|
208
|
+
.then(() => {
|
|
209
|
+
job.permitReleased = true;
|
|
210
|
+
}, () => { });
|
|
211
|
+
resolveExit(code);
|
|
195
212
|
});
|
|
196
213
|
try {
|
|
197
|
-
await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
|
|
214
|
+
const res = await client.request("start_job", { permitToken: input.permit_token, pid: child.pid, pgid: child.pid });
|
|
215
|
+
job.requestId = res.entry?.requestId;
|
|
198
216
|
}
|
|
199
217
|
catch (err) {
|
|
200
218
|
killProcessGroup(child.pid, "SIGKILL");
|
|
201
219
|
return { error: `permit no longer startable (${err.message}); job killed before doing work; acquire again` };
|
|
202
220
|
}
|
|
203
221
|
jobs.set(job.id, job);
|
|
204
|
-
|
|
222
|
+
if (!input.wait) {
|
|
223
|
+
return { job_id: job.id, pid: child.pid, state: "running", note: "auto-releases when the job exits; sleep via wait_for_completion(job_id), quick-check via job_status" };
|
|
224
|
+
}
|
|
225
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.wait_timeout_ms, signal: context.signal });
|
|
226
|
+
return {
|
|
227
|
+
job_id: job.id,
|
|
228
|
+
pid: child.pid,
|
|
229
|
+
command: job.command,
|
|
230
|
+
state: r.state,
|
|
231
|
+
exit_code: r.exitCode,
|
|
232
|
+
output_tail: r.outputTail,
|
|
233
|
+
duration_ms: r.durationMs,
|
|
234
|
+
permit_released: r.permitReleased,
|
|
235
|
+
waited_ms: r.waitedMs,
|
|
236
|
+
next_action: r.nextAction,
|
|
237
|
+
detail: r.detail,
|
|
238
|
+
};
|
|
205
239
|
});
|
|
206
240
|
add("job_status", "Poll a run_with_permit job: running/exited, exit code, output tail.", { job_id: { type: "string" } }, (input) => {
|
|
207
241
|
const job = jobs.get(input.job_id);
|
|
@@ -220,6 +254,7 @@ export default Plugin.define({
|
|
|
220
254
|
if (!job)
|
|
221
255
|
return { error: "unknown job_id" };
|
|
222
256
|
if (!job.exited) {
|
|
257
|
+
job.stopRequested = true;
|
|
223
258
|
killProcessGroup(job.child.pid, "SIGTERM");
|
|
224
259
|
setTimeout(() => {
|
|
225
260
|
if (!job.exited) {
|
|
@@ -229,6 +264,51 @@ export default Plugin.define({
|
|
|
229
264
|
}
|
|
230
265
|
return { job_id: job.id, stopping: true };
|
|
231
266
|
});
|
|
267
|
+
add("wait_for_completion", "Sleep honestly through the EXECUTION phase (replaces sleep-and-poll): bounded blocking wait that wakes promptly when the heavy job finishes. Pass job_id (run_with_permit job; exact exit-code wake) or request_id (daemon-tracked permit; liveness-sweep wake, permit released before you are woken). Structured result on every path: finished (exit_code/output_tail/duration/permit_released), still_running (checkpoint — call again), timeout (wait aborted, job untouched), job_unknown, stopped_by_request. timeout_ms bounds one call (default 600000; 0 = check-only).", {
|
|
268
|
+
job_id: { type: "string", description: "A run_with_permit job_id" },
|
|
269
|
+
request_id: { type: "string", description: "A daemon-tracked permit's request_id" },
|
|
270
|
+
timeout_ms: { type: "number", description: "Bounded sleep (ms, default 600000); still_running checkpoint on timeout — re-call" },
|
|
271
|
+
}, async (input, context) => {
|
|
272
|
+
if (!input.job_id && !input.request_id) {
|
|
273
|
+
return {
|
|
274
|
+
state: "job_unknown",
|
|
275
|
+
waited_ms: 0,
|
|
276
|
+
next_action: "Pass job_id (a run_with_permit job) or request_id (a daemon-tracked permit).",
|
|
277
|
+
detail: "no identifier given",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (input.job_id && jobs.has(input.job_id)) {
|
|
281
|
+
const job = jobs.get(input.job_id);
|
|
282
|
+
const r = await waitForLocalJob(job, { timeoutMs: input.timeout_ms, signal: context.signal });
|
|
283
|
+
return {
|
|
284
|
+
state: r.state,
|
|
285
|
+
job_id: job.id,
|
|
286
|
+
request_id: job.requestId,
|
|
287
|
+
exit_code: r.exitCode,
|
|
288
|
+
output_tail: r.outputTail,
|
|
289
|
+
duration_ms: r.durationMs,
|
|
290
|
+
permit_released: r.permitReleased,
|
|
291
|
+
waited_ms: r.waitedMs,
|
|
292
|
+
started_at: r.startedAt,
|
|
293
|
+
next_action: r.nextAction,
|
|
294
|
+
detail: r.detail,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const requestId = input.request_id ?? input.job_id;
|
|
298
|
+
const r = await waitForTrackedJob({ client, requestId, timeoutMs: input.timeout_ms, signal: context.signal });
|
|
299
|
+
return {
|
|
300
|
+
state: r.state,
|
|
301
|
+
request_id: r.requestId ?? requestId,
|
|
302
|
+
exit_code: r.exitCode,
|
|
303
|
+
duration_ms: r.durationMs,
|
|
304
|
+
permit_released: r.permitReleased,
|
|
305
|
+
waited_ms: r.waitedMs,
|
|
306
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
307
|
+
started_at: r.startedAt,
|
|
308
|
+
next_action: r.nextAction,
|
|
309
|
+
detail: r.detail,
|
|
310
|
+
};
|
|
311
|
+
});
|
|
232
312
|
add("attach_pid", "Associate a process you already started (e.g. a background shell task) with your claimed permit. Atomic; auto-releases when the process group exits.", { permit_token: { type: "string" }, pid: { type: "number" } }, (input) => client.request("attach_pid", { permitToken: input.permit_token, pid: input.pid }));
|
|
233
313
|
add("mark_started", "Tell load-lock you started the work when no process can be associated. Weaker guarantee: heartbeat lease applies (see docs). Prefer run_with_permit.", { permit_token: { type: "string" } }, (input) => client.request("mark_started", { permitToken: input.permit_token }));
|
|
234
314
|
});
|
|
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
|
|
|
89
89
|
const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
|
|
90
90
|
push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
|
|
91
91
|
if (v.daemon) {
|
|
92
|
-
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}
|
|
92
|
+
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
|
|
93
|
+
(v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
|
|
94
|
+
` · events through #${v.latestEventId}`, f.dim);
|
|
93
95
|
}
|
|
94
96
|
else {
|
|
95
97
|
push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
|
|
96
98
|
}
|
|
97
99
|
if (v.daemonRestartNote)
|
|
98
100
|
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
101
|
+
if (v.versionSkewNote)
|
|
102
|
+
push(`note: ${v.versionSkewNote}`, f.warn);
|
|
99
103
|
if (v.machine) {
|
|
100
104
|
const m = v.machine;
|
|
101
105
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface InstalledCopy {
|
|
2
|
+
/** Where this copy was found. */
|
|
3
|
+
source: "cli" | "path" | "opencode-cache";
|
|
4
|
+
/** Path of the copy's package root (its package.json's directory). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version string, or null when the package.json could not be read. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
11
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
12
|
+
* no enclosing package.json matches (or is readable).
|
|
13
|
+
*/
|
|
14
|
+
export declare function enclosingPackage(startDir: string, maxDepth?: number): {
|
|
15
|
+
path: string;
|
|
16
|
+
version: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
19
|
+
export declare function thisCopy(): InstalledCopy | null;
|
|
20
|
+
/**
|
|
21
|
+
* All installed copies of load-lock detectable on this machine:
|
|
22
|
+
* - "cli": the copy this process runs from.
|
|
23
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
24
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
25
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
26
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-copy detection (issue #34): on a given machine several copies
|
|
3
|
+
* of load-lock commonly coexist — the globally-installed CLI on PATH, and
|
|
4
|
+
* the private copy opencode maintains under its npm cache (because
|
|
5
|
+
* `install --harness opencode` registers the bare package name, opencode
|
|
6
|
+
* installs and refreshes its own copy on its own schedule).
|
|
7
|
+
*
|
|
8
|
+
* All scanning is best-effort: missing paths, unreadable files, and
|
|
9
|
+
* unexpected layouts yield skipped entries or null versions — never a throw.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { isWindows } from "../shared/transport.js";
|
|
16
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
17
|
+
const PKG_NAME = "@aron98/load-lock";
|
|
18
|
+
/**
|
|
19
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
20
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
21
|
+
* no enclosing package.json matches (or is readable).
|
|
22
|
+
*/
|
|
23
|
+
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
24
|
+
let dir = startDir;
|
|
25
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
+
const pkgPath = join(dir, "package.json");
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(pkgPath)) {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
30
|
+
if (pkg?.name === PKG_NAME) {
|
|
31
|
+
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
32
|
+
}
|
|
33
|
+
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
34
|
+
// inside some other tool's package).
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(dir);
|
|
42
|
+
if (parent === dir)
|
|
43
|
+
return null;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
49
|
+
export function thisCopy() {
|
|
50
|
+
// dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
|
|
51
|
+
const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
52
|
+
const found = enclosingPackage(startDir);
|
|
53
|
+
if (!found)
|
|
54
|
+
return null;
|
|
55
|
+
return { source: "cli", path: found.path, version: found.version };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* All installed copies of load-lock detectable on this machine:
|
|
59
|
+
* - "cli": the copy this process runs from.
|
|
60
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
61
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
62
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
63
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
64
|
+
*/
|
|
65
|
+
export function findInstalledCopies(platform = process.platform, env = process.env) {
|
|
66
|
+
const copies = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const add = (c) => {
|
|
69
|
+
if (!c)
|
|
70
|
+
return;
|
|
71
|
+
let real;
|
|
72
|
+
try {
|
|
73
|
+
real = realpathSync(c.path);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
real = c.path;
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(real))
|
|
79
|
+
return;
|
|
80
|
+
seen.add(real);
|
|
81
|
+
copies.push(c);
|
|
82
|
+
};
|
|
83
|
+
add(thisCopy());
|
|
84
|
+
// --- PATH scan ------------------------------------------------------------
|
|
85
|
+
try {
|
|
86
|
+
const binNames = isWindows(platform)
|
|
87
|
+
? ["load-lock.cmd", "load-lock.exe", "load-lock"]
|
|
88
|
+
: ["load-lock"];
|
|
89
|
+
const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
|
|
90
|
+
for (const dir of dirs) {
|
|
91
|
+
for (const bin of binNames) {
|
|
92
|
+
try {
|
|
93
|
+
const binPath = join(dir, bin);
|
|
94
|
+
if (!statSync(binPath).isFile())
|
|
95
|
+
continue;
|
|
96
|
+
// A shim/wrapper script is not itself the copy — resolve through
|
|
97
|
+
// its enclosing package (works for npm/bun global layouts where
|
|
98
|
+
// the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
|
|
99
|
+
let realBin;
|
|
100
|
+
try {
|
|
101
|
+
realBin = realpathSync(binPath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
realBin = binPath;
|
|
105
|
+
}
|
|
106
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
|
|
107
|
+
if (found)
|
|
108
|
+
add({ source: "path", path: found.path, version: found.version });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* unreadable PATH entry — skip */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* PATH scan is best-effort */
|
|
118
|
+
}
|
|
119
|
+
// --- opencode npm-cache scan ----------------------------------------------
|
|
120
|
+
// Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
|
|
121
|
+
// where "@aron98" and "load-lock@latest" are two nested directories (a
|
|
122
|
+
// normal npm scoped layout); URL-escaped single-directory variants like
|
|
123
|
+
// "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
|
|
124
|
+
// real package at node_modules/@aron98/load-lock, or a top-level
|
|
125
|
+
// package.json (npm-install root).
|
|
126
|
+
try {
|
|
127
|
+
const cacheRoot = opencodeCacheRoot(platform, env);
|
|
128
|
+
if (cacheRoot) {
|
|
129
|
+
const npmDir = join(cacheRoot, "opencode", "npm");
|
|
130
|
+
if (existsSync(npmDir)) {
|
|
131
|
+
const scanTimestampDirs = (pkgBase) => {
|
|
132
|
+
let tsDirs = [];
|
|
133
|
+
try {
|
|
134
|
+
tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const ts of tsDirs) {
|
|
140
|
+
const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
|
|
141
|
+
const topLevel = join(pkgBase, ts);
|
|
142
|
+
for (const cand of [nested, topLevel]) {
|
|
143
|
+
try {
|
|
144
|
+
const pkgPath = join(cand, "package.json");
|
|
145
|
+
if (!existsSync(pkgPath))
|
|
146
|
+
continue;
|
|
147
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
148
|
+
if (pkg?.name !== PKG_NAME)
|
|
149
|
+
continue;
|
|
150
|
+
add({
|
|
151
|
+
source: "opencode-cache",
|
|
152
|
+
path: cand,
|
|
153
|
+
version: typeof pkg.version === "string" ? pkg.version : null,
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* skip unreadable timestamp dir */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let scopeDirs = [];
|
|
164
|
+
try {
|
|
165
|
+
scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
scopeDirs = [];
|
|
169
|
+
}
|
|
170
|
+
for (const scopeDir of scopeDirs) {
|
|
171
|
+
if (scopeDir.startsWith("@aron98%2Fload-lock")) {
|
|
172
|
+
// URL-escaped single-directory form.
|
|
173
|
+
scanTimestampDirs(join(npmDir, scopeDir));
|
|
174
|
+
}
|
|
175
|
+
else if (scopeDir === "@aron98") {
|
|
176
|
+
// Scoped layout: @aron98/<name>@<version-range>/<ts>/...
|
|
177
|
+
let inner = [];
|
|
178
|
+
try {
|
|
179
|
+
inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const nameDir of inner) {
|
|
185
|
+
if (nameDir.startsWith("load-lock"))
|
|
186
|
+
scanTimestampDirs(join(npmDir, scopeDir, nameDir));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* cache scan is best-effort */
|
|
195
|
+
}
|
|
196
|
+
return copies;
|
|
197
|
+
}
|
|
198
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
199
|
+
function opencodeCacheRoot(platform, env) {
|
|
200
|
+
if (env.XDG_CACHE_HOME)
|
|
201
|
+
return env.XDG_CACHE_HOME;
|
|
202
|
+
if (platform === "win32")
|
|
203
|
+
return env.LOCALAPPDATA ?? null;
|
|
204
|
+
if (platform === "darwin")
|
|
205
|
+
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
206
|
+
return join(env.HOME ?? homedir(), ".cache");
|
|
207
|
+
}
|