@lawaty/lacode 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
34
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
36
  var MAX_WEIGHT = 100;
37
+ var SLOT_CEILING_FACTOR = 2;
38
+ var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
37
39
  var MAX_SLOTS = 4;
38
40
  var COOLDOWN_BASE_MS = 60 * 1e3;
39
41
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
40
42
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
41
43
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
42
44
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
+ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
43
46
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
44
47
  var BASES = ["explore", "implement", "context"];
45
48
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
210
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
211
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
209
212
  var statedCooldown = (text) => {
210
- const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
211
- if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
212
- const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
213
- if (m) return Number(m[1]) * 60 * 1e3;
214
- const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
215
- if (s) return Number(s[1]) * 1e3;
213
+ const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
214
+ const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
215
+ if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
216
+ const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
217
+ if (m) return bounded(Number(m[1]) * 60 * 1e3);
218
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
219
+ if (s) return bounded(Number(s[1]) * 1e3);
216
220
  return void 0;
217
221
  };
218
222
  var retryAfterMs = (headers) => {
@@ -246,10 +250,14 @@ function state(opts = {}) {
246
250
  cooling: /* @__PURE__ */ new Map(),
247
251
  strikes: /* @__PURE__ */ new Map(),
248
252
  activity: /* @__PURE__ */ new Map(),
253
+ probes: /* @__PURE__ */ new Set(),
254
+ probing: false,
255
+ lastProbe: 0,
249
256
  children: /* @__PURE__ */ new Map(),
250
257
  reaped: 0,
251
258
  reaping: false,
252
259
  lastSweep: 0,
260
+ lastDirSweep: 0,
253
261
  id,
254
262
  dir,
255
263
  modelsFile,
@@ -286,10 +294,88 @@ function init(s, onWarn) {
286
294
  onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
287
295
  }
288
296
  }
297
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
298
+ function sweepStateDir(s) {
299
+ const removed = [];
300
+ if (!s.dirReady) return removed;
301
+ let entries;
302
+ try {
303
+ entries = readdirSync(s.dir);
304
+ } catch {
305
+ return removed;
306
+ }
307
+ const now = Date.now();
308
+ const drop = (name) => {
309
+ try {
310
+ unlinkSync(join2(s.dir, name));
311
+ removed.push(name);
312
+ } catch {
313
+ }
314
+ };
315
+ for (const entry of entries) {
316
+ let age;
317
+ try {
318
+ age = now - statSync(join2(s.dir, entry)).mtimeMs;
319
+ } catch {
320
+ continue;
321
+ }
322
+ if (entry.endsWith(".tmp")) {
323
+ if (age > ORPHAN_TMP_MS) drop(entry);
324
+ continue;
325
+ }
326
+ const claim = /^claims\.(.+)\.json$/.exec(entry);
327
+ if (claim) {
328
+ if (claim[1] === s.id) continue;
329
+ if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
330
+ drop(entry);
331
+ continue;
332
+ }
333
+ if (age > DEAD_FILE_MS) drop(entry);
334
+ continue;
335
+ }
336
+ if (entry.startsWith("limit.") && entry.endsWith(".json")) {
337
+ try {
338
+ const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
339
+ if (typeof strike.until !== "number") {
340
+ drop(entry);
341
+ continue;
342
+ }
343
+ if (strike.until > now) continue;
344
+ if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
345
+ drop(entry);
346
+ } catch {
347
+ drop(entry);
348
+ }
349
+ }
350
+ }
351
+ return removed;
352
+ }
289
353
  function pruneOwn(s) {
290
354
  const now = Date.now();
291
355
  for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
292
356
  }
357
+ function pruneActivity(s, now) {
358
+ if (s.activity.size === 0) return;
359
+ const live = /* @__PURE__ */ new Set();
360
+ for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
361
+ for (const [id, seen] of s.activity) {
362
+ if (live.has(id)) continue;
363
+ if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
364
+ }
365
+ }
366
+ function pruneChildren(s, now) {
367
+ if (s.children.size === 0) return;
368
+ for (const [parent, ring] of s.children) {
369
+ const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
370
+ if (fresh.length === 0) s.children.delete(parent);
371
+ else if (fresh.length !== ring.length) s.children.set(parent, fresh);
372
+ }
373
+ }
374
+ function pruneSessions(s) {
375
+ const now = Date.now();
376
+ pruneActivity(s, now);
377
+ pruneChildren(s, now);
378
+ }
293
379
  function publish(s) {
294
380
  if (!s.dirReady) return;
295
381
  const now = Date.now();
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
435
521
  }
436
522
  return strike;
437
523
  }
