@oddessentials/agent-guild 0.5.0 → 0.6.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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/manager/changelog.mjs +195 -0
  4. package/src/manager/main.mjs +3 -0
  5. package/src/manager/news.mjs +5 -5
  6. package/src/manager/server.mjs +27 -8
  7. package/web/app.js +353 -8
  8. package/web/art/art.css +372 -159
  9. package/web/art/characters/anthropic/idle.avif +0 -0
  10. package/web/art/characters/anthropic/idle.webp +0 -0
  11. package/web/art/characters/anthropic/locked.avif +0 -0
  12. package/web/art/characters/anthropic/locked.webp +0 -0
  13. package/web/art/characters/anthropic/working.avif +0 -0
  14. package/web/art/characters/anthropic/working.webp +0 -0
  15. package/web/art/characters/google/idle.avif +0 -0
  16. package/web/art/characters/google/idle.webp +0 -0
  17. package/web/art/characters/google/locked.avif +0 -0
  18. package/web/art/characters/google/locked.webp +0 -0
  19. package/web/art/characters/google/working.avif +0 -0
  20. package/web/art/characters/google/working.webp +0 -0
  21. package/web/art/characters/openai/idle.avif +0 -0
  22. package/web/art/characters/openai/idle.webp +0 -0
  23. package/web/art/characters/openai/locked.avif +0 -0
  24. package/web/art/characters/openai/locked.webp +0 -0
  25. package/web/art/characters/openai/working.avif +0 -0
  26. package/web/art/characters/openai/working.webp +0 -0
  27. package/web/art/characters/shell/idle.avif +0 -0
  28. package/web/art/characters/shell/idle.webp +0 -0
  29. package/web/art/characters/shell/locked.avif +0 -0
  30. package/web/art/characters/shell/locked.webp +0 -0
  31. package/web/art/characters/shell/working.avif +0 -0
  32. package/web/art/characters/shell/working.webp +0 -0
  33. package/web/art/characters/xai/idle.avif +0 -0
  34. package/web/art/characters/xai/idle.webp +0 -0
  35. package/web/art/characters/xai/locked.avif +0 -0
  36. package/web/art/characters/xai/locked.webp +0 -0
  37. package/web/art/characters/xai/working.avif +0 -0
  38. package/web/art/characters/xai/working.webp +0 -0
  39. package/web/art/icons/anthropic.avif +0 -0
  40. package/web/art/icons/anthropic.webp +0 -0
  41. package/web/art/icons/google.avif +0 -0
  42. package/web/art/icons/google.webp +0 -0
  43. package/web/art/icons/openai.avif +0 -0
  44. package/web/art/icons/openai.webp +0 -0
  45. package/web/art/icons/shell.avif +0 -0
  46. package/web/art/icons/shell.webp +0 -0
  47. package/web/art/icons/xai.avif +0 -0
  48. package/web/art/icons/xai.webp +0 -0
  49. package/web/art/page-light.avif +0 -0
  50. package/web/art/page-light.webp +0 -0
  51. package/web/art/page.avif +0 -0
  52. package/web/art/page.webp +0 -0
  53. package/web/art/ui/empty-state.avif +0 -0
  54. package/web/art/ui/empty-state.webp +0 -0
  55. package/web/art/ui/favicon.png +0 -0
  56. package/web/art/ui/frame-ornate.png +0 -0
  57. package/web/art/ui/guild-crest.png +0 -0
  58. package/web/fonts/OFL.txt +93 -0
  59. package/web/fonts/cinzel.woff2 +0 -0
  60. package/web/index.html +35 -11
  61. package/web/styles.css +168 -71
  62. package/web/art/characters/anthropic/idle.png +0 -0
  63. package/web/art/characters/anthropic/locked.png +0 -0
  64. package/web/art/characters/anthropic/working.png +0 -0
  65. package/web/art/characters/google/idle.png +0 -0
  66. package/web/art/characters/google/locked.png +0 -0
  67. package/web/art/characters/google/working.png +0 -0
  68. package/web/art/characters/openai/idle.png +0 -0
  69. package/web/art/characters/openai/locked.png +0 -0
  70. package/web/art/characters/openai/working.png +0 -0
  71. package/web/art/characters/shell/idle.png +0 -0
  72. package/web/art/characters/shell/locked.png +0 -0
  73. package/web/art/characters/shell/working.png +0 -0
  74. package/web/art/characters/xai/idle.png +0 -0
  75. package/web/art/characters/xai/locked.png +0 -0
  76. package/web/art/characters/xai/working.png +0 -0
  77. package/web/art/icons/anthropic.png +0 -0
  78. package/web/art/icons/google.png +0 -0
  79. package/web/art/icons/openai.png +0 -0
  80. package/web/art/icons/shell.png +0 -0
  81. package/web/art/icons/xai.png +0 -0
  82. package/web/art/page.png +0 -0
