@oddessentials/agent-guild 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import { ProviderRegistry } from './providers.mjs';
8
8
  import { SessionManager } from './session-manager.mjs';
9
9
  import { UsageMonitor } from './usage.mjs';
10
10
  import { ModelStats } from './model-stats.mjs';
11
+ import { NewsFeed } from './news.mjs';
11
12
  import { createManagerServer } from './server.mjs';
12
13
  import { SelfUpdate } from './self-update.mjs';
13
14
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
@@ -59,6 +60,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
59
60
  const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
60
61
  const usage = new UsageMonitor({ registry, env: baseEnv });
61
62
  const modelStats = new ModelStats({ registry });
63
+ const news = new NewsFeed({ registry });
62
64
  let closing = null;
63
65
 
64
66
  const refreshVersions = () => {
@@ -91,6 +93,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
91
93
  registry,
92
94
  usage,
93
95
  modelStats,
96
+ news,
94
97
  token,
95
98
  host,
96
99
  port,
@@ -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
+ }
@@ -101,6 +101,7 @@ export function createManagerServer({
101
101
  registry,
102
102
  usage,
103
103
  modelStats,
104
+ news = null,
104
105
  token,
105
106
  host = '127.0.0.1',
106
107
  port = 0,
@@ -230,6 +231,9 @@ export function createManagerServer({
230
231
  if (route === '/model-stats' && method === 'GET') {
231
232
  return sendJson(res, 200, await modelStats.snapshot(manager.list()));
232
233
  }
234
+ if (route === '/news' && method === 'GET' && news) {
235
+ return sendJson(res, 200, news.snapshot());
236
+ }
233
237
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
234
238
  if (installMatch && method === 'POST') {
235
239
  const body = await readJsonBody(req);
@@ -342,6 +346,7 @@ export function createManagerServer({
342
346
  manager.on('event', broadcast);
343
347
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
344
348
  selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
349
+ news?.on('updated', () => broadcast({ type: 'news.updated' }));
345
350
 
346
351
  function handleEvents(ws) {
347
352
  eventClients.add(ws);
package/web/app.js CHANGED
@@ -5,6 +5,8 @@ const TOKEN_KEY = 'agentGuild.token';
5
5
  const CWD_KEY = 'agentGuild.cwd';
6
6
  const ACCOUNTS_KEY = 'agentGuild.accounts';
7
7
  const THEME_KEY = 'agentGuild.theme';
8
+ const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
+ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
8
10
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
9
11
 
10
12
  const $ = (id) => document.getElementById(id);
@@ -15,6 +17,7 @@ const state = {
15
17
  accounts: {},
16
18
  stats: null,
17
19
  statsFor: new Map(),
20
+ news: null,
18
21
  sessions: new Map(),
19
22
  views: new Map(),
20
23
  activeId: null,
@@ -900,6 +903,291 @@ function closeModels() {
900
903
  if ($('models').open) $('models').close();
901
904
  }
902
905
 
906
+ const NEWS_LATEST = 5;
907
+ const NEWS_POLL_MS = 10 * 60 * 1000;
908
+ const NEWS_FILTERS = [['all', 'All'], ['news', 'News'], ['releases', 'Releases'], ['research', 'Research']];
909
+ const newsView = { shown: null, since: Infinity, filter: 'all', opener: null };
910
+ let newsLoading = null;
911
+ let newsAgain = false;
912
+ let newsLoadedAt = 0;
913
+
914
+ function newsSeen() {
915
+ const seen = Date.parse(load(NEWS_SEEN_KEY));
916
+ return Number.isFinite(seen) ? seen : null;
917
+ }
918
+
919
+ function newestTime(items) {
920
+ return items.reduce((newest, item) => Math.max(newest, Date.parse(item.publishedAt) || 0), 0);
921
+ }
922
+
923
+ function loadNews() {
924
+ if (newsLoading) {
925
+ newsAgain = true;
926
+ return newsLoading;
927
+ }
928
+ newsLoading = api('GET', '/news').then(setNews, () => {}).finally(() => {
929
+ newsLoading = null;
930
+ newsLoadedAt = Date.now();
931
+ if (newsAgain) {
932
+ newsAgain = false;
933
+ loadNews();
934
+ }
935
+ });
936
+ return newsLoading;
937
+ }
938
+
939
+ function setNews(news) {
940
+ state.news = news;
941
+ if (newsSeen() === null && news.items.length) save(NEWS_SEEN_KEY, new Date(newestTime(news.items)).toISOString());
942
+ renderLatestNews();
943
+ if ($('news').open) updateNewsPanel();
944
+ }
945
+
946
+ function webHref(value) {
947
+ try {
948
+ const url = new URL(value);
949
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
950
+ } catch {
951
+ return null;
952
+ }
953
+ }
954
+
955
+ function newsTitle(item) {
956
+ const href = webHref(item.url);
957
+ const title = document.createElement(href ? 'a' : 'span');
958
+ title.className = 'news-link';
959
+ title.textContent = item.title;
960
+ if (href) {
961
+ title.href = href;
962
+ title.target = '_blank';
963
+ title.rel = 'noopener noreferrer';
964
+ title.setAttribute('aria-description', 'Opens in a new tab');
965
+ }
966
+ return title;
967
+ }
968
+
969
+ function newsMeta(item) {
970
+ const meta = document.createElement('span');
971
+ meta.className = 'news-meta';
972
+ const time = document.createElement('time');
973
+ time.dateTime = item.publishedAt;
974
+ time.textContent = relativeTime(item.publishedAt);
975
+ time.title = new Date(item.publishedAt).toLocaleString();
976
+ meta.append(`${item.source} · `, time);
977
+ return meta;
978
+ }
979
+
980
+ function isNew(item, since) {
981
+ return Date.parse(item.publishedAt) > since;
982
+ }
983
+
984
+ function newBadge() {
985
+ return badge('new', 'New', 'Published since you last opened the news');
986
+ }
987
+
988
+ function latestNews(items) {
989
+ const sources = new Set();
990
+ const latest = [];
991
+ for (const item of items) {
992
+ if (item.category !== 'news' || sources.has(item.source)) continue;
993
+ sources.add(item.source);
994
+ latest.push(item);
995
+ if (latest.length === NEWS_LATEST) break;
996
+ }
997
+ return latest;
998
+ }
999
+
1000
+ function failureLines(news) {
1001
+ const groups = new Map();
1002
+ for (const source of news.sources) if (source.error) groups.set(source.error, [...(groups.get(source.error) ?? []), source]);
1003
+ return [...groups].map(([error, sources]) => {
1004
+ if (sources.length === 1) {
1005
+ const [source] = sources;
1006
+ return `${source.name}: ${error} · ${source.okAt ? `showing items fetched ${relativeTime(source.okAt)}` : 'nothing fetched yet'}`;
1007
+ }
1008
+ const names = [...new Set(sources.map((source) => source.name))];
1009
+ return `${names.length <= 3 ? listJoin(names) : `${sources.length} sources`}: ${error}`;
1010
+ });
1011
+ }
1012
+
1013
+ function newsNote(news) {
1014
+ if (!news || (news.refreshing && news.items.length === 0)) return 'Loading news…';
1015
+ const failed = news.sources.filter((s) => s.error);
1016
+ if (news.items.length === 0 && failed.length > 0 && failed.length === news.sources.length) {
1017
+ return `News could not be loaded. ${failureLines(news)[0]}. Agent Guild tries again in a few minutes.`;
1018
+ }
1019
+ return 'No news from the last 30 days.';
1020
+ }
1021
+
1022
+ function renderLatestNews() {
1023
+ const news = state.news;
1024
+ const list = $('news-latest');
1025
+ const seen = newsSeen() ?? Infinity;
1026
+ const items = news ? latestNews(news.items) : [];
1027
+ const focused = document.activeElement?.closest?.('#news-latest .news-row')?.dataset.id;
1028
+ list.replaceChildren(...items.map((item) => {
1029
+ const row = document.createElement('li');
1030
+ row.className = 'news-row';
1031
+ row.dataset.id = item.id;
1032
+ row.append(newsTitle(item), ...(isNew(item, seen) ? [newBadge()] : []), newsMeta(item));
1033
+ return row;
1034
+ }));
1035
+ if (focused) [...list.children].find((row) => row.dataset.id === focused)?.querySelector('.news-link')?.focus({ preventScroll: true });
1036
+ list.hidden = items.length === 0;
1037
+ const fresh = news ? news.items.filter((item) => item.category === 'news' && isNew(item, seen)).length : 0;
1038
+ $('news-count').textContent = fresh ? `· ${fresh} new` : '';
1039
+ $('news-note').textContent = items.length ? '' : newsNote(news);
1040
+ $('news-note').hidden = items.length > 0;
1041
+ }
1042
+
1043
+ function dayLabel(time) {
1044
+ const date = new Date(time);
1045
+ const today = new Date();
1046
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
1047
+ if (date.toDateString() === today.toDateString()) return 'Today';
1048
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
1049
+ return date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
1050
+ }
1051
+
1052
+ function newsEntry(item) {
1053
+ const entry = document.createElement('li');
1054
+ entry.className = 'news-item';
1055
+ entry.dataset.id = item.id;
1056
+ const head = document.createElement('div');
1057
+ head.className = 'news-head';
1058
+ head.append(newsTitle(item), ...(isNew(item, newsView.since) ? [newBadge()] : []));
1059
+ entry.append(head, newsMeta(item));
1060
+ if (item.summary) {
1061
+ const summary = document.createElement('p');
1062
+ summary.className = 'news-summary';
1063
+ const discussion = webHref(item.discussion);
1064
+ if (discussion) {
1065
+ const link = document.createElement('a');
1066
+ link.className = 'news-discussion';
1067
+ link.href = discussion;
1068
+ link.target = '_blank';
1069
+ link.rel = 'noopener noreferrer';
1070
+ link.textContent = item.summary;
1071
+ link.setAttribute('aria-description', `Opens the discussion on ${item.source} in a new tab`);
1072
+ summary.append(link);
1073
+ } else {
1074
+ summary.textContent = item.summary;
1075
+ }
1076
+ entry.append(summary);
1077
+ }
1078
+ return entry;
1079
+ }
1080
+
1081
+ function renderNewsFilters() {
1082
+ $('news-filters').replaceChildren(...NEWS_FILTERS.map(([id, label]) => {
1083
+ const chip = document.createElement('button');
1084
+ chip.type = 'button';
1085
+ chip.className = 'news-filter';
1086
+ chip.textContent = label;
1087
+ chip.setAttribute('aria-pressed', String(id === newsView.filter));
1088
+ chip.addEventListener('click', () => {
1089
+ newsView.filter = id;
1090
+ save(NEWS_FILTER_KEY, id);
1091
+ for (const other of $('news-filters').children) other.setAttribute('aria-pressed', String(other === chip));
1092
+ newsView.shown = state.news;
1093
+ renderNewsList();
1094
+ document.querySelector('.news-body').scrollTop = 0;
1095
+ });
1096
+ return chip;
1097
+ }));
1098
+ }
1099
+
1100
+ function renderNewsList() {
1101
+ const shown = newsView.shown;
1102
+ const items = (shown?.items ?? []).filter((item) => newsView.filter === 'all' || item.category === newsView.filter);
1103
+ const groups = [];
1104
+ for (const item of items) {
1105
+ const label = dayLabel(Date.parse(item.publishedAt));
1106
+ if (groups.at(-1)?.label !== label) groups.push({ label, items: [] });
1107
+ groups.at(-1).items.push(item);
1108
+ }
1109
+ $('news-list').replaceChildren(...groups.map((group) => {
1110
+ const section = document.createElement('section');
1111
+ section.className = 'news-group';
1112
+ const heading = document.createElement('h3');
1113
+ heading.className = 'news-day';
1114
+ heading.textContent = group.label;
1115
+ const list = document.createElement('ul');
1116
+ list.className = 'news-items';
1117
+ list.append(...group.items.map(newsEntry));
1118
+ section.append(heading, list);
1119
+ return section;
1120
+ }));
1121
+ const label = NEWS_FILTERS.find(([id]) => id === newsView.filter)[1];
1122
+ $('news-empty').textContent = items.length ? '' : newsView.filter === 'all' || !shown ? newsNote(shown) : `Nothing in ${label} from the last 30 days.`;
1123
+ $('news-empty').hidden = items.length > 0;
1124
+ $('news-fresh').hidden = true;
1125
+ const fresh = (shown?.items ?? []).filter((item) => isNew(item, newsView.since)).length;
1126
+ $('news-sub').textContent = fresh ? `${fresh} new since your last visit` : 'Newest first';
1127
+ }
1128
+
1129
+ function renderNewsStatus() {
1130
+ const news = state.news;
1131
+ const status = $('news-status');
1132
+ status.hidden = !news;
1133
+ if (!news) return;
1134
+ const updated = news.refreshing ? 'checking for news…' : news.refreshedAt ? `updated ${relativeTime(news.refreshedAt)}` : '';
1135
+ const lines = [[`${news.sources.length} source${news.sources.length === 1 ? '' : 's'}`, updated].filter(Boolean).join(' · '), ...failureLines(news)];
1136
+ status.replaceChildren(...lines.map((text) => {
1137
+ const line = document.createElement('span');
1138
+ line.textContent = text;
1139
+ return line;
1140
+ }));
1141
+ }
1142
+
1143
+ function updateNewsPanel() {
1144
+ renderNewsStatus();
1145
+ if (!newsView.shown?.items.length) {
1146
+ newsView.shown = state.news;
1147
+ renderNewsList();
1148
+ return;
1149
+ }
1150
+ const shownIds = new Set(newsView.shown.items.map((item) => item.id));
1151
+ const added = state.news.items.filter((item) => !shownIds.has(item.id) && (newsView.filter === 'all' || item.category === newsView.filter)).length;
1152
+ const fresh = $('news-fresh');
1153
+ fresh.hidden = added === 0;
1154
+ fresh.textContent = `Show ${added} new item${added === 1 ? '' : 's'}`;
1155
+ }
1156
+
1157
+ function showFreshNews() {
1158
+ const before = new Set(newsView.shown.items.map((item) => item.id));
1159
+ newsView.shown = state.news;
1160
+ renderNewsList();
1161
+ const first = [...$('news-list').querySelectorAll('.news-item')].find((entry) => !before.has(entry.dataset.id));
1162
+ (first?.querySelector('.news-link') ?? $('news-close')).focus();
1163
+ }
1164
+
1165
+ function openNews() {
1166
+ const dialog = $('news');
1167
+ const saved = load(NEWS_FILTER_KEY);
1168
+ newsView.filter = NEWS_FILTERS.some(([id]) => id === saved) ? saved : 'all';
1169
+ newsView.since = newsSeen() ?? Infinity;
1170
+ newsView.shown = state.news;
1171
+ renderNewsFilters();
1172
+ renderNewsList();
1173
+ renderNewsStatus();
1174
+ if (!dialog.open) {
1175
+ newsView.opener = document.activeElement;
1176
+ dialog.showModal();
1177
+ }
1178
+ ($('news-list').querySelector('.news-link') ?? $('news-close')).focus();
1179
+ if (!state.news) loadNews();
1180
+ }
1181
+
1182
+ function closeNews() {
1183
+ if ($('news').open) $('news').close();
1184
+ }
1185
+
1186
+ function tickNews() {
1187
+ for (const time of document.querySelectorAll('#news-latest time, #news-list time')) time.textContent = relativeTime(time.dateTime);
1188
+ if ($('news').open) renderNewsStatus();
1189
+ }
1190
+
903
1191
  const CHANNEL_LABELS = {
904
1192
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
905
1193
  };
@@ -1343,6 +1631,7 @@ function enterStopping(running = 0) {
1343
1631
  state.stopRemaining = null;
1344
1632
  closePanel();
1345
1633
  closeModels();
1634
+ closeNews();
1346
1635
  for (const view of state.views.values()) view.dispose();
1347
1636
  state.views.clear();
1348
1637
  state.sessions.clear();
@@ -1400,6 +1689,9 @@ function connectEvents() {
1400
1689
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
1401
1690
  renderSessions();
1402
1691
  setUpgrade(msg.upgrade);
1692
+ loadNews();
1693
+ } else if (msg.type === 'news.updated') {
1694
+ loadNews();
1403
1695
  } else if (msg.type === 'manager.upgrade') {
1404
1696
  setUpgrade(msg.upgrade);
1405
1697
  } else if (msg.type === 'manager.stopping') {
@@ -1453,9 +1745,11 @@ function readTokenFromHash() {
1453
1745
 
1454
1746
  let usageTimer;
1455
1747
  let statsInterval;
1748
+ let newsTimer;
1456
1749
 
1457
1750
  function showAuth(message = '') {
1458
1751
  closeModels();
1752
+ closeNews();
1459
1753
  $('app').hidden = true;
1460
1754
  $('terminal-panel').hidden = true;
1461
1755
  $('stopped').hidden = true;
@@ -1488,6 +1782,8 @@ async function boot() {
1488
1782
  loadStats();
1489
1783
  clearInterval(statsInterval);
1490
1784
  statsInterval = setInterval(loadStats, 60 * 60 * 1000);
1785
+ clearInterval(newsTimer);
1786
+ newsTimer = setInterval(() => { if (document.visibilityState === 'visible') loadNews(); }, NEWS_POLL_MS);
1491
1787
  }
1492
1788
 
1493
1789
  $('auth-form').addEventListener('submit', (e) => {
@@ -1530,6 +1826,21 @@ document.addEventListener('keydown', (e) => {
1530
1826
  }, true);
1531
1827
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
1532
1828
  addEventListener('resize', () => { if (tipFor) hideTip(); });
1829
+ $('news-all').addEventListener('click', openNews);
1830
+ $('news-close').addEventListener('click', closeNews);
1831
+ $('news-fresh').addEventListener('click', showFreshNews);
1832
+ $('news').addEventListener('click', (e) => { if (e.target === $('news')) closeNews(); });
1833
+ $('news').addEventListener('close', () => {
1834
+ const newest = newestTime(newsView.shown?.items ?? []);
1835
+ if (newest > (newsSeen() ?? 0)) save(NEWS_SEEN_KEY, new Date(newest).toISOString());
1836
+ renderLatestNews();
1837
+ const opener = newsView.opener?.isConnected && !newsView.opener.closest('[hidden]') ? newsView.opener : $('news-all');
1838
+ opener.focus();
1839
+ newsView.opener = null;
1840
+ });
1841
+ document.addEventListener('visibilitychange', () => {
1842
+ if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
1843
+ });
1533
1844
  $('models-more').addEventListener('click', () => {
1534
1845
  const before = $('models-list').childElementCount;
1535
1846
  modelsView.all = true;
@@ -1554,6 +1865,7 @@ $('cwd').value = load(CWD_KEY) || '';
1554
1865
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
1555
1866
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
1556
1867
  setInterval(renderSessions, 30000);
1868
+ setInterval(tickNews, 30000);
1557
1869
 
1558
1870
  // The terminal panel sits below the top bar, which wraps onto two rows on
1559
1871
  // narrow screens; publish its height so the panel never covers its controls.
package/web/art/art.css CHANGED
@@ -155,6 +155,26 @@
155
155
  .badge { background: rgba(0, 0, 0, 0.45); border-color: #4a3d25; }
156
156
  .model-pill:hover { border-color: var(--gold-dim); }
157
157
 
158
+ .news-latest {
159
+ border-radius: 6px; border-color: #4a3d25; background: var(--card-bg);
160
+ box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 8px 24px rgba(0, 0, 0, 0.35);
161
+ }
162
+ .news-row + .news-row, .news-bar, .news-filters, .news-item, .news-status { border-color: #2a2418; }
163
+ .news-link { color: #f3ead8; }
164
+ a.news-link:visited { color: var(--muted); }
165
+ .news-panel {
166
+ background: #12141a; border-left-color: #4a3d25;
167
+ box-shadow: inset 1px 0 0 rgba(232, 196, 124, 0.08), -24px 0 60px rgba(0, 0, 0, 0.6);
168
+ }
169
+ .news-panel::backdrop { background: rgba(5, 6, 9, 0.72); }
170
+ .news-bar { padding: 16px 18px 12px; }
171
+ .news-filters, .news-status { padding-left: 18px; padding-right: 18px; }
172
+ .news-body { padding: 0 18px 14px; }
173
+ .news-day { background: #12141a; color: #c9ab72; }
174
+ .news-filter { border-color: #4a3d25; background: rgba(0, 0, 0, 0.35); }
175
+ .news-filter:hover { border-color: var(--gold-dim); }
176
+ .news-filter[aria-pressed="true"] { border-color: #b8955a; color: #fff; background: linear-gradient(#8e8ef6, #6262d6); }
177
+
158
178
  .session-card { padding-left: 152px; min-height: 196px; }
159
179
  .session-card::before {
160
180
  inset: 0 auto 0 0; width: 168px; background-size: 168px auto;
package/web/index.html CHANGED
@@ -47,6 +47,15 @@
47
47
  <div id="sessions" class="session-grid" role="list"></div>
48
48
  <p id="empty" class="empty">No sessions yet. Pick a provider above to start one. Sessions keep running when you close this page.</p>
49
49
  </section>
50
+
51
+ <section class="news" aria-labelledby="news-heading">
52
+ <div class="section-head">
53
+ <h2 id="news-heading">Latest news <span id="news-count" class="count"></span></h2>
54
+ <button id="news-all" class="btn" type="button" aria-haspopup="dialog">All news</button>
55
+ </div>
56
+ <ol id="news-latest" class="news-latest" hidden></ol>
57
+ <p id="news-note" class="empty">Loading news…</p>
58
+ </section>
50
59
  </main>
51
60
 
52
61
  <section id="auth" class="auth" hidden>
@@ -105,6 +114,23 @@
105
114
  <p id="models-source" class="models-source"></p>
106
115
  </dialog>
107
116
 
117
+ <dialog id="news" class="news-panel" aria-labelledby="news-title">
118
+ <div class="news-bar">
119
+ <div class="news-heading">
120
+ <h2 id="news-title">News</h2>
121
+ <p id="news-sub" class="sub"></p>
122
+ </div>
123
+ <button id="news-close" class="btn" type="button">Close</button>
124
+ </div>
125
+ <div id="news-filters" class="news-filters" role="group" aria-label="Show"></div>
126
+ <div class="news-body">
127
+ <button id="news-fresh" class="btn news-fresh" type="button" hidden></button>
128
+ <div id="news-list"></div>
129
+ <p id="news-empty" class="news-empty" hidden></p>
130
+ </div>
131
+ <p id="news-status" class="news-status" hidden></p>
132
+ </dialog>
133
+
108
134
  <div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
109
135
 
110
136
  <div id="toast" class="toast" role="alert" hidden></div>
package/web/styles.css CHANGED
@@ -341,6 +341,59 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
341
341
  .models-note { margin: 14px 0 0; color: var(--muted); }
342
342
  .models-source { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
343
343
 
344
+ .news-latest {
345
+ list-style: none; margin: 0; padding: 0;
346
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
347
+ }
348
+ .news-row { display: flex; align-items: baseline; gap: 4px 10px; min-width: 0; padding: 9px 14px; }
349
+ .news-row + .news-row { border-top: 1px solid var(--border); }
350
+ .news-row .news-link { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
351
+ .news-row .badge, .news-row .news-meta { flex: none; }
352
+ .news-link { color: var(--text); font-weight: 600; text-decoration: none; }
353
+ a.news-link:hover, .news-discussion:hover { text-decoration: underline; }
354
+ a.news-link:visited { color: var(--muted); }
355
+ .news-link:focus-visible, .news-discussion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
356
+ .news-meta { color: var(--muted); font-size: 0.78rem; white-space: nowrap; font-variant-numeric: tabular-nums; }
357
+
358
+ .news-panel {
359
+ width: min(460px, 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; padding: 0;
360
+ border: 0; border-left: 1px solid var(--border); background: var(--surface); color: var(--text);
361
+ box-shadow: -16px 0 48px rgba(0, 0, 0, 0.25);
362
+ }
363
+ .news-panel[open] { display: flex; flex-direction: column; animation: news-in 0.18s ease-out; }
364
+ .news-panel::backdrop { background: rgba(15, 17, 21, 0.4); }
365
+ .news-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
366
+ .news-heading { flex: 1; min-width: 0; }
367
+ .news-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
368
+ .news-filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
369
+ .news-filter {
370
+ font: inherit; font-size: 0.75rem; font-weight: 600; padding: 3px 11px; border-radius: 999px; cursor: pointer;
371
+ border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
372
+ }
373
+ .news-filter:hover { border-color: var(--muted); }
374
+ .news-filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
375
+ .news-filter[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
376
+ .news-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
377
+ .news-fresh { width: 100%; margin-top: 12px; }
378
+ .news-day {
379
+ position: sticky; top: 0; z-index: 1; margin: 0; padding: 12px 0 6px; background: var(--surface);
380
+ color: var(--muted); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
381
+ }
382
+ .news-items { list-style: none; margin: 0; padding: 0; }
383
+ .news-item { padding: 10px 0; border-top: 1px solid var(--border); }
384
+ .news-head { display: flex; align-items: baseline; gap: 6px; }
385
+ .news-head .news-link { overflow-wrap: anywhere; }
386
+ .news-head .badge { flex: none; margin-left: auto; }
387
+ .news-item .news-meta { display: block; margin-top: 3px; }
388
+ .news-summary {
389
+ margin: 4px 0 0; color: var(--muted); font-size: 0.82rem; line-height: 1.45; overflow-wrap: anywhere;
390
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
391
+ }
392
+ .news-discussion { color: inherit; text-decoration: none; }
393
+ .news-empty { margin: 14px 0 0; color: var(--muted); }
394
+ .news-status { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
395
+ .news-status span { display: block; }
396
+
344
397
  .toast {
345
398
  position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20;
346
399
  max-width: min(640px, calc(100vw - 32px)); padding: 10px 14px; border-radius: 8px;
@@ -350,9 +403,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
350
403
  @keyframes pulse { 50% { opacity: 0.35; } }
351
404
  @keyframes ring { 0% { transform: scale(0.85); opacity: 0.8; } 100% { transform: scale(1.3); opacity: 0; } }
352
405
  @keyframes stat-fill { from { transform: scaleX(0); } }
406
+ @keyframes news-in { from { transform: translateX(24px); opacity: 0; } }
353
407
 
354
408
  @media (prefers-reduced-motion: reduce) {
355
- .status-pill.active::before, .agent.working::after, .model-detail .stat-fill { animation: none; }
409
+ .status-pill.active::before, .agent.working::after, .model-detail .stat-fill, .news-panel[open] { animation: none; }
356
410
  .model-name::before { transition: none; }
357
411
  }
358
412
 
@@ -370,4 +424,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
370
424
  .model-detail .stat { padding-bottom: 6px; }
371
425
  .model-detail .stat-reading { row-gap: 1px; }
372
426
  .model-detail .stat-note { grid-column: 1 / -1; }
427
+ .news-row { flex-wrap: wrap; }
428
+ .news-row .news-link { flex-basis: 100%; white-space: normal; }
429
+ .news-panel { border-left: 0; }
373
430
  }