@lawaty/lacode 0.1.5 → 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"];
@@ -58,13 +65,45 @@ Every question of the form "what does this file do", "where is X handled", or
58
65
  ("read src/auth.ts", "grep for 'migration' in *.py", "list the src/
59
66
  directory"), one batch per question. Ask for the exact lines or
60
67
  file:line references you need, not a survey.
61
- - **Implementation \u2192 @implement**, with a finalized plan: the exact
62
- steps, the files to touch, and the constraints you already learned from the
63
- explorers. It edits, writes, and runs tests.
68
+ - **Implementation \u2192 @implement**, one decided unit at a time: the exact
69
+ steps, the exact files, the design you have already chosen, and the command
70
+ that proves it. Never one whole refactor handed over in a single piece \u2014
71
+ see \`# Task size\` below.
64
72
 
65
73
  The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
66
74
  spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
67
75
 
76
+ # Task size
77
+
78
+ \`@implement\` runs on a cheap pooled model. It can carry out a decision; it
79
+ cannot make one. So what you hand it is always a small ask \u2014 never a mid or
80
+ large task.
81
+
82
+ A small ask is one change, one concern, already decided by you. Before handing
83
+ it over you should have explored, through \`@explore\`, far enough to know what
84
+ the change is and to have chosen the design: which class or value object, which
85
+ members, what becomes a \`static\`, what gets deleted. \`@implement\` executes that
86
+ choice. It never invents one.
87
+
88
+ It does not have to arrive fully pre-digested. Letting it find the call sites,
89
+ read the code around them and work out which files it touches is normal and
90
+ expected \u2014 that is exploration an agent should be free to do. What it must never
91
+ be handed is a problem to solve.
92
+
93
+ Too small is fine and costs little. Too large is the failure: "convert the
94
+ decode modules into named units, decide which are dead, keep the output
95
+ byte-identical, verify conformance and report" is not a task, it is the whole
96
+ project. A cheap agent handed it will improvise a design, skip the verification
97
+ you never spelled out, and return something you cannot trust.
98
+
99
+ When the work does not divide into small asks it is not implementation work
100
+ yet \u2014 send it to \`@plan\`, or keep it yourself.
101
+
102
+ Split into one ask per task, spawn independent asks together in a single
103
+ message so they run in parallel, and sequence only what truly depends on what.
104
+ If a returned ask turns out to need a decision you did not make, you make the
105
+ call and hand out a corrected ask.
106
+
68
107
  # What stays yours
69
108
 
70
109
  \`edit\`, \`write\`, and \`bash\` are available to you. Use them to apply what a
@@ -275,6 +314,25 @@ write, and run code/bash to make the plan's changes real. Work from the plan
275
314
  as given \u2014 if it's ambiguous or you believe it's wrong, say so explicitly
276
315
  rather than silently improvising a different approach.
277
316
 
317
+ # Before you start
318
+
319
+ The ask you receive is a small one, and it is already decided: the change is
320
+ specified and the design is chosen. Work from it as given rather than silently
321
+ substituting an approach of your own.
322
+
323
+ Expect to do your own locating. Finding the call sites, reading what you touch
324
+ and working out which files are involved is normal work, not a sign of a bad
325
+ brief \u2014 do it without asking.
326
+
327
+ What is not yours to do is decide. If the ask asks you to choose an approach,
328
+ pick the right abstraction, settle a judgement call, or work out what the
329
+ requirement even is, stop and report that back instead of improvising. A
330
+ confident wrong design costs more than no change at all.
331
+
332
+ Size works the same way. A small ask that turns out to be bigger than it looked
333
+ is a signal to come back and say so: complete the part that is decided, and
334
+ report precisely what else needs deciding.
335
+
278
336
  Make changes incrementally where possible, and verify your own work (run
279
337
  tests/build/lint if available) before considering a step done.
280
338
 
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,30 +38,16 @@ 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
- { index: 2, model: "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", weight: 2 },
48
+ { index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
42
49
  { index: 3, model: "opencode/big-pickle", weight: 1 },
43
- { index: 4, model: "openrouter/thinkingmachines/inkling:free", weight: 1 }
50
+ { index: 4, model: "opencode/step-5-preview-free", weight: 1 }
44
51
  ];
45
52
  function parseSlots(parsed, origin) {
46
53
  const problems = [];
@@ -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);
@@ -512,13 +519,45 @@ Every question of the form "what does this file do", "where is X handled", or
512
519
  ("read src/auth.ts", "grep for 'migration' in *.py", "list the src/
513
520
  directory"), one batch per question. Ask for the exact lines or
514
521
  file:line references you need, not a survey.
515
- - **Implementation \u2192 @implement**, with a finalized plan: the exact
516
- steps, the files to touch, and the constraints you already learned from the
517
- explorers. It edits, writes, and runs tests.
522
+ - **Implementation \u2192 @implement**, one decided unit at a time: the exact
523
+ steps, the exact files, the design you have already chosen, and the command
524
+ that proves it. Never one whole refactor handed over in a single piece \u2014
525
+ see \`# Task size\` below.
518
526
 
519
527
  The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
520
528
  spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
521
529
 
530
+ # Task size
531
+
532
+ \`@implement\` runs on a cheap pooled model. It can carry out a decision; it
533
+ cannot make one. So what you hand it is always a small ask \u2014 never a mid or
534
+ large task.
535
+
536
+ A small ask is one change, one concern, already decided by you. Before handing
537
+ it over you should have explored, through \`@explore\`, far enough to know what
538
+ the change is and to have chosen the design: which class or value object, which
539
+ members, what becomes a \`static\`, what gets deleted. \`@implement\` executes that
540
+ choice. It never invents one.
541
+
542
+ It does not have to arrive fully pre-digested. Letting it find the call sites,
543
+ read the code around them and work out which files it touches is normal and
544
+ expected \u2014 that is exploration an agent should be free to do. What it must never
545
+ be handed is a problem to solve.
546
+
547
+ Too small is fine and costs little. Too large is the failure: "convert the
548
+ decode modules into named units, decide which are dead, keep the output
549
+ byte-identical, verify conformance and report" is not a task, it is the whole
550
+ project. A cheap agent handed it will improvise a design, skip the verification
551
+ you never spelled out, and return something you cannot trust.
552
+
553
+ When the work does not divide into small asks it is not implementation work
554
+ yet \u2014 send it to \`@plan\`, or keep it yourself.
555
+
556
+ Split into one ask per task, spawn independent asks together in a single
557
+ message so they run in parallel, and sequence only what truly depends on what.
558
+ If a returned ask turns out to need a decision you did not make, you make the
559
+ call and hand out a corrected ask.
560
+
522
561
  # What stays yours
523
562
 
524
563
  \`edit\`, \`write\`, and \`bash\` are available to you. Use them to apply what a
@@ -729,6 +768,25 @@ write, and run code/bash to make the plan's changes real. Work from the plan
729
768
  as given \u2014 if it's ambiguous or you believe it's wrong, say so explicitly
730
769
  rather than silently improvising a different approach.
731
770
 
771
+ # Before you start
772
+
773
+ The ask you receive is a small one, and it is already decided: the change is
774
+ specified and the design is chosen. Work from it as given rather than silently
775
+ substituting an approach of your own.
776
+
777
+ Expect to do your own locating. Finding the call sites, reading what you touch
778
+ and working out which files are involved is normal work, not a sign of a bad
779
+ brief \u2014 do it without asking.
780
+
781
+ What is not yours to do is decide. If the ask asks you to choose an approach,
782
+ pick the right abstraction, settle a judgement call, or work out what the
783
+ requirement even is, stop and report that back instead of improvising. A
784
+ confident wrong design costs more than no change at all.
785
+
786
+ Size works the same way. A small ask that turns out to be bigger than it looked
787
+ is a signal to come back and say so: complete the part that is decided, and
788
+ report precisely what else needs deciding.
789
+
732
790
  Make changes incrementally where possible, and verify your own work (run
733
791
  tests/build/lint if available) before considering a step done.
734
792
 
@@ -1443,7 +1501,7 @@ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as sta
1443
1501
  import * as path2 from "node:path";
1444
1502
 
1445
1503
  // src/lib/writer-rule.ts
1446
- import { readFileSync as readFileSync2 } from "node:fs";
1504
+ import { readFileSync as readFileSync3 } from "node:fs";
1447
1505
  import * as path from "node:path";
1448
1506
  var CONTEXT_DIR = path.join(".opencode", "context");
1449
1507
  var WRITER = "context";
@@ -1516,7 +1574,7 @@ var evaluatedForm = (mapDir, projectRoot) => {
1516
1574
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
1517
1575
  let raw;
1518
1576
  try {
1519
- raw = readFileSync2(configPath, "utf8");
1577
+ raw = readFileSync3(configPath, "utf8");
1520
1578
  } catch {
1521
1579
  return void 0;
1522
1580
  }
@@ -1604,11 +1662,11 @@ var changedSince = (before, dir) => {
1604
1662
  for (const name of before.keys()) if (!after.has(name)) changed++;
1605
1663
  return changed;
1606
1664
  };
1607
- var contextAutoUpdateHooks = (async ({ client, directory }) => {
1665
+ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
1608
1666
  const pending = /* @__PURE__ */ new Set();
1609
1667
  const owned = /* @__PURE__ */ new Map();
1610
1668
  let lastRun = 0;
1611
- const state2 = state();
1669
+ const state2 = state(options);
1612
1670
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
1613
1671
  });
1614
1672
  const root = path2.resolve(directory);
@@ -1937,14 +1995,14 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
1937
1995
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
1938
1996
  return info;
1939
1997
  };
1940
- const deliverViaHelper = (title, message, urgency) => new Promise((resolve2) => {
1998
+ const deliverViaHelper = (title, message, urgency) => new Promise((resolve3) => {
1941
1999
  const helper = findHelper();
1942
2000
  if (!helper) {
1943
2001
  if (!warnedHelper) {
1944
2002
  warnedHelper = true;
1945
2003
  void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
1946
2004
  }
1947
- resolve2({ ok: false, detail: "no oc-notify helper found" });
2005
+ resolve3({ ok: false, detail: "no oc-notify helper found" });
1948
2006
  return;
1949
2007
  }
1950
2008
  const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal"), {
@@ -1957,7 +2015,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
1957
2015
  });
1958
2016
  child.on("error", (error) => {
1959
2017
  void log("warn", `notification helper failed: ${String(error)}`, { title });
1960
- resolve2({ ok: false, detail: String(error) });
2018
+ resolve3({ ok: false, detail: String(error) });
1961
2019
  });
1962
2020
  child.on("exit", (code) => {
1963
2021
  const noise = stderr.trim();
@@ -1968,7 +2026,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
1968
2026
  message
1969
2027
  });
1970
2028
  }
1971
- resolve2({ ok, detail: noise || void 0 });
2029
+ resolve3({ ok, detail: noise || void 0 });
1972
2030
  });
1973
2031
  child.unref();
1974
2032
  });
@@ -2093,6 +2151,238 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2093
2151
  };
2094
2152
  });
2095
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
+
2096
2386
  // src/index.ts
2097
2387
  var inertHooks = {
2098
2388
  config: async () => {
@@ -2119,6 +2409,7 @@ var LaCode = (async (input, options) => {
2119
2409
  const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
2120
2410
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
2121
2411
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
2412
+ const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
2122
2413
  const config = async (cfg) => {
2123
2414
  try {
2124
2415
  const injected = applyAgents(cfg, agentSet);
@@ -2159,6 +2450,7 @@ var LaCode = (async (input, options) => {
2159
2450
  await pool.dispose?.();
2160
2451
  await autoupdate.dispose?.();
2161
2452
  await notifier.dispose?.();
2453
+ await selfupdate.dispose?.();
2162
2454
  }
2163
2455
  };
2164
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,30 +38,16 @@ 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
- { index: 2, model: "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", weight: 2 },
48
+ { index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
42
49
  { index: 3, model: "opencode/big-pickle", weight: 1 },
43
- { index: 4, model: "openrouter/thinkingmachines/inkling:free", weight: 1 }
50
+ { index: 4, model: "opencode/step-5-preview-free", weight: 1 }
44
51
  ];
45
52
  function parseSlots(parsed, origin) {
46
53
  const problems = [];
@@ -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.5",
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",
package/presets/README.md CHANGED
@@ -36,10 +36,10 @@ in place, no JSON and no objects:
36
36
 
37
37
  ```text
38
38
  # provider/model-id weight
39
- opencode-go/longcat-2.5-preview-free 3
39
+ opencode-go/step-5-preview-free 3
40
40
  opencode/space-bunny-free 2
41
41
  opencode/big-pickle 1
42
- opencode/longcat-2.5-preview-free 1
42
+ opencode/step-5-preview-free 1
43
43
  ```
44
44
 
45
45
  `#` starts a comment (whole-line or trailing), blank lines are ignored, and the
@@ -20,8 +20,8 @@
20
20
  " 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
21
21
  " so a burst past the summed ceilings degrades to even spreading and never blocks",
22
22
  "",
23
- "With this file: longcat (opencode-go) takes 3 sessions to itself, then space-bunny",
24
- "takes 2, then big-pickle and longcat (Zen) take 1 each -- sequence 1,1,1,2,2,3,4,...",
23
+ "With this file: space-bunny takes 3 sessions to itself, then step-5-preview",
24
+ "(opencode-go) takes 2, then big-pickle and step-5-preview (Zen) take 1 each -- sequence 1,1,1,2,2,3,4,...",
25
25
  "Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
26
26
  "primary/overflow cycle exactly, which is why the old tier values still work.",
27
27
  "",
@@ -53,8 +53,8 @@
53
53
  ],
54
54
  "slots": [
55
55
  { "model": "opencode/space-bunny-free", "weight": 3 },
56
- { "model": "opencode-go/longcat-2.5-preview-free", "weight": 2 },
56
+ { "model": "opencode-go/step-5-preview-free", "weight": 2 },
57
57
  { "model": "opencode/big-pickle", "weight": 1 },
58
- { "model": "opencode/longcat-2.5-preview-free", "weight": 1 }
58
+ { "model": "opencode/step-5-preview-free", "weight": 1 }
59
59
  ]
60
60
  }