@lawaty/lacode 0.7.0 → 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);
@@ -1313,23 +1444,52 @@ on that.
1313
1444
  tidy, reorder or delete content the client did not ask you to touch.
1314
1445
  - Prefer current-state wording: state how things are, not how they changed.
1315
1446
  - Keep the file concise. If it is growing past roughly 150 lines, say so.
1316
- - If \`AGENTS.md\` does not exist, stop and report that there is nothing to edit.
1317
- Do not create it.
1447
+ - If \`AGENTS.md\` does not exist, create it and record the client's instruction in
1448
+ it \u2014 that instruction is the file's first content.
1449
+ - Do NOT add placeholder comments, empty template headings, or example text;
1450
+ write only what the client asked for.
1451
+ - Say plainly in your report that you created the file.
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.
1318
1464
 
1319
1465
  # Reporting
1320
1466
 
1321
1467
  Report briefly what you changed and where. Do not edit any other file.`,
1322
1468
  permission: {
1323
- // Read is scoped to the one file too: the directive is about AGENTS.md, and
1324
- // 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.
1325
1481
  read: {
1326
1482
  "*": "deny",
1327
1483
  "AGENTS.md": "allow",
1328
- "*/AGENTS.md": "allow"
1484
+ "*/AGENTS.md": "allow",
1485
+ "*/.opencode/context/**": "allow",
1486
+ ".opencode/context/**": "allow"
1329
1487
  },
1330
1488
  glob: "deny",
1331
1489
  grep: "deny",
1332
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.
1333
1493
  edit: {
1334
1494
  "*": "deny",
1335
1495
  "AGENTS.md": "allow",
@@ -1465,7 +1625,8 @@ Follow the rules for the file in your system prompt, then report what you change
1465
1625
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1466
1626
 
1467
1627
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
1468
- 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.
1469
1630
 
1470
1631
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
1471
1632
  user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
@@ -1502,7 +1663,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1502
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:
1503
1664
  - one slot per line: \`<provider>/<model-id> [weight]\`
1504
1665
  - at most ${MAX_SLOTS} slots, and a model may appear only once
1505
- - 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.
1506
1667
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1507
1668
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1508
1669
 
@@ -1537,11 +1698,126 @@ function applyCommands(cfg, commands = COMMANDS) {
1537
1698
 
1538
1699
  // src/plugins/agent-pool.ts
1539
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
1540
1814
  var SERVICE = "agent-pool";
1541
1815
  var REAP_INTERVAL_MS = 3e4;
1542
1816
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1817
+ var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
1543
1818
  var ABORT_TIMEOUT_MS = 1e4;
1544
- 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) => {
1545
1821
  const opts = options ?? {};
1546
1822
  const s = state(opts);
1547
1823
  ensureUserConfigDir();
@@ -1553,6 +1829,13 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1553
1829
  await log("warn", message);
1554
1830
  };
1555
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();
1556
1839
  const abortSession = async (sessionID) => {
1557
1840
  if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1558
1841
  try {
@@ -1594,6 +1877,8 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1594
1877
  s.reaping = true;
1595
1878
  s.lastSweep = Date.now();
1596
1879
  try {
1880
+ pruneSessions(s);
1881
+ sweepDirIfDue();
1597
1882
  const stuck = findStuck(s);
1598
1883
  if (stuck.length === 0) return;
1599
1884
  const released = [];
@@ -1658,6 +1943,46 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1658
1943
  "info",
1659
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`
1660
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
+ );
1661
1986
  return {
1662
1987
  config: async (cfg) => {
1663
1988
  const slots = ensureSlots(s, (m) => void warnOnce(m));
@@ -1709,6 +2034,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1709
2034
  const reason2 = status2?.action?.reason ?? "";
1710
2035
  if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
1711
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
+ }
1712
2041
  const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
1713
2042
  const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
1714
2043
  if (cooled.length > 0) {
@@ -1743,6 +2072,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1743
2072
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
1744
2073
  if (!limited && !transientGateway) return;
1745
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
+ }
1746
2079
  if (isUnavailableText(haystack) || !limited && transientGateway) {
1747
2080
  const transient = coolUnavailable(s, model);
1748
2081
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -1784,15 +2117,22 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1784
2117
  }
1785
2118
  acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
1786
2119
  output.args.subagent_type = variant;
1787
- await log("info", `routed ${requested} -> ${variant}`, {
2120
+ await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
1788
2121
  slot: slot.index,
1789
2122
  weight: slot.weight,
1790
2123
  model: slot.model,
1791
- ceiling: slot.weight,
2124
+ ceiling: slotCeiling(slot),
1792
2125
  load: (snap.counts.get(slot.model) ?? 0) + 1
1793
2126
  });
1794
2127
  return;
1795
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
+ }
1796
2136
  } catch (error) {
1797
2137
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1798
2138
  }
