@oddessentials/agent-guild 1.9.2 → 1.11.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,400 @@
1
+ // `agent-guild mcp`: a Model Context Protocol server on stdin and stdout, so a
2
+ // coding agent (Claude Code, Codex CLI, Gemini CLI, Cursor and others) can see
3
+ // and steer the sessions of this computer's Agent Guild through its local API.
4
+ //
5
+ // It never returns the API token or the page link: either one gives full
6
+ // control of every session, and anything a tool returns lands in the agent's
7
+ // context. Typing into a session is off unless `--allow-input` turns it on.
8
+
9
+ import readline from 'node:readline';
10
+ import WebSocket from 'ws';
11
+ import headless from '@xterm/headless';
12
+ import { DEFAULT_HOST, VERSION, loadOrCreateToken, readRuntimeFile, resolvePort } from '../manager/config.mjs';
13
+ import { cleanResumeId } from '../manager/resume-id.mjs';
14
+
15
+ const { Terminal } = headless;
16
+
17
+ /** Newest first; an older client gets its own version back when it is listed. */
18
+ export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05'];
19
+ const REQUEST_TIMEOUT_MS = 10000;
20
+ const START_TIMEOUT_MS = 50000;
21
+ const SCREEN_TIMEOUT_MS = 5000;
22
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,64}$/;
23
+ const HIDDEN = '[token hidden]';
24
+ // 64 bits of the known token; leave colors, short hashes and 12-digit session ids readable.
25
+ const MIN_TOKEN_FRAGMENT = 16;
26
+
27
+ const INSTRUCTIONS = `Agent Guild runs coding agents (Claude Code, Codex CLI, Antigravity CLI, Grok Build, Docker Agent) and shells in real terminals on this computer, and opens them on every other screen.
28
+ Use guild_status first, list_sessions to see the sessions, read_reply for the answer that ended a session's last turn, and read_screen for what its terminal shows now.
29
+ Ask the person before starting or stopping a session. Never ask for, print or store the Agent Guild page link or access token.`;
30
+
31
+ const sessionId = { type: 'string', description: 'The session id from list_sessions.' };
32
+
33
+ const TOOLS = [
34
+ {
35
+ name: 'guild_status',
36
+ title: 'Agent Guild status',
37
+ description: 'Whether Agent Guild is running on this computer, its version, and how many sessions it has.',
38
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
39
+ annotations: { readOnlyHint: true, openWorldHint: false },
40
+ },
41
+ {
42
+ name: 'list_sessions',
43
+ title: 'List sessions',
44
+ description: 'Every session in Agent Guild: its id, tool, folder, status, whether it is working, its model, its sub-agents, and whether it has a reply to read.',
45
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
46
+ annotations: { readOnlyHint: true, openWorldHint: false },
47
+ },
48
+ {
49
+ name: 'read_reply',
50
+ title: 'Read a reply',
51
+ description: "The whole text of the answer that ended a session's last turn, as Markdown. Only tools with reporting on (Claude Code, Codex CLI, Antigravity CLI, Grok Build, Docker Agent) have one.",
52
+ inputSchema: { type: 'object', properties: { session_id: sessionId }, required: ['session_id'], additionalProperties: false },
53
+ annotations: { readOnlyHint: true, openWorldHint: false },
54
+ },
55
+ {
56
+ name: 'read_screen',
57
+ title: 'Read a terminal',
58
+ description: "The text a session's terminal shows right now, as plain lines. Use it to see a question the tool is waiting on.",
59
+ inputSchema: { type: 'object', properties: { session_id: sessionId }, required: ['session_id'], additionalProperties: false },
60
+ annotations: { readOnlyHint: true, openWorldHint: false },
61
+ },
62
+ {
63
+ name: 'start_session',
64
+ title: 'Start a session',
65
+ description: 'Start a new session of a coding tool or shell in Agent Guild. Ask the person first. It opens on every screen connected to Agent Guild.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ tool: { type: 'string', description: 'The tool: "claude", "codex", "antigravity", "grok", "docker" or "shell", or a provider id from guild_status.' },
70
+ cwd: { type: 'string', description: 'The folder to start in. Defaults to the home folder.' },
71
+ name: { type: 'string', description: "A name for the session's card." },
72
+ account: { type: 'string', description: 'The account id to run under. Defaults to "default".' },
73
+ resume: { type: 'string', description: "The id of one of the tool's own earlier sessions to resume." },
74
+ },
75
+ required: ['tool'],
76
+ additionalProperties: false,
77
+ },
78
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
79
+ },
80
+ {
81
+ name: 'stop_session',
82
+ title: 'Stop a session',
83
+ description: "End a session's process. Ask the person first. The session stays listed, with its final screen, until it is removed in Agent Guild.",
84
+ inputSchema: { type: 'object', properties: { session_id: sessionId }, required: ['session_id'], additionalProperties: false },
85
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
86
+ },
87
+ ];
88
+
89
+ const INPUT_TOOL = {
90
+ name: 'send_input',
91
+ title: 'Type into a session',
92
+ description: "Type text into a session's terminal, as if the person typed it, and press Enter unless submit is false. Read the screen first, and send only what the person asked for.",
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ session_id: sessionId,
97
+ text: { type: 'string', description: 'The text to type.' },
98
+ submit: { type: 'boolean', description: 'Press Enter after the text. Defaults to true.' },
99
+ },
100
+ required: ['session_id', 'text'],
101
+ additionalProperties: false,
102
+ },
103
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
104
+ };
105
+
106
+ /** A refusal the agent should read, rather than a fault of the server. */
107
+ class ToolError extends Error {}
108
+
109
+ /** Where the running manager answers, as the launcher finds it. */
110
+ export function managerUrl() {
111
+ const runtime = readRuntimeFile();
112
+ return runtime?.url || `http://${DEFAULT_HOST}:${resolvePort()}`;
113
+ }
114
+
115
+ export function createGuildClient({ url = managerUrl, token = loadOrCreateToken, startTimeoutMs = START_TIMEOUT_MS } = {}) {
116
+ const base = () => (typeof url === 'function' ? url() : url);
117
+ const secret = () => (typeof token === 'function' ? token() : token);
118
+ const auth = () => ({ Authorization: `Bearer ${secret()}` });
119
+ const notRunning = 'Agent Guild is not running on this computer. Ask the person to start it with `agent-guild` (or `agent-guild --no-browser`).';
120
+
121
+ async function health() {
122
+ try {
123
+ const res = await fetch(`${base()}/api/v1/health`, { signal: AbortSignal.timeout(2000) });
124
+ const body = res.ok ? await res.json() : null;
125
+ return body?.name === 'agent-guild' ? body : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ const noSession = (id) => `No session has the id "${id}". Call list_sessions for the current ids.`;
132
+
133
+ async function api(path, { method = 'GET', body, session, timeoutMs = REQUEST_TIMEOUT_MS, late = 'Agent Guild did not answer in time. Try again.' } = {}) {
134
+ const request = {
135
+ method,
136
+ headers: { ...auth(), ...(body ? { 'Content-Type': 'application/json' } : {}) },
137
+ body: body ? JSON.stringify(body) : undefined,
138
+ signal: AbortSignal.timeout(timeoutMs),
139
+ };
140
+ let res;
141
+ try {
142
+ res = await fetch(`${base()}/api/v1${path}`, request);
143
+ } catch (err) {
144
+ throw new ToolError(err?.name === 'TimeoutError' ? late : notRunning);
145
+ }
146
+ const data = await res.json().catch(() => ({}));
147
+ if (res.status === 401) throw new ToolError('Agent Guild refused the access token. Ask the person to restart Agent Guild with `agent-guild restart`.');
148
+ if (res.status === 404 && session) throw new ToolError(noSession(session));
149
+ if (!res.ok) throw new ToolError(data.error?.message || `Agent Guild answered HTTP ${res.status}.`);
150
+ return data;
151
+ }
152
+
153
+ /** Attach to a session's terminal until its first snapshot, then hand the socket and snapshot to `use`. */
154
+ function withTerminal(id, use) {
155
+ const ws = new WebSocket(`${base().replace(/^http/, 'ws')}/api/v1/sessions/${id}/terminal`, { headers: auth() });
156
+ return new Promise((resolve, reject) => {
157
+ const fail = (message) => { clearTimeout(timer); ws.terminate(); reject(new ToolError(message)); };
158
+ const timer = setTimeout(() => fail('Agent Guild did not send the terminal in time.'), SCREEN_TIMEOUT_MS);
159
+ ws.on('message', (raw) => {
160
+ let msg;
161
+ try { msg = JSON.parse(raw); } catch { return; }
162
+ if (msg.type === 'error') return fail(noSession(id));
163
+ if (msg.type !== 'snapshot') return undefined;
164
+ clearTimeout(timer);
165
+ Promise.resolve(use(ws, msg)).then(resolve, reject).finally(() => ws.close());
166
+ return undefined;
167
+ });
168
+ ws.on('unexpected-response', (_req, res) => fail(res.statusCode === 401 ? 'Agent Guild refused the access token.'
169
+ : res.statusCode === 404 ? noSession(id) : `Agent Guild answered HTTP ${res.statusCode}.`));
170
+ ws.on('error', () => fail(notRunning));
171
+ ws.on('close', (code) => { if (code === 4404) fail(noSession(id)); });
172
+ });
173
+ }
174
+
175
+ function redactor() {
176
+ let key = '';
177
+ try { key = secret(); } catch { /* no token to hide */ }
178
+ const gap = '(?:[\\s│┃|]|\\\\[nrt])*';
179
+ const spread = key && new RegExp(key.split('').join(gap), 'g');
180
+ const windows = [];
181
+ for (let i = 0; i + MIN_TOKEN_FRAGMENT <= key.length; i++) windows.push(key.slice(i, i + MIN_TOKEN_FRAGMENT).split('').join(gap));
182
+ const fragments = windows.length ? new RegExp(`(?=(${windows.join('|')}))`, 'g') : null;
183
+ const link = new RegExp(`#token=[0-9a-f](?:${gap}[0-9a-f]){0,63}(?![0-9A-Za-z])`, 'g');
184
+ const hide = (match) => match.replace(/[^\n]+/g, HIDDEN);
185
+ return (value) => {
186
+ value = (spread ? value.replace(spread, hide) : value).replace(link, (match) => `#token=${hide(match.slice(7))}`);
187
+ if (!fragments) return value;
188
+ // Merge overlapping known-token windows so a clipped span is hidden in full,
189
+ // including a final piece shorter than the minimum. Keep its original rows.
190
+ let result = '', start = 0, end = 0;
191
+ for (const match of value.matchAll(fragments)) {
192
+ if (match.index > end) {
193
+ result += hide(value.slice(start, end)) + value.slice(end, match.index);
194
+ start = match.index;
195
+ }
196
+ end = Math.max(end, match.index + match[1].length);
197
+ }
198
+ return result + hide(value.slice(start, end)) + value.slice(end);
199
+ };
200
+ }
201
+
202
+ return { health, api, withTerminal, redactor, notRunning, startTimeoutMs };
203
+ }
204
+
205
+ function checkId(id) {
206
+ if (typeof id !== 'string' || !SESSION_ID.test(id)) throw new ToolError('session_id must be a session id from list_sessions.');
207
+ return id;
208
+ }
209
+
210
+ function summarize(s) {
211
+ return {
212
+ id: s.id,
213
+ name: s.name,
214
+ tool: s.provider?.tool ?? s.provider?.vendor ?? null,
215
+ provider: s.provider?.id ?? null,
216
+ cwd: s.cwd,
217
+ status: s.status,
218
+ label: s.label,
219
+ working: s.status === 'running' && s.activity === 'active',
220
+ model: s.model ? s.model.displayName || s.model.name : null,
221
+ account: s.account?.id ?? null,
222
+ task: s.task,
223
+ agents: (s.agents ?? []).map((a) => ({ name: a.name, status: a.status, detail: a.detail ?? null })),
224
+ hasReply: s.reply != null,
225
+ startedAt: s.startedAt,
226
+ exitCode: s.exitCode,
227
+ };
228
+ }
229
+
230
+ /** The tool a person names, by id, command, tool name or vendor. */
231
+ function findProvider(providers, wanted) {
232
+ const key = String(wanted ?? '').trim().toLowerCase();
233
+ if (!key) return null;
234
+ const names = (p) => [p.id, p.command, p.tool, p.vendor, p.command === '@shell' ? 'shell' : null]
235
+ .filter(Boolean).map((n) => String(n).toLowerCase());
236
+ return providers.find((p) => names(p).includes(key))
237
+ ?? providers.find((p) => names(p).some((n) => n.split(/[\s-]+/)[0] === key))
238
+ ?? null;
239
+ }
240
+
241
+ /** The visible screen of a snapshot, as text, from a headless terminal the same size. */
242
+ function screenText({ data, cols, rows }, redact) {
243
+ const term = new Terminal({ cols, rows, scrollback: 1000, allowProposedApi: true });
244
+ return new Promise((resolve) => {
245
+ term.write(data, () => {
246
+ const buffer = term.buffer.active;
247
+ const all = [];
248
+ for (let y = 0; y < buffer.length; y++) all.push(buffer.getLine(y)?.translateToString(true) ?? '');
249
+ const lines = redact(all.join('\n')).split('\n').slice(buffer.viewportY, buffer.viewportY + term.rows);
250
+ term.dispose();
251
+ while (lines.length && !lines.at(-1).trim()) lines.pop();
252
+ resolve(lines.join('\n'));
253
+ });
254
+ });
255
+ }
256
+
257
+ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
258
+
259
+ export function createToolHandlers(client, { allowInput = false } = {}) {
260
+ const handlers = {
261
+ async guild_status() {
262
+ const up = await client.health();
263
+ if (!up) return text({ running: false, message: client.notRunning, install: 'npm install -g @oddessentials/agent-guild' });
264
+ const [{ sessions = [] }, { providers = [] }] = await Promise.all([client.api('/sessions'), client.api('/providers')]);
265
+ return text({
266
+ running: true,
267
+ version: up.version,
268
+ sessions: sessions.length,
269
+ running_sessions: sessions.filter((s) => s.status === 'running').length,
270
+ working_sessions: sessions.filter((s) => s.status === 'running' && s.activity === 'active').length,
271
+ tools: providers.map((p) => ({ provider: p.id, tool: p.tool, installed: p.available === true })),
272
+ });
273
+ },
274
+ async list_sessions() {
275
+ const { sessions = [] } = await client.api('/sessions');
276
+ if (!sessions.length) return text('Agent Guild has no sessions.');
277
+ return text(sessions.map(summarize));
278
+ },
279
+ async read_reply({ session_id: id }) {
280
+ const { reply } = await client.api(`/sessions/${checkId(id)}/reply`, { session: id });
281
+ return text(reply?.text ?? 'This session has no finished reply yet. read_screen shows its terminal.');
282
+ },
283
+ async read_screen({ session_id: id }) {
284
+ return text(await client.withTerminal(checkId(id), (_ws, snapshot) => screenText(snapshot, client.redactor())) || '(The screen is blank.)');
285
+ },
286
+ async start_session({ tool, cwd, name, account, resume }) {
287
+ let resumeId = null;
288
+ try { resumeId = resume ? cleanResumeId(resume) : null; } catch (err) { throw new ToolError(err.message); }
289
+ const { providers = [] } = await client.api('/providers');
290
+ const provider = findProvider(providers, tool);
291
+ if (!provider) {
292
+ throw new ToolError(`Agent Guild has no tool called "${tool}". It has: ${providers.map((p) => `${p.id} (${p.tool})`).join(', ')}.`);
293
+ }
294
+ if (provider.available === false) {
295
+ throw new ToolError(`${provider.tool} is not installed. The person can install it from its card in Agent Guild${provider.install ? `, or with: ${provider.install}` : ''}.`);
296
+ }
297
+ // No `args`: launch flags such as --dangerously-skip-permissions are the person's to choose on the card.
298
+ const body = { providerId: provider.id };
299
+ for (const [key, value] of Object.entries({ cwd, name, account, resume: resumeId })) if (value) body[key] = String(value);
300
+ const { session } = await client.api('/sessions', {
301
+ method: 'POST',
302
+ body,
303
+ timeoutMs: client.startTimeoutMs,
304
+ late: 'Agent Guild did not finish starting the session in time, so whether it started is unknown: it may still appear. Call list_sessions before starting another.',
305
+ });
306
+ return text({ started: true, session: summarize(session) });
307
+ },
308
+ async stop_session({ session_id: id }) {
309
+ const { session } = await client.api(`/sessions/${checkId(id)}/stop`, { method: 'POST', session: id });
310
+ return text({ stopped: true, session: summarize(session) });
311
+ },
312
+ };
313
+ if (allowInput) {
314
+ handlers.send_input = async ({ session_id: id, text: typed, submit = true }) => {
315
+ if (typeof typed !== 'string') throw new ToolError('text must be a string.');
316
+ await client.withTerminal(checkId(id), (ws, snapshot) => new Promise((resolve, reject) => {
317
+ if (snapshot.session?.status !== 'running') throw new ToolError(`Session "${id}" has exited, so nothing was typed. start_session starts a new one.`);
318
+ ws.send(JSON.stringify({ type: 'input', data: typed + (submit ? '\r' : '') }), (err) => (err ? reject(new ToolError('The terminal closed before the text was sent.')) : resolve()));
319
+ }));
320
+ return text({ sent: true });
321
+ };
322
+ }
323
+ return handlers;
324
+ }
325
+
326
+ /**
327
+ * Serve MCP over newline-delimited JSON-RPC. Resolves when `input` ends.
328
+ * Only protocol messages go to `output`; nothing else may be written there.
329
+ */
330
+ export function serveMcp({ input = process.stdin, output = process.stdout, allowInput = false, client = createGuildClient() } = {}) {
331
+ const tools = allowInput ? [...TOOLS, INPUT_TOOL] : TOOLS;
332
+ const handlers = createToolHandlers(client, { allowInput });
333
+ const hide = (value, redact) => {
334
+ if (typeof value === 'string') return redact(value);
335
+ if (Array.isArray(value)) return value.map((item) => hide(item, redact));
336
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, hide(item, redact)]));
337
+ return value;
338
+ };
339
+ const send = ({ id, ...body }) => output.write(`${JSON.stringify({ jsonrpc: '2.0', id, ...hide(body, client.redactor()) })}\n`);
340
+ const logError = (err) => console.error('[agent-guild mcp]', client.redactor()(String(err?.stack ?? err)));
341
+
342
+ async function call(name, args) {
343
+ const handler = Object.hasOwn(handlers, name) ? handlers[name] : null;
344
+ if (!handler) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
345
+ try {
346
+ return { result: await handler(args ?? {}) };
347
+ } catch (err) {
348
+ if (err instanceof ToolError) return { result: { ...text(err.message), isError: true } };
349
+ logError(err);
350
+ return { result: { ...text('Agent Guild could not complete that request.'), isError: true } };
351
+ }
352
+ }
353
+
354
+ async function handle(msg) {
355
+ const { id, method, params } = msg;
356
+ const respond = id !== undefined && id !== null;
357
+ switch (method) {
358
+ case 'initialize': {
359
+ const asked = params?.protocolVersion;
360
+ return respond && send({ id, result: {
361
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
362
+ capabilities: { tools: { listChanged: false } },
363
+ serverInfo: { name: 'agent-guild', title: 'Agent Guild', version: VERSION, websiteUrl: 'https://github.com/oddessentials/agent-guild/blob/main/docs/mcp.md' },
364
+ instructions: INSTRUCTIONS,
365
+ } });
366
+ }
367
+ case 'ping': return respond && send({ id, result: {} });
368
+ case 'tools/list': return respond && send({ id, result: { tools } });
369
+ case 'tools/call': {
370
+ const outcome = await call(params?.name, params?.arguments);
371
+ return respond && send({ id, ...outcome });
372
+ }
373
+ default:
374
+ // Notifications, such as notifications/initialized, need no answer.
375
+ return respond && send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
376
+ }
377
+ }
378
+
379
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
380
+ const pending = new Set();
381
+ lines.on('line', (line) => {
382
+ if (!line.trim()) return;
383
+ let msg;
384
+ try {
385
+ msg = JSON.parse(line);
386
+ } catch {
387
+ send({ id: null, error: { code: -32700, message: 'Parse error' } });
388
+ return;
389
+ }
390
+ const isObject = msg !== null && typeof msg === 'object' && !Array.isArray(msg);
391
+ if (!isObject || typeof msg.method !== 'string') {
392
+ // Responses are ignored: this server sends no requests of its own.
393
+ if (!isObject || !('result' in msg || 'error' in msg)) send({ id: isObject ? msg.id ?? null : null, error: { code: -32600, message: 'Invalid Request' } });
394
+ return;
395
+ }
396
+ const job = handle(msg).catch(logError).finally(() => pending.delete(job));
397
+ pending.add(job);
398
+ });
399
+ return new Promise((resolve) => lines.once('close', () => Promise.allSettled([...pending]).then(() => resolve())));
400
+ }
package/web/app.js CHANGED
@@ -3414,10 +3414,10 @@ function closeNews() {
3414
3414
  if ($('news').open) $('news').close();
3415
3415
  }
