@oddessentials/agent-guild 0.4.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 (91) hide show
  1. package/README.md +13 -8
  2. package/bin/agent-guild-report.mjs +14 -1
  3. package/bin/agent-guild.mjs +85 -46
  4. package/config/providers.default.json +4 -0
  5. package/package.json +1 -1
  6. package/src/manager/changelog.mjs +195 -0
  7. package/src/manager/launch.mjs +64 -0
  8. package/src/manager/main.mjs +27 -4
  9. package/src/manager/news.mjs +5 -5
  10. package/src/manager/providers.mjs +11 -0
  11. package/src/manager/server.mjs +52 -16
  12. package/src/manager/session-history.mjs +496 -0
  13. package/src/manager/session-manager.mjs +4 -0
  14. package/src/manager/session.mjs +16 -0
  15. package/src/report/hooks.mjs +4 -0
  16. package/web/app.js +754 -56
  17. package/web/art/art.css +372 -159
  18. package/web/art/characters/anthropic/idle.avif +0 -0
  19. package/web/art/characters/anthropic/idle.webp +0 -0
  20. package/web/art/characters/anthropic/locked.avif +0 -0
  21. package/web/art/characters/anthropic/locked.webp +0 -0
  22. package/web/art/characters/anthropic/working.avif +0 -0
  23. package/web/art/characters/anthropic/working.webp +0 -0
  24. package/web/art/characters/google/idle.avif +0 -0
  25. package/web/art/characters/google/idle.webp +0 -0
  26. package/web/art/characters/google/locked.avif +0 -0
  27. package/web/art/characters/google/locked.webp +0 -0
  28. package/web/art/characters/google/working.avif +0 -0
  29. package/web/art/characters/google/working.webp +0 -0
  30. package/web/art/characters/openai/idle.avif +0 -0
  31. package/web/art/characters/openai/idle.webp +0 -0
  32. package/web/art/characters/openai/locked.avif +0 -0
  33. package/web/art/characters/openai/locked.webp +0 -0
  34. package/web/art/characters/openai/working.avif +0 -0
  35. package/web/art/characters/openai/working.webp +0 -0
  36. package/web/art/characters/shell/idle.avif +0 -0
  37. package/web/art/characters/shell/idle.webp +0 -0
  38. package/web/art/characters/shell/locked.avif +0 -0
  39. package/web/art/characters/shell/locked.webp +0 -0
  40. package/web/art/characters/shell/working.avif +0 -0
  41. package/web/art/characters/shell/working.webp +0 -0
  42. package/web/art/characters/xai/idle.avif +0 -0
  43. package/web/art/characters/xai/idle.webp +0 -0
  44. package/web/art/characters/xai/locked.avif +0 -0
  45. package/web/art/characters/xai/locked.webp +0 -0
  46. package/web/art/characters/xai/working.avif +0 -0
  47. package/web/art/characters/xai/working.webp +0 -0
  48. package/web/art/icons/anthropic.avif +0 -0
  49. package/web/art/icons/anthropic.webp +0 -0
  50. package/web/art/icons/google.avif +0 -0
  51. package/web/art/icons/google.webp +0 -0
  52. package/web/art/icons/openai.avif +0 -0
  53. package/web/art/icons/openai.webp +0 -0
  54. package/web/art/icons/shell.avif +0 -0
  55. package/web/art/icons/shell.webp +0 -0
  56. package/web/art/icons/xai.avif +0 -0
  57. package/web/art/icons/xai.webp +0 -0
  58. package/web/art/page-light.avif +0 -0
  59. package/web/art/page-light.webp +0 -0
  60. package/web/art/page.avif +0 -0
  61. package/web/art/page.webp +0 -0
  62. package/web/art/ui/empty-state.avif +0 -0
  63. package/web/art/ui/empty-state.webp +0 -0
  64. package/web/art/ui/favicon.png +0 -0
  65. package/web/art/ui/frame-ornate.png +0 -0
  66. package/web/art/ui/guild-crest.png +0 -0
  67. package/web/fonts/OFL.txt +93 -0
  68. package/web/fonts/cinzel.woff2 +0 -0
  69. package/web/index.html +82 -14
  70. package/web/styles.css +208 -68
  71. package/web/art/characters/anthropic/idle.png +0 -0
  72. package/web/art/characters/anthropic/locked.png +0 -0
  73. package/web/art/characters/anthropic/working.png +0 -0
  74. package/web/art/characters/google/idle.png +0 -0
  75. package/web/art/characters/google/locked.png +0 -0
  76. package/web/art/characters/google/working.png +0 -0
  77. package/web/art/characters/openai/idle.png +0 -0
  78. package/web/art/characters/openai/locked.png +0 -0
  79. package/web/art/characters/openai/working.png +0 -0
  80. package/web/art/characters/shell/idle.png +0 -0
  81. package/web/art/characters/shell/locked.png +0 -0
  82. package/web/art/characters/shell/working.png +0 -0
  83. package/web/art/characters/xai/idle.png +0 -0
  84. package/web/art/characters/xai/locked.png +0 -0
  85. package/web/art/characters/xai/working.png +0 -0
  86. package/web/art/icons/anthropic.png +0 -0
  87. package/web/art/icons/google.png +0 -0
  88. package/web/art/icons/openai.png +0 -0
  89. package/web/art/icons/shell.png +0 -0
  90. package/web/art/icons/xai.png +0 -0
  91. package/web/art/page.png +0 -0