524
+ function clearCooldowns(s, models) {
525
+ const cleared = [];
526
+ const now = Date.now();
527
+ const wanted = models && models.length > 0 ? new Set(models) : void 0;
528
+ let entries;
529
+ try {
530
+ entries = readdirSync(s.dir);
531
+ } catch {
532
+ return cleared;
533
+ }
534
+ for (const entry of entries) {
535
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
536
+ const path4 = join2(s.dir, entry);
537
+ let strike;
538
+ try {
539
+ const parsed = JSON.parse(readFileSync2(path4, "utf8"));
540
+ if (parsed && typeof parsed.model === "string") strike = parsed;
541
+ } catch {
542
+ strike = void 0;
543
+ }
544
+ if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
545
+ try {
546
+ unlinkSync(path4);
547
+ } catch {
548
+ continue;
549
+ }
550
+ if (strike === void 0) {
551
+ cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
552
+ continue;
553
+ }
554
+ s.cooling.delete(strike.model);
555
+ s.strikes.delete(strike.model);
556
+ cleared.push({
557
+ model: strike.model,
558
+ reason: typeof strike.reason === "string" ? strike.reason : "unknown",
559
+ strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
560
+ secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
561
+ });
562
+ }
563
+ return cleared;
564
+ }
565
+ var markProbe = (s, sessionID) => s.probes.add(sessionID);
566
+ var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
567
+ var isProbe = (s, sessionID) => s.probes.has(sessionID);
438
568
  function refreshCatalog(s, serverUrl) {
439
569
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
440
570
  s.catalogAt = Date.now();
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
457
587
  }