3416
3416
 
3417
- // Trailers and tutorials ship in web/videos.json, oldest first, and the page lists tutorials first. Nothing
3417
+ // Videos ship in web/videos.json, oldest first, and the page groups them like the channel's playlists. Nothing
3418
3418
  // reaches YouTube until the dialog opens, and a video plays only once YouTube's image server has answered for
3419
3419
  // its poster.
3420
- const VIDEO_CATEGORIES = { tutorials: ['Tutorials', 'Tutorial'], trailers: ['Trailers', 'Trailer'] };
3420
+ const VIDEO_CATEGORIES = { tutorials: ['Tutorials', 'Tutorial'], trailers: ['Trailers', 'Trailer'], world: ['World', 'World'] };
3421
3421
  const VIDEO_ID = /^[\w-]{11}$/;
3422
3422
  const videosView = { list: [], filter: 'all', selected: null, play: false, poster: 0, opener: null };
3423
3423
 
@@ -3441,8 +3441,22 @@ function videoDate(video) {
3441
3441
  return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
3442
3442
  }
3443
3443
 
3444
- function videoMeta(video) {
3445
- return [VIDEO_CATEGORIES[video.category][1], videoDate(video)].filter(Boolean).join(' · ');
3444
+ function videoLength(video) {
3445
+ const seconds = video.duration;
3446
+ if (!Number.isInteger(seconds) || seconds <= 0) return '';
3447
+ const [h, m, s] = [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60];
3448
+ return [...(h ? [h, String(m).padStart(2, '0')] : [m]), String(s).padStart(2, '0')].join(':');
3449
+ }
3450
+
3451
+ function videoLengthWords(video) {
3452
+ const seconds = video.duration;
3453
+ if (!videoLength(video) || !Intl.DurationFormat) return videoLength(video);
3454
+ return new Intl.DurationFormat(undefined, { style: 'long' })
3455
+ .format({ hours: Math.floor(seconds / 3600), minutes: Math.floor(seconds / 60) % 60, seconds: seconds % 60 });
3456
+ }
3457
+
3458
+ function videoMeta(video, length = false) {
3459
+ return [VIDEO_CATEGORIES[video.category][1], length && videoLength(video), videoDate(video)].filter(Boolean).join(' · ');
3446
3460
  }
3447
3461
 
3448
3462
  function shownVideos() {
@@ -3464,21 +3478,30 @@ function renderVideoFilters() {
3464
3478
  }));
