@oddessentials/agent-guild 0.3.0 → 0.5.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
+ }
@@ -60,6 +60,15 @@ function normalizeUsage(usage) {
60
60
  return null;
61
61
  }
62
62
 
63
+ /** "claude", "codex", "gemini", "grok", a { command, args } that prints past sessions as JSON, or null. */
64
+ function normalizeHistory(history) {
65
+ if (['claude', 'codex', 'gemini', 'grok'].includes(history)) return history;
66
+ if (history && typeof history === 'object' && typeof history.command === 'string' && history.command) {
67
+ return { command: history.command, args: Array.isArray(history.args) ? history.args.map(String) : [] };
68
+ }
69
+ return null;
70
+ }
71
+
63
72
  function stringList(value) {
64
73
  return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
65
74
  }
@@ -153,6 +162,7 @@ function normalize(raw, platform, warnings) {
153
162
  package: merged.package ? String(merged.package) : null,
154
163
  versionArgs: Array.isArray(merged.versionArgs) && merged.versionArgs.length ? merged.versionArgs.map(String) : null,
155
164
  usage: normalizeUsage(merged.usage),
165
+ history: normalizeHistory(merged.history),
156
166
  modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
157
167
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
158
168
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
@@ -612,6 +622,7 @@ export class ProviderRegistry extends EventEmitter {
612
622
  warnings: this.installWarnings(provider, installs),
613
623
  npmNote: provider.npmNote,
614
624
  usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
625
+ historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
615
626
  accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
616
627
  modelPattern: provider.modelPattern,
617
628
  color: provider.color,
@@ -100,7 +100,9 @@ export function createManagerServer({
100
100
  manager,
101
101
  registry,
102
102
  usage,
103
+ history,
103
104
  modelStats,
105
+ news = null,
104
106
  token,
105
107
  host = '127.0.0.1',
106
108
  port = 0,
@@ -108,6 +110,9 @@ export function createManagerServer({
108
110
  version = '0.0.0',
109
111
  selfUpdate = null,
110
112
  extraOrigins = [],
113
+ /** The double-click launcher file for this platform, or null when the package carries none. */
114
+ launcher = null,
115
+ /** @type {(opts: { restart: boolean }) => void} */
111
116
  onShutdownRequest = () => {},
112
117
  }) {
113
118
  const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
@@ -183,9 +188,10 @@ export function createManagerServer({
183
188
  return sendJson(res, 200, { ok: true, name: 'agent-guild', version, pid: process.pid });
184
189
  }
185
190
 
186
- // Agent and model reports may authenticate with the per-session report
187
- // token that the manager injects into each tool's environment.
188
- const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model)$/);
191
+ // Agent, model and tool-session reports may authenticate with the
192
+ // per-session report token that the manager injects into each tool's
193
+ // environment.
194
+ const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session)$/);
189
195
  if (reportMatch && method === 'POST') {
190
196
  const [, id, kind] = reportMatch;
191
197
  const body = await readJsonBody(req);
@@ -194,7 +200,8 @@ export function createManagerServer({
194
200
  reportToken: req.headers['x-agent-guild-report-token'],
195
201
  };
196
202
  if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
197
- return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
203
+ if (kind === 'model') return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
204
+ return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
198
205
  }
199
206
 
200
207
  requireAuth(req, url);
@@ -209,6 +216,7 @@ export function createManagerServer({
209
216
  startedAt,
210
217
  warnings: registry.warnings,
211
218
  upgrade: upgradeInfo(),
219
+ launcher,
212
220
  });
213
221
  }
214
222
  if (route === '/upgrade' && method === 'POST') {
@@ -230,6 +238,17 @@ export function createManagerServer({
230
238
  if (route === '/model-stats' && method === 'GET') {
231
239
  return sendJson(res, 200, await modelStats.snapshot(manager.list()));
232
240
  }
241
+ if (route === '/news' && method === 'GET' && news) {
242
+ return sendJson(res, 200, news.snapshot());
243
+ }
244
+ const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
245
+ if (historyMatch && method === 'GET') {
246
+ const provider = registry.get(historyMatch[1]);
247
+ if (!provider) throw new HttpError(404, `unknown provider "${historyMatch[1]}"`, 'unknown_provider');
248
+ if (!provider.history) throw new HttpError(400, `${provider.tool} has no history source configured`, 'history_unsupported');
249
+ const account = registry.account(provider, url.searchParams.get('account'));
250
+ return sendJson(res, 200, { history: await history.list(provider, account, { limit: url.searchParams.get('limit') }) });
251
+ }
233
252
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
234
253
  if (installMatch && method === 'POST') {
235
254
  const body = await readJsonBody(req);
@@ -254,14 +273,16 @@ export function createManagerServer({
254
273
  err.running = running;
255
274
  throw err;
256
275
  }
276
+ // With `restart`, a new manager is started once this one has closed.
277
+ const restart = body.restart === true;
257
278
  // Refuse new sessions from this moment, before the shutdown itself
258
279
  // runs: a session accepted in between would be ended without warning.
259
280
  manager.closing = true;
260
- sendJson(res, 202, { ok: true, running });
281
+ sendJson(res, 202, { ok: true, running, restart });
261
282
  // Tell every client first, so a second page shows "stopped" rather
262
283
  // than "not reachable" when its socket drops.
263
- broadcast({ type: 'manager.stopping', running });
264
- setImmediate(onShutdownRequest);
284
+ broadcast({ type: 'manager.stopping', running, restart });
285
+ setImmediate(() => onShutdownRequest({ restart }));
265
286
  return undefined;
266
287
  }
267
288
 
@@ -342,10 +363,11 @@ export function createManagerServer({
342
363
  manager.on('event', broadcast);
343
364
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
344
365
  selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
366
+ news?.on('updated', () => broadcast({ type: 'news.updated' }));
345
367
 
346
368
  function handleEvents(ws) {
347
369
  eventClients.add(ws);
348
- safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
370
+ safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
349
371
  ws.on('close', () => eventClients.delete(ws));
350
372
  ws.on('message', () => { /* events socket is server -> client only */ });
351
373
  }