458
588
  function snapshot(s) {
459
589
  pruneOwn(s);
590
+ pruneSessions(s);
460
591
  const siblings = readSiblings(s);
461
592
  const cooling = readLimits(s);
462
593
  const counts = /* @__PURE__ */ new Map();
@@ -466,7 +597,7 @@ function snapshot(s) {
466
597
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
467
598
  }
468
599
  var loadOf = (counts, model) => counts.get(model) ?? 0;
469
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
600
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
470
601
  function roundRobinSequence(slots) {
471
602
  const seq = [];
472
603
  for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
@@ -1319,20 +1450,46 @@ on that.
1319
1450
  write only what the client asked for.
1320
1451
  - Say plainly in your report that you created the file.
1321
1452
 
1453
+ # Before you write
1454
+
1455
+ Read \`.opencode/context/\` first when it is there \u2014 \`architecture.md\`,
1456
+ \`conventions.md\` or \`contexts.md\`, whichever bears on the directive \u2014 so you do
1457
+ not record a fact the project has already settled, and do not contradict one you
1458
+ do not know yet. Read only what is relevant, and carry on if it is missing.
1459
+
1460
+ That map is READ-ONLY to you: the \`context\` agent is its only writer. If the
1461
+ directive contradicts it, say so plainly in your report and still write what the
1462
+ client asked for. Never edit the map to agree with \`AGENTS.md\` \u2014 the two are
1463
+ different files with different jobs, and this one is yours alone.
1464
+
1322
1465
  # Reporting
1323
1466
 
1324
1467
  Report briefly what you changed and where. Do not edit any other file.`,
1325
1468
  permission: {
1326
- // Read is scoped to the one file too: the directive is about AGENTS.md, and
1327
- // nothing else is any of this agent's business.
1469
+ // Read reaches AGENTS.md and the context map, and nothing else.
1470
+ //
1471
+ // The map is here because the directive is about how work is DONE in this
1472
+ // project and the map is the maintained record of what the project IS: reading
1473
+ // it is how this agent avoids recording a fact the code already settles, and
1474
+ // avoids contradicting one it does not know yet. It stays READ-ONLY — `edit`
1475
+ // below still denies the map, so this grant cannot turn the one-writer rule
1476
+ // into a two-writer rule, and the writer guard does not have to care about it
1477
+ // (it reads `edit` alone).
1478
+ //
1479
+ // Both spellings are required, and every allow must sit AFTER the broad deny:
1480
+ // LAST match wins, so an allow written before "*": "deny" is silently inert.
1328
1481
  read: {
1329
1482
  "*": "deny",
1330
1483
  "AGENTS.md": "allow",
1331
- "*/AGENTS.md": "allow"
1484
+ "*/AGENTS.md": "allow",
1485
+ "*/.opencode/context/**": "allow",
1486
+ ".opencode/context/**": "allow"
1332
1487
  },
1333
1488
  glob: "deny",
1334
1489
  grep: "deny",
1335
1490
  list: "deny",
1491
+ // Still exactly one writable path. The map is readable (see above) and stays
1492
+ // unwritable: `context` is its only writer, and this agent is not it.
1336
1493
  edit: {
1337
1494
  "*": "deny",
1338
1495
  "AGENTS.md": "allow",
@@ -1468,7 +1625,8 @@ Follow the rules for the file in your system prompt, then report what you change
1468
1625
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1469
1626
 
1470
1627
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
1471
- slot's load against its weight ceiling, and which models are cooling.
1628
+ slot's load against its ceiling (twice the slot's weight), and which models
1629
+ are cooling.
1472
1630
 
1473
1631
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
1474
1632
  user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
@@ -1505,7 +1663,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1505
1663
  2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
1506
1664
  - one slot per line: \`<provider>/<model-id> [weight]\`
1507
1665
  - at most ${MAX_SLOTS} slots, and a model may appear only once
1508
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the soft ceiling on concurrent claims and the slot's share of the rotation \u2014 NOT a priority; pick order follows line order.
1666
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's share of the rotation \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1509
1667
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1510
1668
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1511
1669
 
@@ -1540,11 +1698,126 @@ function applyCommands(cfg, commands = COMMANDS) {
1540
1698
 
1541
1699
  // src/plugins/agent-pool.ts
1542
1700
  import { tool } from "@opencode-ai/plugin";
1701
+
1702
+ // src/lib/probe.ts
1703
+ var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
1704
+ var PROBE_PROMPT = "ok";
1705
+ var PROBE_TIMEOUT_MS = 45e3;
1706
+ var failureText = (error) => {
1707
+ if (error === void 0 || error === null) return "";
1708
+ if (typeof error === "string") return error;
1709
+ try {
1710
+ return JSON.stringify(error) ?? String(error);
1711
+ } catch {
1712
+ return String(error);
1713
+ }
1714
+ };
1715
+ var shortReason = (text) => {
1716
+ if (!text) return "failed";
1717
+ const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
1718
+ return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
1719
+ };
1720
+ var probeOne = async (client, opts, model, leftSeconds) => {
1721
+ const { state: s, directory } = opts;
1722
+ const { provider, id: modelID } = splitModel(model);
1723
+ const settle = (ok, reason, left) => ({
1724
+ model,
1725
+ ok,
1726
+ reason,
1727
+ hadSeconds: leftSeconds,
1728
+ leftSeconds: left
1729
+ });
1730
+ const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
1731
+ if (!created) return settle(false, "no probe session", leftSeconds);
1732
+ markProbe(s, created);
1733
+ let timer;
1734
+ try {
1735
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
1736
+ const clock = opts.now ?? Date.now;
1737
+ let timedOut = false;
1738
+ const deadline = new Promise((resolve3) => {
1739
+ timer = setTimeout(() => {
1740
+ timedOut = true;
1741
+ void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
1742
+ });
1743
+ resolve3(void 0);
1744
+ }, timeoutMs);
1745
+ });
1746
+ const answer = client.session.prompt({
1747
+ path: { id: created },
1748
+ query: { directory },
1749
+ body: {
1750
+ model: { providerID: provider, modelID },
1751
+ system: PROBE_SYSTEM,
1752
+ parts: [{ type: "text", text: PROBE_PROMPT }]
1753
+ }
1754
+ }).then(
1755
+ (result2) => result2,
1756
+ (error) => ({ error })
1757
+ );
1758
+ const result = await Promise.race([answer, deadline]);
1759
+ const failure = result?.error ?? result?.data?.info?.error;
1760
+ if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
1761
+ clearCooldowns(s, [model]);
1762
+ return settle(true, "answered", 0);
1763
+ }
1764
+ const text = failureText(failure);
1765
+ const stated = statedCooldown(text);
1766
+ if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
1767
+ const still = readLimits(s).get(model);
1768
+ return settle(
1769
+ false,
1770
+ timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
1771
+ still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
1772
+ );
1773
+ } finally {
1774
+ if (timer) clearTimeout(timer);
1775
+ const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
1776
+ if (deleted) unmarkProbe(s, created);
1777
+ }
1778
+ };
1779
+ async function probeCooling(client, opts) {
1780
+ const s = opts.state;
1781
+ const now = opts.now ?? Date.now;
1782
+ const empty = { outcomes: [], cleared: [], kept: [], changed: false };
1783
+ if (s.probing) return empty;
1784
+ const cooling = readLimits(s);
1785
+ const at = now();
1786
+ const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
1787
+ const candidates = [...cooling.values()].filter(
1788
+ (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
1789
+ );
1790
+ if (candidates.length === 0) return empty;
1791
+ s.probing = true;
1792
+ const outcomes = [];
1793
+ try {
1794
+ for (const strike of candidates.sort((a, b) => a.until - b.until)) {
1795
+ outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
1796
+ }
1797
+ } finally {
1798
+ s.probing = false;
1799
+ s.lastProbe = Date.now();
1800
+ }
1801
+ const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
1802
+ const stillLimited = outcomes.filter((o) => !o.ok);
1803
+ return {
1804
+ outcomes,
1805
+ cleared: reAdmitted,
1806
+ kept: stillLimited.map((o) => o.model),
1807
+ // A window the provider restated is a change too: the pool now waits a
1808
+ // different length than it did, and the user is the one who set that.
1809
+ changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
1810
+ };
1811
+ }
1812
+
1813
+ // src/plugins/agent-pool.ts
1543
1814
  var SERVICE = "agent-pool";
1544
1815
  var REAP_INTERVAL_MS = 3e4;
1545
1816
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1817
+ var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
1546
1818
  var ABORT_TIMEOUT_MS = 1e4;
1547
- var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1819
+ var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
1820
+ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
1548
1821
  const opts = options ?? {};
1549
1822
  const s = state(opts);
1550
1823
  ensureUserConfigDir();
@@ -1556,6 +1829,13 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1556
1829
  await log("warn", message);
1557
1830
  };
1558
1831
  init(s, (m) => void warnOnce(m));
1832
+ const sweepDirIfDue = () => {
1833
+ if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
1834
+ s.lastDirSweep = Date.now();
1835
+ const removed = sweepStateDir(s);
1836
+ if (removed.length > 0) void log("info", `swept ${removed.length} stale pool file(s): ${removed.join(", ")}`);
1837
+ };
1838
+ sweepDirIfDue();
1559
1839
  const abortSession = async (sessionID) => {
1560
1840
  if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1561
1841
  try {
@@ -1597,6 +1877,8 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1597
1877
  s.reaping = true;
1598
1878
  s.lastSweep = Date.now();
1599
1879
  try {
1880
+ pruneSessions(s);
1881
+ sweepDirIfDue();
1600
1882
  const stuck = findStuck(s);
1601
1883
  if (stuck.length === 0) return;
1602
1884
  const released = [];
@@ -1661,6 +1943,46 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1661
1943
  "info",
1662
1944
  `reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m, or any claim past the ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}m ceiling however chatty`
1663
1945
  );
1946
+ const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
1947
+ const reportTitle = (report) => report.outcomes.length === 0 ? "nothing cooling to probe" : `probed ${report.outcomes.length} cooling model(s), re-admitted ${report.cleared.length}`;
1948
+ const reportText = (report) => {
1949
+ if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
1950
+ return [
1951
+ ...report.outcomes.map(
1952
+ (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
1953
+ ),
1954
+ report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : "nothing was re-admitted; the pool is short exactly as much as it was."
1955
+ ].join("\n");
1956
+ };
1957
+ const announce = async (report) => {
1958
+ if (!report.changed) return;
1959
+ const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
1960
+ await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
1961
+ try {
1962
+ await client.tui?.showToast?.({
1963
+ body: { title: "pool recheck", message, variant: report.cleared.length > 0 ? "success" : "info", duration: 8e3 }
1964
+ });
1965
+ } catch {
1966
+ }
1967
+ };
1968
+ const runProbe = async (models) => {
1969
+ let report = EMPTY_REPORT;
1970
+ try {
1971
+ report = await probeCooling(client, { state: s, directory, models });
1972
+ } catch (error) {
1973
+ await log("error", `pool probe failed: ${String(error)}`);
1974
+ return EMPTY_REPORT;
1975
+ }
1976
+ await announce(report);
1977
+ return report;
1978
+ };
1979
+ const probeMs = opts.probeMs ?? PROBE_INTERVAL_MS;
1980
+ const probeTimer = probeMs > 0 ? setInterval(() => void runProbe(), probeMs) : void 0;
1981
+ probeTimer?.unref?.();
1982
+ void log(
1983
+ "info",
1984
+ probeMs > 0 ? `cooldown probe armed: every ${Math.round(probeMs / 6e4)}m, checks the models that are cooling by using them (one request each, one at a time); 0 to disable` : "cooldown probe disabled (probeMs: 0); use the pool_recheck tool to check on demand"
1985
+ );
1664
1986
  return {
1665
1987
  config: async (cfg) => {
1666
1988
  const slots = ensureSlots(s, (m) => void warnOnce(m));
@@ -1712,6 +2034,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1712
2034
  const reason2 = status2?.action?.reason ?? "";
1713
2035
  if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
1714
2036
  const provider = status2.action.provider;
2037
+ if (sessionID && isProbe(s, sessionID)) {
2038
+ await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2039
+ return;
2040
+ }
1715
2041
  const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
1716
2042
  const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
1717
2043
  if (cooled.length > 0) {
@@ -1746,6 +2072,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1746
2072
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
1747
2073
  if (!limited && !transientGateway) return;
1748
2074
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2075
+ if (info.sessionID && isProbe(s, info.sessionID)) {
2076
+ await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2077
+ return;
2078
+ }
1749
2079
  if (isUnavailableText(haystack) || !limited && transientGateway) {
1750
2080
  const transient = coolUnavailable(s, model);
1751
2081
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -1787,15 +2117,22 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1787
2117
  }
1788
2118
  acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
1789
2119
  output.args.subagent_type = variant;
1790
- await log("info", `routed ${requested} -> ${variant}`, {
2120
+ await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
1791
2121
  slot: slot.index,
1792
2122
  weight: slot.weight,
1793
2123
  model: slot.model,
1794
- ceiling: slot.weight,
2124
+ ceiling: slotCeiling(slot),
1795
2125
  load: (snap.counts.get(slot.model) ?? 0) + 1
1796
2126
  });
1797
2127
  return;
1798
2128
  }
2129
+ const pinned = s.models.get(requested);
2130
+ const bench = pinned ? snap.cooling.get(pinned) : void 0;
2131
+ if (pinned && bench) {
2132
+ await warnOnce(
2133
+ `${requested} runs ${pinned}, which is cooling (${bench.reason}, strike ${bench.strikes}) -- it cannot fail over, because only ${BASES.join("/")} are pooled. Drop the model line from ${requested} to route it through the pool, or wait out the cooldown.`
2134
+ );
2135
+ }
1799
2136
  } catch (error) {
1800
2137
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1801
2138
  }
@@ -1805,8 +2142,18 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1805
2142
  release(s, claimKey(input.sessionID, input.callID));
1806
2143
  },
1807
2144
  tool: {
2145
+ pool_recheck: tool({
2146
+ description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the rotation; if it is still limited, the existing cooldown stands. Call it after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
2147
+ args: {
2148
+ models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to probe, as provider/model-id. Omit to probe every model that is cooling.")
2149
+ },
2150
+ async execute(args) {
2151
+ const report = await runProbe(args?.models);
2152
+ return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
2153
+ }
2154
+ }),
1808
2155
  pool_status: tool({
1809
- description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its weight ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
2156
+ description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
1810
2157
  args: {},
1811
2158
  async execute() {
1812
2159
  refreshCatalog(s, serverUrl);
@@ -1835,7 +2182,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1835
2182
  const marker = slot.index === next.index ? " <- next" : "";
1836
2183
  const load = entry?.total ?? 0;
1837
2184
  const atCeiling = !underCeiling(slot, snap.counts);
1838
- return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slot.weight}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
2185
+ return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slotCeiling(slot)}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
1839
2186
  };
1840
2187
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
1841
2188
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
@@ -1857,6 +2204,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1857
2204
  `hung past ${Math.round(STUCK_MIN_AGE_MS / 6e4)}min with ${Math.round(STUCK_IDLE_MS / 6e4)}min silence: ${hung} (auto-aborted); reaped total: ${s.reaped}.`,
1858
2205
  `overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
1859
2206
  `watchdog alive: last sweep ${s.lastSweep ? `${Math.max(0, Math.round((now - s.lastSweep) / 1e3))}s ago` : "never"} (every ${Math.round(reapMs / 1e3)}s).`,
2207
+ // When the cooled models were last USED rather than inferred, because
2208
+ // "still cooling" and "nobody has actually asked it lately" are
2209
+ // different situations and this is the line that tells them apart.
2210
+ probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool to ask now.",
1860
2211
  coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
1861
2212
  ].join("\n")
1862
2213
  };