3465
3479
  }
3466
3480
 
3481
+ function videoItem(video) {
3482
+ const thumb = el('img', 'videos-thumb');
3483
+ thumb.alt = '';
3484
+ thumb.loading = 'lazy';
3485
+ thumb.src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
3486
+ thumb.addEventListener('error', () => { thumb.hidden = true; }, { once: true });
3487
+ const length = videoLength(video) ? el('span', 'videos-length', videoLength(video)) : null;
3488
+ length?.setAttribute('aria-hidden', 'true');
3489
+ const item = el('button', 'videos-item', el('span', 'videos-frame', thumb, length),
3490
+ el('span', 'videos-item-text', el('span', 'videos-item-title', video.title),
3491
+ length && el('span', 'visually-hidden', `, ${videoLengthWords(video)}, `), el('span', 'news-meta', videoMeta(video))));
3492
+ item.type = 'button';
3493
+ item.dataset.id = video.id;
3494
+ item.addEventListener('click', () => selectVideo(video, true));
3495
+ if (video === videosView.selected) item.setAttribute('aria-current', 'true');
3496
+ return el('li', null, item);
3497
+ }
3498
+
3467
3499
  function renderVideoList() {
3468
- $('videos-list').replaceChildren(...shownVideos().map((video) => {
3469
- const thumb = el('img', 'videos-thumb');
3470
- thumb.alt = '';
3471
- thumb.loading = 'lazy';
3472
- thumb.src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
3473
- thumb.addEventListener('error', () => { thumb.hidden = true; }, { once: true });
3474
- const item = el('button', 'videos-item', el('span', 'videos-frame', thumb),
3475
- el('span', 'videos-item-text', el('span', 'videos-item-title', video.title), el('span', 'news-meta', videoMeta(video))));
3476
- item.type = 'button';
3477
- item.dataset.id = video.id;
3478
- item.addEventListener('click', () => selectVideo(video, true));
3479
- if (video === videosView.selected) item.setAttribute('aria-current', 'true');
3480
- return el('li', null, item);
3481
- }));
3500
+ const shown = shownVideos();
3501
+ const groups = Object.keys(VIDEO_CATEGORIES).map((id) => [id, shown.filter((video) => video.category === id)]).filter(([, videos]) => videos.length);
3502
+ $('videos-list').replaceChildren(...groups.map(([id, videos]) => el('li', 'videos-group',
3503
+ groups.length > 1 && el('h3', 'videos-group-title', VIDEO_CATEGORIES[id][0]),
3504
+ el('ol', 'videos-group-list', ...videos.map(videoItem)))));
3482
3505
  }