@@ -7,12 +7,15 @@ import { fileURLToPath } from 'node:url';
7
7
  import { ProviderRegistry } from './providers.mjs';
8
8
  import { SessionManager } from './session-manager.mjs';
9
9
  import { UsageMonitor } from './usage.mjs';
10
+ import { SessionHistory } from './session-history.mjs';
10
11
  import { ModelStats } from './model-stats.mjs';
11
12
  import { NewsFeed } from './news.mjs';
13
+ import { Changelog } from './changelog.mjs';
12
14
  import { createManagerServer } from './server.mjs';
13
15
  import { SelfUpdate } from './self-update.mjs';
14
16
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
15
17
  import { writeReportShims } from './report-shims.mjs';
18
+ import { launcherPath, spawnManager } from './launch.mjs';
16
19
  import {
17
20
  DEFAULT_HOST,
18
21
  PACKAGE_FILE,
@@ -59,8 +62,10 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
59
62
  const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
60
63
  const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
61
64
  const usage = new UsageMonitor({ registry, env: baseEnv });
65
+ const history = new SessionHistory({ registry, env: baseEnv });
62
66
  const modelStats = new ModelStats({ registry });
63
67
  const news = new NewsFeed({ registry });
68
+ const changelog = new Changelog({ latest: () => selfUpdate.latest });
64
69
  let closing = null;
65
70
 
66
71
  const refreshVersions = () => {
@@ -70,9 +75,14 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
70
75
  const versionTimer = setInterval(refreshVersions, VERSION_REFRESH_MS);
71
76
  versionTimer.unref();
72
77
 
73
- const shutdown = (reason = 'shutdown') => {
78
+ /**
79
+ * End every session and close the API. With `restart`, a new manager is
80
+ * then started from the package on disk, so it comes up on the version
81
+ * an upgrade installed; clients reconnect to it by themselves.
82
+ */
83
+ const shutdown = (reason = 'shutdown', { restart = false } = {}) => {
74
84
  if (closing) return closing;
75
- console.log(`[manager] stopping (${reason}); ending ${manager.sessions.size} session(s)`);
85
+ console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
76
86
  clearInterval(versionTimer);
77
87
  removeRuntimeFile();
78
88
  // Sessions end before the API closes, and the last event says whether
@@ -80,7 +90,17 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
80
90
  // from a timeout. The manager refuses new sessions meanwhile.
81
91
  closing = manager.shutdown().then(({ remaining }) => {
82
92
  if (remaining > 0) console.warn(`[manager] ${remaining} session process(es) did not confirm exiting in time`);
83
- return api.close({ notice: { type: 'manager.stopped', remaining } });
93
+ return api.close({ notice: { type: 'manager.stopped', remaining, restart } });
94
+ }).then(() => {
95
+ // Only once the port is released: the successor listens on the same one,
96
+ // the bound one rather than a configured 0, so clients find it again.
97
+ if (!restart) return;
98
+ try {
99
+ const child = spawnManager({ note: 'restarting manager', env: { ...process.env, AGENT_GUILD_PORT: String(api.port) } });
100
+ console.log(`[manager] started the next manager (pid ${child.pid})`);
101
+ } catch (err) {
102
+ console.error(`[manager] could not start the next manager: ${err.message}`);
103
+ }
84
104
  });
85
105
  return closing;
86
106
  };
@@ -92,8 +112,10 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
92
112
  manager,
93
113
  registry,
94
114
  usage,
115
+ history,
95
116
  modelStats,
96
117
  news,
118
+ changelog,
97
119
  token,
98
120
  host,
99
121
  port,
@@ -101,7 +123,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
101
123
  version,
102
124
  selfUpdate,
103
125
  extraOrigins,
104
- onShutdownRequest: () => shutdown('requested via API').then(() => process.exit(0)),
126
+ launcher: launcherPath(),
127
+ onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
105
128
  });
106
129
 
107
130
  try {
@@ -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';
@@ -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,
@@ -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
  };
@@ -100,8 +103,10 @@ export function createManagerServer({
100
103
  manager,
101
104
  registry,
102
105
  usage,
106
+ history,
103
107
  modelStats,
104
108
  news = null,
109
+ changelog = null,
105
110
  token,
106
111
  host = '127.0.0.1',
107
112
  port = 0,
@@ -109,6 +114,9 @@ export function createManagerServer({
109
114
  version = '0.0.0',
110
115
  selfUpdate = null,
111
116
  extraOrigins = [],
117
+ /** The double-click launcher file for this platform, or null when the package carries none. */
118
+ launcher = null,
119
+ /** @type {(opts: { restart: boolean }) => void} */
112
120
  onShutdownRequest = () => {},
113
121
  }) {
114
122
  const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
@@ -144,16 +152,27 @@ export function createManagerServer({
144
152
  }
145
153
  }
146
154
 
147
- function serveFile(res, file, { cache = false } = {}) {
148
- fs.readFile(file, (err, data) => {
149
- if (err) return sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
150
- 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 = {
151
161
  ...SECURITY_HEADERS,
152
162
  'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
153
163
  'Cache-Control': cache ? 'public, max-age=3600' : 'no-cache',
154
- '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);
155
175
  });
156
- res.end(data);
157
176
  });
158
177
  }
159
178
 
@@ -161,7 +180,7 @@ export function createManagerServer({
161
180
  if (req.method !== 'GET' && req.method !== 'HEAD') {
162
181
  throw new HttpError(405, 'method not allowed', 'method_not_allowed');
163
182
  }
164
- if (vendor[pathname]) return serveFile(res, vendor[pathname], { cache: true });
183
+ if (vendor[pathname]) return serveFile(req, res, vendor[pathname], { cache: true });
165
184
  let rel;
166
185
  try {
167
186
  rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
@@ -173,7 +192,7 @@ export function createManagerServer({
173
192
  if (file !== webDir && !file.startsWith(webDir + path.sep)) {
174
193
  throw new HttpError(404, 'not found', 'not_found');
175
194
  }
176
- return serveFile(res, file);
195
+ return serveFile(req, res, file);
177
196
  }
178
197
 
179
198
  async function handleApi(req, res, url) {
@@ -184,9 +203,10 @@ export function createManagerServer({
184
203
  return sendJson(res, 200, { ok: true, name: 'agent-guild', version, pid: process.pid });
185
204
  }
186
205
 
187
- // Agent and model reports may authenticate with the per-session report
188
- // token that the manager injects into each tool's environment.
189
- const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model)$/);
206
+ // Agent, model and tool-session reports may authenticate with the
207
+ // per-session report token that the manager injects into each tool's
208
+ // environment.
209
+ const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session)$/);
190
210
  if (reportMatch && method === 'POST') {
191
211
  const [, id, kind] = reportMatch;
192
212
  const body = await readJsonBody(req);
@@ -195,7 +215,8 @@ export function createManagerServer({
195
215
  reportToken: req.headers['x-agent-guild-report-token'],
196
216
  };
197
217
  if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
198
- return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
218
+ if (kind === 'model') return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
219
+ return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
199
220
  }
200
221
 
201
222
  requireAuth(req, url);
@@ -210,6 +231,7 @@ export function createManagerServer({
210
231
  startedAt,
211
232
  warnings: registry.warnings,
212
233
  upgrade: upgradeInfo(),
234
+ launcher,
213
235
  });
214
236
  }
215
237
  if (route === '/upgrade' && method === 'POST') {
@@ -234,6 +256,17 @@ export function createManagerServer({
234
256
  if (route === '/news' && method === 'GET' && news) {
235
257
  return sendJson(res, 200, news.snapshot());
236
258
  }
259
+ if (route === '/changelog' && method === 'GET' && changelog) {
260
+ return sendJson(res, 200, changelog.snapshot());
261
+ }
262
+ const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
263
+ if (historyMatch && method === 'GET') {
264
+ const provider = registry.get(historyMatch[1]);
265
+ if (!provider) throw new HttpError(404, `unknown provider "${historyMatch[1]}"`, 'unknown_provider');
266
+ if (!provider.history) throw new HttpError(400, `${provider.tool} has no history source configured`, 'history_unsupported');
267
+ const account = registry.account(provider, url.searchParams.get('account'));
268
+ return sendJson(res, 200, { history: await history.list(provider, account, { limit: url.searchParams.get('limit') }) });
269
+ }
237
270
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
238
271
  if (installMatch && method === 'POST') {
239
272
  const body = await readJsonBody(req);
@@ -258,14 +291,16 @@ export function createManagerServer({
258
291
  err.running = running;
259
292
  throw err;
260
293
  }
294
+ // With `restart`, a new manager is started once this one has closed.
295
+ const restart = body.restart === true;
261
296
  // Refuse new sessions from this moment, before the shutdown itself
262
297
  // runs: a session accepted in between would be ended without warning.
263
298
  manager.closing = true;
264
- sendJson(res, 202, { ok: true, running });
299
+ sendJson(res, 202, { ok: true, running, restart });
265
300
  // Tell every client first, so a second page shows "stopped" rather
266
301
  // than "not reachable" when its socket drops.
267
- broadcast({ type: 'manager.stopping', running });
268
- setImmediate(onShutdownRequest);
302
+ broadcast({ type: 'manager.stopping', running, restart });
303
+ setImmediate(() => onShutdownRequest({ restart }));
269
304
  return undefined;
270
305
  }
271
306
 
@@ -347,10 +382,11 @@ export function createManagerServer({
347
382
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
348
383
  selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
349
384
  news?.on('updated', () => broadcast({ type: 'news.updated' }));
385
+ changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
350
386
 
351
387
  function handleEvents(ws) {
352
388
  eventClients.add(ws);
353
- safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
389
+ safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
354
390
  ws.on('close', () => eventClients.delete(ws));
355
391
  ws.on('message', () => { /* events socket is server -> client only */ });
356
392
  }