@@ -1865,14 +2216,18 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1865
2216
  },
1866
2217
  dispose: async () => {
1867
2218
  clearInterval(timer);
2219
+ if (probeTimer) clearInterval(probeTimer);
1868
2220
  s.own.clear();
2221
+ s.activity.clear();
2222
+ s.children.clear();
1869
2223
  publish(s);
1870
2224
  }
1871
2225
  };
1872
2226
  });
1873
2227
 
1874
2228
  // src/plugins/context-autoupdate.ts
1875
- import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
2229
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
2230
+ import { homedir as homedir2 } from "node:os";
1876
2231
  import * as path2 from "node:path";
1877
2232
 
1878
2233
  // src/lib/jsonc.ts
@@ -2028,8 +2383,24 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
2028
2383
  ".next",
2029
2384
  ".turbo",
2030
2385
  ".cache",
2031
- ".git"
2386
+ ".git",
2387
+ // Generated payloads. These are OUTPUT, not source: a single decoded corpus or
2388
+ // derived report in one of these is routinely tens of megabytes, and an agent
2389
+ // that samples one with `head`/`cat` produces a tool output large enough to
2390
+ // be worth recording on its own.
2391
+ "derived_outputs",
2392
+ "derived",
2393
+ "artifacts",
2394
+ "corpus",
2395
+ "corpora"
2032
2396
  ]);