3483
3506
 
3484
3507
  function stopVideo() {
@@ -3494,7 +3517,7 @@ function selectVideo(video, play) {
3494
3517
  else item.removeAttribute('aria-current');
3495
3518
  }
3496
3519
  $('videos-now').textContent = video.title;
3497
- $('videos-meta').textContent = videoMeta(video);
3520
+ $('videos-meta').textContent = videoMeta(video, true);
3498
3521
  $('videos-youtube').href = `https://www.youtube.com/watch?v=${video.id}`;
3499
3522
  $('videos-play').setAttribute('aria-label', `Play ${video.title}`);
3500
3523
  const request = ++videosView.poster;
package/web/index.html CHANGED
@@ -575,7 +575,7 @@
575
575
  <div class="models-bar">
576
576
  <div class="models-heading">
577
577
  <h2 id="videos-title">Videos</h2>
578
- <p id="videos-sub" class="sub"></p>
578
+ <p class="sub"><span id="videos-sub"></span> · <a id="videos-channel" class="console-link" href="https://www.youtube.com/@Agent-Guild" target="_blank" rel="noopener noreferrer" aria-description="Opens in a new tab">@Agent-Guild on YouTube</a></p>
579
579
  </div>
580
580
  <button id="videos-close" class="btn" type="button">Close</button>
581
581
  </div>
package/web/styles.css CHANGED
@@ -758,7 +758,7 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
758
758
  .models-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 14px 14px; }
