@lawaty/lacode 0.10.0 → 0.11.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
@@ -33,6 +33,7 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
33
  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
+ var UNAVAILABLE_REASON = "endpoint-unavailable";
36
37
  var MAX_WEIGHT = 100;
37
38
  var SLOT_CEILING_FACTOR = 2;
38
39
  var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
@@ -44,6 +45,10 @@ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
44
45
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
46
  var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
46
47
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
48
+ var MODEL_MISSING_REASON = "model-missing";
49
+ var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
50
+ var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
51
+ var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
47
52
  var BASES = ["explore", "implement", "context"];
48
53
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
49
54
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -52,7 +57,7 @@ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
52
57
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
53
58
  var FALLBACK_SLOTS = [
54
59
  { index: 1, model: "opencode/big-pickle", weight: 3 },
55
- { index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
60
+ { index: 2, model: "opencode-go/beam-free", weight: 2 },
56
61
  { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
57
62
  { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
58
63
  ];
@@ -205,7 +210,14 @@ var LIMIT_PATTERNS = [
205
210
  /overloaded/i,
206
211
  UNAVAILABLE_PATTERN
207
212
  ];
208
- var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
213
+ var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
214
+ var CONTEXT_LIMIT_PATTERN = /(?:context|token|length|window|prompt|char|character|byte|size)\s*(?:limit|length|size|window)/i;
215
+ var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
216
+ var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
217
+ var limitReason = (text) => {
218
+ if (!isLimitText(text)) return void 0;
219
+ return text.match(LIMIT_REASON_PATTERN)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
220
+ };
209
221
  var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
210
222
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
211
223
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
@@ -276,6 +288,21 @@ function ensureSlots(s, onWarn) {
276
288
  for (const problem of problems) onWarn(problem);
277
289
  return slots;
278
290
  }
291
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
292
+ function reloadSlots(s, onWarn) {
293
+ const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
294
+ const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
295
+ if (!changed) return { changed, slots, origin };
296
+ s.slots = slots;
297
+ s.origin = origin;
298
+ for (const problem of problems) onWarn(problem);
299
+ return { changed, slots, origin };
300
+ }
301
+ function slotSources(input = {}) {
302
+ if (input.models !== void 0) return [];
303
+ if (input.modelsFile) return [input.modelsFile];
304
+ return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
305
+ }
279
306
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
280
307
  var selfFile = (id) => `claims.${id}.json`;
281
308
  var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
@@ -465,11 +492,12 @@ function readLimits(s) {
465
492
  try {
466
493
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
467
494
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
495
+ const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
468
496
  if (strike.until > now) {
469
497
  cooling.set(strike.model, strike);
470
- memory.set(strike.model, strike);
498
+ if (countsAsStrike) memory.set(strike.model, strike);
471
499
  } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
472
- memory.set(strike.model, strike);
500
+ if (countsAsStrike) memory.set(strike.model, strike);
473
501
  } else {
474
502
  unlinkSync(path4);
475
503
  }
@@ -486,6 +514,7 @@ function readLimits(s) {
486
514
  }
487
515
  function recordStrike(s, model, reason, stated) {
488
516
  if (!s.dirReady) return;
517
+ if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
489
518
  const now = Date.now();
490
519
  const prior = s.strikes.get(model);
491
520
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
@@ -510,9 +539,8 @@ function coolUnavailable(s, model) {
510
539
  const prior = s.cooling.get(model);
511
540
  const until = now + UNAVAILABLE_COOLDOWN_MS;
512
541
  if (prior && prior.until > until) return prior;
513
- const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
542
+ const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
514
543
  s.cooling.set(model, strike);
515
- s.strikes.set(model, strike);
516
544
  const path4 = join2(s.dir, limitFile(model));
517
545
  try {
518
546
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -1713,7 +1741,7 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1713
1741
  Change nothing. Print a final one-line summary of failures.`
1714
1742
  },
1715
1743
  "pool": {
1716
- description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Restarts opencode afterwards.",
1744
+ description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Picked up live; no restart.",
1717
1745
  template: `Apply this change to the LaCode pool on this machine:
1718
1746
 
1719
1747
  $ARGUMENTS
@@ -1731,16 +1759,27 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1731
1759
 
1732
1760
  3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers the rotation. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
1733
1761
 
1734
- 4. Report the before \u2192 after slot table, the resolved path, and this reminder in substance: nothing hot-reloads, so every running opencode process must be restarted before the new pool takes effect.
1762
+ 4. Report the before \u2192 after slot table and the resolved path. The running process watches the pool files: it reloads the table, re-binds the variant agents and validates every slot, so no restart is needed. If a slot names a model the provider does not serve, it will be benched and named in the log and toast \u2014 report that instead of claiming the swap succeeded.
1735
1763
 
1736
1764
  Two traps that fail silently, so check and state them explicitly:
1737
1765
  - A slot's provider must already exist in the user's opencode config, or that slot cannot resolve at all. If the instruction adds a model on a provider that is not configured, say so.
1738
1766
  - If a slot model is \`omniroute/<id>\`, that id must also appear in the plugin's \`omniroute.models\` allowlist (or be the \`combo\` id, which needs no entry) \u2014 otherwise the pool resolves and every spawn fails with \`ProviderModelNotFoundError\`.
1739
1767
  - A provider \`whitelist\` is an allowlist, and the plugin appends your model to it at config time. Nothing for you to do there.
1740
1768
 
1741
- If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 with the same restart reminder.
1769
+ If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 the running process picks that up automatically too.
1742
1770
 
1743
1771
  Change nothing else. In particular do not touch \`presets/free-tier.json\`, \`~/.config/lacode/pool.json\`, \`FALLBACK_SLOTS\` in \`src/lib/pool.ts\`, or \`tests/models-snapshot.json\`: this command owns the per-machine override and nothing else.`
1772
+ },
1773
+ "pool-recheck": {
1774
+ description: "Probe the pool now \u2014 re-admit benched models that answer, keep the ones still limited, and bench a model the provider no longer serves.",
1775
+ template: `Probe the LaCode pool now and report the result. Run inline; do not spawn a subagent and do not edit the pool.
1776
+
1777
+ $ARGUMENTS
1778
+
1779
+ - If the text above names one or more models, call the \`pool_recheck\` tool with those ids in \`models\` (provider/model-id).
1780
+ - Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
1781
+
1782
+ Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
1744
1783
  }
1745
1784
  };
1746
1785
  function applyCommands(cfg, commands = COMMANDS) {
@@ -1758,7 +1797,269 @@ function applyCommands(cfg, commands = COMMANDS) {
1758
1797
  return { added, kept };
1759
1798
  }
1760
1799
 
1800
+ // src/lib/selfupdate.ts
1801
+ import { spawn } from "node:child_process";
1802
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
1803
+ import { homedir as homedir2 } from "node:os";
1804
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
1805
+ var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
1806
+ var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
1807
+ var FETCH_TIMEOUT_MS = 4e3;
1808
+ var INSTALL_TIMEOUT_MS = 12e4;
1809
+ var LOCK_STALE_MS = 10 * 60 * 1e3;
1810
+ var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
1811
+ var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
1812
+ var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
1813
+ var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
1814
+ var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
1815
+ var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
1816
+ function compareSemver(a, b) {
1817
+ const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
1818
+ const left = parse(a);
1819
+ const right = parse(b);
1820
+ for (let i = 0; i < 3; i++) {
1821
+ const l = left[i] ?? 0;
1822
+ const r = right[i] ?? 0;
1823
+ if (l !== r) return l > r ? 1 : -1;
1824
+ }
1825
+ return 0;
1826
+ }
1827
+ function readInstalledVersion(packageRoot, read = (path4) => {
1828
+ try {
1829
+ return readFileSync3(path4, "utf8");
1830
+ } catch {
1831
+ return void 0;
1832
+ }
1833
+ }) {
1834
+ try {
1835
+ const raw = read(join3(packageRoot, "package.json"));
1836
+ if (raw === void 0) return void 0;
1837
+ const manifest = JSON.parse(raw);
1838
+ if (manifest.name !== PACKAGE_NAME) return void 0;
1839
+ return typeof manifest.version === "string" ? manifest.version : void 0;
1840
+ } catch {
1841
+ return void 0;
1842
+ }
1843
+ }
1844
+ function resolveCacheRoot(packageRoot, exists = (p) => {
1845
+ try {
1846
+ return statSync2(p).isFile();
1847
+ } catch {
1848
+ return false;
1849
+ }
1850
+ }) {
1851
+ const tail = join3("node_modules", "@lawaty", "lacode");
1852
+ if (!packageRoot.endsWith(tail)) return void 0;
1853
+ const cacheRoot = resolve(packageRoot, "..", "..", "..");
1854
+ if (!exists(join3(cacheRoot, "package.json"))) return void 0;
1855
+ return cacheRoot;
1856
+ }
1857
+ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
1858
+ try {
1859
+ return statSync2(p).isFile();
1860
+ } catch {
1861
+ return false;
1862
+ }
1863
+ }) {
1864
+ if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
1865
+ for (const dir of (env.PATH ?? "").split(":")) {
1866
+ if (!dir) continue;
1867
+ const candidate = join3(dir, "opencode");
1868
+ if (exists(candidate)) return candidate;
1869
+ }
1870
+ const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
1871
+ return exists(fallback) ? fallback : void 0;
1872
+ }
1873
+ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
1874
+ const controller = new AbortController();
1875
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1876
+ try {
1877
+ const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
1878
+ if (!response.ok) return void 0;
1879
+ const body = await response.json();
1880
+ return typeof body.version === "string" ? body.version : void 0;
1881
+ } catch {
1882
+ return void 0;
1883
+ } finally {
1884
+ clearTimeout(timer);
1885
+ }
1886
+ }
1887
+ function readState(path4 = STATE_PATH) {
1888
+ try {
1889
+ return JSON.parse(readFileSync3(path4, "utf8"));
1890
+ } catch {
1891
+ return {};
1892
+ }
1893
+ }
1894
+ function writeState(state2, path4 = STATE_PATH) {
1895
+ try {
1896
+ mkdirSync2(dirname2(path4), { recursive: true });
1897
+ writeFileSync2(path4, JSON.stringify(state2));
1898
+ } catch {
1899
+ }
1900
+ }
1901
+ var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
1902
+ var isProcessAlive = (pid) => {
1903
+ try {
1904
+ process.kill(pid, 0);
1905
+ return true;
1906
+ } catch (error) {
1907
+ return error.code === "EPERM";
1908
+ }
1909
+ };
1910
+ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
1911
+ const claim = () => {
1912
+ const fd = openSync(path4, "wx");
1913
+ writeSync(fd, String(process.pid));
1914
+ closeSync(fd);
1915
+ return () => {
1916
+ try {
1917
+ unlinkSync2(path4);
1918
+ } catch {
1919
+ }
1920
+ };
1921
+ };
1922
+ try {
1923
+ mkdirSync2(dirname2(path4), { recursive: true });
1924
+ return claim();
1925
+ } catch {
1926
+ let orphaned = false;
1927
+ try {
1928
+ const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
1929
+ orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
1930
+ } catch {
1931
+ orphaned = true;
1932
+ }
1933
+ if (!orphaned) {
1934
+ try {
1935
+ orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
1936
+ } catch {
1937
+ orphaned = true;
1938
+ }
1939
+ }
1940
+ if (!orphaned) return void 0;
1941
+ try {
1942
+ unlinkSync2(path4);
1943
+ } catch {
1944
+ return void 0;
1945
+ }
1946
+ try {
1947
+ return claim();
1948
+ } catch {
1949
+ return void 0;
1950
+ }
1951
+ }
1952
+ }
1953
+ async function checkForUpdate(opts) {
1954
+ const installed = readInstalledVersion(opts.packageRoot);
1955
+ const latest = await fetchLatest(opts.fetchImpl);
1956
+ return {
1957
+ installed,
1958
+ latest,
1959
+ newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
1960
+ cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
1961
+ bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
1962
+ };
1963
+ }
1964
+ async function refreshCache(opts) {
1965
+ const { cacheRoot, bin, latest } = opts;
1966
+ const spawnImpl = opts.spawnImpl ?? spawn;
1967
+ const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
1968
+ const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
1969
+ const aside = `${cacheRoot}.stale-${Date.now()}`;
1970
+ try {
1971
+ rmSync(aside, { recursive: true, force: true });
1972
+ renameSync2(cacheRoot, aside);
1973
+ } catch (error) {
1974
+ return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
1975
+ }
1976
+ const exited = await new Promise((settle) => {
1977
+ let done = false;
1978
+ const finish = (value) => {
1979
+ if (done) return;
1980
+ done = true;
1981
+ clearTimeout(timer);
1982
+ settle(value);
1983
+ };
1984
+ const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
1985
+ env: { ...process.env, [CHILD_ENV]: "1" },
1986
+ stdio: "ignore"
1987
+ });
1988
+ const timer = setTimeout(() => {
1989
+ try {
1990
+ child.kill("SIGKILL");
1991
+ } catch {
1992
+ }
1993
+ finish(false);
1994
+ }, timeoutMs);
1995
+ child.on("error", () => finish(false));
1996
+ child.on("exit", (code) => finish(code === 0));
1997
+ });
1998
+ const installed = readInstalledVersion(fresh);
1999
+ const ok = exited && installed === latest;
2000
+ if (ok) {
2001
+ try {
2002
+ rmSync(aside, { recursive: true, force: true });
2003
+ } catch {
2004
+ }
2005
+ } else {
2006
+ try {
2007
+ renameSync2(aside, cacheRoot);
2008
+ } catch {
2009
+ }
2010
+ }
2011
+ return {
2012
+ ok,
2013
+ detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
2014
+ };
2015
+ }
2016
+ async function maybeSelfUpdate(opts) {
2017
+ const env = opts.env ?? process.env;
2018
+ if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
2019
+ return { action: "disabled" };
2020
+ }
2021
+ const now = (opts.now ?? Date.now)();
2022
+ const statePath = opts.statePath ?? STATE_PATH;
2023
+ const state2 = readState(statePath);
2024
+ if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
2025
+ const release2 = acquireLock(opts.lockPath, now);
2026
+ if (!release2) return { action: "skipped", detail: "another process is updating" };
2027
+ try {
2028
+ const check = await checkForUpdate(opts);
2029
+ if (!check.installed) return { action: "skipped", detail: "no installed version" };
2030
+ if (!check.latest) {
2031
+ writeState({ ...state2, checkedAt: now }, statePath);
2032
+ return { action: "skipped", detail: "registry unreachable" };
2033
+ }
2034
+ if (!check.newer) {
2035
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2036
+ return { action: "current", detail: `${check.installed} is current` };
2037
+ }
2038
+ if (!check.cacheRoot) {
2039
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2040
+ return { action: "skipped", detail: "not an opencode cache install" };
2041
+ }
2042
+ if (!check.bin) {
2043
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2044
+ opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
2045
+ return { action: "failed", detail: "no opencode binary" };
2046
+ }
2047
+ const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
2048
+ if (result.ok) {
2049
+ writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
2050
+ opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
2051
+ return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
2052
+ }
2053
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2054
+ opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
2055
+ return { action: "failed", detail: result.detail };
2056
+ } finally {
2057
+ release2();
2058
+ }
2059
+ }
2060
+
1761
2061
  // src/plugins/agent-pool.ts
2062
+ import { unwatchFile, watchFile } from "node:fs";
1762
2063
  import { tool } from "@opencode-ai/plugin";
1763
2064
 
1764
2065
  // src/lib/probe.ts
@@ -1776,8 +2077,8 @@ var failureText = (error) => {
1776
2077
  };
1777
2078
  var shortReason = (text) => {
1778
2079
  if (!text) return "failed";
1779
- const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
1780
- return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
2080
+ if (isModelMissingText(text)) return MODEL_MISSING_REASON;
2081
+ return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
1781
2082
  };
1782
2083
  var probeOne = async (client, opts, model, leftSeconds) => {
1783
2084
  const { state: s, directory } = opts;
@@ -1824,8 +2125,12 @@ var probeOne = async (client, opts, model, leftSeconds) => {
1824
2125
  return settle(true, "answered", 0);
1825
2126
  }
1826
2127
  const text = failureText(failure);
1827
- const stated = statedCooldown(text);
1828
- if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
2128
+ if (isModelMissingText(text)) {
2129
+ recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2130
+ } else {
2131
+ const stated = statedCooldown(text);
2132
+ if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
2133
+ }
1829
2134
  const still = readLimits(s).get(model);
1830
2135
  return settle(
1831
2136
  false,
@@ -1838,16 +2143,48 @@ var probeOne = async (client, opts, model, leftSeconds) => {
1838
2143
  if (deleted) unmarkProbe(s, created);
1839
2144
  }
1840
2145
  };
1841
- async function probeCooling(client, opts) {
2146
+ async function probeModels(client, opts) {
1842
2147
  const s = opts.state;
1843
2148
  const now = opts.now ?? Date.now;
1844
- const empty = { outcomes: [], cleared: [], kept: [], changed: false };
2149
+ const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
1845
2150
  if (s.probing) return empty;
2151
+ const models = [...new Set(opts.models)];
2152
+ if (models.length === 0) return empty;
1846
2153
  const cooling = readLimits(s);
1847
2154
  const at = now();
1848
- const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
1849
- const candidates = [...cooling.values()].filter(
1850
- (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
2155
+ const wasCooling = new Set([...cooling.values()].filter((strike) => strike.until > at).map((strike) => strike.model));
2156
+ s.probing = true;
2157
+ const outcomes = [];
2158
+ try {
2159
+ for (const model of models) {
2160
+ const strike = cooling.get(model);
2161
+ outcomes.push(await probeOne(client, opts, model, strike && strike.until > at ? Math.ceil((strike.until - at) / 1e3) : 0));
2162
+ }
2163
+ } finally {
2164
+ s.probing = false;
2165
+ s.lastProbe = Date.now();
2166
+ }
2167
+ const reAdmitted = outcomes.filter((o) => o.ok && wasCooling.has(o.model)).map((o) => o.model);
2168
+ const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
2169
+ const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
2170
+ return {
2171
+ outcomes,
2172
+ cleared: reAdmitted,
2173
+ kept: stillLimited.map((o) => o.model),
2174
+ missing,
2175
+ changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
2176
+ };
2177
+ }
2178
+ async function probeCooling(client, opts) {
2179
+ const s = opts.state;
2180
+ const now = opts.now ?? Date.now;
2181
+ const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
2182
+ if (s.probing) return empty;
2183
+ const cooling = readLimits(s);
2184
+ const at = now();
2185
+ const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
2186
+ const candidates = [...cooling.values()].filter(
2187
+ (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
1851
2188
  );
1852
2189
  if (candidates.length === 0) return empty;
1853
2190
  s.probing = true;
@@ -1861,14 +2198,16 @@ async function probeCooling(client, opts) {
1861
2198
  s.lastProbe = Date.now();
1862
2199
  }
1863
2200
  const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
1864
- const stillLimited = outcomes.filter((o) => !o.ok);
2201
+ const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
2202
+ const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
1865
2203
  return {
1866
2204
  outcomes,
1867
2205
  cleared: reAdmitted,
1868
2206
  kept: stillLimited.map((o) => o.model),
2207
+ missing,
1869
2208
  // A window the provider restated is a change too: the pool now waits a
1870
2209
  // different length than it did, and the user is the one who set that.
1871
- changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
2210
+ changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
1872
2211
  };
1873
2212
  }
1874
2213
 
@@ -1877,6 +2216,8 @@ var SERVICE = "agent-pool";
1877
2216
  var REAP_INTERVAL_MS = 3e4;
1878
2217
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1879
2218
  var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
2219
+ var SLOT_WATCH_MS = 1e3;
2220
+ var SLOT_WATCH_DEBOUNCE_MS = 250;
1880
2221
  var ABORT_TIMEOUT_MS = 1e4;
1881
2222
  var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
1882
2223
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
@@ -1921,11 +2262,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
1921
2262
  const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1922
2263
  if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1923
2264
  };
2265
+ const claimFor = (sessionID) => {
2266
+ if (!sessionID) return void 0;
2267
+ for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
2268
+ return void 0;
2269
+ };
1924
2270
  const failOver = async (sessionID, model, reason) => {
1925
- if (!sessionID) return;
1926
- const entry = [...s.own.entries()].find(([, claim2]) => claim2.g === sessionID);
1927
- if (!entry) return;
1928
- const [key, claim] = entry;
2271
+ const entry = claimFor(sessionID);
2272
+ if (!entry) {
2273
+ await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
2274
+ return;
2275
+ }
2276
+ const { key, claim } = entry;
1929
2277
  const kill = await abortSession(sessionID);
1930
2278
  release(s, key);
1931
2279
  await log(
@@ -2005,20 +2353,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2005
2353
  "info",
2006
2354
  `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`
2007
2355
  );
2008
- const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
2009
- const reportTitle = (report) => report.outcomes.length === 0 ? "nothing cooling to probe" : `probed ${report.outcomes.length} cooling model(s), re-admitted ${report.cleared.length}`;
2356
+ const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
2357
+ const reportTitle = (report) => {
2358
+ if (report.outcomes.length === 0) return "nothing cooling to probe";
2359
+ const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
2360
+ if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
2361
+ return parts.join(", ");
2362
+ };
2010
2363
  const reportText = (report) => {
2011
2364
  if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2012
2365
  return [
2013
2366
  ...report.outcomes.map(
2014
- (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` : ""}`
2367
+ (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2015
2368
  ),
2016
- 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."
2369
+ report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
2017
2370
  ].join("\n");
2018
2371
  };
2019
2372
  const announce = async (report) => {
2020
2373
  if (!report.changed) return;
2021
- 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.`;
2374
+ const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
2022
2375
  await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
2023
2376
  try {
2024
2377
  await client.tui?.showToast?.({
@@ -2045,47 +2398,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2045
2398
  "info",
2046
2399
  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"
2047
2400
  );
2048
- return {
2049
- config: async (cfg) => {
2050
- const slots = ensureSlots(s, (m) => void warnOnce(m));
2051
- const agents = cfg.agent;
2052
- const providers = cfg.provider;
2053
- const known = /* @__PURE__ */ new Set();
2054
- for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2055
- for (const slot of slots) {
2056
- for (const base of BASES) {
2057
- const name = agentFor(base, slot.index);
2058
- const def = agents?.[name];
2059
- if (!def) {
2060
- await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2061
- continue;
2062
- }
2063
- if (def.model && def.model !== slot.model) {
2064
- await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
2065
- }
2066
- def.model = slot.model;
2067
- known.add(name);
2068
- s.models.set(name, slot.model);
2069
- }
2070
- const slash = slot.model.indexOf("/");
2071
- const provider = slot.model.slice(0, slash);
2072
- const id = slot.model.slice(slash + 1);
2073
- const entry = providers?.[provider];
2074
- if (!entry) {
2075
- await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2401
+ let liveConfig;
2402
+ const bind = async (cfg) => {
2403
+ const slots = ensureSlots(s, (m) => void warnOnce(m));
2404
+ const agents = cfg.agent;
2405
+ const providers = cfg.provider;
2406
+ const known = /* @__PURE__ */ new Set();
2407
+ for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2408
+ for (const slot of slots) {
2409
+ for (const base of BASES) {
2410
+ const name = agentFor(base, slot.index);
2411
+ const def = agents?.[name];
2412
+ if (!def) {
2413
+ await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2076
2414
  continue;
2077
2415
  }
2078
- const list = entry.whitelist;
2079
- if (list && !list.includes(id)) {
2080
- list.push(id);
2081
- await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
2416
+ if (def.model && def.model !== slot.model) {
2417
+ await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
2082
2418
  }
2419
+ def.model = slot.model;
2420
+ known.add(name);
2421
+ s.models.set(name, slot.model);
2083
2422
  }
2084
- s.known = known;
2085
- await log(
2086
- "info",
2087
- `${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2088
- );
2423
+ const slash = slot.model.indexOf("/");
2424
+ const provider = slot.model.slice(0, slash);
2425
+ const id = slot.model.slice(slash + 1);
2426
+ const entry = providers?.[provider];
2427
+ if (!entry) {
2428
+ await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2429
+ continue;
2430
+ }
2431
+ const list = entry.whitelist;
2432
+ if (list && !list.includes(id)) {
2433
+ list.push(id);
2434
+ await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
2435
+ }
2436
+ }
2437
+ s.known = known;
2438
+ await log(
2439
+ "info",
2440
+ `${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2441
+ );
2442
+ };
2443
+ const runValidation = async (models) => {
2444
+ let report = EMPTY_REPORT;
2445
+ try {
2446
+ report = await probeModels(client, { state: s, directory, models });
2447
+ } catch (error) {
2448
+ await log("error", `pool validation failed: ${String(error)}`);
2449
+ return EMPTY_REPORT;
2450
+ }
2451
+ await announce(report);
2452
+ return report;
2453
+ };
2454
+ const watchMs = opts.watchMs ?? SLOT_WATCH_MS;
2455
+ const watchDebounceMs = opts.watchDebounceMs ?? SLOT_WATCH_DEBOUNCE_MS;
2456
+ const sources = watchMs > 0 ? slotSources(opts) : [];
2457
+ let reloadTimer;
2458
+ const reloadFromDisk = async () => {
2459
+ const reload = reloadSlots(s, (m) => void warnOnce(m));
2460
+ if (!reload.changed) return;
2461
+ await log(
2462
+ "info",
2463
+ `pool changed (${reload.origin}): ${reload.slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2464
+ );
2465
+ if (liveConfig) await bind(liveConfig);
2466
+ await runValidation(reload.slots.map((slot) => slot.model));
2467
+ };
2468
+ const scheduleReload = () => {
2469
+ if (reloadTimer) return;
2470
+ reloadTimer = setTimeout(() => {
2471
+ reloadTimer = void 0;
2472
+ void reloadFromDisk();
2473
+ }, watchDebounceMs);
2474
+ reloadTimer.unref?.();
2475
+ };
2476
+ for (const source of sources) watchFile(source, { interval: watchMs, persistent: false }, scheduleReload);
2477
+ void log(
2478
+ "info",
2479
+ sources.length > 0 ? `pool config watch armed: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slots and validates every slot` : "pool config watch disabled (watchMs: 0)"
2480
+ );
2481
+ return {
2482
+ config: async (cfg) => {
2483
+ liveConfig = cfg;
2484
+ await bind(cfg);
2089
2485
  },
2090
2486
  event: async ({ event }) => {
2091
2487
  try {
@@ -2093,24 +2489,82 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2093
2489
  if (event.type === "session.status") {
2094
2490
  const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
2095
2491
  const status2 = props.status;
2096
- const reason2 = status2?.action?.reason ?? "";
2097
- if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
2098
- const provider = status2.action.provider;
2492
+ const action = status2?.action;
2493
+ const provider = typeof action?.provider === "string" ? action.provider : void 0;
2494
+ const reason2 = action?.reason ?? "";
2495
+ const message = typeof status2?.message === "string" ? status2.message : "";
2496
+ const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
2497
+ if (status2?.type === "retry" && limited2) {
2099
2498
  if (sessionID && isProbe(s, sessionID)) {
2100
- await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2499
+ await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2101
2500
  return;
2102
2501
  }
2103
- const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2104
- const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
2105
- if (cooled.length > 0) {
2106
- await log("warn", `limit on provider ${provider} (${reason2 || "rate-limited"}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
2107
- provider,
2108
- until,
2109
- models: cooled.map((c) => c.model)
2110
- });
2502
+ const own = claimFor(sessionID);
2503
+ const outage = isUnavailableText(message) && !isQuotaReason(reason2);
2504
+ const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2505
+ if (provider) {
2506
+ const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2507
+ const cooled = coolProvider(s, provider, until, named);
2508
+ if (cooled.length > 0) {
2509
+ await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
2510
+ provider,
2511
+ until,
2512
+ models: cooled.map((c) => c.model)
2513
+ });
2514
+ }
2515
+ } else if (own) {
2516
+ if (outage) {
2517
+ const transient = coolUnavailable(s, own.claim.m);
2518
+ if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2519
+ } else {
2520
+ const stated2 = statedCooldown(message);
2521
+ const strike2 = recordStrike(s, own.claim.m, named, stated2);
2522
+ if (strike2) {
2523
+ await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
2524
+ model: own.claim.m,
2525
+ slot: own.claim.k,
2526
+ session: sessionID
2527
+ });
2528
+ }
2529
+ }
2530
+ }
2531
+ await failOver(sessionID, provider ?? own?.claim.m ?? "?", named);
2532
+ }
2533
+ return;
2534
+ }
2535
+ if (event.type === "session.error") {
2536
+ const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
2537
+ const error = props.error;
2538
+ const haystack2 = JSON.stringify(error) ?? "";
2539
+ if (sessionID && isProbe(s, sessionID)) {
2540
+ await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
2541
+ return;
2542
+ }
2543
+ const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
2544
+ const limited2 = isLimitText(haystack2) || status2 === 429;
2545
+ const outage = isUnavailableText(haystack2);
2546
+ if (!limited2 && !outage) return;
2547
+ const own = claimFor(sessionID);
2548
+ const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2549
+ if (own) {
2550
+ if (outage) {
2551
+ const transient = coolUnavailable(s, own.claim.m);
2552
+ if (transient) {
2553
+ await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
2554
+ }
2555
+ } else {
2556
+ const stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
2557
+ const strike2 = recordStrike(s, own.claim.m, named, stated2);
2558
+ if (strike2) {
2559
+ await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
2560
+ model: own.claim.m,
2561
+ slot: own.claim.k,
2562
+ session: sessionID
2563
+ });
2564
+ }
2111
2565
  }
2112
- await failOver(sessionID, provider, reason2 || "rate-limited");
2113
2566
  }
2567
+ await failOver(sessionID, own?.claim.m ?? "?", named);
2114
2568
  return;
2115
2569
  }
2116
2570
  if (event.type === "session.created") {
@@ -2144,7 +2598,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2144
2598
  return;
2145
2599
  }
2146
2600
  const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2147
- const reason = haystack.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
2601
+ const reason = limitReason(haystack);
2148
2602
  const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
2149
2603
  if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
2150
2604
  await failOver(info.sessionID, model, reason ?? "rate-limited");
@@ -2155,7 +2609,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2155
2609
  "tool.execute.before": async (input, output) => {
2156
2610
  if (input.tool !== "task") return;
2157
2611
  const requested = output.args?.subagent_type;
2158
- if (typeof requested !== "string") return;
2612
+ if (typeof requested !== "string") {
2613
+ await warnOnce(
2614
+ `task spawn has no subagent_type, so the pool cannot route it -- it will run on the caller's own model. Pass subagent_type "explore" or "implement" to use a slot.`
2615
+ );
2616
+ return;
2617
+ }
2159
2618
  const manual = manualVariant(requested);
2160
2619
  if (manual) {
2161
2620
  await log("warn", `refused manual spawn of a pool slot variant; spawn "${manual}" instead`, {
@@ -2204,12 +2663,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2204
2663
  },
2205
2664
  tool: {
2206
2665
  pool_recheck: tool({
2207
- 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.",
2666
+ 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; if the provider does not serve it at all, it is benched so routing stops choosing it. Pass `models` to validate those even when they are not cooling. Call it after editing the pool, 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.",
2208
2667
  args: {
2209
- 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.")
2668
+ models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to validate, as provider/model-id. Omit to probe only the models that are cooling.")
2210
2669
  },
2211
2670
  async execute(args) {
2212
- const report = await runProbe(args?.models);
2671
+ const models = args?.models;
2672
+ const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
2213
2673
  return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
2214
2674
  }
2215
2675
  }),
@@ -2254,7 +2714,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2254
2714
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
2255
2715
  output: [
2256
2716
  `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
2257
- round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when every slot is)`,
2717
+ round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
2258
2718
  ...slots.map(line),
2259
2719
  "",
2260
2720
  "by process:",
@@ -2268,7 +2728,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2268
2728
  // When the cooled models were last USED rather than inferred, because
2269
2729
  // "still cooling" and "nobody has actually asked it lately" are
2270
2730
  // different situations and this is the line that tells them apart.
2271
- 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.",
2731
+ probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : coolingNow.length > 0 ? "never in this process (cooldowns came from disk)" : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool (or run /pool-recheck) to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool (or run /pool-recheck) to ask now.",
2732
+ watchMs > 0 ? `pool config watch: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slot table and validates every slot.` : "pool config watch: off (watchMs: 0).",
2272
2733
  coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
2273
2734
  ].join("\n")
2274
2735
  };
@@ -2278,6 +2739,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2278
2739
  dispose: async () => {
2279
2740
  clearInterval(timer);
2280
2741
  if (probeTimer) clearInterval(probeTimer);
2742
+ if (reloadTimer) clearTimeout(reloadTimer);
2743
+ for (const source of sources) unwatchFile(source, scheduleReload);
2281
2744
  s.own.clear();
2282
2745
  s.activity.clear();
2283
2746
  s.children.clear();
@@ -2287,8 +2750,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2287
2750
  });
2288
2751
 
2289
2752
  // src/plugins/context-autoupdate.ts
2290
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
2291
- import { homedir as homedir2 } from "node:os";
2753
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
2754
+ import { homedir as homedir3 } from "node:os";
2292
2755
  import * as path2 from "node:path";
2293
2756
 
2294
2757
  // src/lib/active-backlog.ts
@@ -2439,7 +2902,7 @@ var parseJsonc = (raw) => {
2439
2902
  };
2440
2903
 
2441
2904
  // src/lib/writer-rule.ts
2442
- import { readFileSync as readFileSync3 } from "node:fs";
2905
+ import { readFileSync as readFileSync4 } from "node:fs";
2443
2906
  import * as path from "node:path";
2444
2907
  var CONTEXT_DIR = path.join(".opencode", "context");
2445
2908
  var WRITER = "context";
@@ -2479,7 +2942,7 @@ var agentsForm = (mapDir, projectRoot) => {
2479
2942
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
2480
2943
  let raw;
2481
2944
  try {
2482
- raw = readFileSync3(configPath, "utf8");
2945
+ raw = readFileSync4(configPath, "utf8");
2483
2946
  } catch {
2484
2947
  return void 0;
2485
2948
  }
@@ -2580,7 +3043,7 @@ var snapshotMap = (dir) => {
2580
3043
  for (const name of readdirSync2(dir)) {
2581
3044
  const file = path2.join(dir, name);
2582
3045
  try {
2583
- const st = statSync2(file);
3046
+ const st = statSync3(file);
2584
3047
  if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
2585
3048
  } catch {
2586
3049
  }
@@ -2597,15 +3060,15 @@ var changedSince = (before, dir) => {
2597
3060
  return changed;
2598
3061
  };
2599
3062
  var RETAIN_TRANSCRIPTS = 20;
2600
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
3063
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
2601
3064
  var retireSession = async (client, directory, sessionID, mode, log) => {
2602
3065
  try {
2603
3066
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
2604
3067
  const dir = transcriptDir();
2605
- mkdirSync2(dir, { recursive: true });
3068
+ mkdirSync3(dir, { recursive: true });
2606
3069
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2607
3070
  const file = path2.join(dir, `${stamp}-${mode}.json`);
2608
- writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
3071
+ writeFileSync3(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
2609
3072
  pruneTranscripts(dir);
2610
3073
  const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
2611
3074
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
@@ -2626,7 +3089,7 @@ var pruneTranscripts = (dir) => {
2626
3089
  const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
2627
3090
  for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
2628
3091
  try {
2629
- unlinkSync2(path2.join(dir, name));
3092
+ unlinkSync3(path2.join(dir, name));
2630
3093
  } catch {
2631
3094
  }
2632
3095
  }
@@ -2655,9 +3118,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2655
3118
  const normalizeActiveBacklog = async () => {
2656
3119
  try {
2657
3120
  if (!existsSync2(activeBacklogPath)) return { removed: 0 };
2658
- const before = readFileSync4(activeBacklogPath, "utf8");
3121
+ const before = readFileSync5(activeBacklogPath, "utf8");
2659
3122
  const { text, removed } = normalizeBacklog(before);
2660
- if (text !== before) writeFileSync2(activeBacklogPath, text);
3123
+ if (text !== before) writeFileSync3(activeBacklogPath, text);
2661
3124
  if (removed.length > 0) {
2662
3125
  await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
2663
3126
  removed: removed.length
@@ -2810,11 +3273,14 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2810
3273
  });
2811
3274
 
2812
3275
  // src/plugins/notify.ts
2813
- import { spawn } from "node:child_process";
3276
+ import { spawn as spawn2 } from "node:child_process";
2814
3277
  import { existsSync as existsSync3 } from "node:fs";
2815
3278
  import * as path3 from "node:path";
2816
3279
 
2817
3280
  // src/lib/notify.ts
3281
+ import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync6, statSync as statSync4, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "node:fs";
3282
+ import { homedir as homedir4 } from "node:os";
3283
+ import { join as join6 } from "node:path";
2818
3284
  var INTERNAL_TITLE_PREFIXES = [
2819
3285
  // context-autoupdate spawns the cartographer through the session API, with no
2820
3286
  // parentID, so it looks exactly like a root session going idle. Notifying for
@@ -2834,6 +3300,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
2834
3300
  };
2835
3301
  var PERMISSION_REPLIES = ["once", "always", "reject"];
2836
3302
  var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
3303
+ var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
3304
+ var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
2837
3305
  var safeStringify = (value) => {
2838
3306
  try {
2839
3307
  return JSON.stringify(value) ?? void 0;
@@ -2905,7 +3373,11 @@ var shouldNotify = (input) => {
2905
3373
  }
2906
3374
  const notification = {
2907
3375
  notify: true,
2908
- title: `opencode \u2014 ${title}`,
3376
+ // The session's name on its own, with nothing in front of it. The popup's
3377
+ // header is the notification's APP name, and that is "LaCode" -- so a
3378
+ // prefix here would only repeat the app, and it would repeat it wrongly:
3379
+ // the app is not "opencode".
3380
+ title,
2909
3381
  message: notifyMessage(outcome, detail),
2910
3382
  urgency: outcome === "error" ? "critical" : "normal"
2911
3383
  };
@@ -2987,6 +3459,143 @@ var Notifier = class {
2987
3459
  return this.lastSent.size;
2988
3460
  }
2989
3461
  };
3462
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join6(homedir4(), ".local", "share", "opencode", "lacode", "notify");
3463
+ var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
3464
+ var CLAIM_WINDOW_MS = 5e3;
3465
+ var isPidAlive2 = (pid) => {
3466
+ try {
3467
+ process.kill(pid, 0);
3468
+ return true;
3469
+ } catch (error) {
3470
+ return error.code === "EPERM";
3471
+ }
3472
+ };
3473
+ var claimFile = (sessionID, kind) => join6(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3474
+ var claimNotification = (sessionID, kind) => {
3475
+ const path4 = claimFile(sessionID, kind);
3476
+ try {
3477
+ mkdirSync4(notificationStateDir(), { recursive: true });
3478
+ } catch {
3479
+ return true;
3480
+ }
3481
+ const take = () => {
3482
+ try {
3483
+ const fd = openSync2(path4, "wx");
3484
+ try {
3485
+ writeSync2(fd, String(process.pid));
3486
+ } finally {
3487
+ closeSync2(fd);
3488
+ }
3489
+ return "taken";
3490
+ } catch (error) {
3491
+ return error.code === "EEXIST" ? "held" : "impossible";
3492
+ }
3493
+ };
3494
+ const first = take();
3495
+ if (first !== "held") {
3496
+ return true;
3497
+ }
3498
+ const judge = () => {
3499
+ let raw;
3500
+ try {
3501
+ raw = readFileSync6(path4, "utf8").trim();
3502
+ } catch (error) {
3503
+ return error.code === "ENOENT" ? "gone" : "unknown";
3504
+ }
3505
+ let holder;
3506
+ const empty = raw === "";
3507
+ if (!empty) {
3508
+ const parsed = Number.parseInt(raw, 10);
3509
+ holder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3510
+ }
3511
+ if (holder === process.pid) return "ours";
3512
+ let age = Number.POSITIVE_INFINITY;
3513
+ try {
3514
+ age = Date.now() - statSync4(path4).mtimeMs;
3515
+ } catch {
3516
+ return "gone";
3517
+ }
3518
+ if (empty && age < CLAIM_WINDOW_MS) {
3519
+ return "held";
3520
+ }
3521
+ if (holder !== void 0 && isPidAlive2(holder) && age < CLAIM_WINDOW_MS) {
3522
+ return "held";
3523
+ }
3524
+ return "stale";
3525
+ };
3526
+ const judged = judge();
3527
+ if (judged === "ours") return true;
3528
+ if (judged === "held") return false;
3529
+ if (judged === "unknown") return true;
3530
+ if (judged === "gone") return take() !== "held";
3531
+ const lockPath = `${path4}.takeover`;
3532
+ const takeOver = () => {
3533
+ const create = () => {
3534
+ try {
3535
+ const fd = openSync2(lockPath, "wx");
3536
+ try {
3537
+ writeSync2(fd, String(process.pid));
3538
+ } finally {
3539
+ closeSync2(fd);
3540
+ }
3541
+ return () => {
3542
+ try {
3543
+ unlinkSync4(lockPath);
3544
+ } catch {
3545
+ }
3546
+ };
3547
+ } catch (error) {
3548
+ return error.code === "EEXIST" ? void 0 : () => {
3549
+ };
3550
+ }
3551
+ };
3552
+ const release3 = create();
3553
+ if (release3 !== void 0) return release3;
3554
+ let lockHolder;
3555
+ let lockAge = Number.POSITIVE_INFINITY;
3556
+ try {
3557
+ const parsed = Number.parseInt(readFileSync6(lockPath, "utf8").trim(), 10);
3558
+ lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3559
+ lockAge = Date.now() - statSync4(lockPath).mtimeMs;
3560
+ } catch {
3561
+ return create();
3562
+ }
3563
+ if (lockAge < CLAIM_WINDOW_MS && (lockHolder === void 0 || isPidAlive2(lockHolder))) {
3564
+ return void 0;
3565
+ }
3566
+ try {
3567
+ unlinkSync4(lockPath);
3568
+ } catch {
3569
+ return void 0;
3570
+ }
3571
+ return create();
3572
+ };
3573
+ const release2 = takeOver();
3574
+ if (release2 === void 0) return false;
3575
+ try {
3576
+ const current = judge();
3577
+ if (current === "ours") return true;
3578
+ if (current === "held") return false;
3579
+ if (current === "unknown") return true;
3580
+ try {
3581
+ unlinkSync4(path4);
3582
+ } catch {
3583
+ }
3584
+ return take() !== "held";
3585
+ } finally {
3586
+ release2();
3587
+ }
3588
+ };
3589
+ var releaseNotificationClaim = (sessionID, kind) => {
3590
+ const path4 = claimFile(sessionID, kind);
3591
+ try {
3592
+ const parsed = Number.parseInt(readFileSync6(path4, "utf8").trim(), 10);
3593
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
3594
+ if (parsed !== process.pid) return;
3595
+ unlinkSync4(path4);
3596
+ } catch {
3597
+ }
3598
+ };
2990
3599
 
2991
3600
  // src/plugins/notify.ts
2992
3601
  var SERVICE3 = "notify";
@@ -2995,6 +3604,9 @@ var MAX_INFLIGHT_HELPERS = 4;
2995
3604
  var HELPER_TIMEOUT_MS = 3e4;
2996
3605
  var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
2997
3606
  var MAX_TRACKED_SESSIONS = 500;
3607
+ var MIN_VISIBLE_MS = 5e3;
3608
+ var PERMISSION_SETTLE_MS = 250;
3609
+ var UNANSWERABLE_REQUEST = "";
2998
3610
  var SOUND_ENV = "LACODE_SOUND";
2999
3611
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
3000
3612
  var findHelper = () => {
@@ -3020,7 +3632,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3020
3632
  const active = /* @__PURE__ */ new Set();
3021
3633
  const titles = /* @__PURE__ */ new Map();
3022
3634
  const pending = /* @__PURE__ */ new Set();
3023
- const permissions = /* @__PURE__ */ new Set();
3635
+ const permissions = /* @__PURE__ */ new Map();
3636
+ const pendingPermissionPopups = /* @__PURE__ */ new Map();
3637
+ const live = /* @__PURE__ */ new Map();
3024
3638
  let warnedHelper = false;
3025
3639
  let inflight = 0;
3026
3640
  const rememberActive = (sessionID) => {
@@ -3031,17 +3645,43 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3031
3645
  titles.set(id, title);
3032
3646
  while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
3033
3647
  };
3648
+ const rememberLive = (sessionID, popup) => {
3649
+ const previous = live.get(sessionID);
3650
+ if (previous?.timer) clearTimeout(previous.timer);
3651
+ live.set(sessionID, popup);
3652
+ while (live.size > MAX_TRACKED_SESSIONS) {
3653
+ const oldest = live.keys().next().value;
3654
+ const forgotten = live.get(oldest);
3655
+ if (forgotten?.timer) clearTimeout(forgotten.timer);
3656
+ live.delete(oldest);
3657
+ }
3658
+ };
3659
+ const forgetLive = (sessionID) => {
3660
+ const popup = live.get(sessionID);
3661
+ if (popup?.timer) clearTimeout(popup.timer);
3662
+ live.delete(sessionID);
3663
+ };
3664
+ const rememberPermissions = (sessionID, requests) => {
3665
+ permissions.set(sessionID, requests);
3666
+ while (permissions.size > MAX_TRACKED_SESSIONS) {
3667
+ const oldest = permissions.keys().next().value;
3668
+ permissions.delete(oldest);
3669
+ }
3670
+ };
3671
+ const forgetPermission = (sessionID, requestID) => {
3672
+ const outstanding = permissions.get(sessionID);
3673
+ if (!outstanding) return;
3674
+ outstanding.delete(requestID);
3675
+ if (outstanding.size === 0) permissions.delete(sessionID);
3676
+ };
3677
+ const forgetPermissionPopup = (sessionID) => {
3678
+ const timer = pendingPermissionPopups.get(sessionID);
3679
+ if (timer) clearTimeout(timer);
3680
+ pendingPermissionPopups.delete(sessionID);
3681
+ };
3034
3682
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
3035
3683
  });
3036
- const answerPermission = async (sessionID, requestID, action) => {
3037
- const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
3038
- if (!response) {
3039
- void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
3040
- sessionID,
3041
- requestID
3042
- });
3043
- return;
3044
- }
3684
+ const replyPermission = async (sessionID, requestID, response) => {
3045
3685
  const errorStatus = (error) => {
3046
3686
  const candidate = error;
3047
3687
  const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
@@ -3054,6 +3694,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3054
3694
  const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
3055
3695
  if (status === 404) {
3056
3696
  void log("debug", "permission already answered", { sessionID, requestID });
3697
+ forgetPermission(sessionID, requestID);
3057
3698
  return;
3058
3699
  }
3059
3700
  if ("error" in reply) {
@@ -3070,13 +3711,27 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3070
3711
  });
3071
3712
  return;
3072
3713
  }
3714
+ forgetPermission(sessionID, requestID);
3073
3715
  void log("info", "permission answered from the notification", { sessionID, requestID, response });
3074
3716
  };
3717
+ const answerPermission = async (sessionID, requestIDs, action) => {
3718
+ const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
3719
+ if (!response) {
3720
+ void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
3721
+ sessionID,
3722
+ requestIDs
3723
+ });
3724
+ return;
3725
+ }
3726
+ for (const requestID of requestIDs) {
3727
+ await replyPermission(sessionID, requestID, response);
3728
+ }
3729
+ };
3075
3730
  const fetchInfo = async (sessionID) => {
3076
3731
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
3077
3732
  return info;
3078
3733
  };
3079
- const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
3734
+ const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
3080
3735
  if (inflight >= maxInflight) {
3081
3736
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
3082
3737
  resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
@@ -3091,15 +3746,17 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3091
3746
  resolve3({ ok: false, detail: "no oc-notify helper found" });
3092
3747
  return;
3093
3748
  }
3749
+ const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
3094
3750
  const actionable = Boolean(requestID);
3095
3751
  const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
3096
- const child = actionable ? spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3752
+ const child = actionable ? spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3097
3753
  stdio: ["ignore", "pipe", "pipe"],
3098
3754
  detached: false,
3099
- env: { ...process.env, [REQUEST_ID_ENV]: requestID }
3100
- }) : spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3755
+ env: { ...childEnv, [REQUEST_ID_ENV]: requestID }
3756
+ }) : spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3101
3757
  stdio: ["ignore", "ignore", "pipe"],
3102
- detached: true
3758
+ detached: true,
3759
+ env: childEnv
3103
3760
  });
3104
3761
  inflight++;
3105
3762
  let settled = false;
@@ -3147,6 +3804,88 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3147
3804
  if (!actionable) child.unref();
3148
3805
  });
3149
3806
  const deliver = deps.deliver ?? deliverViaHelper;
3807
+ const closeViaHelper = (sessionID) => {
3808
+ try {
3809
+ const helper = findHelper();
3810
+ if (!helper) {
3811
+ if (!warnedHelper) {
3812
+ warnedHelper = true;
3813
+ void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
3814
+ }
3815
+ return;
3816
+ }
3817
+ const child = spawn2(helper, helperArgv(helper, "opencode", "", "normal", "utilities-terminal", "none"), {
3818
+ stdio: ["ignore", "ignore", "pipe"],
3819
+ detached: true,
3820
+ env: { ...process.env, [NOTIFY_CLOSE_ENV]: sessionID }
3821
+ });
3822
+ child.on("error", (error) => {
3823
+ void log("warn", `notification close failed: ${String(error)}`, { sessionID });
3824
+ });
3825
+ child.on("exit", (code) => {
3826
+ if (code !== 0) void log("warn", `notification not closed: helper exit ${code}`, { sessionID });
3827
+ });
3828
+ child.unref();
3829
+ } catch (error) {
3830
+ void log("warn", `notification close failed: ${String(error)}`, { sessionID });
3831
+ }
3832
+ };
3833
+ const close = deps.close ?? closeViaHelper;
3834
+ const dismiss = (sessionID) => {
3835
+ const popup = live.get(sessionID);
3836
+ if (!popup) return;
3837
+ if (popup.timer) {
3838
+ clearTimeout(popup.timer);
3839
+ popup.timer = void 0;
3840
+ }
3841
+ popup.raisedAt = Date.now();
3842
+ const failed = (error) => void log("warn", `notification dismiss failed: ${String(error)}`, { sessionID });
3843
+ try {
3844
+ const outcome = close(sessionID);
3845
+ if (outcome && typeof outcome.catch === "function") void outcome.catch(failed);
3846
+ } catch (error) {
3847
+ failed(error);
3848
+ }
3849
+ };
3850
+ const scheduleDismiss = (sessionID) => {
3851
+ const popup = live.get(sessionID);
3852
+ if (!popup) return;
3853
+ if (popup.timer) return;
3854
+ const delay = Math.max(0, popup.raisedAt + MIN_VISIBLE_MS - Date.now());
3855
+ const timer = setTimeout(() => dismiss(sessionID), delay);
3856
+ timer.unref?.();
3857
+ popup.timer = timer;
3858
+ };
3859
+ const noteInteraction = (sessionID) => {
3860
+ const popup = live.get(sessionID);
3861
+ if (!popup) return;
3862
+ popup.interacted = true;
3863
+ scheduleDismiss(sessionID);
3864
+ };
3865
+ const claimReleases = /* @__PURE__ */ new Map();
3866
+ const scheduleClaimRelease = (sessionID, kind) => {
3867
+ const key = `${kind}:${sessionID}`;
3868
+ if (claimReleases.has(key)) return;
3869
+ const timer = setTimeout(() => {
3870
+ claimReleases.delete(key);
3871
+ releaseNotificationClaim(sessionID, kind);
3872
+ }, COOLDOWN_MS2);
3873
+ timer.unref?.();
3874
+ claimReleases.set(key, timer);
3875
+ };
3876
+ const raisePermissionPopup = (sessionID, requestID, headline) => {
3877
+ forgetPermissionPopup(sessionID);
3878
+ const timer = setTimeout(() => {
3879
+ if (pendingPermissionPopups.get(sessionID) !== timer) return;
3880
+ pendingPermissionPopups.delete(sessionID);
3881
+ const outstanding = permissions.get(sessionID);
3882
+ if (!outstanding || outstanding.size === 0) return;
3883
+ const detail = outstanding.size > 1 ? `${outstanding.size} requests` : headline;
3884
+ void announce(sessionID, "permission", detail || void 0, requestID);
3885
+ }, PERMISSION_SETTLE_MS);
3886
+ timer.unref?.();
3887
+ pendingPermissionPopups.set(sessionID, timer);
3888
+ };
3150
3889
  const announce = async (sessionID, outcome, detail, requestID) => {
3151
3890
  try {
3152
3891
  const info = await fetchInfo(sessionID);
@@ -3180,10 +3919,27 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3180
3919
  void log("debug", "suppressed: cooldown", { sessionID });
3181
3920
  return;
3182
3921
  }
3922
+ if (!claimNotification(sessionID, kind)) {
3923
+ void log("debug", "suppressed: another process is raising this", { sessionID, kind });
3924
+ return;
3925
+ }
3183
3926
  const message = isRemote ? `${decision.message} (remote)` : decision.message;
3184
3927
  const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
3185
- const sent = await deliver(decision.title, message, decision.urgency, sound, requestID);
3186
- if (sent.action && requestID) await answerPermission(sessionID, requestID, sent.action);
3928
+ const previous = live.get(sessionID);
3929
+ const popup = { raisedAt: Date.now(), interacted: previous?.interacted ?? false };
3930
+ rememberLive(sessionID, popup);
3931
+ if (popup.interacted) scheduleDismiss(sessionID);
3932
+ let sent = { ok: false };
3933
+ try {
3934
+ sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
3935
+ } finally {
3936
+ scheduleClaimRelease(sessionID, kind);
3937
+ }
3938
+ if (sent.action && requestID) {
3939
+ const outstanding = permissions.get(sessionID);
3940
+ const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
3941
+ await answerPermission(sessionID, ids.length > 0 ? ids : [requestID], sent.action);
3942
+ }
3187
3943
  const answered = Boolean(sent.action);
3188
3944
  void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
3189
3945
  sessionID,
@@ -3200,7 +3956,14 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3200
3956
  try {
3201
3957
  if (event.type === "session.status") {
3202
3958
  const { sessionID: sessionID2, status } = event.properties;
3203
- if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
3959
+ if (status.type === "busy" && sessionID2) {
3960
+ rememberActive(sessionID2);
3961
+ }
3962
+ return;
3963
+ }
3964
+ if (event.type === "message.updated") {
3965
+ const info = event.properties.info;
3966
+ if (info?.role === "user" && info.sessionID) noteInteraction(info.sessionID);
3204
3967
  return;
3205
3968
  }
3206
3969
  if (event.type === "session.updated") {
@@ -3220,22 +3983,34 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3220
3983
  }
3221
3984
  if (event.type === "question.replied" || event.type === "question.rejected") {
3222
3985
  const sessionID2 = event.properties?.sessionID;
3223
- if (sessionID2) pending.delete(sessionID2);
3986
+ if (sessionID2) {
3987
+ pending.delete(sessionID2);
3988
+ noteInteraction(sessionID2);
3989
+ }
3224
3990
  return;
3225
3991
  }
3226
3992
  if (event.type === "permission.asked") {
3227
3993
  const properties = event.properties;
3228
3994
  const sessionID2 = properties?.sessionID;
3229
3995
  if (!sessionID2) return;
3230
- permissions.add(sessionID2);
3231
- rememberActive(sessionID2);
3232
3996
  const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
3233
- void announce(sessionID2, "permission", permissionHeadline(properties) || void 0, requestID);
3997
+ const outstanding = permissions.get(sessionID2) ?? /* @__PURE__ */ new Set();
3998
+ const first = outstanding.size === 0;
3999
+ outstanding.add(requestID ?? UNANSWERABLE_REQUEST);
4000
+ rememberPermissions(sessionID2, outstanding);
4001
+ rememberActive(sessionID2);
4002
+ if (!first) return;
4003
+ raisePermissionPopup(sessionID2, requestID, permissionHeadline(properties));
3234
4004
  return;
3235
4005
  }
3236
4006
  if (event.type === "permission.replied") {
3237
4007
  const sessionID2 = event.properties?.sessionID;
3238
- if (sessionID2) permissions.delete(sessionID2);
4008
+ if (sessionID2) {
4009
+ const requestID = event.properties?.requestID;
4010
+ if (requestID) forgetPermission(sessionID2, requestID);
4011
+ else permissions.delete(sessionID2);
4012
+ noteInteraction(sessionID2);
4013
+ }
3239
4014
  return;
3240
4015
  }
3241
4016
  if (event.type === "session.error") {
@@ -3259,6 +4034,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3259
4034
  titles.delete(sessionID2);
3260
4035
  pending.delete(sessionID2);
3261
4036
  permissions.delete(sessionID2);
4037
+ forgetLive(sessionID2);
4038
+ forgetPermissionPopup(sessionID2);
3262
4039
  }
3263
4040
  return;
3264
4041
  }
@@ -3754,267 +4531,6 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3754
4531
  };
3755
4532
  };
3756
4533
 
3757
- // src/lib/selfupdate.ts
3758
- import { spawn as spawn2 } from "node:child_process";
3759
- import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
3760
- import { homedir as homedir3 } from "node:os";
3761
- import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
3762
- var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3763
- var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
3764
- var FETCH_TIMEOUT_MS = 4e3;
3765
- var INSTALL_TIMEOUT_MS = 12e4;
3766
- var LOCK_STALE_MS = 10 * 60 * 1e3;
3767
- var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
3768
- var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
3769
- var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
3770
- var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
3771
- var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
3772
- var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
3773
- function compareSemver(a, b) {
3774
- const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
3775
- const left = parse(a);
3776
- const right = parse(b);
3777
- for (let i = 0; i < 3; i++) {
3778
- const l = left[i] ?? 0;
3779
- const r = right[i] ?? 0;
3780
- if (l !== r) return l > r ? 1 : -1;
3781
- }
3782
- return 0;
3783
- }
3784
- function readInstalledVersion(packageRoot, read = (path4) => {
3785
- try {
3786
- return readFileSync5(path4, "utf8");
3787
- } catch {
3788
- return void 0;
3789
- }
3790
- }) {
3791
- try {
3792
- const raw = read(join6(packageRoot, "package.json"));
3793
- if (raw === void 0) return void 0;
3794
- const manifest = JSON.parse(raw);
3795
- if (manifest.name !== PACKAGE_NAME) return void 0;
3796
- return typeof manifest.version === "string" ? manifest.version : void 0;
3797
- } catch {
3798
- return void 0;
3799
- }
3800
- }
3801
- function resolveCacheRoot(packageRoot, exists = (p) => {
3802
- try {
3803
- return statSync3(p).isFile();
3804
- } catch {
3805
- return false;
3806
- }
3807
- }) {
3808
- const tail = join6("node_modules", "@lawaty", "lacode");
3809
- if (!packageRoot.endsWith(tail)) return void 0;
3810
- const cacheRoot = resolve2(packageRoot, "..", "..", "..");
3811
- if (!exists(join6(cacheRoot, "package.json"))) return void 0;
3812
- return cacheRoot;
3813
- }
3814
- function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
3815
- try {
3816
- return statSync3(p).isFile();
3817
- } catch {
3818
- return false;
3819
- }
3820
- }) {
3821
- if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
3822
- for (const dir of (env.PATH ?? "").split(":")) {
3823
- if (!dir) continue;
3824
- const candidate = join6(dir, "opencode");
3825
- if (exists(candidate)) return candidate;
3826
- }
3827
- const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
3828
- return exists(fallback) ? fallback : void 0;
3829
- }
3830
- async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
3831
- const controller = new AbortController();
3832
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3833
- try {
3834
- const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
3835
- if (!response.ok) return void 0;
3836
- const body = await response.json();
3837
- return typeof body.version === "string" ? body.version : void 0;
3838
- } catch {
3839
- return void 0;
3840
- } finally {
3841
- clearTimeout(timer);
3842
- }
3843
- }
3844
- function readState(path4 = STATE_PATH) {
3845
- try {
3846
- return JSON.parse(readFileSync5(path4, "utf8"));
3847
- } catch {
3848
- return {};
3849
- }
3850
- }
3851
- function writeState(state2, path4 = STATE_PATH) {
3852
- try {
3853
- mkdirSync3(dirname3(path4), { recursive: true });
3854
- writeFileSync3(path4, JSON.stringify(state2));
3855
- } catch {
3856
- }
3857
- }
3858
- var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
3859
- var isProcessAlive = (pid) => {
3860
- try {
3861
- process.kill(pid, 0);
3862
- return true;
3863
- } catch (error) {
3864
- return error.code === "EPERM";
3865
- }
3866
- };
3867
- function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3868
- const claim = () => {
3869
- const fd = openSync(path4, "wx");
3870
- writeSync(fd, String(process.pid));
3871
- closeSync(fd);
3872
- return () => {
3873
- try {
3874
- unlinkSync3(path4);
3875
- } catch {
3876
- }
3877
- };
3878
- };
3879
- try {
3880
- mkdirSync3(dirname3(path4), { recursive: true });
3881
- return claim();
3882
- } catch {
3883
- let orphaned = false;
3884
- try {
3885
- const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
3886
- orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
3887
- } catch {
3888
- orphaned = true;
3889
- }
3890
- if (!orphaned) {
3891
- try {
3892
- orphaned = now - statSync3(path4).mtimeMs > LOCK_STALE_MS;
3893
- } catch {
3894
- orphaned = true;
3895
- }
3896
- }
3897
- if (!orphaned) return void 0;
3898
- try {
3899
- unlinkSync3(path4);
3900
- } catch {
3901
- return void 0;
3902
- }
3903
- try {
3904
- return claim();
3905
- } catch {
3906
- return void 0;
3907
- }
3908
- }
3909
- }
3910
- async function checkForUpdate(opts) {
3911
- const installed = readInstalledVersion(opts.packageRoot);
3912
- const latest = await fetchLatest(opts.fetchImpl);
3913
- return {
3914
- installed,
3915
- latest,
3916
- newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
3917
- cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
3918
- bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
3919
- };
3920
- }
3921
- async function refreshCache(opts) {
3922
- const { cacheRoot, bin, latest } = opts;
3923
- const spawnImpl = opts.spawnImpl ?? spawn2;
3924
- const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
3925
- const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
3926
- const aside = `${cacheRoot}.stale-${Date.now()}`;
3927
- try {
3928
- rmSync(aside, { recursive: true, force: true });
3929
- renameSync2(cacheRoot, aside);
3930
- } catch (error) {
3931
- return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
3932
- }
3933
- const exited = await new Promise((settle) => {
3934
- let done = false;
3935
- const finish = (value) => {
3936
- if (done) return;
3937
- done = true;
3938
- clearTimeout(timer);
3939
- settle(value);
3940
- };
3941
- const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
3942
- env: { ...process.env, [CHILD_ENV]: "1" },
3943
- stdio: "ignore"
3944
- });
3945
- const timer = setTimeout(() => {
3946
- try {
3947
- child.kill("SIGKILL");
3948
- } catch {
3949
- }
3950
- finish(false);
3951
- }, timeoutMs);
3952
- child.on("error", () => finish(false));
3953
- child.on("exit", (code) => finish(code === 0));
3954
- });
3955
- const installed = readInstalledVersion(fresh);
3956
- const ok = exited && installed === latest;
3957
- if (ok) {
3958
- try {
3959
- rmSync(aside, { recursive: true, force: true });
3960
- } catch {
3961
- }
3962
- } else {
3963
- try {
3964
- renameSync2(aside, cacheRoot);
3965
- } catch {
3966
- }
3967
- }
3968
- return {
3969
- ok,
3970
- detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
3971
- };
3972
- }
3973
- async function maybeSelfUpdate(opts) {
3974
- const env = opts.env ?? process.env;
3975
- if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
3976
- return { action: "disabled" };
3977
- }
3978
- const now = (opts.now ?? Date.now)();
3979
- const statePath = opts.statePath ?? STATE_PATH;
3980
- const state2 = readState(statePath);
3981
- if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
3982
- const release2 = acquireLock(opts.lockPath, now);
3983
- if (!release2) return { action: "skipped", detail: "another process is updating" };
3984
- try {
3985
- const check = await checkForUpdate(opts);
3986
- if (!check.installed) return { action: "skipped", detail: "no installed version" };
3987
- if (!check.latest) {
3988
- writeState({ ...state2, checkedAt: now }, statePath);
3989
- return { action: "skipped", detail: "registry unreachable" };
3990
- }
3991
- if (!check.newer) {
3992
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
3993
- return { action: "current", detail: `${check.installed} is current` };
3994
- }
3995
- if (!check.cacheRoot) {
3996
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
3997
- return { action: "skipped", detail: "not an opencode cache install" };
3998
- }
3999
- if (!check.bin) {
4000
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
4001
- opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
4002
- return { action: "failed", detail: "no opencode binary" };
4003
- }
4004
- const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
4005
- if (result.ok) {
4006
- writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
4007
- opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
4008
- return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
4009
- }
4010
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
4011
- opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
4012
- return { action: "failed", detail: result.detail };
4013
- } finally {
4014
- release2();
4015
- }
4016
- }
4017
-
4018
4534
  // src/plugins/selfupdate.ts
4019
4535
  async function selfUpdateHooks(input, options) {
4020
4536
  const opts = options ?? {};
@@ -4041,6 +4557,13 @@ var LaCode = (async (input, options) => {
4041
4557
  if (opts.enabled === false) return inertHooks;
4042
4558
  const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
4043
4559
  });
4560
+ let version;
4561
+ try {
4562
+ version = readInstalledVersion(PACKAGE_ROOT);
4563
+ } catch {
4564
+ version = void 0;
4565
+ }
4566
+ await log("info", version ? `lacode ${version} started` : "lacode started");
4044
4567
  const settled = async (label, make) => {
4045
4568
  try {
4046
4569
  return await make();