@leuria/cli 0.1.0 → 0.1.1

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.
@@ -41,6 +41,14 @@ async function getRegistryEntry(id) {
41
41
  }
42
42
  return null;
43
43
  }
44
+ function runnableHere(entry) {
45
+ const d = entry.distribution;
46
+ return Boolean(d.npx || d.uvx || d.binary?.[platformKey()]);
47
+ }
48
+ function clearRegistryCache() {
49
+ cache = null;
50
+ failedAt = 0;
51
+ }
44
52
 
45
53
  // src/home.ts
46
54
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
@@ -454,9 +462,33 @@ async function ensureAgentOnce(id, options) {
454
462
  }
455
463
  if (installed?.version === entry.version) return installed;
456
464
  options.onProgress?.(`Installing ${entry.name} ${entry.version} from the ACP registry\u2026`);
457
- const agent = await install(entry);
458
- await profileFor(id)?.prepare?.(agent);
459
- return agent;
465
+ return install(entry);
466
+ }
467
+ function installedIds() {
468
+ const root = agentsDir();
469
+ if (!existsSync3(root)) return [];
470
+ const ids = readdirSync(root).filter((name) => name.includes("@") && existsSync3(join3(root, name, MANIFEST))).map((name) => name.slice(0, name.lastIndexOf("@")));
471
+ return [...new Set(ids)];
472
+ }
473
+ function pruneAgentVersions() {
474
+ const root = agentsDir();
475
+ for (const id of installedIds()) {
476
+ const versions = readdirSync(root).filter((name) => name.startsWith(`${id}@`) && existsSync3(join3(root, name, MANIFEST))).sort(compareVersions);
477
+ for (const old of versions.slice(0, -1)) rmSync(join3(root, old), { recursive: true, force: true });
478
+ }
479
+ }
480
+ async function updateAgents(onUpdated) {
481
+ clearRegistryCache();
482
+ for (const id of installedIds()) {
483
+ const installed = installedAgent(id);
484
+ const entry = await getRegistryEntry(id);
485
+ if (!installed || !entry || !runnableHere(entry) || compareVersions(entry.version, installed.version) <= 0) continue;
486
+ try {
487
+ const agent = await ensureAgent(id, { update: true });
488
+ await onUpdated?.(agent, installed.version);
489
+ } catch {
490
+ }
491
+ }
460
492
  }