759
759
  .remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
760
760
  .videos { width: min(1120px, calc(100vw - 32px)); }
761
- .videos-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 320px; }
761
+ .videos-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) min(320px, 45%); }
762
762
  .videos-stage { display: grid; align-content: start; gap: 12px; min-width: 0; padding: 16px; }
763
763
  /* The player keeps 16:9 and gives way to the bar and caption on short windows. */
764
764
  .videos-screen {
@@ -785,7 +785,9 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
785
785
  .videos-caption h3 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; }
786
786
  .videos-caption .news-meta { margin: 3px 0 0; }
787
787
  .videos-nav { display: flex; flex-direction: column; min-height: 0; overflow: auto; border-left: 1px solid var(--border); }
788
- .videos-list { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; }
788
+ .videos-list, .videos-group-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
789
+ .videos-list { padding: 8px; gap: 10px; }
790
+ .videos-group-title { margin: 4px 6px; color: var(--muted); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
789
791
  .videos-item {
790
792
  display: grid; grid-template-columns: 128px minmax(0, 1fr); align-items: center; gap: 12px; width: 100%; padding: 6px;
791
793
  border: 0; border-radius: calc(var(--radius) - 2px); background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer;
@@ -793,11 +795,15 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
793
795
  .videos-item:hover, .videos-item[aria-current="true"] { background: var(--surface-2); }
794
796
  .videos-item[aria-current="true"] .videos-frame { outline: 2px solid var(--accent); outline-offset: 1px; }
795
797
  .videos-item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
796
- .videos-frame { display: block; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 4px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border); }
798
+ .videos-frame { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 4px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border); }
797
799
  .videos-thumb { display: block; width: 100%; height: 100%; object-fit: cover; }