2397
+ var NO_BULK_READS = [
2398
+ "Do not read generated, vendored or corpus payloads: `node_modules/`, `dist/`,",
2399
+ "`build/`, `out/`, `*.min.js`, `derived_outputs/`, `*.bytes`, and large `.json`/`.csv`",
2400
+ "corpora. They are megabytes each and describe no architecture. Read the source",
2401
+ "that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
2402
+ "`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
2403
+ ].join(" ");
2033
2404
  var toPosix2 = (value) => value.split(path2.sep).join("/");
2034
2405
  var findProjectConfig = () => {
2035
2406
  let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
@@ -2064,6 +2435,33 @@ var changedSince = (before, dir) => {
2064
2435
  for (const name of before.keys()) if (!after.has(name)) changed++;
2065
2436
  return changed;
2066
2437
  };
2438
+ var RETAIN_TRANSCRIPTS = 20;
2439
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
2440
+ var retireSession = async (client, directory, sessionID, mode) => {
2441
+ try {
2442
+ const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
2443
+ const dir = transcriptDir();
2444
+ mkdirSync2(dir, { recursive: true });
2445
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2446
+ const file = path2.join(dir, `${stamp}-${mode}.json`);
2447
+ writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
2448
+ pruneTranscripts(dir);
2449
+ await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
2450
+ } catch {
2451
+ }
2452
+ };
2453
+ var pruneTranscripts = (dir) => {
2454
+ try {
2455
+ const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
2456
+ for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
2457
+ try {
2458
+ unlinkSync2(path2.join(dir, name));
2459
+ } catch {
2460
+ }
2461
+ }
2462
+ } catch {
2463
+ }
2464
+ };
2067
2465
  var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2068