@@ -1802,8 +2142,18 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1802
2142
  release(s, claimKey(input.sessionID, input.callID));
1803
2143
  },
1804
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
+ }),
1805
2155
  pool_status: tool({
1806
- 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.",
1807
2157
  args: {},
1808
2158
  async execute() {
1809
2159
  refreshCatalog(s, serverUrl);
@@ -1832,7 +2182,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1832
2182
  const marker = slot.index === next.index ? " <- next" : "";
1833
2183
  const load = entry?.total ?? 0;
1834
2184
  const atCeiling = !underCeiling(slot, snap.counts);
1835
- 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("; ") : ""}]`;
1836
2186
  };
1837
2187
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
1838
2188
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
@@ -1854,6 +2204,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1854
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}.`,
1855
2205
  `overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
1856
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.",
1857
2211
  coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
1858
2212
  ].join("\n")
1859
2213
  };
@@ -1862,14 +2216,18 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1862
2216
  },
1863
2217
  dispose: async () => {
1864
2218
  clearInterval(timer);
2219
+ if (probeTimer) clearInterval(probeTimer);
1865
2220
  s.own.clear();
2221
+ s.activity.clear();
2222
+ s.children.clear();
1866
2223
  publish(s);
1867
2224
  }
1868
2225
  };
1869
2226
  });
1870
2227
 
1871
2228
  // src/plugins/context-autoupdate.ts
1872
- 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";
1873
2231
  import * as path2 from "node:path";
1874
2232
 
1875
2233
  // src/lib/jsonc.ts
@@ -2025,8 +2383,24 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
2025
2383
  ".next",
2026
2384
  ".turbo",
2027
2385
  ".cache",
2028
- ".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"
2029
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(" ");
2030
2404
  var toPosix2 = (value) => value.split(path2.sep).join("/");
2031
2405
  var findProjectConfig = () => {
2032
2406
  let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
@@ -2061,6 +2435,33 @@ var changedSince = (before, dir) => {
2061
2435
  for (const name of before.keys()) if (!after.has(name)) changed++;
2062
2436
  return changed;
2063
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
+ };
2064
2465
  var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2065
2466
  const pending = /* @__PURE__ */ new Set();
2066
2467
  const owned = /* @__PURE__ */ new Map();
@@ -2094,6 +2495,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2094
2495
  "This session touched these paths (starting context, not an exhaustive list):",
2095
2496
  listed(files),
2096
2497
  "",
2498
+ NO_BULK_READS,
2499
+ "",
2097
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."
2098
2501
  ].join("\n") : [
2099
2502
  "Incremental context update. Project root: " + directory,
@@ -2101,6 +2504,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2101
2504
  "This session touched these paths (start from these and their immediate architectural neighborhood):",
2102
2505
  listed(files),
2103
2506
  "",
2507
+ NO_BULK_READS,
2508
+ "",
2104
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".'
2105
2510
  ].join("\n");
2106
2511
  return {
@@ -2116,6 +2521,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2116
2521
  lastRun = Date.now();
2117
2522
  return;
2118
2523
  }
2524
+ if (isProbe(state2, sessionID)) return;
2119
2525
  const now = Date.now();
2120
2526
  for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
2121
2527
  if (owned.size > 0) return;
@@ -2160,6 +2566,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2160
2566
  body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
2161
2567
  }).then(async (result) => {
2162
2568
  release(state2, claimKey2);
2569
+ await retireSession(client, directory, target, mode);
2163
2570
  const failure = result.error ?? result.data?.info?.error;
2164
2571
  if (failure) {
2165
2572
  requeue();
@@ -2183,6 +2590,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2183
2590
  }
2184
2591
  }).catch(async (error) => {
2185
2592
  release(state2, claimKey2);
2593
+ await retireSession(client, directory, target, mode);
2186
2594
  requeue();
2187
2595
  await log("error", `auto ${mode} failed: ${String(error)}`);
2188
2596
  });
@@ -2369,6 +2777,9 @@ var Notifier = class {
2369
2777
  // src/plugins/notify.ts
2370
2778
  var SERVICE3 = "notify";
2371
2779
  var COOLDOWN_MS2 = 5e3;
2780
+ var MAX_INFLIGHT_HELPERS = 4;
2781
+ var HELPER_TIMEOUT_MS = 3e4;
2782
+ var MAX_TRACKED_SESSIONS = 500;
2372
2783
  var SOUND_ENV = "LACODE_SOUND";
2373
2784
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
2374
2785
  var findHelper = () => {
@@ -2385,6 +2796,8 @@ var findHelper = () => {
2385
2796
  return void 0;
2386
2797
  };
2387
2798
  var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2799
+ const maxInflight = deps.maxInflightHelpers ?? MAX_INFLIGHT_HELPERS;
2800
+ const helperTimeoutMs = deps.helperTimeoutMs ?? HELPER_TIMEOUT_MS;
2388
2801
  const opts = options ?? {};
2389
2802
  const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
2390
2803
  const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
@@ -2394,6 +2807,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2394
2807
  const pending = /* @__PURE__ */ new Set();
2395
2808
  const permissions = /* @__PURE__ */ new Set();
2396
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
+ };
2397
2819
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
2398
2820
  });
2399
2821
  const fetchInfo = async (sessionID) => {
@@ -2401,6 +2823,11 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2401
2823
  return info;
2402
2824
  };
2403
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
+ }
2404
2831
  const helper = findHelper();
2405
2832
  if (!helper) {
2406
2833
  if (!warnedHelper) {
@@ -2414,13 +2841,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2414
2841
  stdio: ["ignore", "ignore", "pipe"],
2415
2842
  detached: true
2416
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?.();
2417
2863
  let stderr = "";
2418
2864
  child.stderr?.on("data", (chunk) => {
2419
2865
  if (stderr.length < 400) stderr += String(chunk);
2420
2866
  });
2421
2867
  child.on("error", (error) => {
2422
2868
  void log("warn", `notification helper failed: ${String(error)}`, { title });
2423
- resolve3({ ok: false, detail: String(error) });
2869
+ finish({ ok: false, detail: String(error) });
2424
2870
  });
2425
2871
  child.on("exit", (code) => {
2426
2872
  const noise = stderr.trim();
@@ -2431,7 +2877,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2431
2877
  message
2432
2878
  });
2433
2879
  }
2434
- resolve3({ ok, detail: noise || void 0 });
2880
+ finish({ ok, detail: noise || void 0 });
2435
2881
  });
2436
2882
  child.unref();
2437
2883
  });
@@ -2486,12 +2932,12 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2486
2932
  try {
2487
2933
  if (event.type === "session.status") {
2488
2934
  const { sessionID: sessionID2, status } = event.properties;
2489
- if (status.type === "busy" && sessionID2) active.add(sessionID2);
2935
+ if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
2490
2936
  return;
2491
2937
  }
2492
2938
  if (event.type === "session.updated") {
2493
2939
  const info = event.properties.info;
2494
- if (info?.id) titles.set(info.id, info.title);
2940
+ if (info?.id) rememberTitle(info.id, info.title);
2495
2941
  return;
2496
2942
  }
2497
2943
  if (event.type === "question.asked") {
@@ -2499,7 +2945,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2499
2945
  const sessionID2 = properties?.sessionID;
2500
2946
  if (!sessionID2) return;
2501
2947
  pending.add(sessionID2);
2502
- active.add(sessionID2);
2948
+ rememberActive(sessionID2);
2503
2949
  const headline = questionHeadline(properties.questions);
2504
2950
  void announce(sessionID2, "question", headline || void 0);
2505
2951
  return;
@@ -2514,7 +2960,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2514
2960
  const sessionID2 = properties?.sessionID;
2515
2961
  if (!sessionID2) return;
2516
2962
  permissions.add(sessionID2);
2517
- active.add(sessionID2);
2963
+ rememberActive(sessionID2);
2518
2964
  void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
2519
2965
  return;
2520
2966
  }
@@ -2727,6 +3173,12 @@ function createSelector(config, deps = {}) {
2727
3173
  const now = deps.now ?? Date.now;
2728
3174
  const stats = /* @__PURE__ */ new Map();
2729
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
+ };
2730
3182
  let cands = [];
2731
3183
  const ensure = (modelID) => {
2732
3184
  let stat = stats.get(modelID);
@@ -2763,7 +3215,10 @@ function createSelector(config, deps = {}) {
2763
3215
  if (list.length === 0) return null;
2764
3216
  if (config.stickyPerSession) {
2765
3217
  const held = sticky.get(sessionID);
2766
- 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
+ }
2767
3222
  }
2768
3223
  let bestID = null;
2769
3224
  let bestStat = null;
@@ -2778,7 +3233,7 @@ function createSelector(config, deps = {}) {
2778
3233
  }
2779
3234
  }
2780
3235
  if (bestID === null) return null;
2781
- if (config.stickyPerSession) sticky.set(sessionID, bestID);
3236
+ if (config.stickyPerSession) rememberSticky(sessionID, bestID);
2782
3237
  return bestID;
2783
3238
  },
2784
3239
  stickyFor(sessionID) {
@@ -2810,6 +3265,9 @@ function createSelector(config, deps = {}) {
2810
3265
  const out = {};
2811
3266
  for (const [id, stat] of stats) out[id] = { ...stat };
2812
3267
  return out;
3268
+ },
3269
+ stickySize() {
3270
+ return sticky.size;
2813
3271
  }
2814
3272
  };
2815
3273
  }
@@ -3029,8 +3487,8 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3029
3487
 
3030
3488
  // src/lib/selfupdate.ts
3031
3489
  import { spawn as spawn2 } from "node:child_process";
3032
- 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";
3033
- 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";
3034
3492
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
3035
3493
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3036
3494
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
@@ -3040,7 +3498,7 @@ var LOCK_STALE_MS = 10 * 60 * 1e3;
3040
3498
  var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
3041
3499
  var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
3042
3500
  var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
3043
- var STATE_DIR = join6(homedir2(), ".local", "share", "opencode", "lacode");
3501
+ var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
3044
3502
  var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
3045
3503
  var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
3046
3504
  function compareSemver(a, b) {
@@ -3097,7 +3555,7 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
3097
3555
  const candidate = join6(dir, "opencode");
3098
3556
  if (exists(candidate)) return candidate;
3099
3557
  }
3100
- const fallback = join6(homedir2(), ".opencode", "bin", "opencode");
3558
+ const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
3101
3559
  return exists(fallback) ? fallback : void 0;
3102
3560
  }
3103
3561
  async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -3123,8 +3581,8 @@ function readState(path4 = STATE_PATH) {
3123
3581
  }
3124
3582
  function writeState(state2, path4 = STATE_PATH) {
3125
3583
  try {
3126
- mkdirSync2(dirname3(path4), { recursive: true });
3127
- writeFileSync2(path4, JSON.stringify(state2));
3584
+ mkdirSync3(dirname3(path4), { recursive: true });
3585
+ writeFileSync3(path4, JSON.stringify(state2));
3128
3586
  } catch {
3129
3587
  }
3130
3588
  }
@@ -3144,13 +3602,13 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3144
3602
  closeSync(fd);
3145
3603
  return () => {
3146
3604
  try {
3147
- unlinkSync2(path4);
3605
+ unlinkSync3(path4);
3148
3606
  } catch {
3149
3607
  }
3150
3608
  };
3151
3609
  };
3152
3610
  try {
3153
- mkdirSync2(dirname3(path4), { recursive: true });
3611
+ mkdirSync3(dirname3(path4), { recursive: true });
3154
3612
  return claim();
3155
3613
  } catch {
3156
3614
  let orphaned = false;
@@ -3169,7 +3627,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3169
3627
  }
3170
3628
  if (!orphaned) return void 0;
3171
3629
  try {
3172
- unlinkSync2(path4);
3630
+ unlinkSync3(path4);
3173
3631
  } catch {
3174
3632
  return void 0;
3175
3633
  }