800
+ .videos-length {
801
+ position: absolute; right: 4px; bottom: 4px; padding: 1px 4px; border-radius: 3px;
802
+ background: rgba(0, 0, 0, 0.78); color: #fff; font-size: 0.68rem; font-weight: 600; line-height: 1.3; font-variant-numeric: tabular-nums;
803
+ }
798
804
  .videos-item-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; font-size: 0.85rem; font-weight: 600; line-height: 1.3; }
799
805
  .videos-item .news-meta { display: block; margin-top: 3px; white-space: normal; }
800
- @media (max-width: 900px) {
806
+ @media (max-width: 900px) and (orientation: portrait) {
801
807
  .videos-body { display: flex; flex-direction: column; }
802
808
  .videos-stage { flex: none; }
803
809
  .videos-nav { flex: 1 1 auto; border-left: 0; border-top: 1px solid var(--border); }
package/web/videos.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "videos": [
3
- { "id": "ziT62WtXQ1M", "title": "Agent Guild Trailer", "category": "trailers", "published": "2026-10-01" },
4
- { "id": "Zttp-vs_6z0", "title": "Agent Guild - The Yard Update v0.38.0", "category": "trailers", "published": "2026-10-07" },
5
- { "id": "mEEI3cuOGzU", "title": "Docker-Agent Joins Agent Guild v0.47.0", "category": "trailers", "published": "2026-10-09" },
6
- { "id": "XPSRCpiHokk", "title": "01 - Installation (Windows)", "category": "tutorials", "published": "2026-10-11" },
7
- { "id": "5DRFbKgGoUk", "title": "01 - Installation (Linux Headless)", "category": "tutorials", "published": "2026-10-11" },
8
- { "id": "QXIH4pv8CDk", "title": "02 - Claude and Codex Install", "category": "tutorials", "published": "2026-10-11" }
3
+ { "id": "xQ0cBp1wqhA", "title": "Run Them All: Launch Trailer (v0.1.0)", "category": "trailers", "published": "2026-10-01", "duration": 90 },
4
+ { "id": "5Pg85-_TvRI", "title": "The Yard Update (v0.38.0)", "category": "trailers", "published": "2026-10-07", "duration": 29 },
5
+ { "id": "C4aAHThCOeo", "title": "Docker Agent Joins the Guild (v0.47.0)", "category": "trailers", "published": "2026-10-09", "duration": 27 },
6
+ { "id": "unHsaXn4VL4", "title": "Install Agent Guild on Windows (Tutorial 1)", "category": "tutorials", "published": "2026-10-11", "duration": 153 },
7
+ { "id": "yPnUo7-cpHo", "title": "Add Claude Code and Codex (Tutorial 2)", "category": "tutorials", "published": "2026-10-11", "duration": 125 },
8
+ { "id": "OJ1n184WVc4", "title": "Run Agent Guild on a Headless Linux Server", "category": "tutorials", "published": "2026-10-11", "duration": 172 },
9
+ { "id": "n4uXFv6Xgcw", "title": "Agent Guild: World Official Trailer", "category": "world", "published": "2026-10-18", "duration": 62 },
10
+ { "id": "_vQuW0v-uMY", "title": "Built for Builders: Official Trailer", "category": "trailers", "published": "2026-10-23", "duration": 75 },
11
+ { "id": "qykaJBmkcM8", "title": "Quests and Glory: The Guild Board and Achievements", "category": "world", "published": "2026-10-23", "duration": 39 },
12
+ { "id": "tsrkFwj5cgQ", "title": "Use Agent Guild From Your Phone or Anywhere", "category": "tutorials", "published": "2026-10-23", "duration": 263 },
13
+ { "id": "nwAssNU0OVM", "title": "Happy Thanksgiving! New Workshop Pieces", "category": "world", "published": "2026-10-24", "duration": 43 },
14
+ { "id": "buiktzLc5yU", "title": "Everywhere: Official Trailer", "category": "trailers", "published": "2026-10-25", "duration": 92 }
9
15
  ]
10
16
  }