2466
  const pending = /* @__PURE__ */ new Set();
2069
2467
  const owned = /* @__PURE__ */ new Map();
@@ -2097,6 +2495,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2097
2495
  "This session touched these paths (starting context, not an exhaustive list):",
2098
2496
  listed(files),
2099
2497
  "",
2498
+ NO_BULK_READS,
2499
+ "",
2100
2500
  "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
2101
2501
  ].join("\n") : [
2102
2502
  "Incremental context update. Project root: " + directory,
@@ -2104,6 +2504,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2104
2504
  "This session touched these paths (start from these and their immediate architectural neighborhood):",
2105
2505
  listed(files),
2106
2506
  "",
2507
+ NO_BULK_READS,
2508
+ "",
2107
2509
  'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed".'
2108
2510
  ].join("\n");
2109
2511
  return {
@@ -2119,6 +2521,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2119
2521
  lastRun = Date.now();
2120
2522
  return;
2121
2523
  }
2524
+ if (isProbe(state2, sessionID)) return;
2122
2525
  const now = Date.now();
2123
2526
  for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
2124
2527
  if (owned.size > 0) return;
@@ -2163,6 +2566,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2163
2566
  body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
2164
2567
  }).then(async (result) => {
2165
2568
  release(state2, claimKey2);
2569
+ await retireSession(client, directory, target, mode);
2166
2570
  const failure = result.error ?? result.data?.info?.error;
2167
2571
  if (failure) {
2168
2572
  requeue();
@@ -2186,6 +2590,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2186
2590
  }
2187
2591
  }).catch(async (error) => {
2188
2592
  release(state2, claimKey2);
2593
+ await retireSession(client, directory, target, mode);
2189
2594
  requeue();
2190
2595
  await log("error", `auto ${mode} failed: ${String(error)}`);
2191
2596
  });
