@lawaty/lacode 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lacode.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/lacode
4
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
6
- import { spawnSync as spawnSync2 } from "node:child_process";
4
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
5
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
6
+ import { spawnSync as spawnSync3 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
9
9
  import { spawn, spawnSync } from "node:child_process";
@@ -279,9 +279,17 @@ function compareSemver(a, b) {
279
279
  }
280
280
  return 0;
281
281
  }
282
- function readInstalledVersion(packageRoot) {
282
+ function readInstalledVersion(packageRoot, read = (path) => {
283
+ try {
284
+ return readFileSync2(path, "utf8");
285
+ } catch {
286
+ return void 0;
287
+ }
288
+ }) {
283
289
  try {
284
- const manifest = JSON.parse(readFileSync2(join3(packageRoot, "package.json"), "utf8"));
290
+ const raw = read(join3(packageRoot, "package.json"));
291
+ if (raw === void 0) return void 0;
292
+ const manifest = JSON.parse(raw);
285
293
  if (manifest.name !== PACKAGE_NAME) return void 0;
286
294
  return typeof manifest.version === "string" ? manifest.version : void 0;
287
295
  } catch {
@@ -395,16 +403,229 @@ async function refreshCache(opts) {
395
403
  };
396
404
  }
397
405
 
406
+ // src/lib/version.ts
407
+ import { spawnSync as spawnSync2 } from "node:child_process";
408
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "node:fs";
409
+ import { homedir as homedir3 } from "node:os";
410
+ import { isAbsolute, join as join4, relative, sep } from "node:path";
411
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
412
+
413
+ // src/lib/jsonc.ts
414
+ var stripJsonc = (raw) => {
415
+ let out = "";
416
+ let i = 0;
417
+ while (i < raw.length) {
418
+ const ch = raw[i];
419
+ if (ch === '"') {
420
+ out += ch;
421
+ i++;
422
+ while (i < raw.length) {
423
+ out += raw[i];
424
+ if (raw[i] === "\\") {
425
+ out += raw[i + 1] ?? "";
426
+ i += 2;
427
+ continue;
428
+ }
429
+ if (raw[i] === '"') {
430
+ i++;
431
+ break;
432
+ }
433
+ i++;
434
+ }
435
+ continue;
436
+ }
437
+ if (ch === "/" && raw[i + 1] === "/") {
438
+ while (i < raw.length && raw[i] !== "\n") i++;
439
+ continue;
440
+ }
441
+ if (ch === "/" && raw[i + 1] === "*") {
442
+ i += 2;
443
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
444
+ i += 2;
445
+ continue;
446
+ }
447
+ out += ch;
448
+ i++;
449
+ }
450
+ return out.replace(/,(\s*[}\]])/g, "$1");
451
+ };
452
+ var parseJsonc = (raw) => {
453
+ try {
454
+ return JSON.parse(stripJsonc(raw));
455
+ } catch {
456
+ return void 0;
457
+ }
458
+ };
459
+
460
+ // src/lib/version.ts
461
+ var nodeStat = (path) => {
462
+ try {
463
+ return statSync2(path).mtimeMs;
464
+ } catch {
465
+ return void 0;
466
+ }
467
+ };
468
+ var nodeExists = (path) => existsSync2(path);
469
+ var nodeRead = (path) => {
470
+ try {
471
+ return readFileSync3(path, "utf8");
472
+ } catch {
473
+ return void 0;
474
+ }
475
+ };
476
+ var realGit = (root) => (args) => {
477
+ try {
478
+ const result = spawnSync2("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5e3 });
479
+ if (result.status !== 0) return void 0;
480
+ return (result.stdout ?? "").trim();
481
+ } catch {
482
+ return void 0;
483
+ }
484
+ };
485
+ var walkSources = (root) => {
486
+ const found = [];
487
+ const stack = [join4(root, "src")];
488
+ while (stack.length > 0) {
489
+ const dir = stack.pop();
490
+ let names;
491
+ try {
492
+ names = readdirSync(dir);
493
+ } catch {
494
+ continue;
495
+ }
496
+ for (const name of names) {
497
+ const full = join4(dir, name);
498
+ if (name.endsWith(".ts")) found.push(full);
499
+ else if (name === "plugins" || name === "lib" || name === "tunnel") stack.push(full);
500
+ }
501
+ }
502
+ return found;
503
+ };
504
+ var checkDist = (root, deps) => {
505
+ if (!deps.exists(join4(root, "dist"))) return { dist: "unknown" };
506
+ const built = deps.stat(join4(root, "dist", "index.js"));
507
+ if (built === void 0) return { dist: "unknown" };
508
+ const sources = deps.listSources(root).map((file) => ({ file, mtime: deps.stat(file) }));
509
+ const newer = sources.filter((s) => s.mtime !== void 0 && s.mtime > built).map((s) => relative(root, s.file));
510
+ if (newer.length > 0) return { dist: "stale", stale: newer };
511
+ return { dist: "current" };
512
+ };
513
+ var readPluginEntries = (raw) => {
514
+ const parsed = parseJsonc(raw);
515
+ const list = parsed?.plugin;
516
+ if (!Array.isArray(list)) return void 0;
517
+ const entries = [];
518
+ for (const item of list) {
519
+ if (typeof item === "string") entries.push({ specifier: item });
520
+ else if (Array.isArray(item) && typeof item[0] === "string") {
521
+ entries.push({ specifier: item[0], options: item[1] ?? void 0 });
522
+ }
523
+ }
524
+ return entries;
525
+ };
526
+ var isInside = (child, parent) => {
527
+ const rel = relative(parent, child);
528
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
529
+ };
530
+ var resolvesHere = (specifier, packageRoot) => {
531
+ if (specifier === PACKAGE_NAME) {
532
+ return packageRoot.split(sep).includes("node_modules");
533
+ }
534
+ if (specifier.startsWith("file://")) {
535
+ try {
536
+ const path = fileURLToPath2(specifier);
537
+ return isInside(path, packageRoot) || isInside(packageRoot, path);
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
542
+ if (specifier.startsWith(".")) return resolvesHere(new URL(specifier, `file://${packageRoot}/`).href, packageRoot);
543
+ return false;
544
+ };
545
+ function readVersionReport(deps = {}) {
546
+ const packageRoot = deps.packageRoot ?? PACKAGE_ROOT;
547
+ const home = deps.home ?? homedir3();
548
+ const git = deps.git ?? realGit(packageRoot);
549
+ const readFile = deps.readFile ?? nodeRead;
550
+ const stat = deps.stat ?? nodeStat;
551
+ const exists = deps.exists ?? nodeExists;
552
+ const listSources = deps.listSources ?? walkSources;
553
+ const isCheckout = exists(join4(packageRoot, ".git"));
554
+ const report = {
555
+ version: readInstalledVersion(packageRoot, readFile),
556
+ root: packageRoot,
557
+ source: isCheckout ? "checkout" : "installed"
558
+ };
559
+ if (isCheckout) {
560
+ report.commit = git(["rev-parse", "--short", "HEAD"]) || "(unknown)";
561
+ report.dirty = (git(["status", "--porcelain"]) ?? "").length > 0;
562
+ }
563
+ const dist = checkDist(packageRoot, { stat, exists, listSources });
564
+ report.dist = dist.dist;
565
+ if (dist.stale) report.staleSources = dist.stale;
566
+ const configPath = [join4(home, ".config", "opencode", "opencode.jsonc"), join4(home, ".config", "opencode", "opencode.json")].find(
567
+ (candidate) => exists(candidate)
568
+ );
569
+ if (!configPath) {
570
+ report.note = `No opencode config found under ${join4(home, ".config", "opencode")}; nothing to compare this copy against.`;
571
+ return report;
572
+ }
573
+ report.configPath = configPath;
574
+ const entries = readPluginEntries(readFile(configPath) ?? "");
575
+ if (!entries) {
576
+ report.note = `${configPath} has no readable \`plugin\` array.`;
577
+ return report;
578
+ }
579
+ report.loaded = entries.map((e) => e.specifier);
580
+ const lacodeEntries = entries.filter((e) => e.specifier.includes("lacode"));
581
+ const ours = lacodeEntries.filter((e) => resolvesHere(e.specifier, packageRoot));
582
+ if (lacodeEntries.length === 0) {
583
+ report.servedByThisCopy = false;
584
+ report.note = `${configPath} loads no LaCode plugin entry.`;
585
+ return report;
586
+ }
587
+ if (ours.length > 0) {
588
+ report.servedByThisCopy = ours.length === lacodeEntries.length;
589
+ if (!report.servedByThisCopy) {
590
+ report.note = `${configPath} loads LaCode from more than one place; only some entries resolve to this copy.`;
591
+ }
592
+ return report;
593
+ }
594
+ report.servedByThisCopy = false;
595
+ report.note = `opencode loads LaCode from ${lacodeEntries.map((e) => e.specifier).join(", ")} \u2014 NOT this copy at ${packageRoot}. The version above is this CLI, not necessarily what opencode runs.`;
596
+ return report;
597
+ }
598
+ var formatVersion = (report) => {
599
+ const lines = [
600
+ ["version", report.version ?? "(unknown)"],
601
+ ["root", report.root],
602
+ ["source", report.source]
603
+ ];
604
+ if (report.commit) {
605
+ lines.push(["commit", `${report.commit}${report.dirty ? " (dirty)" : ""}`]);
606
+ }
607
+ if (report.dist) {
608
+ const stale = report.staleSources?.length ? ` \u2014 ${report.staleSources.length} source file(s) newer than dist/index.js` : "";
609
+ lines.push(["dist", `${report.dist}${stale}`]);
610
+ }
611
+ if (report.configPath) lines.push(["config", report.configPath]);
612
+ if (report.loaded?.length) lines.push(["plugin", report.loaded.join(", ")]);
613
+ if (report.servedByThisCopy !== void 0) {
614
+ lines.push(["this copy", report.servedByThisCopy ? "yes \u2014 opencode loads this copy" : "NO \u2014 see note"]);
615
+ }
616
+ return lines.map(([key, value]) => `${key.padEnd(10)} ${String(value)}`).join("\n");
617
+ };
618
+
398
619
  // bin/lacode
399
620
  if (!process.features?.typescript) {
400
621
  if (process.env.LACODE_STRIP_TYPECHECKED === "1") {
401
622
  console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
402
623
  process.exit(1);
403
624
  }
404
- const { spawnSync: spawnSync3 } = await import("node:child_process");
405
- const result = spawnSync3(
625
+ const { spawnSync: spawnSync4 } = await import("node:child_process");
626
+ const result = spawnSync4(
406
627
  process.execPath,
407
- ["--experimental-strip-types", "--no-warnings", fileURLToPath2(import.meta.url), ...process.argv.slice(2)],
628
+ ["--experimental-strip-types", "--no-warnings", fileURLToPath3(import.meta.url), ...process.argv.slice(2)],
408
629
  { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
409
630
  );
410
631
  process.exit(result.status ?? 1);
@@ -425,6 +646,15 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
425
646
  lacode doctor
426
647
  Print what the environment resolves to. Diagnostics only.
427
648
 
649
+ lacode version
650
+ Report WHICH LaCode is speaking and WHICH one opencode actually loads.
651
+ Version, source (checkout or install), the commit it was built from,
652
+ and whether dist/ is behind src/ after a git pull. Compares itself
653
+ against the plugin array in your opencode config, because a config may
654
+ load the plugin by absolute path and report a version you did not ask
655
+ about. Never reaches the network; \`lacode update --check\` is what asks
656
+ npm whether something newer exists.
657
+
428
658
  lacode update [--check]
429
659
  Bring this machine's installed LaCode up to the latest published
430
660
  version. opencode's plugin cache never re-resolves on its own, so
@@ -485,6 +715,18 @@ switch (command) {
485
715
  process.exit(0);
486
716
  break;
487
717
  }
718
+ case "version": {
719
+ const report = readVersionReport();
720
+ console.log(formatVersion(report));
721
+ if (report.note) console.log(`
722
+ ${report.note}`);
723
+ if (report.dist === "stale") {
724
+ console.log("\nRebuild before trusting the rest of this report: npm run build");
725
+ for (const file of report.staleSources ?? []) console.log(` newer than dist: ${file}`);
726
+ }
727
+ process.exit(0);
728
+ break;
729
+ }
488
730
  case "update": {
489
731
  const checkOnly = argv.includes("--check");
490
732
  const check = await checkForUpdate({ packageRoot: PACKAGE_ROOT });
@@ -534,15 +776,15 @@ switch (command) {
534
776
  }
535
777
  const { user, host, key } = resolveTunnel();
536
778
  const pub = `${key}.pub`;
537
- if (!existsSync2(key)) {
538
- const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
779
+ if (!existsSync3(key)) {
780
+ const gen = spawnSync3("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
539
781
  if (gen.status !== 0) {
540
782
  console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
541
783
  process.exit(1);
542
784
  }
543
785
  console.log(`Generated ${key}`);
544
786
  }
545
- const pubkey = existsSync2(pub) ? readFileSync3(pub, "utf8").trim() : "(public key missing)";
787
+ const pubkey = existsSync3(pub) ? readFileSync4(pub, "utf8").trim() : "(public key missing)";
546
788
  const self = process.argv[1] ?? "lacode";
547
789
  console.log("\nOn the REMOTE host (where opencode runs), export:");
548
790
  console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
package/dist/pool.js CHANGED
@@ -30,6 +30,7 @@ var ORPHAN_TMP_MS = 60 * 1e3;
30
30
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
31
31
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
32
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
+ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
33
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
34
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
35
36
  var MAX_WEIGHT = 100;
@@ -203,6 +204,8 @@ var LIMIT_PATTERNS = [
203
204
  ];
204
205
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
205
206
  var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
+ var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
+ var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
206
209
  var statedCooldown = (text) => {
207
210
  const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
208
211
  if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
@@ -291,7 +294,7 @@ function publish(s) {
291
294
  if (!s.dirReady) return;
292
295
  const now = Date.now();
293
296
  const payload = {};
294
- for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
297
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) <= CLAIM_TTL_MS) payload[key] = claim;
295
298
  try {
296
299
  writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
297
300
  renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
@@ -510,6 +513,11 @@ function pickSlot(s, snap, options) {
510
513
  function agentFor(base, slot) {
511
514
  return `${base}-${slot}`;
512
515
  }
516
+ function manualVariant(name) {
517
+ const direct = /^(.*)-([1-9][0-9]*)$/.exec(name);
518
+ if (!direct) return void 0;
519
+ return BASES.includes(direct[1]) ? direct[1] : void 0;
520
+ }
513
521
  function acquire(s, base, slot, ownerSession, key, target) {
514
522
  const now = Date.now();
515
523
  s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
@@ -567,12 +575,17 @@ function idleFor(s, claim, now) {
567
575
  const seen = s.activity.get(target);
568
576
  return seen ? now - seen : Infinity;
569
577
  }
578
+ function condemnReason(s, claim, now = Date.now()) {
579
+ const age = now - claim.t;
580
+ if (age > MAX_CLAIM_AGE_MS) return "overran";
581
+ if (age > STUCK_MIN_AGE_MS && idleFor(s, claim, now) >= STUCK_IDLE_MS) return "hung";
582
+ return void 0;
583
+ }
570
584
  function findStuck(s, now = Date.now()) {
571
585
  const stuck = [];
572
586
  for (const [key, claim] of s.own) {
573
- if (now - claim.t <= STUCK_MIN_AGE_MS) continue;
574
- if (idleFor(s, claim, now) < STUCK_IDLE_MS) continue;
575
- stuck.push({ key, claim });
587
+ const reason = condemnReason(s, claim, now);
588
+ if (reason) stuck.push({ key, claim, reason });
576
589
  }
577
590
  return stuck;
578
591
  }
@@ -608,6 +621,7 @@ export {
608
621
  DEFAULT_PRESET,
609
622
  FALLBACK_SLOTS,
610
623
  HANG_COOLDOWN_MS,
624
+ MAX_CLAIM_AGE_MS,
611
625
  MAX_SLOTS,
612
626
  MAX_WEIGHT,
613
627
  ORPHAN_TMP_MS,
@@ -626,6 +640,7 @@ export {
626
640
  backfillChild,
627
641
  bindChild,
628
642
  claimKey,
643
+ condemnReason,
629
644
  coolProvider,
630
645
  coolUnavailable,
631
646
  ensureSlots,
@@ -636,7 +651,9 @@ export {
636
651
  isAvailable,
637
652
  isLimitText,
638
653
  isQuotaReason,
654
+ isTransientGatewayText,
639
655
  isUnavailableText,
656
+ manualVariant,
640
657
  noteActivity,
641
658
  parseSlotText,
642
659
  parseSlots,
@@ -0,0 +1,111 @@
1
+ # Configuration reference
2
+
3
+ The plugin options, where the model pool gets its slots, and exactly what LaCode writes into your config. The short version is in the [README](../README.md#configure).
4
+
5
+ ## Pool preset resolution
6
+
7
+ The slot list is the first of these that yields usable slots:
8
+
9
+ 1. plugin options — `models` in the [tuple](../README.md#configure)
10
+ 2. `~/.config/lacode/pool.json` (its parent directory is created for you)
11
+ 3. the bundled `presets/free-tier.json`
12
+ 4. a built-in fallback, so routing always works
13
+
14
+ A missing, unreadable or structurally broken source falls through to the next and
15
+ logs one warning; nothing here throws into opencode. Nothing hot-reloads —
16
+ change a preset, then restart every opencode process.
17
+
18
+ **Any model list is accepted, including paid ones** (US-7). There is no free-tier
19
+ allowlist anywhere in the package, and no cost check of any kind: only the
20
+ structure is validated. The bundled preset is built from free models because
21
+ that is a sensible default, not because the pool requires it.
22
+
23
+
24
+ ## Which models run which agent
25
+
26
+ Only the **pool** writes a model onto a pooled agent, from whichever source wins
27
+ above. The pooled bases (`explore`, `implement`, `context`)
28
+ and their hidden `-1`..`-4` variants therefore carry no model of their own: a
29
+ model written next to an agent would be a second source of truth that silently
30
+ wins whenever routing falls through. Spawn a base and let the pool route it.
31
+
32
+
33
+ ## ⚠️ Per-agent tool grants must be in your own config
34
+
35
+ **LaCode cannot grant tools to its agents, and this is not an oversight you can
36
+ work around by configuring it.** If you keep a global `"tools"` block in your
37
+ config — for example `"tools": { "playwright_*": false }` — then the pooled
38
+ exploration and implementation bases (`explore` and `implement`, plus their
39
+ hidden `-1`..`-4` pool variants) lose browser access, and **nothing tells them why**: the tool call is simply
40
+ refused mid-task. `context` gets no grant either, which is correct — it
41
+ writes the context map and has no business in a browser.
42
+
43
+ opencode folds an agent's `tools` into its permission list **at config-load
44
+ time**, while decoding the config — before any plugin runs. A plugin's `config`
45
+ hook mutates the already-decoded config, so the fold never re-runs and anything
46
+ it puts in `agent.tools` is inert. (Verified on opencode 1.18.34/1.18.35 with
47
+ `opencode debug agent explore`: a grant written in your own config shows two
48
+ `playwright_*` entries — `deny` then `allow`, last one winning — while the same
49
+ grant injected by the plugin shows only the `deny`.) LaCode's other injected
50
+ fields are not affected; `permission`, `prompt`, `model`, `description`,
51
+ `temperature`, `mode` and `hidden` are all read after the plugin hook runs.
52
+
53
+ **So if you use a global `"tools"` block, declare the grants yourself:**
54
+
55
+ ```jsonc
56
+ {
57
+ "tools": { "playwright_*": false },
58
+ "agent": {
59
+ "explore": { "tools": { "playwright_*": true } },
60
+ "implement": { "tools": { "playwright_*": true } }
61
+ }
62
+ }
63
+ ```
64
+
65
+ The hidden pool variants (`explore-1`, `implement-2`, …) inherit
66
+ nothing here — add a line per variant, or drop the global
67
+ deny and scope it to the agents that should not have browser access instead.
68
+ Note that you cannot *spawn* a variant by name: the pool refuses
69
+ `explore-1` and friends and points you at the bare base, so per-variant
70
+ permissions only ever apply to a spawn the plugin routed itself.
71
+
72
+ Nothing hot-reloads: after editing your config, restart every running opencode
73
+ process.
74
+
75
+
76
+ ## What gets injected
77
+
78
+ **17 agent keys**: the 5 bases `build`, `plan`, `explore`, `implement`,
79
+ `context`, plus one hidden
80
+ `<base>-1`..`-4` pool variant for each of the 3 pooled bases.
81
+
82
+ `build` and `plan` exist in opencode as built-ins, so on a stock config those
83
+ two keys are already taken and merge-without-clobber keeps the built-in — the
84
+ LaCode definitions of `build` and `plan` are what you get if you remove the
85
+ built-ins of those names first.
86
+
87
+ **Five keys are deliberately not injected.** `basic` is a byte-for-byte
88
+ duplicate of `plan` apart from a pinned model, and `plan` is injected instead.
89
+ `expert` pins a specific model and carries a personal benchmark note in its
90
+ description — a package cannot ship someone else's provider, quota or benchmark
91
+ results — though its escalation ladder still resolves, because the two deep
92
+ tiers it escalates to *are* injected. And `explore`, `general` and `implement`
93
+ are only `{ "disable": true }` stubs in the author's own config: turning off
94
+ opencode's built-in agents is a decision about one person's configuration, not
95
+ a default a package should impose on a stranger.
96
+
97
+ One **field** is deliberately not injected either: `tools`, for the
98
+ load-time-folding reason explained above. Nothing else the plugin writes into
99
+ an agent is subject to it.
100
+
101
+ **Installation merges, it does not clobber** (US-16). An agent, command or
102
+ instruction is added only when that key is absent from your config. A
103
+ user-defined agent of the same name is left byte-identical — not patched, not
104
+ partially merged. Skips are counted and logged once.
105
+
106
+ **`enabled: false` turns all of it off** (US-17). No pool model rewriting, no
107
+ agent injection, no command injection, no reaper timer armed, no
108
+ `~/.config/lacode` directory created.
109
+
110
+ These three are specified in [docs/USER-STORIES.md](USER-STORIES.md) as
111
+ US-7, US-16 and US-17, and each has tests.