package/README.md CHANGED
@@ -44,7 +44,7 @@ coding tool, and see at a glance which sessions and agents are working.
44
44
  can watch. Sessions keep running; once they are done, **Restart to use
45
45
  vX.Y.Z** in the top bar switches to the new version.
46
46
  * **Light or dark.** The page follows your system theme and the top-bar
47
- toggle switches it. The guild artwork is the dark theme.
47
+ toggle switches it.
48
48
 
49
49
  ## Requirements
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
@@ -0,0 +1,195 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { compareVersions } from './versions.mjs';
3
+ import { FeedError, USER_AGENT, decodeEntities, failure, readBody, refusal, webUrl } from './news.mjs';
4
+
5
+ const REPOSITORY = 'oddessentials/agent-guild';
6
+ const RELEASES_URL = `https://github.com/${REPOSITORY}/releases`;
7
+ const API_URL = `https://api.github.com/repos/${REPOSITORY}/releases?per_page=30`;
8
+ const TTL_MS = 60 * 60 * 1000;
9
+ const RETRY_MS = 10 * 60 * 1000;
10
+ const MISSING_MS = 2 * 60 * 1000;
11
+ const FETCH_TIMEOUT_MS = 20000;
12
+ const MAX_BYTES = 5 * 1024 * 1024;
13
+ const MAX_CHANGES = 100;
14
+ const MAX_NOTES = 20000;
15
+ const MAX_LINE = 1000;
16
+ const RELEASE_TAG = /^v?(\d+\.\d+\.\d+)$/;
17
+ const VERSION_HEADING = /^v?\d+\.\d+\.\d+(?![\d.])/;
18
+ const INSTALL_STEPS = /^install or update$/i;
19
+ const COMMIT_LINK = /\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi;
20
+ const INLINE = /\*\*(.+?)\*\*|`([^`]+)`|!\[[^\]]*\]\([^)]*\)|<(https?:\/\/[^>\s]+)>|\[([^\]]+)\]\(([^)\s]+)\)|<\/?[a-z][^>]*>/gi;
21
+
22
+ const iso = (time) => (time ? new Date(time).toISOString() : null);
23
+
24
+ function textRun(text, strong) {
25
+ return strong ? { text: decodeEntities(text), strong: true } : { text: decodeEntities(text) };
26
+ }
27
+
28
+ function inline(source, strong = false) {
29
+ const runs = [];
30
+ let at = 0;
31
+ for (const match of source.matchAll(INLINE)) {
32
+ if (match.index > at) runs.push(textRun(source.slice(at, match.index), strong));
33
+ const [, bold, code, autolink, label, href] = match;
34
+ if (bold !== undefined) {
35
+ runs.push(...inline(bold, true));
36
+ } else if (code !== undefined) {
37
+ runs.push({ ...(strong && { strong: true }), text: code, code: true });
38
+ } else if (autolink !== undefined || label !== undefined) {
39
+ const url = webUrl(autolink ?? href);
40
+ runs.push({ ...textRun(label ?? autolink, strong), ...(url && { url }) });
41
+ }
42
+ at = match.index + match[0].length;
43
+ }
44
+ if (at < source.length) runs.push(textRun(source.slice(at), strong));
45
+ return runs;
46
+ }
47
+
48
+ const sameStyle = (a, b) => !a.url && !b.url && Boolean(a.code) === Boolean(b.code) && Boolean(a.strong) === Boolean(b.strong);
49
+
50
+ function tidy(runs) {
51
+ const tidied = [];
52
+ for (const run of runs) {
53
+ const text = run.text.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ');
54
+ if (!text) continue;
55
+ const last = tidied.at(-1);
56
+ if (last && sameStyle(last, run)) last.text += text;
57
+ else tidied.push({ ...run, text });
58
+ }
59
+ if (tidied.length) {
60
+ tidied[0].text = tidied[0].text.trimStart();
61
+ tidied.at(-1).text = tidied.at(-1).text.trimEnd();
62
+ }
63
+ return tidied.filter((run) => run.text);
64
+ }
65
+
66
+ function plain(source) {
67
+ return tidy(inline(source)).map((run) => run.text).join('');
68
+ }
69
+
70
+ export function parseNotes(markdown) {
71
+ const sections = [];
72
+ let section = null;
73
+ let change = null;
74
+ let skipping = false;
75
+ let fenced = false;
76
+ let count = 0;
77
+ for (const whole of String(markdown ?? '').slice(0, MAX_NOTES).replace(/<!--[\s\S]*?(?:-->|$)/g, '').split(/\r?\n/)) {
78
+ const line = whole.slice(0, MAX_LINE);
79
+ if (/^\s*(?:```|~~~)/.test(line)) {
80
+ fenced = !fenced;
81
+ change = null;
82
+ continue;
83
+ }
84
+ if (fenced) continue;
85
+ const heading = /^\s{0,3}#{1,6}\s+(.*?)(?:\s+#+)?\s*$/.exec(line);
86
+ if (heading) {
87
+ const title = plain(heading[1]);
88
+ change = null;
89
+ section = null;
90
+ skipping = INSTALL_STEPS.test(title);
91
+ if (title && !skipping && !VERSION_HEADING.test(title)) sections.push(section = { title, changes: [] });
92
+ continue;
93
+ }
94
+ if (skipping) continue;
95
+ if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
96
+ change = null;
97
+ continue;
98
+ }
99
+ const bullet = /^\s*(?:[-*+]|\d{1,9}[.)])\s+(.*)$/.exec(line);
100
+ const body = (bullet ? bullet[1] : line.replace(/^\s*(?:>\s?)+/, '')).trim();
101
+ if (!body) {
102
+ change = null;
103
+ continue;
104
+ }
105
+ const runs = tidy(inline(body.replace(COMMIT_LINK, '')));
106
+ if (runs.length === 0) continue;
107
+ if (!bullet && change) {
108
+ change.splice(0, change.length, ...tidy([...change, { text: ' ' }, ...runs]));
109
+ continue;
110
+ }
111
+ if (count === MAX_CHANGES) break;
112
+ if (!section) sections.push(section = { title: null, changes: [] });
113
+ section.changes.push(change = runs);
114
+ count++;
115
+ }
116
+ return sections.filter((s) => s.changes.length > 0);
117
+ }
118
+
119
+ export function parseReleases(json) {
120
+ const list = JSON.parse(json);
121
+ if (!Array.isArray(list)) throw new FeedError('sent no releases');
122
+ const releases = new Map();
123
+ for (const release of list) {
124
+ if (!release || typeof release !== 'object' || release.draft || release.prerelease) continue;
125
+ const tag = String(release.tag_name ?? '');
126
+ const version = RELEASE_TAG.exec(tag)?.[1];
127
+ if (!version || releases.has(version)) continue;
128
+ const published = Date.parse(release.published_at);
129
+ releases.set(version, {
130
+ version,
131
+ url: webUrl(String(release.html_url ?? '')) ?? `${RELEASES_URL}/tag/${encodeURIComponent(tag)}`,
132
+ publishedAt: Number.isFinite(published) ? new Date(published).toISOString() : null,
133
+ sections: parseNotes(typeof release.body === 'string' ? release.body : ''),
134
+ });
135
+ }
136
+ return [...releases.values()].sort((a, b) => compareVersions(b.version, a.version));
137
+ }
138
+
139
+ export class Changelog extends EventEmitter {
140
+ constructor({
141
+ latest = () => null, fetchImpl = fetch, url = API_URL,
142
+ ttlMs = TTL_MS, retryMs = RETRY_MS, missingMs = MISSING_MS, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_BYTES,
143
+ } = {}) {
144
+ super();
145
+ this.latest = latest;
146
+ this.fetchImpl = fetchImpl;
147
+ this.url = url;
148
+ this.ttlMs = ttlMs;
149
+ this.retryMs = retryMs;
150
+ this.missingMs = missingMs;
151
+ this.timeoutMs = timeoutMs;
152
+ this.maxBytes = maxBytes;
153
+ this.releases = [];
154
+ this.etag = null;
155
+ this.checkedAt = 0;
156
+ this.okAt = 0;
157
+ this.error = null;
158
+ this.refreshing = null;
159
+ }
160
+
161
+ snapshot() {
162
+ this._refreshDue();
163
+ return { refreshing: Boolean(this.refreshing), okAt: iso(this.okAt), error: this.error, releases: this.releases };
164
+ }
165
+
166
+ _refreshDue() {
167
+ if (this.refreshing) return;
168
+ const latest = this.latest();
169
+ const missing = Boolean(latest) && !this.releases.some((release) => release.version === latest);
170
+ const wait = this.error ? this.retryMs : missing ? this.missingMs : this.ttlMs;
171
+ if (Date.now() - this.checkedAt < wait) return;
172
+ this.refreshing = this._refresh().finally(() => {
173
+ this.refreshing = null;
174
+ this.emit('updated');
175
+ });
176
+ }
177
+
178
+ async _refresh() {
179
+ this.checkedAt = Date.now();
180
+ try {
181
+ const headers = { 'User-Agent': USER_AGENT, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' };
182
+ if (this.etag) headers['If-None-Match'] = this.etag;
183
+ const res = await this.fetchImpl(this.url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
184
+ if (res.status !== 304) {
185
+ if (!res.ok) throw new FeedError(refusal(res));
186
+ this.releases = parseReleases(await readBody(res, this.maxBytes));
187
+ this.etag = res.headers.get('etag');
188
+ }
189
+ this.error = null;
190
+ this.okAt = Date.now();
191
+ } catch (err) {
192
+ this.error = failure(err, this.timeoutMs);
193
+ }
194
+ }
195
+ }
@@ -10,6 +10,7 @@ import { UsageMonitor } from './usage.mjs';
10
10
  import { SessionHistory } from './session-history.mjs';
11
11
  import { ModelStats } from './model-stats.mjs';
12
12
  import { NewsFeed } from './news.mjs';
13
+ import { Changelog } from './changelog.mjs';
13
14
  import { createManagerServer } from './server.mjs';
14
15
  import { SelfUpdate } from './self-update.mjs';
15
16
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
@@ -64,6 +65,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
64
65
  const history = new SessionHistory({ registry, env: baseEnv });
65
66
  const modelStats = new ModelStats({ registry });
66
67
  const news = new NewsFeed({ registry });
68
+ const changelog = new Changelog({ latest: () => selfUpdate.latest });
67
69
  let closing = null;
68
70
 
69
71
  const refreshVersions = () => {
@@ -113,6 +115,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
113
115
  history,
114
116
  modelStats,
115
117
  news,
118
+ changelog,
116
119
  token,
117
120
  host,
118
121
  port,
@@ -14,7 +14,7 @@ const LIMITS = { news: 20, releases: 5, research: 10 };
14
14
  const TITLE_MAX = 300;
15
15
  const SUMMARY_MAX = 240;
16
16
  const RAW_MAX = 8000;
17
- const USER_AGENT = `agent-guild/${VERSION} (+https://github.com/oddessentials/agent-guild)`;
17
+ export const USER_AGENT = `agent-guild/${VERSION} (+https://github.com/oddessentials/agent-guild)`;
18
18
  const FEED_ACCEPT = 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5';
19
19
 
20
20
  const latestRelease = (id, name, repo, provider) => ({
@@ -64,7 +64,7 @@ const NAMED = {
64
64
  uacute: 'ú', uuml: 'ü', ccedil: 'ç', ntilde: 'ñ', szlig: 'ß', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü',
65
65
  };
66
66
 
67
- class FeedError extends Error {}
67
+ export class FeedError extends Error {}
68
68
 
69
69
  const fields = new Map();
70
70
  const attrs = new Map();
@@ -329,7 +329,7 @@ function merge(previous, fresh, limit, now) {
329
329
  return [...byKey.values()].sort((a, b) => b.time - a.time).slice(0, limit);
330
330
  }
331
331
 
332
- async function readBody(res, maxBytes) {
332
+ export async function readBody(res, maxBytes) {
333
333
  const tooLarge = () => new FeedError(`sent more than ${maxBytes / 1024 / 1024} MB`);
334
334
  if (Number(res.headers.get('content-length')) > maxBytes) throw tooLarge();
335
335
  const chunks = [];
@@ -349,12 +349,12 @@ async function readBody(res, maxBytes) {
349
349
  }
350
350
  }
351
351
 
352
- function refusal(res) {
352
+ export function refusal(res) {
353
353
  if ((res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0') return 'GitHub API rate limit exceeded';
354
354
  return `HTTP ${res.status}`;
355
355
  }
356
356
 
357
- function failure(err, timeoutMs) {
357
+ export function failure(err, timeoutMs) {
358
358
  if (err instanceof FeedError) return err.message;
359
359
  if (err?.name === 'TimeoutError') return `did not answer within ${timeoutMs / 1000} seconds`;
360
360
  if (err instanceof SyntaxError) return 'sent data that could not be read';
@@ -21,6 +21,9 @@ const MIME = {
21
21
  '.css': 'text/css; charset=utf-8',
22
22
  '.svg': 'image/svg+xml',
23
23
  '.png': 'image/png',
24
+ '.avif': 'image/avif',
25
+ '.webp': 'image/webp',
26
+ '.woff2': 'font/woff2',
24
27
  '.ico': 'image/x-icon',
25
28
  '.json': 'application/json; charset=utf-8',
26
29
  };
@@ -103,6 +106,7 @@ export function createManagerServer({
103
106
  history,
104
107
  modelStats,
105
108
  news = null,
109
+ changelog = null,
106
110
  token,
107
111
  host = '127.0.0.1',
108
112
  port = 0,
@@ -148,16 +152,27 @@ export function createManagerServer({
148
152
  }
149
153
  }
150
154
 
151
- function serveFile(res, file, { cache = false } = {}) {
152
- fs.readFile(file, (err, data) => {
153
- if (err) return sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
154
- res.writeHead(200, {
155
+ function serveFile(req, res, file, { cache = false } = {}) {
156
+ const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
157
+ fs.stat(file, (statErr, stat) => {
158
+ if (statErr || !stat.isFile()) return notFound();
159
+ const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
160
+ const headers = {
155
161
  ...SECURITY_HEADERS,
156
162
  'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
157
163
  'Cache-Control': cache ? 'public, max-age=3600' : 'no-cache',
158
- 'Content-Length': data.length,
164
+ ETag: etag,
165
+ };
166
+ const known = String(req.headers['if-none-match'] || '').split(',').map((tag) => tag.trim());
167
+ if (known.includes(etag) || known.includes('*')) {
168
+ res.writeHead(304, headers);
169
+ return res.end();
170
+ }
171
+ fs.readFile(file, (err, data) => {
172
+ if (err) return notFound();
173
+ res.writeHead(200, { ...headers, 'Content-Length': data.length });
174
+ res.end(data);
159
175
  });
160
- res.end(data);
161
176
  });
162
177
  }
163
178
 
@@ -165,7 +180,7 @@ export function createManagerServer({
165
180
  if (req.method !== 'GET' && req.method !== 'HEAD') {
166
181
  throw new HttpError(405, 'method not allowed', 'method_not_allowed');
167
182
  }
168
- if (vendor[pathname]) return serveFile(res, vendor[pathname], { cache: true });
183
+ if (vendor[pathname]) return serveFile(req, res, vendor[pathname], { cache: true });
169
184
  let rel;
170
185
  try {
171
186
  rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
@@ -177,7 +192,7 @@ export function createManagerServer({
177
192
  if (file !== webDir && !file.startsWith(webDir + path.sep)) {
178
193
  throw new HttpError(404, 'not found', 'not_found');
179
194
  }
180
- return serveFile(res, file);
195
+ return serveFile(req, res, file);
181
196
  }
182
197
 
183
198
  async function handleApi(req, res, url) {
@@ -241,6 +256,9 @@ export function createManagerServer({
241
256
  if (route === '/news' && method === 'GET' && news) {
242
257
  return sendJson(res, 200, news.snapshot());
243
258
  }
259
+ if (route === '/changelog' && method === 'GET' && changelog) {
260
+ return sendJson(res, 200, changelog.snapshot());
261
+ }
244
262
  const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
245
263
  if (historyMatch && method === 'GET') {
246
264
  const provider = registry.get(historyMatch[1]);
@@ -364,6 +382,7 @@ export function createManagerServer({
364
382
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
365
383
  selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
366
384
  news?.on('updated', () => broadcast({ type: 'news.updated' }));
385
+ changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
367
386
 
368
387
  function handleEvents(ws) {
369
388
  eventClients.add(ws);