@@ -2372,6 +2777,9 @@ var Notifier = class {
2372
2777
  // src/plugins/notify.ts
2373
2778
  var SERVICE3 = "notify";
2374
2779
  var COOLDOWN_MS2 = 5e3;
2780
+ var MAX_INFLIGHT_HELPERS = 4;
2781
+ var HELPER_TIMEOUT_MS = 3e4;
2782
+ var MAX_TRACKED_SESSIONS = 500;
2375
2783
  var SOUND_ENV = "LACODE_SOUND";
2376
2784
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
2377
2785
  var findHelper = () => {
@@ -2388,6 +2796,8 @@ var findHelper = () => {
2388
2796
  return void 0;
2389
2797
  };
2390
2798
  var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2799
+ const maxInflight = deps.maxInflightHelpers ?? MAX_INFLIGHT_HELPERS;
2800
+ const helperTimeoutMs = deps.helperTimeoutMs ?? HELPER_TIMEOUT_MS;
2391
2801
  const opts = options ?? {};
2392
2802
  const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
2393
2803
  const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
@@ -2397,6 +2807,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2397
2807
  const pending = /* @__PURE__ */ new Set();
2398
2808
  const permissions = /* @__PURE__ */ new Set();
2399
2809
  let warnedHelper = false;
2810
+ let inflight = 0;
2811
+ const rememberActive = (sessionID) => {
2812
+ active.add(sessionID);
2813
+ while (active.size > MAX_TRACKED_SESSIONS) active.delete(active.values().next().value);
2814
+ };
2815
+ const rememberTitle = (id, title) => {
2816
+ titles.set(id, title);
2817
+ while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
2818
+ };
2400
2819
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
2401
2820
  });
2402
2821
  const fetchInfo = async (sessionID) => {
@@ -2404,6 +2823,11 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2404
2823
  return info;
2405
2824
  };
2406
2825
  const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
2826
+ if (inflight >= maxInflight) {
2827
+ void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
2828
+ resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
2829
+ return;
2830
+ }
2407
2831
  const helper = findHelper();
2408
2832
  if (!helper) {
2409
2833
  if (!warnedHelper) {
@@ -2417,13 +2841,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2417
2841
  stdio: ["ignore", "ignore", "pipe"],
2418
2842
  detached: true
2419
2843
  });
2844
+ inflight++;
2845
+ let settled = false;
2846
+ let timer;
2847
+ const finish = (result) => {
2848
+ if (settled) return;
2849
+ settled = true;
2850
+ inflight--;
2851
+ if (timer) clearTimeout(timer);
2852
+ resolve3(result);
2853
+ };
2854
+ timer = setTimeout(() => {
2855
+ try {
2856
+ child.kill("SIGKILL");
2857
+ } catch {
2858
+ }
2859
+ void log("warn", `notification helper timed out after ${helperTimeoutMs}ms`, { title });
2860
+ finish({ ok: false, detail: `helper timed out after ${helperTimeoutMs}ms` });
2861
+ }, helperTimeoutMs);
2862
+ timer.unref?.();
2420
2863
  let stderr = "";
2421
2864
  child.stderr?.on("data", (chunk) => {
2422
2865
  if (stderr.length < 400) stderr += String(chunk);
2423
2866
  });
2424
2867
  child.on("error", (error) => {
2425
2868
  void log("warn", `notification helper failed: ${String(error)}`, { title });
2426
- resolve3({ ok: false, detail: String(error) });
2869
+ finish({ ok: false, detail: String(error) });
2427
2870
  });
2428
2871
  child.on("exit", (code) => {
2429
2872
  const noise = stderr.trim();
@@ -2434,7 +2877,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2434
2877
  message
2435
2878
  });
2436
2879
  }
2437
- resolve3({ ok, detail: noise || void 0 });
2880
+ finish({ ok, detail: noise || void 0 });
2438
2881
  });
2439
2882
  child.unref();
2440
2883
  });
