@oddessentials/agent-guild 0.2.0 → 0.4.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.
@@ -0,0 +1,470 @@
1
+ import crypto from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import { VERSION } from './config.mjs';
4
+
5
+ const TTL_MS = 30 * 60 * 1000;
6
+ const RETRY_MS = 10 * 60 * 1000;
7
+ const RELEASE_TTL_MS = 60 * 60 * 1000;
8
+ const FETCH_TIMEOUT_MS = 20000;
9
+ const MAX_BYTES = 5 * 1024 * 1024;
10
+ const CONCURRENCY = 4;
11
+ const WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
12
+ const FUTURE_MS = 2 * 24 * 60 * 60 * 1000;
13
+ const LIMITS = { news: 20, releases: 5, research: 10 };
14
+ const TITLE_MAX = 300;
15
+ const SUMMARY_MAX = 240;
16
+ const RAW_MAX = 8000;
17
+ const USER_AGENT = `agent-guild/${VERSION} (+https://github.com/oddessentials/agent-guild)`;
18
+ const FEED_ACCEPT = 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5';
19
+
20
+ const latestRelease = (id, name, repo, provider) => ({
21
+ id, name, category: 'releases', provider, format: 'github', every: RELEASE_TTL_MS,
22
+ url: `https://api.github.com/repos/${repo}/releases/latest`,
23
+ });
24
+
25
+ export const FEEDS = [
26
+ { id: 'openai', name: 'OpenAI', category: 'news', url: 'https://openai.com/news/rss.xml' },
27
+ { id: 'google-deepmind', name: 'Google DeepMind', category: 'news', url: 'https://deepmind.google/blog/rss.xml' },
28
+ { id: 'google-ai', name: 'Google AI', category: 'news', url: 'https://blog.google/innovation-and-ai/technology/ai/rss/' },
29
+ { id: 'hugging-face', name: 'Hugging Face', category: 'news', url: 'https://huggingface.co/blog/feed.xml' },
30
+ { id: 'ollama-blog', name: 'Ollama', category: 'news', url: 'https://ollama.com/blog/rss.xml' },
31
+ latestRelease('claude-code', 'Claude Code', 'anthropics/claude-code', 'anthropic'),
32
+ latestRelease('codex-cli', 'Codex CLI', 'openai/codex', 'openai'),
33
+ latestRelease('gemini-cli', 'Gemini CLI', 'google-gemini/gemini-cli', 'google'),
34
+ { id: 'ollama', name: 'Ollama', category: 'releases', url: 'https://github.com/ollama/ollama/releases.atom' },
35
+ { id: 'llama-cpp', name: 'llama.cpp', category: 'releases', url: 'https://github.com/ggml-org/llama.cpp/releases.atom' },
36
+ { id: 'transformers', name: 'Transformers', category: 'releases', url: 'https://github.com/huggingface/transformers/releases.atom' },
37
+ { id: 'arxiv-cs-cl', name: 'arXiv cs.CL', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.CL' },
38
+ { id: 'arxiv-cs-ai', name: 'arXiv cs.AI', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.AI' },
39
+ { id: 'simon-willison', name: 'Simon Willison\'s Weblog', category: 'news', url: 'https://simonwillison.net/atom/everything/' },
40
+ { id: 'interconnects', name: 'Interconnects', category: 'news', url: 'https://www.interconnects.ai/feed' },
41
+ { id: 'lil-log', name: 'Lil\'Log', category: 'news', url: 'https://lilianweng.github.io/index.xml' },
42
+ {
43
+ id: 'hacker-news', name: 'Hacker News', category: 'news', format: 'hn', filter: true,
44
+ url: 'https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points%3E%3D50&hitsPerPage=100&attributesToRetrieve=title,url,created_at,points,num_comments,objectID&attributesToHighlight=none',
45
+ },
46
+ { id: 'llm-digest', name: 'LLM Digest', category: 'news', url: 'https://www.llm-digest.com/rss.xml' },
47
+ { id: 'slashdot', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotMain' },
48
+ { id: 'slashdot-developers', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotDevelopers' },
49
+ ];
50
+
51
+ const TERMS = /\bagents?\b|\bagentic\b|\bmulti[-\s]?agents?\b|\btool[-\s]?use\b|\bmcp\b|\bollama\b|\bllama\.cpp\b|\bllamacpp\b|\bggml\b|\bgguf\b|\bopen[-\s]?weights?\b|\blocal[-\s]+models?\b|\bvllm\b|\bsglang\b|\bquanti[sz]\w*\b/i;
52
+ const TRACKING = new Set(['fbclid', 'gclid', 'mc_cid', 'mc_eid', 'igshid', 'ref_src']);
53
+ const ARXIV_PATH = /^\/(?:abs|pdf|html)\/([a-z-]+\/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?(?:\.pdf)?$/i;
54
+ const PRERELEASE = /(?:^|[^a-z])(?:alpha|beta|rc|nightly|preview|canary|dev|pre)(?![a-z])/i;
55
+ const ARXIV_PREFIX = /^arXiv:\S+\s+Announce Type:\s*[\w-]+\s+Abstract:\s*/i;
56
+ const ENTRY = /<((?:[\w-]+:)?(?:item|entry))\b[^>]*>([\s\S]*?)<\/\1\s*>/gi;
57
+ const DATE_FIELDS = ['pubDate', 'published', 'dc:date', 'updated', 'issued', 'modified'];
58
+ const NAMED = {
59
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', shy: '', zwj: '', zwnj: '',
60
+ ndash: '–', mdash: '—', hellip: '…', lsquo: '‘', rsquo: '’', sbquo: '‚', ldquo: '“', rdquo: '”', bdquo: '„', laquo: '«', raquo: '»',
61
+ bull: '•', middot: '·', prime: '′', Prime: '″', copy: '©', reg: '®', trade: '™', deg: '°', plusmn: '±', times: '×', divide: '÷',
62
+ euro: '€', pound: '£', cent: '¢', yen: '¥', sect: '§', para: '¶', larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔',
63
+ aacute: 'á', agrave: 'à', acirc: 'â', auml: 'ä', eacute: 'é', egrave: 'è', ecirc: 'ê', iacute: 'í', oacute: 'ó', ouml: 'ö',
64
+ uacute: 'ú', uuml: 'ü', ccedil: 'ç', ntilde: 'ñ', szlig: 'ß', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü',
65
+ };
66
+
67
+ class FeedError extends Error {}
68
+
69
+ const fields = new Map();
70
+ const attrs = new Map();
71
+
72
+ export function matchesTerms(title, text = '') {
73
+ return TERMS.test(`${title}\n${text}`);
74
+ }
75
+
76
+ export function decodeEntities(text) {
77
+ return text.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z][a-z0-9]{1,31}));/gi, (match, dec, hex, name) => {
78
+ if (name) return Object.hasOwn(NAMED, name) ? NAMED[name] : match;
79
+ const code = dec ? Number(dec) : parseInt(hex, 16);
80
+ return code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff) ? String.fromCodePoint(code) : '�';
81
+ });
82
+ }
83
+
84
+ function tidy(text) {
85
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim();
86
+ }
87
+
88
+ function stripTags(html) {
89
+ return html
90
+ .replace(/<(script|style|template)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ')
91
+ .replace(/<\/?(?:p|div|br|li|ul|ol|h[1-6]|tr|td|th|table|blockquote|pre|section|article|header|footer|figure|figcaption|hr)\b[^>]*>/gi, ' ')
92
+ .replace(/<[^>]*>/g, '')
93
+ .replace(/<[^>]*$/, '');
94
+ }
95
+
96
+ export function clip(text, max) {
97
+ if (text.length <= max) return text;
98
+ let cut = text.slice(0, max);
99
+ if (/[\ud800-\udbff]$/.test(cut)) cut = cut.slice(0, -1);
100
+ const space = cut.lastIndexOf(' ');
101
+ if (space >= max * 0.6) cut = cut.slice(0, space);
102
+ return `${cut.replace(/[\s.,;:–—-]+$/, '')}…`;
103
+ }
104
+
105
+ function protect(xml) {
106
+ const blocks = [];
107
+ const text = xml
108
+ .replace(/\u0000/g, '')
109
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, inner) => `\u0000${blocks.push(inner) - 1}\u0000`)
110
+ .replace(/<!--[\s\S]*?-->/g, '');
111
+ return { text, blocks };
112
+ }
113
+
114
+ function xmlText(inner, blocks) {
115
+ return inner.split(/\u0000(\d+)\u0000/).map((part, i) => (i % 2 ? blocks[Number(part)] ?? '' : decodeEntities(part))).join('');
116
+ }
117
+
118
+ function field(raw, name) {
119
+ let re = fields.get(name);
120
+ if (!re) fields.set(name, (re = new RegExp(`<${name}(\\s[^>]*)?(?:/>|>([\\s\\S]*?)</${name}\\s*>)`, 'i')));
121
+ const match = re.exec(raw);
122
+ return match ? { attrs: match[1] ?? '', inner: match[2] ?? '' } : null;
123
+ }
124
+
125
+ function attr(source, name) {
126
+ let re = attrs.get(name);
127
+ if (!re) attrs.set(name, (re = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')));
128
+ const match = re.exec(source);
129
+ return match ? decodeEntities(match[1] ?? match[2]) : null;
130
+ }
131
+
132
+ function textOf(found, blocks, { html, max = RAW_MAX }) {
133
+ const type = attr(found.attrs, 'type');
134
+ const source = xmlText(found.inner.slice(0, max), blocks).slice(0, max);
135
+ return tidy(decodeEntities((type ? /html/i.test(type) : html) ? stripTags(source) : source));
136
+ }
137
+
138
+ export function webUrl(value, base) {
139
+ try {
140
+ const url = new URL(String(value).trim(), base);
141
+ if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.href.length > 2000) return null;
142
+ return url.href;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function entryLink(raw, blocks, base) {
149
+ const links = [...raw.matchAll(/<link\b([^>]*)>/gi)]
150
+ .map(([, found]) => ({ href: attr(found, 'href'), rel: attr(found, 'rel') ?? 'alternate', type: attr(found, 'type') }))
151
+ .filter((link) => link.href);
152
+ const alternate = links.find((link) => link.rel === 'alternate' && (!link.type || /html/i.test(link.type))) ?? links.find((link) => link.rel === 'alternate');
153
+ if (alternate) return webUrl(alternate.href, base);
154
+ const text = field(raw, 'link');
155
+ if (text && text.inner.trim()) return webUrl(tidy(xmlText(text.inner, blocks)), base);
156
+ const guid = field(raw, 'guid');
157
+ return guid && attr(guid.attrs, 'isPermaLink') !== 'false' ? webUrl(tidy(xmlText(guid.inner, blocks)), base) : null;
158
+ }
159
+
160
+ function entryDate(raw, blocks) {
161
+ for (const name of DATE_FIELDS) {
162
+ const found = field(raw, name);
163
+ if (!found) continue;
164
+ const time = Date.parse(tidy(xmlText(found.inner, blocks)));
165
+ if (Number.isFinite(time)) return time;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function feedEntries(xml) {
171
+ const { text, blocks } = protect(xml);
172
+ if (!/<(?:[\w-]+:)?(?:rss|feed|RDF)\b/i.test(text)) throw new FeedError('did not send a feed');
173
+ return [...text.matchAll(ENTRY)].map(([, , raw]) => ({ raw, blocks, date: entryDate(raw, blocks) }));
174
+ }
175
+
176
+ function readEntry({ raw, blocks }, base) {
177
+ const title = field(raw, 'title');
178
+ const body = [['summary', false], ['description', true], ['content:encoded', true], ['content', false]]
179
+ .map(([name, html]) => [field(raw, name), html])
180
+ .find(([found]) => found);
181
+ const announce = field(raw, 'arxiv:announce_type');
182
+ return {
183
+ title: title ? clip(textOf(title, blocks, { html: false, max: Infinity }), TITLE_MAX) : '',
184
+ link: entryLink(raw, blocks, base),
185
+ text: body ? textOf(body[0], blocks, { html: body[1] }).replace(ARXIV_PREFIX, '') : '',
186
+ announce: announce ? tidy(xmlText(announce.inner, blocks)) : null,
187
+ };
188
+ }
189
+
190
+ export function parseFeed(xml, base) {
191
+ return feedEntries(xml).map((entry) => ({ ...readEntry(entry, base), date: entry.date }));
192
+ }
193
+
194
+ function count(value, word) {
195
+ return Number.isInteger(value) && value >= 0 ? `${value} ${word}${value === 1 ? '' : 's'}` : null;
196
+ }
197
+
198
+ export function parseHackerNews(text) {
199
+ const hits = JSON.parse(text)?.hits;
200
+ if (!Array.isArray(hits)) throw new FeedError('sent no stories');
201
+ return hits.filter((hit) => hit && typeof hit === 'object').map((hit) => {
202
+ const id = String(hit.objectID ?? '');
203
+ const thread = /^\d+$/.test(id) ? `https://news.ycombinator.com/item?id=${id}` : null;
204
+ const link = (typeof hit.url === 'string' && webUrl(hit.url)) || thread;
205
+ return {
206
+ title: clip(tidy(decodeEntities(String(hit.title ?? ''))), TITLE_MAX),
207
+ link,
208
+ text: [count(hit.points, 'point'), count(hit.num_comments, 'comment')].filter(Boolean).join(' · '),
209
+ discussion: link === thread ? null : thread,
210
+ date: Date.parse(hit.created_at),
211
+ };
212
+ });
213
+ }
214
+
215
+ export function markdownText(markdown) {
216
+ return String(markdown)
217
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ')
218
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
219
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
220
+ .replace(/<[^>]*>/g, ' ')
221
+ .split(/\r?\n/)
222
+ .filter((line) => !/^\s*(?:#{1,6}\s|\*\*full changelog\b|full changelog\b)/i.test(line))
223
+ .map((line) => tidy(decodeEntities(line
224
+ .replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, '')
225
+ .replace(/^#\d+\s+|\s*\(#\d+\)/g, '')
226
+ .replace(/\s+by @[\w-]+(?:\s+in\s+\S+)?/g, '')
227
+ .replace(/https?:\/\/\S+/g, '')
228
+ .replace(/[*_`~]+/g, ''))))
229
+ .filter(Boolean)
230
+ .join(' · ');
231
+ }
232
+
233
+ export function releaseTitle(name, title) {
234
+ const label = title.replace(/^(?:patch\s+)?release\b:?\s*/i, '').replace(/^rust-(?=v?\d)/i, '').trim() || title;
235
+ return label.toLowerCase().startsWith(name.toLowerCase()) ? label : `${name} ${label}`;
236
+ }
237
+
238
+ export function parseGithubRelease(text, name) {
239
+ const release = JSON.parse(text);
240
+ if (!release || typeof release !== 'object' || typeof release.tag_name !== 'string') throw new FeedError('sent no release');
241
+ if (release.draft || release.prerelease) return [];
242
+ return [{
243
+ title: releaseTitle(name, tidy(String(release.name || release.tag_name))),
244
+ link: webUrl(release.html_url ?? ''),
245
+ text: markdownText(release.body ?? ''),
246
+ date: Date.parse(release.published_at),
247
+ }];
248
+ }
249
+
250
+ function releaseTag(link) {
251
+ const tag = /^https?:\/\/(?:www\.)?github\.com\/[^/]+\/[^/]+\/releases\/tag\/([^/?#]+)/i.exec(link)?.[1];
252
+ if (!tag) return null;
253
+ try {
254
+ return decodeURIComponent(tag);
255
+ } catch {
256
+ return tag;
257
+ }
258
+ }
259
+
260
+ export function isPrerelease(tag) {
261
+ return PRERELEASE.test(tag);
262
+ }
263
+
264
+ const tracking = (key) => /^utm_/i.test(key) || TRACKING.has(key.toLowerCase());
265
+
266
+ export function cleanUrl(link) {
267
+ const url = new URL(link);
268
+ const keys = [...url.searchParams.keys()].filter(tracking);
269
+ if (keys.length === 0) return link;
270
+ for (const key of keys) url.searchParams.delete(key);
271
+ return url.href;
272
+ }
273
+
274
+ export function canonicalUrl(link) {
275
+ const url = new URL(link);
276
+ const host = url.hostname.toLowerCase().replace(/^www\./, '');
277
+ const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : '';
278
+ let path = url.pathname || '/';
279
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
280
+ const arxiv = host.endsWith('arxiv.org') ? ARXIV_PATH.exec(path) : null;
281
+ if (arxiv) path = `/abs/${arxiv[1]}`;
282
+ const query = [...url.searchParams]
283
+ .filter(([key]) => !tracking(key))
284
+ .sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
285
+ return `${host}${port}${path}${query.length ? `?${new URLSearchParams(query)}` : ''}`;
286
+ }
287
+
288
+ function summaryFor(text, title) {
289
+ const rest = text.toLowerCase().startsWith(title.toLowerCase()) ? text.slice(title.length).replace(/^[\s.,;:–—-]+/, '') : text;
290
+ return clip(rest, SUMMARY_MAX);
291
+ }
292
+
293
+ function select(feed, entries, now) {
294
+ const fresh = entries
295
+ .filter((entry) => Number.isFinite(entry.date) && entry.date - now <= FUTURE_MS && now - entry.date <= WINDOW_MS)
296
+ .sort((a, b) => b.date - a.date);
297
+ const items = [];
298
+ for (const entry of fresh) {
299
+ if (items.length >= LIMITS[feed.category]) break;
300
+ const found = entry.raw === undefined ? entry : { ...readEntry(entry, feed.url), date: entry.date };
301
+ if (!found.title || !found.link) continue;
302
+ if (found.announce && /^replace/i.test(found.announce)) continue;
303
+ const release = feed.category === 'releases' && feed.format !== 'github';
304
+ const tag = releaseTag(found.link) ?? (release ? found.title.split(' ')[0] : null);
305
+ if (tag && isPrerelease(tag)) continue;
306
+ if (feed.filter && !matchesTerms(found.title, found.text)) continue;
307
+ const key = canonicalUrl(found.link);
308
+ items.push({
309
+ key,
310
+ id: crypto.createHash('sha256').update(key).digest('hex').slice(0, 16),
311
+ title: release ? releaseTitle(feed.name, found.title) : found.title,
312
+ url: cleanUrl(found.link),
313
+ discussion: found.discussion ?? null,
314
+ summary: summaryFor(found.text, found.title),
315
+ date: found.date,
316
+ time: Math.min(found.date, now),
317
+ });
318
+ }
319
+ return items;
320
+ }
321
+
322
+ function merge(previous, fresh, limit, now) {
323
+ const byKey = new Map(previous.filter((item) => now - item.time <= WINDOW_MS).map((item) => [item.key, item]));
324
+ for (const item of fresh) {
325
+ const stored = byKey.get(item.key);
326
+ if (stored?.date === item.date) item.time = stored.time;
327
+ byKey.set(item.key, item);
328
+ }
329
+ return [...byKey.values()].sort((a, b) => b.time - a.time).slice(0, limit);
330
+ }
331
+
332
+ async function readBody(res, maxBytes) {
333
+ const tooLarge = () => new FeedError(`sent more than ${maxBytes / 1024 / 1024} MB`);
334
+ if (Number(res.headers.get('content-length')) > maxBytes) throw tooLarge();
335
+ const chunks = [];
336
+ let size = 0;
337
+ for await (const chunk of res.body ?? []) {
338
+ size += chunk.byteLength;
339
+ if (size > maxBytes) throw tooLarge();
340
+ chunks.push(chunk);
341
+ }
342
+ const bytes = Buffer.concat(chunks);
343
+ const charset = /charset\s*=\s*["']?([\w.:-]+)/i.exec(res.headers.get('content-type') ?? '')?.[1]
344
+ ?? /^\s*<\?xml[^>]*encoding\s*=\s*["']([\w.:-]+)/i.exec(bytes.subarray(0, 256).toString('latin1'))?.[1];
345
+ try {
346
+ return new TextDecoder(charset ?? 'utf-8').decode(bytes);
347
+ } catch {
348
+ return new TextDecoder().decode(bytes);
349
+ }
350
+ }
351
+
352
+ function refusal(res) {
353
+ if ((res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0') return 'GitHub API rate limit exceeded';
354
+ return `HTTP ${res.status}`;
355
+ }
356
+
357
+ function failure(err, timeoutMs) {
358
+ if (err instanceof FeedError) return err.message;
359
+ if (err?.name === 'TimeoutError') return `did not answer within ${timeoutMs / 1000} seconds`;
360
+ if (err instanceof SyntaxError) return 'sent data that could not be read';
361
+ return `could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
362
+ }
363
+
364
+ const iso = (time) => (time ? new Date(time).toISOString() : null);
365
+
366
+ export class NewsFeed extends EventEmitter {
367
+ constructor({
368
+ registry = null, feeds = FEEDS, fetchImpl = fetch, ttlMs = TTL_MS, retryMs = RETRY_MS, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_BYTES,
369
+ } = {}) {
370
+ super();
371
+ this.registry = registry;
372
+ this.fetchImpl = fetchImpl;
373
+ this.ttlMs = ttlMs;
374
+ this.retryMs = retryMs;
375
+ this.timeoutMs = timeoutMs;
376
+ this.maxBytes = maxBytes;
377
+ this.states = feeds.map((feed) => ({ feed, items: [], etag: null, lastModified: null, checkedAt: 0, okAt: 0, error: null }));
378
+ this.refreshedAt = 0;
379
+ this.refreshing = null;
380
+ }
381
+
382
+ snapshot() {
383
+ const active = this.states.filter((state) => !state.feed.provider || this._installed(state.feed.provider));
384
+ this._refreshDue(active);
385
+ return this._describe(active);
386
+ }
387
+
388
+ _installed(id) {
389
+ const provider = this.registry?.providers?.find((p) => p.id === id);
390
+ try {
391
+ return Boolean(provider && this.registry.resolve(provider));
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
397
+ _refreshDue(active) {
398
+ if (this.refreshing) return;
399
+ const now = Date.now();
400
+ const due = active.filter((state) => now - state.checkedAt >= (state.error ? this.retryMs : state.feed.every ?? this.ttlMs));
401
+ if (due.length === 0) return;
402
+ this.refreshing = this._refresh(due).finally(() => {
403
+ this.refreshing = null;
404
+ this.emit('updated');
405
+ });
406
+ }
407
+
408
+ async _refresh(states) {
409
+ const queue = [...states];
410
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
411
+ while (queue.length) await this._update(queue.shift());
412
+ }));
413
+ this.refreshedAt = Date.now();
414
+ }
415
+
416
+ async _update(state) {
417
+ const { feed } = state;
418
+ state.checkedAt = Date.now();
419
+ try {
420
+ const headers = { 'User-Agent': USER_AGENT, Accept: feed.format === 'github' ? 'application/vnd.github+json' : feed.format === 'hn' ? 'application/json' : FEED_ACCEPT };
421
+ if (feed.format === 'github') headers['X-GitHub-Api-Version'] = '2022-11-28';
422
+ if (state.etag) headers['If-None-Match'] = state.etag;
423
+ if (state.lastModified) headers['If-Modified-Since'] = state.lastModified;
424
+ const res = await this.fetchImpl(feed.url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
425
+ if (res.status !== 304) {
426
+ if (!res.ok) throw new FeedError(refusal(res));
427
+ const text = await readBody(res, this.maxBytes);
428
+ const entries = feed.format === 'hn' ? parseHackerNews(text) : feed.format === 'github' ? parseGithubRelease(text, feed.name) : feedEntries(text);
429
+ const now = Date.now();
430
+ state.items = merge(state.items, select(feed, entries, now), LIMITS[feed.category], now);
431
+ state.etag = res.headers.get('etag');
432
+ state.lastModified = res.headers.get('last-modified');
433
+ }
434
+ state.error = null;
435
+ state.okAt = Date.now();
436
+ } catch (err) {
437
+ state.error = failure(err, this.timeoutMs);
438
+ }
439
+ }
440
+
441
+ _describe(active) {
442
+ const now = Date.now();
443
+ const taken = new Set();
444
+ const items = [];
445
+ for (const { feed, items: own } of active) {
446
+ for (const item of own) {
447
+ if (now - item.time > WINDOW_MS || taken.has(item.key)) continue;
448
+ taken.add(item.key);
449
+ items.push({ feed, item });
450
+ }
451
+ }
452
+ items.sort((a, b) => b.item.time - a.item.time);
453
+ return {
454
+ refreshedAt: iso(this.refreshedAt),
455
+ refreshing: Boolean(this.refreshing),
456
+ sources: active.map(({ feed, error, okAt }) => ({ id: feed.id, name: feed.name, category: feed.category, error, okAt: iso(okAt) })),
457
+ items: items.map(({ feed, item }) => ({
458
+ id: item.id,
459
+ title: item.title,
460
+ url: item.url,
461
+ discussion: item.discussion,
462
+ summary: item.summary,
463
+ source: feed.name,
464
+ sourceId: feed.id,
465
+ category: feed.category,
466
+ publishedAt: iso(item.time),
467
+ })),
468
+ };
469
+ }
470
+ }
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
13
13
  import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
14
- import { CHANNEL_LABELS, classifyInstall, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
14
+ import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
15
15
  import { weavePaths } from './shell-env.mjs';
16
+ import { paths } from './config.mjs';
16
17
 
17
18
  const here = path.dirname(fileURLToPath(import.meta.url));
18
19
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
@@ -86,6 +87,50 @@ function normalizeChannels(raw) {
86
87
  return out;
87
88
  }
88
89
 
90
+ function normalizeHooks(raw) {
91
+ if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || typeof raw.example !== 'string') return null;
92
+ const parts = raw.path.split(/[\\/]+/);
93
+ if (parts.some((part) => part === '' || part === '.' || part === '..') || !/^[A-Za-z0-9._-]+$/.test(raw.example)) return null;
94
+ return { path: parts.join('/'), example: raw.example };
95
+ }
96
+
97
+ const DEFAULT_ACCOUNT = 'default';
98
+
99
+ function normalizeAccounts(raw, id, homeVar, warnings) {
100
+ const accounts = [{ id: DEFAULT_ACCOUNT, label: 'Default', dir: null }];
101
+ if (raw === undefined || raw === null) return accounts;
102
+ if (!Array.isArray(raw)) {
103
+ warnings.push(`provider "${id}": ignored accounts; it must be an array`);
104
+ return accounts;
105
+ }
106
+ if (!homeVar && raw.length > 0) {
107
+ warnings.push(`provider "${id}": ignored accounts; the provider has no homeVar`);
108
+ return accounts;
109
+ }
110
+ for (const entry of raw) {
111
+ const item = typeof entry === 'string' ? { id: entry } : entry;
112
+ const accountId = item && typeof item === 'object' ? String(item.id ?? '') : '';
113
+ if (!ID_RE.test(accountId)) {
114
+ warnings.push(`provider "${id}": skipped account with invalid id ${JSON.stringify(item?.id ?? entry)}`);
115
+ continue;
116
+ }
117
+ const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 40) : accountId.charAt(0).toUpperCase() + accountId.slice(1);
118
+ const dir = typeof item.dir === 'string' && item.dir.trim() ? item.dir.trim() : null;
119
+ const existing = accounts.find((a) => a.id === accountId);
120
+ if (existing) {
121
+ if (accountId !== DEFAULT_ACCOUNT) {
122
+ warnings.push(`provider "${id}": skipped duplicate account "${accountId}"`);
123
+ continue;
124
+ }
125
+ existing.label = label;
126
+ if (dir) warnings.push(`provider "${id}": ignored dir of the default account; it uses the tool's own home folder`);
127
+ continue;
128
+ }
129
+ accounts.push({ id: accountId, label, dir });
130
+ }
131
+ return accounts;
132
+ }
133
+
89
134
  function httpsUrl(value, field, id, warnings) {
90
135
  if (value === undefined || value === null || value === '') return null;
91
136
  try {
@@ -99,6 +144,7 @@ function httpsUrl(value, field, id, warnings) {
99
144
  function normalize(raw, platform, warnings) {
100
145
  const merged = { ...raw, ...(raw[platform] || {}) };
101
146
  for (const key of PLATFORM_KEYS) delete merged[key];
147
+ const homeVar = typeof merged.homeVar === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(merged.homeVar) ? merged.homeVar : null;
102
148
  return {
103
149
  id: merged.id,
104
150
  vendor: String(merged.vendor || merged.id),
@@ -111,6 +157,10 @@ function normalize(raw, platform, warnings) {
111
157
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
112
158
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
113
159
  env: normalizeEnv(merged.env),
160
+ homeVar,
161
+ accountEnv: normalizeEnv(merged.accountEnv),
162
+ hooks: normalizeHooks(merged.hooks),
163
+ accounts: normalizeAccounts(merged.accounts, merged.id, homeVar, warnings),
114
164
  color: String(merged.color || '#64748B'),
115
165
  monogram: String(merged.monogram || String(merged.vendor || merged.id).charAt(0)).slice(0, 2),
116
166
  icon: merged.icon ? String(merged.icon) : null,
@@ -172,10 +222,12 @@ export class ProviderRegistry extends EventEmitter {
172
222
  * @param {string} [opts.registryUrl] npm registry for lookups and installs; default: npm's own configuration
173
223
  * @param {boolean} [opts.checkUpdates] false skips registry lookups entirely
174
224
  * @param {Function} [opts.fetchImpl]
225
+ * @param {string} [opts.accountsDir] where accounts without a dir get their home folders
175
226
  */
176
- constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null } = {}) {
227
+ constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts } = {}) {
177
228
  super();
178
229
  this.userFile = userFile;
230
+ this.accountsDir = accountsDir;
179
231
  this.env = env || process.env;
180
232
  this.platform = platform;
181
233
  this.iconDir = iconDir;
@@ -184,6 +236,7 @@ export class ProviderRegistry extends EventEmitter {
184
236
  this.fetchImpl = fetchImpl;
185
237
  this.pathReader = pathReader;
186
238
  this._pathReadAt = 0;
239
+ this._pathPending = null;
187
240
  this._installs = new Map();
188
241
  this.versions = new Map();
189
242
  this._refreshing = null;
@@ -209,6 +262,8 @@ export class ProviderRegistry extends EventEmitter {
209
262
  npmRegistryUrl() {
210
263
  if (this.registryUrl) return Promise.resolve(this.registryUrl);
211
264
  this._npmRegistry ??= (async () => {
265
+ // PATH discovery in flight may change which npm is found; wait for it.
266
+ await this._pathPending;
212
267
  const npm = this.resolveNpm();
213
268
  if (!npm) return DEFAULT_NPM_REGISTRY;
214
269
  try {
@@ -236,11 +291,18 @@ export class ProviderRegistry extends EventEmitter {
236
291
  return pending;
237
292
  }
238
293
 
239
- async refreshPath({ force = false } = {}) {
240
- if (!this.pathReader) return false;
294
+ /** Resolves to true when the PATH changed. A concurrent caller waits for the read already in flight. */
295
+ refreshPath({ force = false } = {}) {
296
+ if (!this.pathReader) return Promise.resolve(false);
241
297
  const now = Date.now();
242
- if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return false;
298
+ if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return this._pathPending ?? Promise.resolve(false);
243
299
  this._pathReadAt = now;
300
+ const pending = this._readPath().finally(() => { if (this._pathPending === pending) this._pathPending = null; });
301
+ this._pathPending = pending;
302
+ return pending;
303
+ }
304
+
305
+ async _readPath() {
244
306
  const discovered = await Promise.resolve().then(() => this.pathReader()).catch(() => null);
245
307
  if (!discovered) return false;
246
308
  const key = pathKey(this.env, this.platform);
@@ -462,6 +524,27 @@ export class ProviderRegistry extends EventEmitter {
462
524
  return this.providers.find((p) => p.id === id) || null;
463
525
  }
464
526
 
527
+ accountFor(provider, account) {
528
+ if (account.id === DEFAULT_ACCOUNT || !provider.homeVar) return { id: account.id, label: account.label, dir: null, env: {} };
529
+ const p = this.platform === 'win32' ? path.win32 : path.posix;
530
+ const own = account.dir ? expandHome(account.dir, this.env, this.platform) : null;
531
+ const dir = own && p.isAbsolute(own) ? p.normalize(own) : p.join(this.accountsDir, provider.id, own || account.id);
532
+ const env = { [provider.homeVar]: dir };
533
+ for (const [key, value] of Object.entries(provider.accountEnv)) env[key] = value.replaceAll('{dir}', dir);
534
+ return { id: account.id, label: account.label, dir, env };
535
+ }
536
+
537
+ accountsFor(provider) {
538
+ return provider.accounts.map((account) => this.accountFor(provider, account));
539
+ }
540
+
541
+ account(provider, id = null) {
542
+ const wanted = id === null || id === undefined || id === '' ? DEFAULT_ACCOUNT : String(id);
543
+ const found = provider.accounts.find((a) => a.id === wanted);
544
+ if (!found) throw refusal(404, 'unknown_account', `${provider.tool} has no account "${wanted}"`);
545
+ return this.accountFor(provider, found);
546
+ }
547
+
465
548
  commandFor(provider) {
466
549
  return provider.command === '@shell' ? defaultShell(this.env, this.platform) : provider.command;
467
550
  }
@@ -529,6 +612,7 @@ export class ProviderRegistry extends EventEmitter {
529
612
  warnings: this.installWarnings(provider, installs),
530
613
  npmNote: provider.npmNote,
531
614
  usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
615
+ accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
532
616
  modelPattern: provider.modelPattern,
533
617
  color: provider.color,
534
618
  monogram: provider.monogram,