461
493
  async function resolveAgentCommand(id, model) {
462
494
  if (isLlmId(id)) {
@@ -529,6 +561,12 @@ async function install(entry) {
529
561
  throw error;
530
562
  }
531
563
  const agent = { id: entry.id, name: entry.name, version: entry.version, dir, ...launch };
564
+ try {
565
+ await profileFor(entry.id)?.prepare?.(agent);
566
+ } catch (error) {
567
+ rmSync(dir, { recursive: true, force: true });
568
+ throw error;
569
+ }
532
570
  writeFileSync3(join3(dir, MANIFEST), `${JSON.stringify(agent, null, 2)}
533
571
  `);
534
572
  return agent;
@@ -1050,7 +1088,11 @@ function checkTimeoutMs() {
1050
1088
  const value = Number(process.env.LEURIA_CHECK_TIMEOUT_MS);
1051
1089
  return Number.isFinite(value) && value > 0 ? value : 15e3;
1052
1090
  }
1053
- async function checkSignIn(id, onProgress) {
1091
+ function signInStallMs() {
1092
+ const value = Number(process.env.LEURIA_SIGNIN_STALL_MS);
1093
+ return Number.isFinite(value) && value > 0 ? value : 1e4;
1094
+ }
1095
+ async function checkSignIn(id, onProgress, options = {}) {
1054
1096
  if (isLlmId(id)) return checkLlm(id);
1055
1097
  const { session, close } = await open(id, onProgress);
1056
1098
  let timer;
@@ -1061,7 +1103,7 @@ async function checkSignIn(id, onProgress) {
1061
1103
  });
1062
1104
  try {
1063
1105
  const status = await Promise.race([timedOut, check()]);
1064
- rememberReady(id, status.ok);
1106
+ if (!options.quiet) rememberReady(id, status.ok);
1065
1107
  return status;
1066
1108
  } finally {
1067
1109
  clearTimeout(timer);
@@ -1078,7 +1120,11 @@ async function checkSignIn(id, onProgress) {
1078
1120
  }
1079
1121
  async function signIn(id, options = {}) {
1080
1122
  if (isLlmId(id)) return checkLlm(id);
1081
- const link = options.onUrl && !options.terminal ? catchBrowserLinks(options.onUrl) : null;
1123
+ let pageOpened = false;
1124
+ const link = options.onUrl && !options.terminal ? catchBrowserLinks((url) => {
1125
+ pageOpened = true;
1126
+ options.onUrl?.(url);
1127
+ }) : null;
1082
1128
  let opened;
1083
1129
  try {
1084
1130
  opened = await open(id, options.onProgress, options.terminal, link?.env);
@@ -1086,11 +1132,26 @@ async function signIn(id, options = {}) {
1086
1132
  link?.stop();
1087
1133
  throw error;
1088
1134
  }
1089
- const { agent, session, env, sandbox } = opened;
1135
+ const { agent, env, sandbox } = opened;
1090
1136
  const close = () => {
1091
1137
  opened.close();
1092
1138
  link?.stop();
1093
1139
  };
1140
+ async function authenticateOrStall(session, methodId) {
1141
+ const auth = session.authenticate(methodId);
1142
+ if (!link) return auth;
1143
+ let timer;
1144
+ const stalled = new Promise((resolve) => {
1145
+ timer = setTimeout(() => {
1146
+ if (!pageOpened) resolve("stalled");
1147
+ }, signInStallMs());
1148
+ });
1149
+ try {
1150
+ return await Promise.race([auth, stalled]);
1151
+ } finally {
1152
+ clearTimeout(timer);
1153
+ }
1154
+ }
1094
1155
  const cancelled = new Promise((resolve) => {
1095
1156
  const onAbort = () => {
1096
1157
  resolve({ ok: false, detail: "cancelled", methods: [] });
@@ -1105,6 +1166,7 @@ async function signIn(id, options = {}) {
1105
1166
  close();
1106
1167
  }
1107
1168
  async function run() {
1169
+ let session = opened.session;
1108
1170
  const connected = await session.connect();
1109
1171
  if (connected.error) return { ok: false, detail: `the agent did not start: ${connected.error}`, methods: [] };
1110
1172
  const all = session.info?.authMethods ?? [];
@@ -1129,7 +1191,16 @@ async function signIn(id, options = {}) {
1129
1191
  if (code !== 0) return { ok: false, detail: `the sign-in ended with code ${code}`, methods };
1130
1192
  return checkSignIn(id, options.onProgress);
1131
1193
  }
1132
- const auth = await session.authenticate(method.id);
1194
+ let auth = await authenticateOrStall(session, method.id);
1195
+ if (auth === "stalled") {
1196
+ opened.close();
1197
+ if (options.signal?.aborted) return { ok: false, detail: "cancelled", methods };
1198
+ opened = await open(id, options.onProgress, options.terminal, link?.env);
1199
+ session = opened.session;
1200
+ const again = await session.connect();
1201
+ if (again.error) return { ok: false, detail: `the agent did not start: ${again.error}`, methods };
1202
+ auth = await session.authenticate(method.id);
1203
+ }
1133
1204
  if (auth.error) return { ok: false, detail: auth.error, methods };
1134
1205
  const created = await session.newSession();
1135
1206
  if (created.error) return { ok: false, detail: created.error, methods };
@@ -1224,8 +1295,18 @@ var GrantStore = class {
1224
1295
  for (const listener of this.changedListeners) listener(grant.origin);
1225
1296
  return true;
1226
1297
  }
1227
- /** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` replaces what it declared. */
1228
- create(origin, app, needs) {
1298
+ /** Replace the site's skills (it changed its list). Its open conversations keep the skills they started with. */
1299
+ setSkills(origin, skills) {
1300
+ this.reload();
1301
+ const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
1302
+ if (!grant) return false;
1303
+ if (skills) grant.skills = skills;
1304
+ else delete grant.skills;
1305
+ this.save();
1306
+ return true;
1307
+ }
1308
+ /** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` and `skills` replace what it declared. */
1309
+ create(origin, app, needs, skills) {
1229
1310
  this.reload();
1230
1311
  const normalized = normalizeOrigin(origin);
1231
1312
  const token = randomBytes(32).toString("base64url");
@@ -1238,7 +1319,8 @@ var GrantStore = class {
1238
1319
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1239
1320
  ...previous?.agent ? { agent: previous.agent } : {},
1240
1321
  ...previous?.model ? { model: previous.model } : {},
1241
- ...needs ? { needs } : {}
1322
+ ...needs ? { needs } : {},
1323
+ ...skills ? { skills } : {}
1242
1324
  });
1243
1325
  this.save();
1244
1326
  return token;
@@ -1456,7 +1538,7 @@ function parseEmbedBody(body) {
1456
1538
  }
1457
1539
 
1458
1540
  // src/pairing.ts
1459
- import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1541
+ import { createHash as createHash5, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1460
1542
 
1461
1543
  // src/needs.ts
1462
1544
  var EFFORTS = ["light", "standard", "deep"];
@@ -1473,13 +1555,363 @@ function parseNeeds(input) {
1473
1555
  return Object.keys(needs).length ? needs : void 0;
1474
1556
  }
1475
1557
 
1558
+ // src/skills.ts
1559
+ import { createHash as createHash4 } from "crypto";
1560
+ import cacache from "cacache";
1561
+
1562
+ // src/skill-sources.ts
1563
+ import { createHash as createHash3 } from "crypto";
1564
+ import { lookup } from "dns/promises";
1565
+ import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
1566
+ import { tmpdir as tmpdir3 } from "os";
1567
+ import { extname, join as join6, relative, sep } from "path";
1568
+ import { downloadTemplate } from "giget";
1569
+ import ipaddr from "ipaddr.js";
1570
+ import { parse as parseYaml } from "yaml";
1571
+ var TEXT_FILES = /* @__PURE__ */ new Set([".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".csv"]);
1572
+ var MAX_SKILL_BYTES = 256 * 1024;
1573
+ var MAX_FILES = 50;
1574
+ var MAX_TOTAL_BYTES = 1024 * 1024;
1575
+ var MAX_DESCRIPTION = 1024;
1576
+ var FETCH_TIMEOUT_MS = 15e3;
1577
+ var MAX_DEPTH = 5;
1578
+ var WELL_KNOWN = [".well-known/agent-skills", ".well-known/skills"];
1579
+ var DISCOVERY_V2 = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
1580
+ var SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1581
+ var REPO_PART = /^[A-Za-z0-9_.-]+$/;
1582
+ function parseSkillSource(input, origin) {
1583
+ let ref = input.trim();
1584
+ if (ref.startsWith("/") || /^https?:\/\//i.test(ref)) {
1585
+ const url = new URL(ref, origin);
1586
+ if (url.hostname === "github.com") return parseGithubUrl(url);
1587
+ if (url.origin !== origin) throw new Error(`${input}: skills come from GitHub or from the site's own origin (${origin})`);
1588
+ url.hash = "";
1589
+ return { kind: "site", url: url.toString() };
1590
+ }
1591
+ ref = ref.replace(/^github:/, "");
1592
+ let pin;
1593
+ let skill;
1594
+ const hash = ref.indexOf("#");
1595
+ if (hash >= 0) {
1596
+ const fragment = ref.slice(hash + 1);
1597
+ ref = ref.slice(0, hash);
1598
+ const at2 = fragment.indexOf("@");
1599
+ pin = (at2 >= 0 ? fragment.slice(0, at2) : fragment) || void 0;
1600
+ skill = at2 >= 0 ? fragment.slice(at2 + 1) || void 0 : void 0;
1601
+ }
1602
+ const at = ref.match(/^([^/]+)\/([^/@]+)@(.+)$/);
1603
+ if (at) return github(input, `${at[1]}/${at[2]}`, pin, void 0, skill ?? at[3]);
1604
+ const parts = ref.split("/").filter(Boolean);
1605
+ if (parts.length < 2) throw new Error(`${input}: expected owner/repo, owner/repo@skill, a GitHub URL, or a URL on the site`);
1606
+ return github(input, `${parts[0]}/${parts[1]}`, pin, parts.slice(2).join("/") || void 0, skill);
1607
+ }
1608
+ function parseGithubUrl(url) {
1609
+ const [owner, repo, marker, pin, ...rest] = url.pathname.split("/").filter(Boolean);
1610
+ if (!owner || !repo) throw new Error(`${url}: not a GitHub repository`);
1611
+ const tree = marker === "tree" && pin;
1612
+ return github(url.toString(), `${owner}/${repo.replace(/\.git$/, "")}`, tree ? pin : void 0, tree ? rest.join("/") || void 0 : void 0, void 0);
1613
+ }
1614
+ function github(input, repo, ref, subpath, skill) {
1615
+ const [owner, name] = repo.split("/");
1616
+ if (![owner, name].every((part) => part && REPO_PART.test(part) && !/^\.+$/.test(part))) throw new Error(`${input}: not a GitHub repository`);
1617
+ if (subpath && subpath.split("/").some((p) => p === ".." || p === "." || !p)) throw new Error(`${input}: bad folder`);
1618
+ if (ref && !/^[\w./-]+$/.test(ref)) throw new Error(`${input}: bad ref`);
1619
+ return { kind: "github", repo, ...ref ? { ref } : {}, ...subpath ? { subpath } : {}, ...skill ? { skill } : {} };
1620
+ }
1621
+ function sourceLabel(source) {
1622
+ return source.kind === "github" ? source.repo : new URL(source.url).host;
1623
+ }
1624
+ async function fetchSkills(source) {
1625
+ const skills = source.kind === "github" ? await fromGithub(source) : await fromSite(source.url);
1626
+ if (!skills.length) throw new Error("no skill found");
1627
+ return skills;
1628
+ }
1629
+ function parseSkillMd(text, files = {}) {
1630
+ if (text.length > MAX_SKILL_BYTES) throw new Error("SKILL.md is too long");
1631
+ const match = text.replace(/^/, "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
1632
+ if (!match) throw new Error("SKILL.md has no front matter");
1633
+ const meta = parseYaml(match[1]);
1634
+ const name = typeof meta?.name === "string" ? meta.name.trim() : "";
1635
+ const description = typeof meta?.description === "string" ? meta.description.trim().replace(/\s+/g, " ") : "";
1636
+ if (!SKILL_NAME.test(name) || name.length > 64) throw new Error(`SKILL.md name "${name}" is not a skill name`);
1637
+ if (!description) throw new Error(`SKILL.md of ${name} has no description`);
1638
+ return { name, description: description.slice(0, MAX_DESCRIPTION), body: match[2].trim(), files };
1639
+ }
1640
+ async function fromGithub(source) {
1641
+ const dir = await mkdtemp(join6(tmpdir3(), "leuria-skills-"));
1642
+ try {
1643
+ const path2 = source.subpath ? `/${source.subpath}` : "";
1644
+ await downloadTemplate(`gh:${source.repo}${path2}#${source.ref ?? "HEAD"}`, {
1645
+ dir,
1646
+ force: true,
1647
+ forceClean: true,
1648
+ registry: false,
1649
+ silent: true,
1650
+ ignore: (file) => !TEXT_FILES.has(extname(file).toLowerCase())
1651
+ });
1652
+ const found = [];
1653
+ for (const skillFile of await findSkillFiles(dir, 0)) {
1654
+ let skill;
1655
+ try {
1656
+ skill = parseSkillMd(await readFile(skillFile, "utf-8"));
1657
+ } catch {
1658
+ continue;
1659
+ }
1660
+ if (source.skill && skill.name !== source.skill) continue;
1661
+ skill.files = await readTextFiles(join6(skillFile, ".."));
1662
+ found.push(skill);
1663
+ }
1664
+ return found;
1665
+ } finally {
1666
+ await rm(dir, { recursive: true, force: true });
1667
+ }
1668
+ }
1669
+ async function findSkillFiles(dir, depth) {
1670
+ const entries = await readdir(dir, { withFileTypes: true });
1671
+ const own = entries.find((e) => e.isFile() && e.name.toLowerCase() === "skill.md");
1672
+ if (own) return [join6(dir, own.name)];
1673
+ if (depth >= MAX_DEPTH) return [];
1674
+ const nested = await Promise.all(
1675
+ entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => findSkillFiles(join6(dir, e.name), depth + 1))
1676
+ );
1677
+ return nested.flat();
1678
+ }
1679
+ async function readTextFiles(dir) {
1680
+ const files = {};
1681
+ let total = 0;
1682
+ const walk = async (at, depth) => {
1683
+ for (const entry of await readdir(at, { withFileTypes: true })) {
1684
+ const full = join6(at, entry.name);
1685
+ if (entry.isDirectory() && depth < MAX_DEPTH) await walk(full, depth + 1);
1686
+ if (!entry.isFile() || !TEXT_FILES.has(extname(entry.name).toLowerCase())) continue;
1687
+ const path2 = relative(dir, full).split(sep).join("/");
1688
+ if (path2.toLowerCase() === "skill.md" || Object.keys(files).length >= MAX_FILES) continue;
1689
+ const size = (await stat(full)).size;
1690
+ if (size > MAX_SKILL_BYTES || total + size > MAX_TOTAL_BYTES) continue;
1691
+ total += size;
1692
+ files[path2] = await readFile(full, "utf-8");
1693
+ }
1694
+ };
1695
+ await walk(dir, 0);
1696
+ return files;
1697
+ }
1698
+ async function fromSite(url) {
1699
+ const parsed = new URL(url);
1700
+ if (/\/skill\.md$/i.test(parsed.pathname)) return [parseSkillMd(await siteText(url))];
1701
+ const named2 = parsed.pathname.match(/\/\.well-known\/(?:agent-skills|skills)\/([^/]+)\/?$/)?.[1];
1702
+ const base = parsed.pathname.replace(/\/\.well-known\/.*$/, "").replace(/\/$/, "");
1703
+ for (const wellKnown of WELL_KNOWN) {
1704
+ const indexUrl = `${parsed.origin}${base}/${wellKnown}/index.json`;
1705
+ let index;
1706
+ try {
1707
+ index = JSON.parse(await siteText(indexUrl));
1708
+ } catch {
1709
+ continue;
1710
+ }
1711
+ const entries = indexEntries(index).filter((e) => !named2 || e.name === named2);
1712
+ const skills = [];
1713
+ for (const entry of entries) {
1714
+ try {
1715
+ skills.push(await fromIndexEntry(entry, indexUrl, `${parsed.origin}${base}/${wellKnown}/${entry.name}`));
1716
+ } catch {
1717
+ }
1718
+ }
1719
+ return skills;
1720
+ }
1721
+ throw new Error("the site has no .well-known/agent-skills/index.json");
1722
+ }
1723
+ function indexEntries(index) {
1724
+ const record = index;
1725
+ if (!record || !Array.isArray(record.skills)) return [];
1726
+ const v2 = record.$schema === DISCOVERY_V2;
1727
+ return record.skills.flatMap((raw) => {
1728
+ const e = raw;
1729
+ if (typeof e.name !== "string" || !SKILL_NAME.test(e.name)) return [];
1730
+ if (v2) {
1731
+ if (e.type !== "skill-md" || typeof e.url !== "string" || typeof e.digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(e.digest)) return [];
1732
+ return [{ name: e.name, url: e.url, digest: e.digest }];
1733
+ }
1734
+ const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === "string" && !f.startsWith("/") && !f.includes("..")) : [];
1735
+ return files.some((f) => f.toLowerCase() === "skill.md") ? [{ name: e.name, files }] : [];
1736
+ });
1737
+ }
1738
+ async function fromIndexEntry(entry, indexUrl, folder) {
1739
+ if (entry.url) {
1740
+ const text = await siteText(new URL(entry.url, indexUrl).toString());
1741
+ const digest = `sha256:${createHash3("sha256").update(text).digest("hex")}`;
1742
+ if (digest !== entry.digest) throw new Error(`${entry.name}: SKILL.md doesn't match its digest`);
1743
+ return named(parseSkillMd(text), entry.name);
1744
+ }
1745
+ const files = {};
1746
+ let skillMd = "";
1747
+ for (const file of (entry.files ?? []).slice(0, MAX_FILES)) {
1748
+ const text = await siteText(`${folder}/${file}`);
1749
+ if (file.toLowerCase() === "skill.md") skillMd = text;
1750
+ else if (TEXT_FILES.has(extname(file).toLowerCase())) files[file] = text;
1751
+ }
1752
+ return named(parseSkillMd(skillMd, files), entry.name);
1753
+ }
1754
+ function named(skill, name) {
1755
+ if (skill.name !== name) throw new Error(`the index names ${name}, its SKILL.md ${skill.name}`);
1756
+ return skill;
1757
+ }
1758
+ async function siteText(url) {
1759
+ const { hostname } = new URL(url);
1760
+ const host = hostname.replace(/^\[|\]$/g, "");
1761
+ if (!isLoopbackName(host)) {
1762
+ const addresses = ipaddr.isValid(host) ? [{ address: host }] : await lookup(host, { all: true });
1763
+ for (const { address } of addresses) {
1764
+ const range = ipaddr.process(address).range();
1765
+ if (range !== "unicast") throw new Error(`${hostname} resolves to a ${range} address`);
1766
+ }
1767
+ }
1768
+ const res = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1769
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
1770
+ if (Number(res.headers.get("content-length")) > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
1771
+ const text = await res.text();
1772
+ if (text.length > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
1773
+ return text;
1774
+ }
1775
+ function isLoopbackName(host) {
1776
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
1777
+ return ipaddr.isValid(host) && ipaddr.process(host).range() === "loopback";
1778
+ }
1779
+
1780
+ // src/skills.ts
1781
+ var READ_SKILL = "read_skill";
1782
+ var MAX_REFS = 16;
1783
+ var MAX_SKILLS = 16;
1784
+ var MAX_REF_LENGTH = 300;
1785
+ function parseSkillRefs(input) {
1786
+ const raw = typeof input === "string" ? [input] : Array.isArray(input) ? input : [];
1787
+ const refs = [...new Set(raw.filter((r) => typeof r === "string").map((r) => r.trim()).filter((r) => r && r.length <= MAX_REF_LENGTH))];
1788
+ return refs.length ? refs.slice(0, MAX_REFS) : void 0;
1789
+ }
1790
+ var sameRefs = (a, b) => (a ?? []).join("\n") === (b ?? []).join("\n");
1791
+ var SkillService = class {
1792
+ constructor(options) {
1793
+ this.options = options;
1794
+ this.cache = options.cache === void 0 ? homePath("skills") : options.cache;
1795
+ }
1796
+ options;
1797
+ cache;
1798
+ memory = /* @__PURE__ */ new Map();
1799
+ refreshing = /* @__PURE__ */ new Map();
1800
+ /**
1801
+ * Fetch and cache the skills `refs` name for `origin`. A ref that fails
1802
+ * is logged and skipped; the first skill of a name wins.
1803
+ */
1804
+ async resolve(origin, refs) {
1805
+ const list = [];
1806
+ for (const ref of refs) {
1807
+ let found;
1808
+ try {
1809
+ found = await (this.options.fetch ?? fetchRef)(ref, origin);
1810
+ } catch (error) {
1811
+ this.options.logger.warn("skill source failed", { origin, ref, error: error instanceof Error ? error.message : String(error) });
1812
+ continue;
1813
+ }
1814
+ for (const skill of found.skills) {
1815
+ if (list.length >= MAX_SKILLS || list.some((s) => s.name === skill.name)) continue;
1816
+ list.push({ name: skill.name, description: skill.description, source: found.source, shared: found.shared, integrity: await this.put(skill) });
1817
+ }
1818
+ }
1819
+ return list;
1820
+ }
1821
+ /**
1822
+ * A connected site sent its skill refs with a session: when they changed,
1823
+ * fetch them in the background and update its grant. Sessions starting
1824
+ * meanwhile keep the skills it had.
1825
+ */
1826
+ refresh(origin, refs) {
1827
+ const grant = this.options.grants.get(origin);
1828
+ if (!grant || sameRefs(grant.skills?.refs, refs) || this.refreshing.has(origin)) return;
1829
+ const done = (async () => {
1830
+ const list = refs ? await this.resolve(origin, refs) : [];
1831
+ const before = new Set((this.options.grants.get(origin)?.skills?.list ?? []).map((s) => s.name));
1832
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1833
+ const marked = list.map((s) => before.has(s.name) ? s : { ...s, addedAt: now });
1834
+ this.options.grants.setSkills(origin, refs ? { refs, list: marked } : void 0);
1835
+ })().catch((error) => this.options.logger.warn("skills refresh failed", { origin, error: String(error) })).finally(() => this.refreshing.delete(origin));
1836
+ this.refreshing.set(origin, done);
1837
+ }
1838
+ /** The skills a session of `origin` gets, from the cache. */
1839
+ async forSession(origin) {
1840
+ const list = this.options.grants.get(origin)?.skills?.list ?? [];
1841
+ const loaded = await Promise.all(list.map((s) => this.get(s.integrity).catch(() => null)));
1842
+ return loaded.filter((s) => s !== null);
1843
+ }
1844
+ /** Wait for background refreshes (tests). */
1845
+ async settled() {
1846
+ await Promise.all(this.refreshing.values());
1847
+ }
1848
+ async put(skill) {
1849
+ const data = JSON.stringify(skill);
1850
+ if (this.cache === null) {
1851
+ const integrity = `sha256-${createHash4("sha256").update(data).digest("base64")}`;
1852
+ this.memory.set(integrity, skill);
1853
+ return integrity;
1854
+ }
1855
+ return String(await cacache.put(this.cache, `skill:${skill.name}`, data, { algorithms: ["sha256"] }));
1856
+ }
1857
+ async get(integrity) {
1858
+ if (this.cache === null) {
1859
+ const skill = this.memory.get(integrity);
1860
+ if (!skill) throw new Error("not cached");
1861
+ return skill;
1862
+ }
1863
+ return JSON.parse((await cacache.get.byDigest(this.cache, integrity)).toString("utf-8"));
1864
+ }
1865
+ };
1866
+ async function fetchRef(ref, origin) {
1867
+ const source = parseSkillSource(ref, origin);
1868
+ return { skills: await fetchSkills(source), source: sourceLabel(source), shared: source.kind === "github" };
1869
+ }
1870
+ function skillsPrompt(skills) {
1871
+ return [
1872
+ "This site gives you skills: instructions for tasks on this site.",
1873
+ `When a request matches a skill, call \`${READ_SKILL}\` with its name first and follow what it says.`,
1874
+ "",
1875
+ ...skills.map((s) => `- ${s.name}: ${s.description}`)
1876
+ ].join("\n");
1877
+ }
1878
+ function readSkillTool(skills) {
1879
+ return {
1880
+ name: READ_SKILL,
1881
+ description: "Read one of this site's skills: its instructions, or one of its files.",
1882
+ inputSchema: {
1883
+ type: "object",
1884
+ properties: {
1885
+ name: { type: "string", enum: skills.map((s) => s.name), description: "The skill's name." },
1886
+ file: { type: "string", description: "A file the skill mentions, by its path. Leave out for the skill's instructions." }
1887
+ },
1888
+ required: ["name"]
1889
+ },
1890
+ annotations: { readOnlyHint: true }
1891
+ };
1892
+ }
1893
+ function readSkill(skills, args) {
1894
+ const skill = skills.find((s) => s.name === args.name);
1895
+ if (!skill) throw new Error(`No skill named ${String(args.name)}. Skills: ${skills.map((s) => s.name).join(", ")}`);
1896
+ if (typeof args.file === "string" && args.file) {
1897
+ const file = skill.files[args.file.replace(/^\.\//, "")];
1898
+ if (file === void 0) throw new Error(`${skill.name} has no file ${args.file}`);
1899
+ return file;
1900
+ }
1901
+ const files = Object.keys(skill.files);
1902
+ return files.length ? `${skill.body}
1903
+
1904
+ ---
1905
+ Files of this skill (read one with \`file\`): ${files.join(", ")}` : skill.body;
1906
+ }
1907
+
1476
1908
  // src/pairing.ts
1477
1909
  var REQUEST_TTL_MS = 5 * 6e4;
1478
1910
  var MAX_PENDING = 20;
1479
1911
  var WAIT_MS = 25e3;
1480
1912
  var DENIED_COOLDOWN_MS = 3e4;
1481
1913
  var NONCE = /^[A-Za-z0-9_-]{22,128}$/;
1482
- var hashNonce = (nonce) => createHash3("sha256").update(nonce).digest();
1914
+ var hashNonce = (nonce) => createHash5("sha256").update(nonce).digest();
1483
1915
  var Pairing = class {
1484
1916
  constructor(options) {
1485
1917
  this.options = options;
@@ -1508,7 +1940,7 @@ var Pairing = class {
1508
1940
  if (this.options.selfOrigins.has(origin)) return { error: "The link doesn't name a website." };
1509
1941
  if (typeof input.nonce !== "string" || !NONCE.test(input.nonce)) return { error: "The link is incomplete." };
1510
1942
  const app = typeof input.app === "string" && input.app.trim() ? input.app.trim().slice(0, 80) : void 0;
1511
- const request = this.open(origin, app, input.nonce, parseNeeds(input.needs));
1943
+ const request = this.open(origin, app, input.nonce, parseNeeds(input.needs), parseSkillRefs(input.skills));
1512
1944
  return "error" in request ? request : { requestId: request.id };
1513
1945
  }
1514
1946
  /** Returns true when the request was a pairing route. */
@@ -1534,7 +1966,7 @@ var Pairing = class {
1534
1966
  sendJson(res, 403, { error: "Forbidden" });
1535
1967
  return true;
1536
1968
  }
1537
- sendJson(res, 200, { decision: request?.decision ?? "expired" });
1969
+ sendJson(res, 200, { decision: request?.decision ?? "expired", ...request?.skills && !request.skills.list ? { skillsLoading: true } : {} });
1538
1970
  return true;
1539
1971
  }
1540
1972
  if (req.method === "POST" && action === "decide") {
@@ -1579,7 +2011,7 @@ var Pairing = class {
1579
2011
  return;
1580
2012
  }
1581
2013
  const app = typeof body.app === "string" && body.app.trim() ? body.app.trim().slice(0, 80) : void 0;
1582
- const opened = this.open(origin, app, nonce, parseNeeds(body.needs));
2014
+ const opened = this.open(origin, app, nonce, parseNeeds(body.needs), parseSkillRefs(body.skills));
1583
2015
  if ("error" in opened) {
1584
2016
  sendJson(res, opened.status, { error: opened.error });
1585
2017
  return;
@@ -1604,7 +2036,7 @@ var Pairing = class {
1604
2036
  }
1605
2037
  }
1606
2038
  /** Ask the visitor about `origin`, or update the question already asked. */
1607
- open(origin, app, nonce, needs) {
2039
+ open(origin, app, nonce, needs, skillRefs) {
1608
2040
  const denied = this.deniedAt.get(origin);
1609
2041
  if (denied && Date.now() - denied < DENIED_COOLDOWN_MS) return { error: "The visitor just said no to this site", status: 429 };
1610
2042
  const existing = [...this.requests.values()].find((r) => r.origin === origin && r.decision === "pending");
@@ -1612,6 +2044,7 @@ var Pairing = class {
1612
2044
  existing.nonce = hashNonce(nonce);
1613
2045
  if (app) existing.app = app;
1614
2046
  if (needs) existing.needs = needs;
2047
+ if (skillRefs && !sameRefs(existing.skills?.refs, skillRefs)) this.fetchSkills(existing, skillRefs);
1615
2048
  this.options.onRequest?.(this.info(existing));
1616
2049
  return existing;
1617
2050
  }
@@ -1629,16 +2062,35 @@ var Pairing = class {
1629
2062
  waiters: /* @__PURE__ */ new Set()
1630
2063
  };
1631
2064
  this.requests.set(request.id, request);
2065
+ if (skillRefs) this.fetchSkills(request, skillRefs);
1632
2066
  this.options.logger.info("pairing requested", { origin, app });
1633
2067
  this.options.onRequest?.(this.info(request));
1634
2068
  return request;
1635
2069
  }
2070
+ /**
2071
+ * Fetch the skills the site names, then show them: the question is asked
2072
+ * again with them. Answered meanwhile: they go to the site's grant.
2073
+ */
2074
+ fetchSkills(request, refs) {
2075
+ const { resolveSkills } = this.options;
2076
+ if (!resolveSkills) return;
2077
+ const skills = { refs };
2078
+ request.skills = skills;
2079
+ void resolveSkills(request.origin, refs).catch(() => []).then((list) => {
2080
+ if (request.skills !== skills) return;
2081
+ skills.list = list;
2082
+ if (request.decision === "pending") this.options.onRequest?.(this.info(request));
2083
+ else if (request.decision === "allowed") this.options.grants.setSkills(request.origin, { refs, list });
2084
+ });
2085
+ }
1636
2086
  info(request) {
2087
+ const skills = request.skills;
1637
2088
  return {
1638
2089
  requestId: request.id,
1639
2090
  origin: request.origin,
1640
2091
  app: request.app,
1641
2092
  ...request.needs ? { needs: request.needs } : {},
2093
+ ...skills ? { skills: { loading: !skills.list, list: (skills.list ?? []).map(({ name, description, source, shared }) => ({ name, description, source, shared })) } } : {},
1642
2094
  approveUrl: `http://127.0.0.1:${this.options.port}/connect/${request.id}`
1643
2095
  };
1644
2096
  }
@@ -1661,7 +2113,8 @@ var Pairing = class {
1661
2113
  }
1662
2114
  decide(request, allow, granted) {
1663
2115
  if (allow) {
1664
- request.token = this.options.grants.create(request.origin, request.app, request.needs);
2116
+ const skills = request.skills ? { refs: request.skills.refs, list: request.skills.list ?? [] } : void 0;
2117
+ request.token = this.options.grants.create(request.origin, request.app, request.needs, skills);
1665
2118
  granted?.(request.origin);
1666
2119
  request.decision = "allowed";
1667
2120
  } else {
@@ -1750,6 +2203,15 @@ button.ink:hover { background: var(--ink-hover); }
1750
2203
  button:disabled { opacity: 0.5; cursor: default; }
1751
2204
  button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1752
2205
  .small { font-size: 12px; }
2206
+ .skills { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; font-size: 14px; }
2207
+ .skills summary { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; list-style: none; }
2208
+ .skills summary::-webkit-details-marker { display: none; }
2209
+ .skills summary span:last-child { color: var(--muted); white-space: nowrap; }
2210
+ .skills[open] summary span:last-child::after { content: " \\25B4"; }
2211
+ .skills:not([open]) summary span:last-child::after { content: " \\25BE"; }
2212
+ .skills ul { margin-top: 10px; gap: 10px; }
2213
+ .skills li { display: block; }
2214
+ .skills li p { font-size: 13px; }
1753
2215
  `;
1754
2216
  var MARK = "M181 61 C184 74 191 82 206 86 C191 90 184 98 181 111 C178 98 171 90 156 86 C171 82 178 74 181 61 Z M59 92 C60 109 67 128 79 143 C91 158 105 168 124 174 C149 182 164 193 171 207 C177 219 177 239 177 255 C182 255 182 240 184 226 C188 199 201 187 223 179 C247 171 270 158 284 142 C298 127 303 109 303 92 C298 91 292 101 284 108 C265 125 245 130 226 126 C217 124 211 114 205 112 C199 110 199 116 199 121 C198 127 191 130 181 130 C171 130 165 127 163 121 C162 116 165 110 159 111 C151 112 148 122 137 126 C118 131 98 125 78 109 C69 102 65 91 59 92 Z";
1755
2217
  var LOGO = `<span class="logo" role="img" aria-label="Leuria"><svg width="22" height="22" viewBox="53 30 256 256" aria-hidden="true"><path d="${MARK}" fill="currentColor"/></svg>leuria</span>`;
@@ -1774,6 +2236,7 @@ function approvePage(request, agentName2) {
1774
2236
  <p>It will talk to ${agent}, on this computer.</p>
1775
2237
  <div><div class="eyebrow">It can</div><ul class="can"><li>${CHECK}Ask your AI to answer you</li><li>${CHECK}Let your AI use its own page's tools</li></ul></div>
1776
2238
  <div><div class="eyebrow">It can't</div><ul class="cant"><li>${CROSS}See your files or run programs</li><li>${CROSS}See what you do on other websites</li></ul></div>
2239
+ ${skillsSection(request)}
1777
2240
  ${insecure ? `<p class="warn">This site doesn't use a secure connection.</p>` : ""}
1778
2241
  <p class="small">You can disconnect it at any time with <code>leuria sites revoke ${escapeHtml(request.origin)}</code>.</p>
1779
2242
  <div class="actions"><button type="button" class="soft" id="deny">Not now</button><button type="button" class="ink" id="allow">Allow</button></div>`;
@@ -1794,14 +2257,25 @@ async function decide(allow) {
1794
2257
  }
1795
2258
  document.getElementById("allow").addEventListener("click", () => decide(true));
1796
2259
  document.getElementById("deny").addEventListener("click", () => decide(false));
1797
- // Answered in the Leuria app instead: close this window too.
2260
+ // Answered in the Leuria app instead: close this window too. The site's skills fetched: show them.
2261
+ const skillsLoading = ${request.skills && !request.skills.list ? "true" : "false"};
1798
2262
  setInterval(async () => {
1799
2263
  const res = await fetch(location.pathname + "/state").catch(() => null);
1800
2264
  const state = res && res.ok ? await res.json() : null;
1801
2265
  if (state && state.decision !== "pending") done("Answered in Leuria", "You can close this window.");
2266
+ else if (state && skillsLoading && !state.skillsLoading) location.reload();
1802
2267
  }, 1500);`;
1803
2268
  return page("Connect your AI", body, script);
1804
2269
  }
2270
+ function skillsSection(request) {
2271
+ const skills = request.skills;
2272
+ if (!skills) return "";
2273
+ if (!skills.list) return `<p>Checking the skills this site uses to guide your AI\u2026</p>`;
2274
+ if (!skills.list.length) return "";
2275
+ const count = skills.list.length === 1 ? "1 skill" : `${skills.list.length} skills`;
2276
+ const items = skills.list.map((s) => `<li><strong>${escapeHtml(s.name)}</strong><p>${escapeHtml(s.description)}</p><p class="small">${s.shared ? `Shared skill \xB7 ${escapeHtml(s.source)}` : `From ${escapeHtml(s.source)}`}</p></li>`).join("");
2277
+ return `<details class="skills"><summary><span>This site uses ${count} to guide your AI.</span><span>See details</span></summary><ul>${items}</ul></details>`;
2278
+ }
1805
2279
  function messagePage(title, text) {
1806
2280
  return page(title, `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>`);
1807
2281
  }
@@ -1832,12 +2306,12 @@ var POST_ACTIONS = /* @__PURE__ */ new Set([
1832
2306
  "cancel",
1833
2307
  "close"
1834
2308
  ]);
1835
- async function handleSessionRequest(req, res, pathname, sm, requester) {
2309
+ async function handleSessionRequest(req, res, pathname, sm, requester, onSkills) {
1836
2310
  const segments = pathname.split("/").filter(Boolean);
1837
2311
  if (segments[0] !== "session") return false;
1838
2312
  try {
1839
2313
  if (req.method === "POST" && segments.length === 2 && segments[1] === "prepare") {
1840
- await handlePrepare(req, res, sm, requester);
2314
+ await handlePrepare(req, res, sm, requester, onSkills);
1841
2315
  return true;
1842
2316
  }
1843
2317
  const sessionId = segments[1];
@@ -1886,8 +2360,9 @@ async function runAction(req, sm, sessionId, action) {
1886
2360
  return sm.close(sessionId);
1887
2361
  }
1888
2362
  }
1889
- async function handlePrepare(req, res, sm, origin) {
2363
+ async function handlePrepare(req, res, sm, origin, onSkills) {
1890
2364
  const body = await parseBody(req);
2365
+ if ("skills" in body) onSkills?.(origin, parseSkillRefs(body.skills));
1891
2366
  if (body.prompt !== void 0 && (typeof body.prompt !== "string" || !body.prompt.trim())) {
1892
2367
  sendJson(res, 400, { error: "prompt must be a non-empty string when given" });
1893
2368
  return;
@@ -1938,8 +2413,8 @@ function handleStream(req, res, sm, sessionId) {
1938
2413
  // src/session-manager.ts
1939
2414
  import { randomUUID } from "crypto";
1940
2415
  import { mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
1941
- import { tmpdir as tmpdir3 } from "os";
1942
- import { join as join6 } from "path";
2416
+ import { tmpdir as tmpdir4 } from "os";
2417
+ import { join as join7 } from "path";
1943
2418
 
1944
2419
  // src/llm/llm-session.ts
1945
2420
  var LlmSession = class {
@@ -2260,6 +2735,12 @@ var SessionManager = class {
2260
2735
  return;
2261
2736
  }
2262
2737
  if (TERMINAL.has(session.status)) return;
2738
+ const skills = await this.options.loadSkills?.(session.params.origin).catch(() => []) ?? [];
2739
+ if (skills.length) {
2740
+ this.options.webMcpServer.setSkills(session.id, skills);
2741
+ session.params.systemPrompt = [session.params.systemPrompt, skillsPrompt(skills)].filter(Boolean).join("\n\n");
2742
+ }
2743
+ if (TERMINAL.has(session.status)) return;
2263
2744
  const live = launch.llm ? this.llmSession(session, launch.llm) : this.acpSession(session, launch);
2264
2745
  session.live = live;
2265
2746
  const timeoutMs = this.options.startTimeoutMs ?? 6e4;
@@ -2418,7 +2899,7 @@ var SessionManager = class {
2418
2899
  * files (CLAUDE.md, repo) to act on.
2419
2900
  */
2420
2901
  allocateSandbox(sessionId) {
2421
- const dir = join6(tmpdir3(), `leuria-${sessionId}`);
2902
+ const dir = join7(tmpdir4(), `leuria-${sessionId}`);
2422
2903
  mkdirSync6(dir, { recursive: true });
2423
2904
  return dir;
2424
2905
  }
@@ -2509,6 +2990,7 @@ var WebMcpServer = class {
2509
2990
  tools: /* @__PURE__ */ new Map(),
2510
2991
  resources: /* @__PURE__ */ new Map(),
2511
2992
  prompts: /* @__PURE__ */ new Map(),
2993
+ skills: [],
2512
2994
  pendingRequests: /* @__PURE__ */ new Map(),
2513
2995
  pingTimer: null
2514
2996
  };
@@ -2617,20 +3099,33 @@ var WebMcpServer = class {
2617
3099
  this.logger.info("WebMCP channel removed", { sessionId, channelId });
2618
3100
  }
2619
3101
  // ── In-process access (the engine's own agent loop for LLM providers) ──
2620
- /** The page tools a session's browser has declared. */
3102
+ /** Give a session its site's skills: the agent then also gets `read_skill`. */
3103
+ setSkills(sessionId, skills) {
3104
+ const channelId = this.sessionChannels.get(sessionId);
3105
+ const channel = channelId ? this.channels.get(channelId) : void 0;
3106
+ if (channel) channel.skills = skills;
3107
+ }
3108
+ /** The tools a session's agent gets: the page's, and `read_skill` when the site has skills. */
2621
3109
  listTools(sessionId) {
2622
3110
  const channelId = this.sessionChannels.get(sessionId);
2623
3111
  const channel = channelId ? this.channels.get(channelId) : void 0;
2624
- return channel ? Array.from(channel.tools.values()) : [];
3112
+ return channel ? this.toolsOf(channel) : [];
2625
3113
  }
2626
- /** Run a page tool in the browser; resolves with its JSON result. Throws on tool errors. */
3114
+ /** Run a page tool in the browser (or `read_skill` here); resolves with its JSON result. Throws on tool errors. */
2627
3115
  async callTool(sessionId, name, args) {
2628
3116
  const channelId = this.sessionChannels.get(sessionId);
2629
3117
  const channel = channelId ? this.channels.get(channelId) : void 0;
2630
3118
  if (!channel) throw new Error("The page is not connected");
3119
+ if (name === READ_SKILL && channel.skills.length) return readSkill(channel.skills, args);
2631
3120
  if (!channel.tools.has(name)) throw new Error(`Unknown tool: ${name}`);
2632
3121
  return this.forwardToBrowser(channel, "callTool", { tool: name, arguments: args });
2633
3122
  }
3123
+ /** The page's tools, and the engine's `read_skill` in place of a page tool of that name. */
3124
+ toolsOf(channel) {
3125
+ const page2 = Array.from(channel.tools.values());
3126
+ if (!channel.skills.length) return page2;
3127
+ return [...page2.filter((t) => t.name !== READ_SKILL), readSkillTool(channel.skills)];
3128
+ }
2634
3129
  // ── HTTP / WS dispatch (called from daemon) ──────────────────────────
2635
3130
  /** Returns true when the request path belongs to WebMCP. */
2636
3131
  matches(pathname) {
@@ -3036,7 +3531,7 @@ var WebMcpServer = class {
3036
3531
  }
3037
3532
  case "tools/list":
3038
3533
  return {
3039
- tools: Array.from(channel.tools.values()).map((t) => ({
3534
+ tools: this.toolsOf(channel).map((t) => ({
3040
3535
  name: t.name,
3041
3536
  description: t.description,
3042
3537
  inputSchema: t.inputSchema,
@@ -3047,6 +3542,9 @@ var WebMcpServer = class {
3047
3542
  const name = req.params?.name;
3048
3543
  const args = req.params?.arguments;
3049
3544
  if (!name) throw new Error("Missing tool name");
3545
+ if (name === READ_SKILL && channel.skills.length) {
3546
+ return { content: [{ type: "text", text: readSkill(channel.skills, args ?? {}) }] };
3547
+ }
3050
3548
  if (!channel.tools.has(name)) {
3051
3549
  throw new Error(`Unknown tool: ${name}`);
3052
3550
  }
@@ -3175,12 +3673,14 @@ async function startEngine(options) {
3175
3673
  const selfOrigins = /* @__PURE__ */ new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
3176
3674
  const allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
3177
3675
  const silent = options.silent ?? Boolean(options.admin);
3676
+ const skills = options.skills ?? new SkillService({ grants, logger });
3178
3677
  const webMcpServer = new WebMcpServer(logger, port);
3179
3678
  const sessions = new SessionManager({
3180
3679
  webMcpServer,
3181
3680
  port,
3182
3681
  logger,
3183
3682
  resolveAgent: options.resolveAgent,
3683
+ loadSkills: (origin) => skills.forSession(origin),
3184
3684
  startTimeoutMs: options.startTimeoutMs,
3185
3685
  stdioMcpCommand: options.stdioMcpCommand,
3186
3686
  onAgentState: options.onAgentState
@@ -3194,6 +3694,7 @@ async function startEngine(options) {
3194
3694
  port,
3195
3695
  linksOnly: silent,
3196
3696
  agentName: () => agentName2(),
3697
+ resolveSkills: (origin, refs) => skills.resolve(origin, refs),
3197
3698
  onRequest: options.onPairingRequest,
3198
3699
  onDecided: options.onPairingDecided
3199
3700
  });
@@ -3328,7 +3829,8 @@ async function startEngine(options) {
3328
3829
  return;
3329
3830
  }
3330
3831
  const requester = caller.kind === "site" ? caller.origin : "local";
3331
- if (await handleSessionRequest(req, res, pathname, sessions, requester)) return;
3832
+ const onSkills = caller.kind === "site" ? (origin, refs) => skills.refresh(origin, refs) : void 0;
3833
+ if (await handleSessionRequest(req, res, pathname, sessions, requester, onSkills)) return;
3332
3834
  sendJson(res, 404, { error: "Not found" });
3333
3835
  } catch (err) {
3334
3836
  logger.error("request failed", {
@@ -3407,6 +3909,8 @@ export {
3407
3909
  decidePermission,
3408
3910
  installedAgent,
3409
3911
  ensureAgent,
3912
+ pruneAgentVersions,
3913
+ updateAgents,
3410
3914
  resolveAgentCommand,
3411
3915
  resolveSiteAgent,
3412
3916
  agentName,