@@ -2489,12 +2932,12 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2489
2932
  try {
2490
2933
  if (event.type === "session.status") {
2491
2934
  const { sessionID: sessionID2, status } = event.properties;
2492
- if (status.type === "busy" && sessionID2) active.add(sessionID2);
2935
+ if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
2493
2936
  return;
2494
2937
  }
2495
2938
  if (event.type === "session.updated") {
2496
2939
  const info = event.properties.info;
2497
- if (info?.id) titles.set(info.id, info.title);
2940
+ if (info?.id) rememberTitle(info.id, info.title);
2498
2941
  return;
2499
2942
  }
2500
2943
  if (event.type === "question.asked") {
@@ -2502,7 +2945,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2502
2945
  const sessionID2 = properties?.sessionID;
2503
2946
  if (!sessionID2) return;
2504
2947
  pending.add(sessionID2);
2505
- active.add(sessionID2);
2948
+ rememberActive(sessionID2);
2506
2949
  const headline = questionHeadline(properties.questions);
2507
2950
  void announce(sessionID2, "question", headline || void 0);
2508
2951
  return;
@@ -2517,7 +2960,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2517
2960
  const sessionID2 = properties?.sessionID;
2518
2961
  if (!sessionID2) return;
2519
2962
  permissions.add(sessionID2);
2520
- active.add(sessionID2);
2963
+ rememberActive(sessionID2);
2521
2964
  void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
2522
2965
  return;
2523
2966
  }
@@ -2730,6 +3173,12 @@ function createSelector(config, deps = {}) {
2730
3173
  const now = deps.now ?? Date.now;
2731
3174
  const stats = /* @__PURE__ */ new Map();
2732
3175
  const sticky = /* @__PURE__ */ new Map();
3176
+ const MAX_STICKY = 500;
3177
+ const rememberSticky = (sessionID, modelID) => {
3178
+ sticky.delete(sessionID);
3179
+ sticky.set(sessionID, modelID);
3180
+ while (sticky.size > MAX_STICKY) sticky.delete(sticky.keys().next().value);
3181
+ };
2733
3182
  let cands = [];
2734
3183
  const ensure = (modelID) => {
2735
3184
  let stat = stats.get(modelID);
@@ -2766,7 +3215,10 @@ function createSelector(config, deps = {}) {
2766
3215
  if (list.length === 0) return null;
2767
3216
  if (config.stickyPerSession) {
2768
3217
  const held = sticky.get(sessionID);
2769
- if (held !== void 0 && list.includes(held) && !cooling(held)) return held;
3218
+ if (held !== void 0 && list.includes(held) && !cooling(held)) {
3219
+ rememberSticky(sessionID, held);
3220
+ return held;
3221
+ }
2770
3222
  }
2771
3223
  let bestID = null;
2772
3224
  let bestStat = null;
@@ -2781,7 +3233,7 @@ function createSelector(config, deps = {}) {
2781
3233
  }
2782
3234
  }
2783
3235
  if (bestID === null) return null;
2784
- if (config.stickyPerSession) sticky.set(sessionID, bestID);
3236
+ if (config.stickyPerSession) rememberSticky(sessionID, bestID);
2785
3237
  return bestID;
2786
3238
  },
2787
3239
  stickyFor(sessionID) {
@@ -2813,6 +3265,9 @@ function createSelector(config, deps = {}) {
2813
3265
  const out = {};
2814
3266
  for (const [id, stat] of stats) out[id] = { ...stat };
2815
3267
  return out;
3268
+ },
3269
+ stickySize() {
3270
+ return sticky.size;
2816
3271
  }
2817
3272
  };
2818
3273
  }
@@ -3032,8 +3487,8 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3032
3487
 
3033
3488
  // src/lib/selfupdate.ts
3034
3489
  import { spawn as spawn2 } from "node:child_process";
3035
- 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";
3036
- import { homedir as homedir2 } from "node:os";
3490
+ import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
3491
+ import { homedir as homedir3 } from "node:os";
3037
3492
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
3038
3493
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3039
3494
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
@@ -3043,7 +3498,7 @@ var LOCK_STALE_MS = 10 * 60 * 1e3;
3043
3498
  var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
3044
3499
  var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
3045
3500
  var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
3046
- var STATE_DIR = join6(homedir2(), ".local", "share", "opencode", "lacode");
3501
+ var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
3047
3502
  var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
3048
3503
  var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
3049
3504
  function compareSemver(a, b) {
@@ -3100,7 +3555,7 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
3100
3555
  const candidate = join6(dir, "opencode");
3101
3556
  if (exists(candidate)) return candidate;
3102
3557
  }
3103
- const fallback = join6(homedir2(), ".opencode", "bin", "opencode");
3558
+ const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
3104
3559
  return exists(fallback) ? fallback : void 0;
3105
3560
  }
3106
3561
  async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -3126,8 +3581,8 @@ function readState(path4 = STATE_PATH) {
3126
3581
  }
3127
3582
  function writeState(state2, path4 = STATE_PATH) {
3128
3583
  try {
3129
- mkdirSync2(dirname3(path4), { recursive: true });
3130
- writeFileSync2(path4, JSON.stringify(state2));
3584
+ mkdirSync3(dirname3(path4), { recursive: true });
3585
+ writeFileSync3(path4, JSON.stringify(state2));
3131
3586
  } catch {
3132
3587
  }
3133
3588
  }
@@ -3147,13 +3602,13 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3147
3602
  closeSync(fd);
3148
3603
  return () => {
3149
3604
  try {
3150
- unlinkSync2(path4);
3605
+ unlinkSync3(path4);
3151
3606
  } catch {
3152
3607
  }
3153
3608
  };
3154
3609
  };
3155
3610
  try {
3156
- mkdirSync2(dirname3(path4), { recursive: true });
3611
+ mkdirSync3(dirname3(path4), { recursive: true });
3157
3612
  return claim();
3158
3613
  } catch {
3159
3614
  let orphaned = false;
@@ -3172,7 +3627,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3172
3627
  }
3173
3628
  if (!orphaned) return void 0;
3174
3629
  try {
3175
- unlinkSync2(path4);
3630
+ unlinkSync3(path4);
3176
3631
  } catch {
3177
3632
  return void 0;
3178
3633
  }