@lawaty/lacode 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -283,6 +283,19 @@ option is optional:
283
283
  | `models` | — | An inline slot list. Highest-priority source. |
284
284
  | `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
285
285
  | `reapMs` | `30000` | Hang-reaper interval. |
286
+ | `selfUpdate` | `true` | Check for a newer release at most once per ~6h and install it in the background; it applies on the next opencode start. `false` — or `LACODE_NO_SELFUPDATE=1` — turns the check off. |
287
+
288
+ ### Staying up to date
289
+
290
+ opencode's plugin cache is **write-once**: it resolves `@lawaty/lacode` to an
291
+ exact version once and never re-resolves — not on restart, not with
292
+ `opencode plugin -g -f`, not with a range pin, not with a tagged config spec. So
293
+ LaCode keeps itself current instead (US-27): at startup it compares its own
294
+ version to npm's `latest` (at most once per ~6h, silent when offline) and, when
295
+ behind, reinstalls its own cache entry in the background using the opencode
296
+ binary — no npm or bun required. The running session keeps its loaded code; the
297
+ new version takes effect on the next start, and the log says so. Run
298
+ `lacode update` to do it on demand, or `lacode update --check` to just report.
286
299
 
287
300
  ### Pool preset resolution
288
301
 
package/dist/agents.js CHANGED
@@ -1,25 +1,18 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
+ import { join as join2 } from "node:path";
5
+
6
+ // src/lib/package-root.ts
7
+ import { readFileSync } from "node:fs";
4
8
  import { dirname, join } from "node:path";
5
9
  import { fileURLToPath } from "node:url";
6
- var CLAIM_TTL_MS = 10 * 60 * 1e3;
7
- var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
8
- var ORPHAN_TMP_MS = 60 * 1e3;
9
- var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
- var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
- var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
- var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
- var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
14
- var MAX_SLOTS = 4;
15
- var COOLDOWN_BASE_MS = 60 * 1e3;
16
- var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
- var STRIKE_DECAY_MS = 30 * 60 * 1e3;
10
+ var PACKAGE_NAME = "@lawaty/lacode";
18
11
  var findPackageRoot = (start) => {
19
12
  let dir = start;
20
13
  for (let up = 0; up < 6; up++) {
21
14
  try {
22
- if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@lawaty/lacode") return dir;
15
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
23
16
  } catch {
24
17
  }
25
18
  const parent = dirname(dir);
@@ -29,11 +22,25 @@ var findPackageRoot = (start) => {
29
22
  return join(start, "..", "..");
30
23
  };
31
24
  var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
32
- var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
33
- var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
34
- var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
35
- var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
36
- var USER_MODELS_TXT = join(USER_CONFIG_DIR, "pool.txt");
25
+
26
+ // src/lib/pool.ts
27
+ var CLAIM_TTL_MS = 10 * 60 * 1e3;
28
+ var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
29
+ var ORPHAN_TMP_MS = 60 * 1e3;
30
+ var CATALOG_TTL_MS = 10 * 60 * 1e3;
31
+ var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
+ var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
+ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
34
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
35
+ var MAX_SLOTS = 4;
36
+ var COOLDOWN_BASE_MS = 60 * 1e3;
37
+ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
38
+ var STRIKE_DECAY_MS = 30 * 60 * 1e3;
39
+ var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
40
+ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
41
+ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
42
+ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
43
+ var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
37
44
 
38
45
  // src/agents.ts
39
46
  var POOLED_BASES = ["explore", "implement", "context"];
package/dist/index.js CHANGED
@@ -1,8 +1,29 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
+ import { join as join2 } from "node:path";
5
+
6
+ // src/lib/package-root.ts
7
+ import { readFileSync } from "node:fs";
4
8
  import { dirname, join } from "node:path";
5
9
  import { fileURLToPath } from "node:url";
10
+ var PACKAGE_NAME = "@lawaty/lacode";
11
+ var findPackageRoot = (start) => {
12
+ let dir = start;
13
+ for (let up = 0; up < 6; up++) {
14
+ try {
15
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
16
+ } catch {
17
+ }
18
+ const parent = dirname(dir);
19
+ if (parent === dir) break;
20
+ dir = parent;
21
+ }
22
+ return join(start, "..", "..");
23
+ };
24
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
+
26
+ // src/lib/pool.ts
6
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
7
28
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
8
29
  var ORPHAN_TMP_MS = 60 * 1e3;
@@ -17,25 +38,11 @@ var COOLDOWN_BASE_MS = 60 * 1e3;
17
38
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
18
39
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
19
40
  var BASES = ["explore", "implement", "context"];
20
- var findPackageRoot = (start) => {
21
- let dir = start;
22
- for (let up = 0; up < 6; up++) {
23
- try {
24
- if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@lawaty/lacode") return dir;
25
- } catch {
26
- }
27
- const parent = dirname(dir);
28
- if (parent === dir) break;
29
- dir = parent;
30
- }
31
- return join(start, "..", "..");
32
- };
33
- var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
34
- var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
35
- var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
36
- var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
37
- var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
38
- var USER_MODELS_TXT = join(USER_CONFIG_DIR, "pool.txt");
41
+ var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
42
+ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
43
+ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
44
+ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
45
+ var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
39
46
  var FALLBACK_SLOTS = [
40
47
  { index: 1, model: "opencode/space-bunny-free", weight: 3 },
41
48
  { index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
@@ -89,7 +96,7 @@ function parseSlotText(text, origin) {
89
96
  }
90
97
  var readJSON = (path4) => {
91
98
  try {
92
- return { ok: true, value: JSON.parse(readFileSync(path4, "utf8")) };
99
+ return { ok: true, value: JSON.parse(readFileSync2(path4, "utf8")) };
93
100
  } catch (error) {
94
101
  return { ok: false, error: String(error) };
95
102
  }
@@ -105,7 +112,7 @@ function readOptional(path4, origin, problems) {
105
112
  function readTextOptional(path4, origin, problems) {
106
113
  if (!existsSync(path4)) return void 0;
107
114
  try {
108
- return readFileSync(path4, "utf8");
115
+ return readFileSync2(path4, "utf8");
109
116
  } catch (error) {
110
117
  problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
111
118
  return void 0;
@@ -147,7 +154,7 @@ function resolveModels(input = {}) {
147
154
  function readSlotFile(path4) {
148
155
  let text;
149
156
  try {
150
- text = readFileSync(path4, "utf8");
157
+ text = readFileSync2(path4, "utf8");
151
158
  } catch (error) {
152
159
  return { slots: FALLBACK_SLOTS, problems: [`${path4} unreadable (${String(error)}); using built-in defaults`] };
153
160
  }
@@ -215,7 +222,7 @@ var retryAfterMs = (headers) => {
215
222
  };
216
223
  function state(opts = {}) {
217
224
  const id = opts.id ?? String(process.pid);
218
- const dir = opts.dir ?? join(homedir(), ".local", "share", "opencode", "agent-pool");
225
+ const dir = opts.dir ?? join2(homedir(), ".local", "share", "opencode", "agent-pool");
219
226
  const modelsFile = opts.modelsFile;
220
227
  const models = opts.models;
221
228
  const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
@@ -264,7 +271,7 @@ function init(s, onWarn) {
264
271
  mkdirSync(s.dir, { recursive: true });
265
272
  s.dirReady = true;
266
273
  try {
267
- unlinkSync(join(s.dir, selfFile(s.id)));
274
+ unlinkSync(join2(s.dir, selfFile(s.id)));
268
275
  } catch {
269
276
  }
270
277
  } catch {
@@ -281,8 +288,8 @@ function publish(s) {
281
288
  const payload = {};
282
289
  for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
283
290
  try {
284
- writeFileSync(join(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
285
- renameSync(join(s.dir, `${selfFile(s.id)}.tmp`), join(s.dir, selfFile(s.id)));
291
+ writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
292
+ renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
286
293
  } catch {
287
294
  s.dirReady = false;
288
295
  }
@@ -297,7 +304,7 @@ function readSiblings(s) {
297
304
  }
298
305
  const now = Date.now();
299
306
  for (const entry of entries) {
300
- const path4 = join(s.dir, entry);
307
+ const path4 = join2(s.dir, entry);
301
308
  let age;
302
309
  try {
303
310
  age = now - statSync(path4).mtimeMs;
@@ -321,7 +328,7 @@ function readSiblings(s) {
321
328
  continue;
322
329
  }
323
330
  try {
324
- const parsed = JSON.parse(readFileSync(path4, "utf8"));
331
+ const parsed = JSON.parse(readFileSync2(path4, "utf8"));
325
332
  const claims = Object.values(parsed).filter(
326
333
  (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
327
334
  );
@@ -342,9 +349,9 @@ function readLimits(s) {
342
349
  const now = Date.now();
343
350
  for (const entry of entries) {
344
351
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
345
- const path4 = join(s.dir, entry);
352
+ const path4 = join2(s.dir, entry);
346
353
  try {
347
- const strike = JSON.parse(readFileSync(path4, "utf8"));
354
+ const strike = JSON.parse(readFileSync2(path4, "utf8"));
348
355
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
349
356
  if (strike.until > now) cooling.set(strike.model, strike);
350
357
  else unlinkSync(path4);
@@ -366,7 +373,7 @@ function recordStrike(s, model, reason, stated) {
366
373
  const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
367
374
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
368
375
  s.cooling.set(model, strike);
369
- const path4 = join(s.dir, limitFile(model));
376
+ const path4 = join2(s.dir, limitFile(model));
370
377
  try {
371
378
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
372
379
  renameSync(`${path4}.tmp`, path4);
@@ -382,7 +389,7 @@ function coolUnavailable(s, model) {
382
389
  if (prior && prior.until > until) return prior;
383
390
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
384
391
  s.cooling.set(model, strike);
385
- const path4 = join(s.dir, limitFile(model));
392
+ const path4 = join2(s.dir, limitFile(model));
386
393
  try {
387
394
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
388
395
  renameSync(`${path4}.tmp`, path4);
@@ -1494,7 +1501,7 @@ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as sta
1494
1501
  import * as path2 from "node:path";
1495
1502
 
1496
1503
  // src/lib/writer-rule.ts
1497
- import { readFileSync as readFileSync2 } from "node:fs";
1504
+ import { readFileSync as readFileSync3 } from "node:fs";
1498
1505
  import * as path from "node:path";
1499
1506
  var CONTEXT_DIR = path.join(".opencode", "context");
1500
1507
  var WRITER = "context";
@@ -1567,7 +1574,7 @@ var evaluatedForm = (mapDir, projectRoot) => {
1567
1574
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
1568
1575
  let raw;
1569
1576
  try {
1570
- raw = readFileSync2(configPath, "utf8");
1577
+ raw = readFileSync3(configPath, "utf8");
1571
1578
  } catch {
1572
1579
  return void 0;
1573
1580
  }
@@ -1655,11 +1662,11 @@ var changedSince = (before, dir) => {
1655
1662
  for (const name of before.keys()) if (!after.has(name)) changed++;
1656
1663
  return changed;
1657
1664
  };
1658
- var contextAutoUpdateHooks = (async ({ client, directory }) => {
1665
+ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
1659
1666
  const pending = /* @__PURE__ */ new Set();
1660
1667
  const owned = /* @__PURE__ */ new Map();
1661
1668
  let lastRun = 0;
1662
- const state2 = state();
1669
+ const state2 = state(options);
1663
1670
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
1664
1671
  });
1665
1672
  const root = path2.resolve(directory);
@@ -1988,14 +1995,14 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
1988
1995
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
1989
1996
  return info;
1990
1997
  };
1991
- const deliverViaHelper = (title, message, urgency) => new Promise((resolve2) => {
1998
+ const deliverViaHelper = (title, message, urgency) => new Promise((resolve3) => {
1992
1999
  const helper = findHelper();
1993
2000
  if (!helper) {
1994
2001
  if (!warnedHelper) {
1995
2002
  warnedHelper = true;
1996
2003
  void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
1997
2004
  }
1998
- resolve2({ ok: false, detail: "no oc-notify helper found" });
2005
+ resolve3({ ok: false, detail: "no oc-notify helper found" });
1999
2006
  return;
2000
2007
  }
2001
2008
  const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal"), {
@@ -2008,7 +2015,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2008
2015
  });
2009
2016
  child.on("error", (error) => {
2010
2017
  void log("warn", `notification helper failed: ${String(error)}`, { title });
2011
- resolve2({ ok: false, detail: String(error) });
2018
+ resolve3({ ok: false, detail: String(error) });
2012
2019
  });
2013
2020
  child.on("exit", (code) => {
2014
2021
  const noise = stderr.trim();
@@ -2019,7 +2026,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2019
2026
  message
2020
2027
  });
2021
2028
  }
2022
- resolve2({ ok, detail: noise || void 0 });
2029
+ resolve3({ ok, detail: noise || void 0 });
2023
2030
  });
2024
2031
  child.unref();
2025
2032
  });
@@ -2144,6 +2151,238 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2144
2151
  };
2145
2152
  });
2146
2153
 
2154
+ // src/lib/selfupdate.ts
2155
+ import { spawn as spawn2 } from "node:child_process";
2156
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
2157
+ import { homedir as homedir2 } from "node:os";
2158
+ import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
2159
+ var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
2160
+ var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
2161
+ var FETCH_TIMEOUT_MS = 4e3;
2162
+ var INSTALL_TIMEOUT_MS = 12e4;
2163
+ var LOCK_STALE_MS = 10 * 60 * 1e3;
2164
+ var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
2165
+ var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
2166
+ var STATE_DIR = join6(homedir2(), ".local", "share", "opencode", "lacode");
2167
+ var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
2168
+ var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
2169
+ function compareSemver(a, b) {
2170
+ const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
2171
+ const left = parse(a);
2172
+ const right = parse(b);
2173
+ for (let i = 0; i < 3; i++) {
2174
+ const l = left[i] ?? 0;
2175
+ const r = right[i] ?? 0;
2176
+ if (l !== r) return l > r ? 1 : -1;
2177
+ }
2178
+ return 0;
2179
+ }
2180
+ function readInstalledVersion(packageRoot) {
2181
+ try {
2182
+ const manifest = JSON.parse(readFileSync4(join6(packageRoot, "package.json"), "utf8"));
2183
+ if (manifest.name !== PACKAGE_NAME) return void 0;
2184
+ return typeof manifest.version === "string" ? manifest.version : void 0;
2185
+ } catch {
2186
+ return void 0;
2187
+ }
2188
+ }
2189
+ function resolveCacheRoot(packageRoot, exists = (p) => {
2190
+ try {
2191
+ return statSync3(p).isFile();
2192
+ } catch {
2193
+ return false;
2194
+ }
2195
+ }) {
2196
+ const tail = join6("node_modules", "@lawaty", "lacode");
2197
+ if (!packageRoot.endsWith(tail)) return void 0;
2198
+ const cacheRoot = resolve2(packageRoot, "..", "..", "..");
2199
+ if (!exists(join6(cacheRoot, "package.json"))) return void 0;
2200
+ return cacheRoot;
2201
+ }
2202
+ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
2203
+ try {
2204
+ return statSync3(p).isFile();
2205
+ } catch {
2206
+ return false;
2207
+ }
2208
+ }) {
2209
+ if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
2210
+ for (const dir of (env.PATH ?? "").split(":")) {
2211
+ if (!dir) continue;
2212
+ const candidate = join6(dir, "opencode");
2213
+ if (exists(candidate)) return candidate;
2214
+ }
2215
+ const fallback = join6(homedir2(), ".opencode", "bin", "opencode");
2216
+ return exists(fallback) ? fallback : void 0;
2217
+ }
2218
+ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
2219
+ const controller = new AbortController();
2220
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2221
+ try {
2222
+ const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
2223
+ if (!response.ok) return void 0;
2224
+ const body = await response.json();
2225
+ return typeof body.version === "string" ? body.version : void 0;
2226
+ } catch {
2227
+ return void 0;
2228
+ } finally {
2229
+ clearTimeout(timer);
2230
+ }
2231
+ }
2232
+ function readState(path4 = STATE_PATH) {
2233
+ try {
2234
+ return JSON.parse(readFileSync4(path4, "utf8"));
2235
+ } catch {
2236
+ return {};
2237
+ }
2238
+ }
2239
+ function writeState(state2, path4 = STATE_PATH) {
2240
+ try {
2241
+ mkdirSync2(dirname3(path4), { recursive: true });
2242
+ writeFileSync2(path4, JSON.stringify(state2));
2243
+ } catch {
2244
+ }
2245
+ }
2246
+ var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
2247
+ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
2248
+ try {
2249
+ mkdirSync2(dirname3(path4), { recursive: true });
2250
+ const fd = openSync(path4, "wx");
2251
+ writeSync(fd, String(process.pid));
2252
+ closeSync(fd);
2253
+ return () => {
2254
+ try {
2255
+ unlinkSync2(path4);
2256
+ } catch {
2257
+ }
2258
+ };
2259
+ } catch {
2260
+ try {
2261
+ if (now - statSync3(path4).mtimeMs > LOCK_STALE_MS) unlinkSync2(path4);
2262
+ } catch {
2263
+ }
2264
+ return void 0;
2265
+ }
2266
+ }
2267
+ async function checkForUpdate(opts) {
2268
+ const installed = readInstalledVersion(opts.packageRoot);
2269
+ const latest = await fetchLatest(opts.fetchImpl);
2270
+ return {
2271
+ installed,
2272
+ latest,
2273
+ newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
2274
+ cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
2275
+ bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
2276
+ };
2277
+ }
2278
+ async function refreshCache(opts) {
2279
+ const { cacheRoot, bin, latest } = opts;
2280
+ const spawnImpl = opts.spawnImpl ?? spawn2;
2281
+ const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
2282
+ const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
2283
+ const aside = `${cacheRoot}.stale-${Date.now()}`;
2284
+ try {
2285
+ rmSync(aside, { recursive: true, force: true });
2286
+ renameSync2(cacheRoot, aside);
2287
+ } catch (error) {
2288
+ return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
2289
+ }
2290
+ const exited = await new Promise((settle) => {
2291
+ let done = false;
2292
+ const finish = (value) => {
2293
+ if (done) return;
2294
+ done = true;
2295
+ clearTimeout(timer);
2296
+ settle(value);
2297
+ };
2298
+ const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
2299
+ env: { ...process.env, [CHILD_ENV]: "1" },
2300
+ stdio: "ignore"
2301
+ });
2302
+ const timer = setTimeout(() => {
2303
+ try {
2304
+ child.kill("SIGKILL");
2305
+ } catch {
2306
+ }
2307
+ finish(false);
2308
+ }, timeoutMs);
2309
+ child.on("error", () => finish(false));
2310
+ child.on("exit", (code) => finish(code === 0));
2311
+ });
2312
+ const installed = readInstalledVersion(fresh);
2313
+ const ok = exited && installed === latest;
2314
+ if (ok) {
2315
+ try {
2316
+ rmSync(aside, { recursive: true, force: true });
2317
+ } catch {
2318
+ }
2319
+ } else {
2320
+ try {
2321
+ renameSync2(aside, cacheRoot);
2322
+ } catch {
2323
+ }
2324
+ }
2325
+ return {
2326
+ ok,
2327
+ detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
2328
+ };
2329
+ }
2330
+ async function maybeSelfUpdate(opts) {
2331
+ const env = opts.env ?? process.env;
2332
+ if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1") {
2333
+ return { action: "disabled" };
2334
+ }
2335
+ const now = (opts.now ?? Date.now)();
2336
+ const statePath = opts.statePath ?? STATE_PATH;
2337
+ const state2 = readState(statePath);
2338
+ if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
2339
+ const release2 = acquireLock(opts.lockPath, now);
2340
+ if (!release2) return { action: "skipped", detail: "another process is updating" };
2341
+ try {
2342
+ const check = await checkForUpdate(opts);
2343
+ if (!check.installed) return { action: "skipped", detail: "no installed version" };
2344
+ if (!check.latest) {
2345
+ writeState({ ...state2, checkedAt: now }, statePath);
2346
+ return { action: "skipped", detail: "registry unreachable" };
2347
+ }
2348
+ if (!check.newer) {
2349
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2350
+ return { action: "current", detail: `${check.installed} is current` };
2351
+ }
2352
+ if (!check.cacheRoot) {
2353
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2354
+ return { action: "skipped", detail: "not an opencode cache install" };
2355
+ }
2356
+ if (!check.bin) {
2357
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2358
+ opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
2359
+ return { action: "failed", detail: "no opencode binary" };
2360
+ }
2361
+ const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
2362
+ if (result.ok) {
2363
+ writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
2364
+ opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
2365
+ return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
2366
+ }
2367
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2368
+ opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
2369
+ return { action: "failed", detail: result.detail };
2370
+ } finally {
2371
+ release2();
2372
+ }
2373
+ }
2374
+
2375
+ // src/plugins/selfupdate.ts
2376
+ async function selfUpdateHooks(input, options) {
2377
+ const opts = options ?? {};
2378
+ if (opts.enabled === false || opts.selfUpdate === false) return {};
2379
+ const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
2380
+ });
2381
+ void maybeSelfUpdate({ packageRoot: PACKAGE_ROOT, enabled: opts.enabled, log }).catch(() => {
2382
+ });
2383
+ return {};
2384
+ }
2385
+
2147
2386
  // src/index.ts
2148
2387
  var inertHooks = {
2149
2388
  config: async () => {
@@ -2170,6 +2409,7 @@ var LaCode = (async (input, options) => {
2170
2409
  const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
2171
2410
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
2172
2411
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
2412
+ const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
2173
2413
  const config = async (cfg) => {
2174
2414
  try {
2175
2415
  const injected = applyAgents(cfg, agentSet);
@@ -2210,6 +2450,7 @@ var LaCode = (async (input, options) => {
2210
2450
  await pool.dispose?.();
2211
2451
  await autoupdate.dispose?.();
2212
2452
  await notifier.dispose?.();
2453
+ await selfupdate.dispose?.();
2213
2454
  }
2214
2455
  };
2215
2456
  });
package/dist/lacode.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/lacode
4
- import { fileURLToPath } from "node:url";
5
- import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
6
6
  import { spawnSync as spawnSync2 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
@@ -189,6 +189,167 @@ function describe(env = process.env, platform = process.platform) {
189
189
  };
190
190
  }
191
191
 
192
+ // src/lib/package-root.ts
193
+ import { readFileSync } from "node:fs";
194
+ import { dirname, join as join2 } from "node:path";
195
+ import { fileURLToPath } from "node:url";
196
+ var PACKAGE_NAME = "@lawaty/lacode";
197
+ var findPackageRoot = (start) => {
198
+ let dir = start;
199
+ for (let up = 0; up < 6; up++) {
200
+ try {
201
+ if (JSON.parse(readFileSync(join2(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
202
+ } catch {
203
+ }
204
+ const parent = dirname(dir);
205
+ if (parent === dir) break;
206
+ dir = parent;
207
+ }
208
+ return join2(start, "..", "..");
209
+ };
210
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
211
+
212
+ // src/lib/selfupdate.ts
213
+ import { spawn } from "node:child_process";
214
+ import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
215
+ import { homedir as homedir2 } from "node:os";
216
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
217
+ var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
218
+ var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
219
+ var FETCH_TIMEOUT_MS = 4e3;
220
+ var INSTALL_TIMEOUT_MS = 12e4;
221
+ var LOCK_STALE_MS = 10 * 60 * 1e3;
222
+ var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
223
+ var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
224
+ var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
225
+ var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
226
+ function compareSemver(a, b) {
227
+ const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
228
+ const left = parse(a);
229
+ const right = parse(b);
230
+ for (let i = 0; i < 3; i++) {
231
+ const l = left[i] ?? 0;
232
+ const r = right[i] ?? 0;
233
+ if (l !== r) return l > r ? 1 : -1;
234
+ }
235
+ return 0;
236
+ }
237
+ function readInstalledVersion(packageRoot) {
238
+ try {
239
+ const manifest = JSON.parse(readFileSync2(join3(packageRoot, "package.json"), "utf8"));
240
+ if (manifest.name !== PACKAGE_NAME) return void 0;
241
+ return typeof manifest.version === "string" ? manifest.version : void 0;
242
+ } catch {
243
+ return void 0;
244
+ }
245
+ }
246
+ function resolveCacheRoot(packageRoot, exists = (p) => {
247
+ try {
248
+ return statSync(p).isFile();
249
+ } catch {
250
+ return false;
251
+ }
252
+ }) {
253
+ const tail = join3("node_modules", "@lawaty", "lacode");
254
+ if (!packageRoot.endsWith(tail)) return void 0;
255
+ const cacheRoot = resolve(packageRoot, "..", "..", "..");
256
+ if (!exists(join3(cacheRoot, "package.json"))) return void 0;
257
+ return cacheRoot;
258
+ }
259
+ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
260
+ try {
261
+ return statSync(p).isFile();
262
+ } catch {
263
+ return false;
264
+ }
265
+ }) {
266
+ if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
267
+ for (const dir of (env.PATH ?? "").split(":")) {
268
+ if (!dir) continue;
269
+ const candidate = join3(dir, "opencode");
270
+ if (exists(candidate)) return candidate;
271
+ }
272
+ const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
273
+ return exists(fallback) ? fallback : void 0;
274
+ }
275
+ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
276
+ const controller = new AbortController();
277
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
278
+ try {
279
+ const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
280
+ if (!response.ok) return void 0;
281
+ const body = await response.json();
282
+ return typeof body.version === "string" ? body.version : void 0;
283
+ } catch {
284
+ return void 0;
285
+ } finally {
286
+ clearTimeout(timer);
287
+ }
288
+ }
289
+ async function checkForUpdate(opts) {
290
+ const installed = readInstalledVersion(opts.packageRoot);
291
+ const latest = await fetchLatest(opts.fetchImpl);
292
+ return {
293
+ installed,
294
+ latest,
295
+ newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
296
+ cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
297
+ bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
298
+ };
299
+ }
300
+ async function refreshCache(opts) {
301
+ const { cacheRoot, bin, latest } = opts;
302
+ const spawnImpl = opts.spawnImpl ?? spawn;
303
+ const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
304
+ const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
305
+ const aside = `${cacheRoot}.stale-${Date.now()}`;
306
+ try {
307
+ rmSync(aside, { recursive: true, force: true });
308
+ renameSync(cacheRoot, aside);
309
+ } catch (error) {
310
+ return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
311
+ }
312
+ const exited = await new Promise((settle) => {
313
+ let done = false;
314
+ const finish = (value) => {
315
+ if (done) return;
316
+ done = true;
317
+ clearTimeout(timer);
318
+ settle(value);
319
+ };
320
+ const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
321
+ env: { ...process.env, [CHILD_ENV]: "1" },
322
+ stdio: "ignore"
323
+ });
324
+ const timer = setTimeout(() => {
325
+ try {
326
+ child.kill("SIGKILL");
327
+ } catch {
328
+ }
329
+ finish(false);
330
+ }, timeoutMs);
331
+ child.on("error", () => finish(false));
332
+ child.on("exit", (code) => finish(code === 0));
333
+ });
334
+ const installed = readInstalledVersion(fresh);
335
+ const ok = exited && installed === latest;
336
+ if (ok) {
337
+ try {
338
+ rmSync(aside, { recursive: true, force: true });
339
+ } catch {
340
+ }
341
+ } else {
342
+ try {
343
+ renameSync(aside, cacheRoot);
344
+ } catch {
345
+ }
346
+ }
347
+ return {
348
+ ok,
349
+ detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
350
+ };
351
+ }
352
+
192
353
  // bin/lacode
193
354
  if (!process.features?.typescript) {
194
355
  if (process.env.LACODE_STRIP_TYPECHECKED === "1") {
@@ -198,7 +359,7 @@ if (!process.features?.typescript) {
198
359
  const { spawnSync: spawnSync3 } = await import("node:child_process");
199
360
  const result = spawnSync3(
200
361
  process.execPath,
201
- ["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
362
+ ["--experimental-strip-types", "--no-warnings", fileURLToPath2(import.meta.url), ...process.argv.slice(2)],
202
363
  { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
203
364
  );
204
365
  process.exit(result.status ?? 1);
@@ -216,6 +377,13 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
216
377
  lacode doctor
217
378
  Print what the environment resolves to. Diagnostics only.
218
379
 
380
+ lacode update [--check]
381
+ Bring this machine's installed LaCode up to the latest published
382
+ version. opencode's plugin cache never re-resolves on its own, so
383
+ this is the manual half of the automatic check the plugin runs at
384
+ startup. --check only reports; it installs nothing. The new version
385
+ takes effect on the next opencode start.
386
+
219
387
  Environment:
220
388
  OC_LOCAL_USER desktop user the tunnel authenticates as
221
389
  OC_TUNNEL_PORT reverse-forward listener port (default 10022)
@@ -269,6 +437,40 @@ switch (command) {
269
437
  process.exit(0);
270
438
  break;
271
439
  }
440
+ case "update": {
441
+ const checkOnly = argv.includes("--check");
442
+ const check = await checkForUpdate({ packageRoot: PACKAGE_ROOT });
443
+ if (!check.installed) {
444
+ console.error("lacode update: could not read the installed version (run from an installed package).");
445
+ process.exit(1);
446
+ }
447
+ if (!check.latest) {
448
+ console.error("lacode update: could not reach the npm registry; check your connection and retry.");
449
+ process.exit(1);
450
+ }
451
+ if (!check.newer) {
452
+ console.log(`lacode ${check.installed} is up to date (latest ${check.latest}).`);
453
+ process.exit(0);
454
+ }
455
+ console.log(`lacode ${check.installed} -> ${check.latest} available.`);
456
+ if (checkOnly) process.exit(0);
457
+ if (!check.cacheRoot) {
458
+ console.error("This is not an opencode cache install; update it with your package manager instead.");
459
+ process.exit(1);
460
+ }
461
+ if (!check.bin) {
462
+ console.error("lacode update: no opencode binary found to perform the install.");
463
+ process.exit(1);
464
+ }
465
+ const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest });
466
+ if (!result.ok) {
467
+ console.error(`lacode update: install failed (${result.detail}).`);
468
+ process.exit(1);
469
+ }
470
+ console.log(`Updated to ${check.latest}. Restart opencode to apply.`);
471
+ process.exit(0);
472
+ break;
473
+ }
272
474
  case "receive": {
273
475
  const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
274
476
  const result = sendLocal(request);
@@ -292,7 +494,7 @@ switch (command) {
292
494
  }
293
495
  console.log(`Generated ${key}`);
294
496
  }
295
- const pubkey = existsSync(pub) ? readFileSync(pub, "utf8").trim() : "(public key missing)";
497
+ const pubkey = existsSync(pub) ? readFileSync3(pub, "utf8").trim() : "(public key missing)";
296
498
  const self = process.argv[1] ?? "lacode";
297
499
  console.log("\nOn the REMOTE host (where opencode runs), export:");
298
500
  console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
package/dist/pool.js CHANGED
@@ -1,8 +1,29 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
+ import { join as join2 } from "node:path";
5
+
6
+ // src/lib/package-root.ts
7
+ import { readFileSync } from "node:fs";
4
8
  import { dirname, join } from "node:path";
5
9
  import { fileURLToPath } from "node:url";
10
+ var PACKAGE_NAME = "@lawaty/lacode";
11
+ var findPackageRoot = (start) => {
12
+ let dir = start;
13
+ for (let up = 0; up < 6; up++) {
14
+ try {
15
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
16
+ } catch {
17
+ }
18
+ const parent = dirname(dir);
19
+ if (parent === dir) break;
20
+ dir = parent;
21
+ }
22
+ return join(start, "..", "..");
23
+ };
24
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
+
26
+ // src/lib/pool.ts
6
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
7
28
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
8
29
  var ORPHAN_TMP_MS = 60 * 1e3;
@@ -17,25 +38,11 @@ var COOLDOWN_BASE_MS = 60 * 1e3;
17
38
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
18
39
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
19
40
  var BASES = ["explore", "implement", "context"];
20
- var findPackageRoot = (start) => {
21
- let dir = start;
22
- for (let up = 0; up < 6; up++) {
23
- try {
24
- if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@lawaty/lacode") return dir;
25
- } catch {
26
- }
27
- const parent = dirname(dir);
28
- if (parent === dir) break;
29
- dir = parent;
30
- }
31
- return join(start, "..", "..");
32
- };
33
- var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
34
- var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
35
- var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
36
- var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
37
- var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
38
- var USER_MODELS_TXT = join(USER_CONFIG_DIR, "pool.txt");
41
+ var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
42
+ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
43
+ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
44
+ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
45
+ var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
39
46
  var FALLBACK_SLOTS = [
40
47
  { index: 1, model: "opencode/space-bunny-free", weight: 3 },
41
48
  { index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
@@ -89,7 +96,7 @@ function parseSlotText(text, origin) {
89
96
  }
90
97
  var readJSON = (path) => {
91
98
  try {
92
- return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
99
+ return { ok: true, value: JSON.parse(readFileSync2(path, "utf8")) };
93
100
  } catch (error) {
94
101
  return { ok: false, error: String(error) };
95
102
  }
@@ -105,7 +112,7 @@ function readOptional(path, origin, problems) {
105
112
  function readTextOptional(path, origin, problems) {
106
113
  if (!existsSync(path)) return void 0;
107
114
  try {
108
- return readFileSync(path, "utf8");
115
+ return readFileSync2(path, "utf8");
109
116
  } catch (error) {
110
117
  problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
111
118
  return void 0;
@@ -147,7 +154,7 @@ function resolveModels(input = {}) {
147
154
  function readSlotFile(path) {
148
155
  let text;
149
156
  try {
150
- text = readFileSync(path, "utf8");
157
+ text = readFileSync2(path, "utf8");
151
158
  } catch (error) {
152
159
  return { slots: FALLBACK_SLOTS, problems: [`${path} unreadable (${String(error)}); using built-in defaults`] };
153
160
  }
@@ -215,7 +222,7 @@ var retryAfterMs = (headers) => {
215
222
  };
216
223
  function state(opts = {}) {
217
224
  const id = opts.id ?? String(process.pid);
218
- const dir = opts.dir ?? join(homedir(), ".local", "share", "opencode", "agent-pool");
225
+ const dir = opts.dir ?? join2(homedir(), ".local", "share", "opencode", "agent-pool");
219
226
  const modelsFile = opts.modelsFile;
220
227
  const models = opts.models;
221
228
  const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
@@ -264,7 +271,7 @@ function init(s, onWarn) {
264
271
  mkdirSync(s.dir, { recursive: true });
265
272
  s.dirReady = true;
266
273
  try {
267
- unlinkSync(join(s.dir, selfFile(s.id)));
274
+ unlinkSync(join2(s.dir, selfFile(s.id)));
268
275
  } catch {
269
276
  }
270
277
  } catch {
@@ -281,8 +288,8 @@ function publish(s) {
281
288
  const payload = {};
282
289
  for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
283
290
  try {
284
- writeFileSync(join(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
285
- renameSync(join(s.dir, `${selfFile(s.id)}.tmp`), join(s.dir, selfFile(s.id)));
291
+ writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
292
+ renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
286
293
  } catch {
287
294
  s.dirReady = false;
288
295
  }
@@ -297,7 +304,7 @@ function readSiblings(s) {
297
304
  }
298
305
  const now = Date.now();
299
306
  for (const entry of entries) {
300
- const path = join(s.dir, entry);
307
+ const path = join2(s.dir, entry);
301
308
  let age;
302
309
  try {
303
310
  age = now - statSync(path).mtimeMs;
@@ -321,7 +328,7 @@ function readSiblings(s) {
321
328
  continue;
322
329
  }
323
330
  try {
324
- const parsed = JSON.parse(readFileSync(path, "utf8"));
331
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
325
332
  const claims = Object.values(parsed).filter(
326
333
  (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
327
334
  );
@@ -342,9 +349,9 @@ function readLimits(s) {
342
349
  const now = Date.now();
343
350
  for (const entry of entries) {
344
351
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
345
- const path = join(s.dir, entry);
352
+ const path = join2(s.dir, entry);
346
353
  try {
347
- const strike = JSON.parse(readFileSync(path, "utf8"));
354
+ const strike = JSON.parse(readFileSync2(path, "utf8"));
348
355
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
349
356
  if (strike.until > now) cooling.set(strike.model, strike);
350
357
  else unlinkSync(path);
@@ -366,7 +373,7 @@ function recordStrike(s, model, reason, stated) {
366
373
  const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
367
374
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
368
375
  s.cooling.set(model, strike);
369
- const path = join(s.dir, limitFile(model));
376
+ const path = join2(s.dir, limitFile(model));
370
377
  try {
371
378
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
372
379
  renameSync(`${path}.tmp`, path);
@@ -382,7 +389,7 @@ function coolUnavailable(s, model) {
382
389
  if (prior && prior.until > until) return prior;
383
390
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
384
391
  s.cooling.set(model, strike);
385
- const path = join(s.dir, limitFile(model));
392
+ const path = join2(s.dir, limitFile(model));
386
393
  try {
387
394
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
388
395
  renameSync(`${path}.tmp`, path);
@@ -317,6 +317,25 @@ The pain: LaCode once shipped a `tools: { "playwright_*": true }` grant on its f
317
317
 
318
318
  ---
319
319
 
320
+ ### US-27: The package keeps itself current, without npm and without being asked
321
+ **As a** user who installed LaCode once, **I want** each new release to reach my machine automatically, **so that** I never have to learn that opencode freezes the plugin version and hand-clear a cache to upgrade.
322
+
323
+ The pain: opencode's plugin cache is **write-once**. It resolves `@lawaty/lacode` to an exact version once and never re-resolves — not on restart, not with `opencode plugin -g -f`, not with a range pin, not with a tagged config spec (all four verified against opencode 1.18.34). A published release therefore reaches a client only when something installs it into that cache again, and nothing in opencode ever does. The plugin has to do it itself, and it may assume only one tool: the opencode binary the code already runs inside.
324
+
325
+ **Story ID(s) for tests:** US-27
326
+ **Acceptance criteria:**
327
+ - [ ] Given a client runs the plugin and the registry reports a newer version, When the check runs, Then the plugin reinstalls its own cache entry in the background and logs one line naming the old and new version, and startup is never blocked on the registry.
328
+ - [ ] Given the check ran recently, When the plugin loads again within the interval, Then the registry is consulted at most once per interval, so a session start never waits on npm and npm is not hammered.
329
+ - [ ] Given a machine with neither npm nor bun, When an update runs, Then it is performed by the opencode binary alone, because that is the one tool every client is guaranteed to have.
330
+ - [ ] Given an update is installed, When the running session continues, Then it keeps its loaded code and the new version takes effect on the next start, and the log line says exactly that.
331
+ - [ ] Given two opencode processes start together and both find an update, When they race, Then a cross-process lock ensures only one performs the install.
332
+ - [ ] Given `selfUpdate: false` in the plugin options or `LACODE_NO_SELFUPDATE=1` in the environment, When the plugin loads, Then it performs no check at all.
333
+ - [ ] Given the registry is unreachable, When the check runs, Then it is silent, records nothing that would suppress a later retry forever, and never throws into opencode.
334
+ - [ ] Given a plain source checkout rather than an opencode cache install, When the check runs, Then it does not attempt to reinstall, so a development tree is never overwritten.
335
+ - [ ] Given `lacode update` or `lacode update --check`, When it is run, Then it reports (or installs) the update for this machine and exits non-zero only on a real failure — a missing registry, no cache to update, or a failed install.
336
+
337
+ ---
338
+
320
339
  ## Epic G — Context and cost (a maintained map instead of cold exploration)
321
340
 
322
341
  The pain: every task opened by exploring the repository from scratch. The same
@@ -408,6 +427,7 @@ table was filled in.
408
427
  | US-16 | F | `tests/stories/f-adoption-safety.test.mjs` |
409
428
  | US-17 | F | `tests/stories/f-adoption-safety.test.mjs` |
410
429
  | US-24 | F | `tests/stories/f-adoption-safety.test.mjs` |
430
+ | US-27 | F | `tests/stories/h-self-update.test.mjs` |
411
431
  | US-19 | G | `tests/stories/g-context-management.test.mjs` |
412
432
  | US-20 | G | `tests/stories/g-context-management.test.mjs` |
413
433
  | US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",