@oddessentials/agent-guild 0.5.0 → 0.7.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 (86) hide show
  1. package/README.md +182 -135
  2. package/package.json +1 -1
  3. package/src/manager/changelog.mjs +195 -0
  4. package/src/manager/config.mjs +1 -0
  5. package/src/manager/github.mjs +842 -0
  6. package/src/manager/main.mjs +10 -3
  7. package/src/manager/news.mjs +5 -5
  8. package/src/manager/server.mjs +70 -8
  9. package/src/manager/session-manager.mjs +32 -3
  10. package/src/manager/session.mjs +4 -1
  11. package/web/app.js +1005 -11
  12. package/web/art/art.css +374 -159
  13. package/web/art/characters/anthropic/idle.avif +0 -0
  14. package/web/art/characters/anthropic/idle.webp +0 -0
  15. package/web/art/characters/anthropic/locked.avif +0 -0
  16. package/web/art/characters/anthropic/locked.webp +0 -0
  17. package/web/art/characters/anthropic/working.avif +0 -0
  18. package/web/art/characters/anthropic/working.webp +0 -0
  19. package/web/art/characters/google/idle.avif +0 -0
  20. package/web/art/characters/google/idle.webp +0 -0
  21. package/web/art/characters/google/locked.avif +0 -0
  22. package/web/art/characters/google/locked.webp +0 -0
  23. package/web/art/characters/google/working.avif +0 -0
  24. package/web/art/characters/google/working.webp +0 -0
  25. package/web/art/characters/openai/idle.avif +0 -0
  26. package/web/art/characters/openai/idle.webp +0 -0
  27. package/web/art/characters/openai/locked.avif +0 -0
  28. package/web/art/characters/openai/locked.webp +0 -0
  29. package/web/art/characters/openai/working.avif +0 -0
  30. package/web/art/characters/openai/working.webp +0 -0
  31. package/web/art/characters/shell/idle.avif +0 -0
  32. package/web/art/characters/shell/idle.webp +0 -0
  33. package/web/art/characters/shell/locked.avif +0 -0
  34. package/web/art/characters/shell/locked.webp +0 -0
  35. package/web/art/characters/shell/working.avif +0 -0
  36. package/web/art/characters/shell/working.webp +0 -0
  37. package/web/art/characters/xai/idle.avif +0 -0
  38. package/web/art/characters/xai/idle.webp +0 -0
  39. package/web/art/characters/xai/locked.avif +0 -0
  40. package/web/art/characters/xai/locked.webp +0 -0
  41. package/web/art/characters/xai/working.avif +0 -0
  42. package/web/art/characters/xai/working.webp +0 -0
  43. package/web/art/icons/anthropic.avif +0 -0
  44. package/web/art/icons/anthropic.webp +0 -0
  45. package/web/art/icons/google.avif +0 -0
  46. package/web/art/icons/google.webp +0 -0
  47. package/web/art/icons/openai.avif +0 -0
  48. package/web/art/icons/openai.webp +0 -0
  49. package/web/art/icons/shell.avif +0 -0
  50. package/web/art/icons/shell.webp +0 -0
  51. package/web/art/icons/xai.avif +0 -0
  52. package/web/art/icons/xai.webp +0 -0
  53. package/web/art/page-light.avif +0 -0
  54. package/web/art/page-light.webp +0 -0
  55. package/web/art/page.avif +0 -0
  56. package/web/art/page.webp +0 -0
  57. package/web/art/ui/empty-state.avif +0 -0
  58. package/web/art/ui/empty-state.webp +0 -0
  59. package/web/art/ui/favicon.png +0 -0
  60. package/web/art/ui/frame-ornate.png +0 -0
  61. package/web/art/ui/guild-crest.png +0 -0
  62. package/web/fonts/OFL.txt +93 -0
  63. package/web/fonts/cinzel.woff2 +0 -0
  64. package/web/index.html +101 -15
  65. package/web/styles.css +240 -71
  66. package/web/art/characters/anthropic/idle.png +0 -0
  67. package/web/art/characters/anthropic/locked.png +0 -0
  68. package/web/art/characters/anthropic/working.png +0 -0
  69. package/web/art/characters/google/idle.png +0 -0
  70. package/web/art/characters/google/locked.png +0 -0
  71. package/web/art/characters/google/working.png +0 -0
  72. package/web/art/characters/openai/idle.png +0 -0
  73. package/web/art/characters/openai/locked.png +0 -0
  74. package/web/art/characters/openai/working.png +0 -0
  75. package/web/art/characters/shell/idle.png +0 -0
  76. package/web/art/characters/shell/locked.png +0 -0
  77. package/web/art/characters/shell/working.png +0 -0
  78. package/web/art/characters/xai/idle.png +0 -0
  79. package/web/art/characters/xai/locked.png +0 -0
  80. package/web/art/characters/xai/working.png +0 -0
  81. package/web/art/icons/anthropic.png +0 -0
  82. package/web/art/icons/google.png +0 -0
  83. package/web/art/icons/openai.png +0 -0
  84. package/web/art/icons/shell.png +0 -0
  85. package/web/art/icons/xai.png +0 -0
  86. package/web/art/page.png +0 -0
package/web/app.js CHANGED
@@ -7,7 +7,13 @@ const ACCOUNTS_KEY = 'agentGuild.accounts';
7
7
  const THEME_KEY = 'agentGuild.theme';
8
8
  const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
9
  const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
10
+ const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
11
+ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
12
+ const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
13
+ const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
10
14
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
15
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
16
+ const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
11
17
 
12
18
  const $ = (id) => document.getElementById(id);
13
19
  const state = {
@@ -18,6 +24,8 @@ const state = {
18
24
  stats: null,
19
25
  statsFor: new Map(),
20
26
  news: null,
27
+ changelog: null,
28
+ github: null,
21
29
  sessions: new Map(),
22
30
  views: new Map(),
23
31
  activeId: null,
@@ -50,9 +58,20 @@ function save(key, value) { try { value === null ? localStorage.removeItem(key)
50
58
  // ---- helpers --------------------------------------------------------------
51
59
 
52
60
  let toastTimer;
53
- function toast(message, ms = 5000) {
61
+ function toast(message, ms = 5000, action = null) {
54
62
  const el = $('toast');
55
- el.textContent = message;
63
+ el.replaceChildren(message);
64
+ if (action) {
65
+ const button = document.createElement('button');
66
+ button.type = 'button';
67
+ button.className = 'btn toast-action';
68
+ button.textContent = action.label;
69
+ button.addEventListener('click', () => {
70
+ el.hidden = true;
71
+ action.run();
72
+ });
73
+ el.append(button);
74
+ }
56
75
  el.hidden = false;
57
76
  clearTimeout(toastTimer);
58
77
  toastTimer = setTimeout(() => { el.hidden = true; }, ms);
@@ -67,6 +86,7 @@ function setConnection(kind, label) {
67
86
  $('stop-manager').hidden = !state.connected;
68
87
  $('restart-manager').hidden = !state.connected || !state.restartable;
69
88
  renderUpgrade();
89
+ guardLeaving();
70
90
  }
71
91
 
72
92
  // ---- the running version --------------------------------------------------
@@ -82,9 +102,42 @@ function renderVersion() {
82
102
  badge.hidden = !v;
83
103
  if (!v) return;
84
104
  const dev = isDevelopmentBuild(v);
105
+ const unread = unreadRelease();
106
+ const notes = unread ? `What’s new in v${unread}` : 'What’s new';
85
107
  badge.textContent = dev ? 'dev' : `v${v}`;
86
- badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`].filter(Boolean).join(' · ');
87
- badge.setAttribute('aria-label', dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`);
108
+ badge.classList.toggle('unread', Boolean(unread));
109
+ badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`, notes].filter(Boolean).join(' · ');
110
+ badge.setAttribute('aria-label', `${dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`}. ${notes}${unread ? ', not read yet' : ''}`);
111
+ }
112
+
113
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+$/;
114
+
115
+ function compareReleases(a, b) {
116
+ const [x, y] = [a, b].map((version) => version.split('.').map(Number));
117
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
118
+ }
119
+
120
+ function newestRelease(versions) {
121
+ return versions.filter((v) => RELEASE_VERSION.test(v ?? '')).reduce((newest, v) => (newest && compareReleases(newest, v) >= 0 ? newest : v), null);
122
+ }
123
+
124
+ function seenRelease() {
125
+ const saved = load(CHANGELOG_SEEN_KEY);
126
+ return RELEASE_VERSION.test(saved ?? '') ? saved : changelogView.seen;
127
+ }
128
+
129
+ function markReleasesSeen(...versions) {
130
+ const newest = newestRelease([seenRelease(), ...versions]);
131
+ if (!newest) return;
132
+ changelogView.seen = newest;
133
+ save(CHANGELOG_SEEN_KEY, newest);
134
+ }
135
+
136
+ function unreadRelease() {
137
+ if (!RELEASE_VERSION.test(state.version ?? '')) return null;
138
+ if (!seenRelease()) markReleasesSeen(state.version);
139
+ const newest = newestRelease([state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion]);
140
+ return compareReleases(newest, seenRelease()) > 0 ? newest : null;
88
141
  }
89
142
 
90
143
  // ---- theme ----------------------------------------------------------------
@@ -94,7 +147,8 @@ function applyTheme(theme) {
94
147
  document.documentElement.dataset.theme = theme;
95
148
  const button = $('theme-toggle');
96
149
  const other = theme === 'dark' ? 'light' : 'dark';
97
- button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
150
+ button.textContent = theme === 'dark' ? 'Light' : 'Dark';
151
+ button.dataset.next = other;
98
152
  button.title = `Switch to the ${other} theme`;
99
153
  button.setAttribute('aria-label', `Switch to the ${other} theme`);
100
154
  }
@@ -103,10 +157,18 @@ function currentTheme() {
103
157
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
104
158
  }
105
159
 
106
- function toggleTheme() {
160
+ function toggleTheme(event) {
107
161
  const theme = currentTheme() === 'dark' ? 'light' : 'dark';
108
162
  save(THEME_KEY, theme);
109
- applyTheme(theme);
163
+ if (!document.startViewTransition || reducedMotion.matches) return applyTheme(theme);
164
+ const box = event.currentTarget.getBoundingClientRect();
165
+ const x = box.left + box.width / 2;
166
+ const y = box.top + box.height / 2;
167
+ const root = document.documentElement.style;
168
+ root.setProperty('--reveal-x', `${Math.round(x)}px`);
169
+ root.setProperty('--reveal-y', `${Math.round(y)}px`);
170
+ root.setProperty('--reveal-r', `${Math.ceil(Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)))}px`);
171
+ document.startViewTransition(() => applyTheme(theme));
110
172
  }
111
173
 
112
174
  // ---- upgrading the manager ------------------------------------------------
@@ -130,6 +192,15 @@ function renderUpgrade() {
130
192
  restart.title = pending
131
193
  ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
132
194
  : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
195
+ const panelUpgrade = $('changelog-upgrade');
196
+ panelUpgrade.hidden = button.hidden;
197
+ panelUpgrade.textContent = button.textContent;
198
+ panelUpgrade.title = button.title;
199
+ const panelRestart = $('changelog-restart');
200
+ panelRestart.hidden = restart.hidden || !pending;
201
+ panelRestart.textContent = restart.textContent;
202
+ panelRestart.title = restart.title;
203
+ $('changelog-actions').hidden = panelUpgrade.hidden && panelRestart.hidden;
133
204
  let text = '';
134
205
  let title = '';
135
206
  const last = u?.lastInstall;
@@ -158,6 +229,8 @@ function setUpgrade(upgrade) {
158
229
  const before = state.upgrade;
159
230
  state.upgrade = upgrade || null;
160
231
  renderUpgrade();
232
+ renderVersion();
233
+ if ($('changelog').open) renderChangelog();
161
234
  const pending = state.upgrade?.pendingVersion;
162
235
  if (pending && pending !== before?.pendingVersion) {
163
236
  toast(state.restartable
@@ -242,9 +315,13 @@ function paintProviderIcon(el, provider) {
242
315
  const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
243
316
 
244
317
  function renderAgents(container, agents) {
318
+ const known = container.dataset.rendered ? new Set([...container.children].map((el) => el.dataset.agent)) : null;
319
+ container.dataset.rendered = 'true';
245
320
  container.replaceChildren(...agents.map((agent) => {
246
321
  const el = document.createElement('span');
247
322
  el.className = `agent ${agent.status}`;
323
+ el.dataset.agent = agent.id;
324
+ if (known && !known.has(agent.id)) el.classList.add('summon');
248
325
  el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
249
326
  el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
250
327
  el.textContent = (agent.name || '?').charAt(0).toUpperCase();
@@ -327,6 +404,8 @@ function renderAccounts(card, provider) {
327
404
  });
328
405
  }
329
406
 
407
+ let dealt = false;
408
+
330
409
  function renderProviders() {
331
410
  const list = $('providers');
332
411
  const tpl = $('provider-template');
@@ -371,6 +450,10 @@ function renderProviders() {
371
450
  renderModelStats(node, provider);
372
451
  return node;
373
452
  }));
453
+ if (!dealt && state.providers.length) {
454
+ dealt = true;
455
+ if (!reducedMotion.matches) list.classList.add('deal');
456
+ }
374
457
  }
375
458
 
376
459
  const openCopies = new Set();
@@ -426,7 +509,7 @@ function renderHint(hint, provider) {
426
509
  link.href = docs;
427
510
  link.target = '_blank';
428
511
  link.rel = 'noopener noreferrer';
429
- link.textContent = 'Docs ↗';
512
+ link.textContent = 'Docs';
430
513
  hint.append(' ', link);
431
514
  }
432
515
 
@@ -810,7 +893,7 @@ function modelFacts(card) {
810
893
  link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
811
894
  link.target = '_blank';
812
895
  link.rel = 'noopener noreferrer';
813
- link.textContent = 'OpenRouter ↗';
896
+ link.textContent = 'OpenRouter';
814
897
  link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
815
898
  const facts = document.createElement('div');
816
899
  facts.className = 'model-more';
@@ -1229,6 +1312,212 @@ function tickNews() {
1229
1312
  if ($('news').open) renderNewsStatus();
1230
1313
  }
1231
1314
 
1315
+ const RELEASE_BADGES = {
1316
+ running: ['in-use', 'Running', 'The session manager runs this version'],
1317
+ installed: ['installed', 'Installed', 'Installed. Restart the session manager to use it.'],
1318
+ newer: ['new', 'New', 'Released after the version the session manager runs'],
1319
+ };
1320
+ const changelogView = { opener: null, failed: null, seen: null };
1321
+ let changelogLoading = null;
1322
+ let changelogAgain = false;
1323
+
1324
+ function loadChangelog() {
1325
+ if (changelogLoading) {
1326
+ changelogAgain = true;
1327
+ return changelogLoading;
1328
+ }
1329
+ changelogLoading = api('GET', '/changelog').then((changelog) => {
1330
+ state.changelog = changelog;
1331
+ changelogView.failed = null;
1332
+ }, (err) => {
1333
+ if (err instanceof AuthError) return showAuth(err.message);
1334
+ changelogView.failed = state.connected ? err.message : 'the session manager is not reachable';
1335
+ }).finally(() => {
1336
+ changelogLoading = null;
1337
+ if ($('changelog').open) {
1338
+ markReleasesSeen(state.changelog?.releases[0]?.version);
1339
+ renderVersion();
1340
+ renderChangelog();
1341
+ }
1342
+ if (changelogAgain) {
1343
+ changelogAgain = false;
1344
+ loadChangelog();
1345
+ }
1346
+ });
1347
+ return changelogLoading;
1348
+ }
1349
+
1350
+ function openChangelog() {
1351
+ const dialog = $('changelog');
1352
+ renderChangelog();
1353
+ if (!dialog.open) {
1354
+ changelogView.opener = document.activeElement;
1355
+ dialog.showModal();
1356
+ }
1357
+ ($('changelog-list').querySelector('a') ?? $('changelog-close')).focus();
1358
+ markReleasesSeen(state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion, state.changelog?.releases[0]?.version);
1359
+ renderVersion();
1360
+ loadChangelog();
1361
+ }
1362
+
1363
+ function closeChangelog() {
1364
+ if ($('changelog').open) $('changelog').close();
1365
+ }
1366
+
1367
+ function releasesLink(label) {
1368
+ const link = document.createElement('a');
1369
+ link.className = 'console-link';
1370
+ link.href = RELEASES_URL;
1371
+ link.target = '_blank';
1372
+ link.rel = 'noopener noreferrer';
1373
+ link.textContent = label;
1374
+ link.setAttribute('aria-description', 'Opens in a new tab');
1375
+ return link;
1376
+ }
1377
+
1378
+ function releaseStatus(version) {
1379
+ const running = state.version;
1380
+ if (!RELEASE_VERSION.test(running ?? '')) return null;
1381
+ const order = compareReleases(version, running);
1382
+ if (order <= 0) return order === 0 ? 'running' : null;
1383
+ const installed = state.upgrade?.pendingVersion;
1384
+ return RELEASE_VERSION.test(installed ?? '') && compareReleases(version, installed) <= 0 ? 'installed' : 'newer';
1385
+ }
1386
+
1387
+ function changeRun(run) {
1388
+ const href = run.url ? webHref(run.url) : null;
1389
+ let node;
1390
+ if (href) {
1391
+ node = document.createElement('a');
1392
+ node.className = 'console-link';
1393
+ node.href = href;
1394
+ node.target = '_blank';
1395
+ node.rel = 'noopener noreferrer';
1396
+ node.textContent = run.text;
1397
+ } else if (run.code) {
1398
+ node = document.createElement('code');
1399
+ node.textContent = run.text;
1400
+ } else {
1401
+ node = document.createTextNode(run.text);
1402
+ }
1403
+ if (!run.strong) return node;
1404
+ const strong = document.createElement('strong');
1405
+ strong.append(node);
1406
+ return strong;
1407
+ }
1408
+
1409
+ function changeGroup(section) {
1410
+ const group = document.createElement('div');
1411
+ group.className = 'changelog-group';
1412
+ if (section.title) {
1413
+ const title = document.createElement('h4');
1414
+ title.className = 'changelog-type';
1415
+ title.classList.toggle('breaking', /breaking/i.test(section.title));
1416
+ title.textContent = section.title;
1417
+ group.append(title);
1418
+ }
1419
+ const list = document.createElement('ul');
1420
+ list.className = 'changelog-changes';
1421
+ list.append(...section.changes.map((runs) => {
1422
+ const item = document.createElement('li');
1423
+ item.append(...runs.map(changeRun));
1424
+ return item;
1425
+ }));
1426
+ group.append(list);
1427
+ return group;
1428
+ }
1429
+
1430
+ function releaseEntry(release) {
1431
+ const entry = document.createElement('li');
1432
+ entry.className = 'changelog-release';
1433
+ entry.dataset.version = release.version;
1434
+ const heading = document.createElement('h3');
1435
+ heading.className = 'news-head';
1436
+ const href = webHref(release.url);
1437
+ const title = document.createElement(href ? 'a' : 'span');
1438
+ title.className = 'news-link';
1439
+ title.textContent = `v${release.version}`;
1440
+ if (href) {
1441
+ title.href = href;
1442
+ title.target = '_blank';
1443
+ title.rel = 'noopener noreferrer';
1444
+ title.setAttribute('aria-description', 'Opens the release on GitHub in a new tab');
1445
+ }
1446
+ heading.append(title);
1447
+ const status = releaseStatus(release.version);
1448
+ if (status) heading.append(badge(...RELEASE_BADGES[status]));
1449
+ entry.append(heading);
1450
+ if (release.publishedAt) {
1451
+ const time = document.createElement('time');
1452
+ time.className = 'news-meta';
1453
+ time.dateTime = release.publishedAt;
1454
+ time.textContent = relativeTime(release.publishedAt);
1455
+ time.title = new Date(release.publishedAt).toLocaleString();
1456
+ entry.append(time);
1457
+ }
1458
+ entry.append(...release.sections.map(changeGroup));
1459
+ return entry;
1460
+ }
1461
+
1462
+ function changelogSummary() {
1463
+ const v = state.version;
1464
+ if (!v) return 'Agent Guild releases, newest first';
1465
+ if (isDevelopmentBuild(v)) return `Development build (${v}) · releases, newest first`;
1466
+ const u = state.upgrade;
1467
+ const parts = [`Running v${v}`];
1468
+ if (u?.pendingVersion) parts.push(`v${u.pendingVersion} installed`);
1469
+ const newer = state.changelog?.releases.some((release) => compareReleases(release.version, v) > 0);
1470
+ if (u?.available && u.latestVersion) parts.push(`v${u.latestVersion} available`);
1471
+ else if (!u?.pendingVersion && u?.latestVersion === v && !newer) parts.push('the latest release');
1472
+ return parts.join(' · ');
1473
+ }
1474
+
1475
+ function changelogNote(changelog) {
1476
+ if (changelog?.releases.length) return [];
1477
+ if (changelogView.failed) return [`Release notes could not be loaded: ${changelogView.failed}. `, releasesLink('Read them on GitHub')];
1478
+ if (!changelog || changelog.refreshing) return ['Loading release notes…'];
1479
+ if (changelog.error) return [`Release notes could not be loaded. GitHub Releases: ${changelog.error}. `, releasesLink('Read them on GitHub')];
1480
+ return ['No releases yet.'];
1481
+ }
1482
+
1483
+ function renderChangelogStatus(changelog) {
1484
+ const lines = [];
1485
+ if (changelog) {
1486
+ const updated = changelog.refreshing ? 'checking for new releases…' : changelog.okAt ? `updated ${relativeTime(changelog.okAt)}` : '';
1487
+ lines.push(['From GitHub Releases', updated].filter(Boolean).join(' · '));
1488
+ if (changelog.error && changelog.releases.length) lines.push(`The last check failed (${changelog.error}); showing notes fetched ${relativeTime(changelog.okAt)}.`);
1489
+ }
1490
+ lines.push(releasesLink('All releases on GitHub'));
1491
+ $('changelog-status').replaceChildren(...lines.map((content) => {
1492
+ const line = document.createElement('span');
1493
+ line.append(content);
1494
+ return line;
1495
+ }));
1496
+ $('changelog-status').hidden = false;
1497
+ }
1498
+
1499
+ function renderChangelog() {
1500
+ const changelog = state.changelog;
1501
+ const releases = changelog?.releases ?? [];
1502
+ $('changelog-sub').textContent = changelogSummary();
1503
+ const list = $('changelog-list');
1504
+ const body = list.parentElement;
1505
+ const top = body.scrollTop;
1506
+ const entry = document.activeElement?.closest?.('#changelog-list .changelog-release');
1507
+ const focus = entry && { version: entry.dataset.version, index: [...entry.querySelectorAll('a')].indexOf(document.activeElement) };
1508
+ list.replaceChildren(...releases.map(releaseEntry));
1509
+ list.hidden = releases.length === 0;
1510
+ const note = changelogNote(changelog);
1511
+ $('changelog-note').replaceChildren(...note);
1512
+ $('changelog-note').hidden = note.length === 0;
1513
+ renderChangelogStatus(changelog);
1514
+ body.scrollTop = top;
1515
+ if (focus) {
1516
+ const again = [...list.children].find((el) => el.dataset.version === focus.version);
1517
+ (again?.querySelectorAll('a')[focus.index] ?? again?.querySelector('a') ?? $('changelog-close')).focus({ preventScroll: true });
1518
+ }
1519
+ }
1520
+
1232
1521
  const CHANNEL_LABELS = {
1233
1522
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
1234
1523
  };
@@ -1336,8 +1625,12 @@ function shortId(id) {
1336
1625
  return id.length > 12 ? id.slice(0, 8) : id;
1337
1626
  }
1338
1627
 
1628
+ function copyText(text, what) {
1629
+ navigator.clipboard?.writeText(text).then(() => toast(`Copied ${what}`, 2500), () => toast(text, 10000));
1630
+ }
1631
+
1339
1632
  function copyId(id) {
1340
- navigator.clipboard?.writeText(id).then(() => toast(`Copied ${id}`, 2500), () => toast(id, 8000));
1633
+ copyText(id, id);
1341
1634
  }
1342
1635
 
1343
1636
  function paintIdButton(button, id) {
@@ -1507,9 +1800,602 @@ function resumeById(event) {
1507
1800
  if (provider && id) resumeFromHistory(provider, id, null);
1508
1801
  }
1509
1802
 
1803
+ // ---- GitHub ---------------------------------------------------------------
1804
+
1805
+ const GITHUB_SCOPES = {
1806
+ repo: 'Read and write access to all your repositories, private ones included. GitHub offers apps no read-only choice; Agent Guild only lists them.',
1807
+ 'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
1808
+ };
1809
+ const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, opener: null, card: null, started: new Set(), announced: new Set() };
1810
+ let githubLoading = null;
1811
+
1812
+ function el(tag, className, ...children) {
1813
+ const node = document.createElement(tag);
1814
+ if (className) node.className = className;
1815
+ node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
1816
+ return node;
1817
+ }
1818
+
1819
+ function button(label, onClick, className = 'btn') {
1820
+ const node = el('button', className, label);
1821
+ node.type = 'button';
1822
+ node.addEventListener('click', onClick);
1823
+ return node;
1824
+ }
1825
+
1826
+ function externalLink(label, href, className = 'console-link') {
1827
+ const link = el('a', className, label);
1828
+ link.href = webHref(href) ?? '';
1829
+ link.target = '_blank';
1830
+ link.rel = 'noopener noreferrer';
1831
+ link.setAttribute('aria-description', 'Opens in a new tab');
1832
+ return link;
1833
+ }
1834
+
1835
+ function githubAccount() {
1836
+ return state.github?.accounts.find((a) => a.id === githubView.accountId) ?? null;
1837
+ }
1838
+
1839
+ function selectGitHubAccount(id) {
1840
+ if (githubView.accountId === id) return;
1841
+ githubView.accountId = id;
1842
+ githubView.repos = null;
1843
+ githubView.reposFor = null;
1844
+ githubView.error = null;
1845
+ save(GITHUB_ACCOUNT_KEY, id === null ? null : String(id));
1846
+ }
1847
+
1848
+ let githubAgain = false;
1849
+
1850
+ function loadGitHub() {
1851
+ if (githubLoading) {
1852
+ githubAgain = true;
1853
+ return githubLoading;
1854
+ }
1855
+ githubLoading = api('GET', '/github').then(({ github }) => setGitHub(github), (err) => {
1856
+ if (err instanceof AuthError) return showAuth(err.message);
1857
+ toast(err.message, 8000);
1858
+ }).finally(() => {
1859
+ githubLoading = null;
1860
+ if (githubAgain) {
1861
+ githubAgain = false;
1862
+ loadGitHub();
1863
+ }
1864
+ });
1865
+ return githubLoading;
1866
+ }
1867
+
1868
+ function setGitHub(github) {
1869
+ const before = state.github?.signIn;
1870
+ state.github = github;
1871
+ const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
1872
+ if (done) {
1873
+ selectGitHubAccount(done.accountId);
1874
+ Object.assign(githubView, { repos: null, reposFor: null, error: null });
1875
+ }
1876
+ if (!githubAccount()) selectGitHubAccount(github.accounts[0]?.id ?? null);
1877
+ const account = githubAccount();
1878
+ if (done && account) {
1879
+ toast(done.again
1880
+ ? `You are already signed in as @${account.login}; that sign-in was renewed. To add another account, switch to it on github.com first.`
1881
+ : `Signed in to GitHub as @${account.login}.`, done.again ? 10000 : 4000);
1882
+ }
1883
+ if (!$('github').open) return;
1884
+ renderGitHub();
1885
+ ensureRepos();
1886
+ }
1887
+
1888
+ function cloneParent() {
1889
+ return $('github-parent').value.trim();
1890
+ }
1891
+
1892
+ function reposKey(account) {
1893
+ return account ? `${account.id}\n${cloneParent()}` : null;
1894
+ }
1895
+
1896
+ function ensureRepos() {
1897
+ const account = githubAccount();
1898
+ if (!account || account.needsSignIn) return;
1899
+ const key = reposKey(account);
1900
+ if (githubView.reposFor !== key && githubView.loading !== key) loadRepos();
1901
+ }
1902
+
1903
+ async function loadRepos({ refresh = false } = {}) {
1904
+ const account = githubAccount();
1905
+ if (!account) return;
1906
+ const key = reposKey(account);
1907
+ const parent = cloneParent();
1908
+ githubView.loading = key;
1909
+ renderGitHub();
1910
+ const ask = (withParent) => {
1911
+ const query = new URLSearchParams();
1912
+ if (withParent && parent) query.set('parent', parent);
1913
+ if (refresh) query.set('refresh', '1');
1914
+ return api('GET', `/github/accounts/${account.id}/repos?${query}`);
1915
+ };
1916
+ let result = null;
1917
+ let error = null;
1918
+ let parentError = null;
1919
+ try {
1920
+ try {
1921
+ result = (await ask(true)).repos;
1922
+ } catch (err) {
1923
+ if (err.code !== 'bad_cwd') throw err;
1924
+ parentError = err.message;
1925
+ result = (await ask(false)).repos;
1926
+ }
1927
+ } catch (err) {
1928
+ if (err instanceof AuthError) return showAuth(err.message);
1929
+ error = err.message;
1930
+ }
1931
+ if (githubView.loading !== key) return;
1932
+ Object.assign(githubView, { loading: null, reposFor: key, error, parentError });
1933
+ if (result || !githubView.repos || githubView.repos.accountId !== account.id) githubView.repos = result;
1934
+ renderGitHub();
1935
+ }
1936
+
1937
+ async function startGitHubSignIn() {
1938
+ try {
1939
+ setGitHub((await api('POST', '/github/sign-in')).github);
1940
+ } catch (err) {
1941
+ if (err instanceof AuthError) return showAuth(err.message);
1942
+ toast(err.message, 8000);
1943
+ }
1944
+ }
1945
+
1946
+ async function cancelGitHubSignIn() {
1947
+ try {
1948
+ setGitHub((await api('DELETE', '/github/sign-in')).github);
1949
+ } catch (err) {
1950
+ toast(err.message);
1951
+ }
1952
+ }
1953
+
1954
+ async function setupGitHubSsh(account) {
1955
+ try {
1956
+ const result = await api('POST', `/github/accounts/${account.id}/ssh`);
1957
+ if (result.account.ssh.status === 'ready') toast(`SSH is ready for @${account.login}.`, 4000);
1958
+ } catch (err) {
1959
+ if (err instanceof AuthError) return showAuth(err.message);
1960
+ toast(err.message, 8000);
1961
+ }
1962
+ loadGitHub();
1963
+ }
1964
+
1965
+ async function signOutGitHub(account) {
1966
+ const text = `Sign out of @${account.login}? Agent Guild forgets this sign-in. Its SSH key stays in the Agent Guild data folder and on your GitHub account, so existing clones keep working.\n\nTo remove Agent Guild's access entirely, revoke it on GitHub; GitHub then also removes the SSH keys Agent Guild added.`;
1967
+ if (!confirm(text)) return;
1968
+ try {
1969
+ const { github } = await api('DELETE', `/github/accounts/${account.id}`);
1970
+ if (githubView.accountId === account.id) selectGitHubAccount(null);
1971
+ setGitHub(github);
1972
+ } catch (err) {
1973
+ toast(err.message);
1974
+ }
1975
+ }
1976
+
1977
+ function openGitHub() {
1978
+ const dialog = $('github');
1979
+ $('github-parent').value = load(CLONE_PARENT_KEY) ?? $('cwd').value.trim();
1980
+ $('github-filter').value = '';
1981
+ githubView.card = null;
1982
+ if (!dialog.open) {
1983
+ githubView.opener = document.activeElement;
1984
+ dialog.showModal();
1985
+ }
1986
+ renderGitHub();
1987
+ if (githubAccount() && !githubAccount().needsSignIn) loadRepos();
1988
+ loadGitHub();
1989
+ ($('github-card').querySelector('.btn.primary') ?? $('github-close')).focus();
1990
+ }
1991
+
1992
+ function closeGitHub({ focusOpener = true } = {}) {
1993
+ if (!$('github').open) return;
1994
+ if (!focusOpener) githubView.opener = false;
1995
+ $('github').close();
1996
+ }
1997
+
1998
+ function renderGitHub() {
1999
+ const github = state.github;
2000
+ const account = githubAccount();
2001
+ const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
2002
+ const sub = !github ? 'Loading…'
2003
+ : !account ? 'Clone your repositories over SSH'
2004
+ : [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
2005
+ $('github-sub').textContent = sub;
2006
+ renderGitHubChips(github);
2007
+ renderGitHubCard(github, account);
2008
+ renderGitHubStatus(github, account);
2009
+ renderGitHubRepos(github, account, repos);
2010
+ }
2011
+
2012
+ function githubAvatar(account) {
2013
+ if (account.avatar?.startsWith('data:image/')) {
2014
+ const img = el('img', 'github-avatar');
2015
+ img.src = account.avatar;
2016
+ img.alt = '';
2017
+ return img;
2018
+ }
2019
+ const mono = el('span', 'github-avatar', account.login.charAt(0).toUpperCase());
2020
+ mono.style.setProperty('--c', `hsl(${hueFor(account.login)} 45% 45%)`);
2021
+ return mono;
2022
+ }
2023
+
2024
+ function renderGitHubChips(github) {
2025
+ const accounts = github?.accounts ?? [];
2026
+ $('github-accounts').hidden = accounts.length === 0;
2027
+ const host = $('github-chips');
2028
+ const focused = document.activeElement?.closest?.('#github-chips .account-chip')?.dataset.account;
2029
+ host.replaceChildren(...accounts.map((account) => {
2030
+ const chip = el('button', `account-chip github-chip${account.needsSignIn ? ' unsigned' : ''}`, githubAvatar(account), el('span', null, account.login));
2031
+ chip.type = 'button';
2032
+ chip.setAttribute('role', 'tab');
2033
+ chip.dataset.account = account.id;
2034
+ chip.setAttribute('aria-selected', String(account.id === githubView.accountId));
2035
+ chip.title = account.needsSignIn ? `@${account.login}: sign in again` : `${account.name ? `${account.name} · ` : ''}@${account.login}${account.ssh.status === 'ready' ? ' · SSH ready' : ' · SSH not set up'}`;
2036
+ chip.addEventListener('click', () => {
2037
+ selectGitHubAccount(account.id);
2038
+ githubView.card = null;
2039
+ renderGitHub();
2040
+ ensureRepos();
2041
+ });
2042
+ return chip;
2043
+ }));
2044
+ if (focused) [...host.children].find((chip) => chip.dataset.account === focused)?.focus({ preventScroll: true });
2045
+ const pending = github?.signIn?.status === 'pending';
2046
+ $('github-add').hidden = accounts.length === 0 || pending;
2047
+ }
2048
+
2049
+ function minutesLeft(iso) {
2050
+ return Math.max(1, Math.ceil((Date.parse(iso) - Date.now()) / 60000));
2051
+ }
2052
+
2053
+ function scopeList(github) {
2054
+ return el('ul', 'github-scopes', ...(github.scopes ?? []).map((scope) => el('li', null, el('code', null, scope), ` ${GITHUB_SCOPES[scope] ?? ''}`)));
2055
+ }
2056
+
2057
+ function signInCard(github) {
2058
+ return [
2059
+ el('h3', null, 'Sign in to GitHub'),
2060
+ el('p', null, 'See your repositories and clone them over SSH, with a key Agent Guild keeps for each account.'),
2061
+ el('p', 'github-small', 'GitHub will ask you to allow Agent Guild to:'),
2062
+ scopeList(github),
2063
+ el('p', 'github-small', 'Your sign-in stays in the Agent Guild data folder on this computer; this page never receives it.'),
2064
+ el('div', 'github-actions', button('Sign in with GitHub', startGitHubSignIn, 'btn primary')),
2065
+ ];
2066
+ }
2067
+
2068
+ function codeCard(signIn) {
2069
+ const code = signIn.userCode;
2070
+ const open = externalLink('Open GitHub', signIn.verificationUri, 'btn primary');
2071
+ open.addEventListener('click', () => navigator.clipboard?.writeText(code).catch(() => {}));
2072
+ open.title = `Copies the code and opens ${signIn.verificationUri}`;
2073
+ const codeText = el('span', 'github-code-text', code);
2074
+ codeText.setAttribute('aria-label', `Code ${[...code].join(' ')}`);
2075
+ return [
2076
+ el('h3', null, 'Enter this code on GitHub'),
2077
+ el('div', 'github-code', codeText, button('Copy', () => copyText(code, 'the code'))),
2078
+ el('div', 'github-actions', open, button('Cancel', cancelGitHubSignIn)),
2079
+ el('p', 'github-small github-waiting', `Waiting for you to approve Agent Guild on GitHub. The code expires in ${minutesLeft(signIn.expiresAt)} min.`),
2080
+ el('p', 'github-small', 'Adding another account? Switch to it on github.com before you enter the code.'),
2081
+ ];
2082
+ }
2083
+
2084
+ function signInResultCard(signIn) {
2085
+ const text = signIn.status === 'expired' ? 'The code expired before it was entered on GitHub.'
2086
+ : signIn.status === 'denied' ? 'The sign-in was declined on GitHub.'
2087
+ : `The sign-in did not finish: ${signIn.error || 'GitHub refused it'}.`;
2088
+ return [
2089
+ el('h3', null, 'Not signed in'),
2090
+ el('p', null, text),
2091
+ el('div', 'github-actions', button('Try again', startGitHubSignIn, 'btn primary'), button('Dismiss', cancelGitHubSignIn)),
2092
+ ];
2093
+ }
2094
+
2095
+ function signInAgainCard(account) {
2096
+ return [
2097
+ el('h3', null, `Sign in again as @${account.login}`),
2098
+ el('p', null, `GitHub no longer accepts Agent Guild's sign-in for @${account.login}. If github.com is signed in to another account, switch to @${account.login} there first.`),
2099
+ el('div', 'github-actions', button('Sign in again', startGitHubSignIn, 'btn primary'), button('Sign out', () => signOutGitHub(account))),
2100
+ ];
2101
+ }
2102
+
2103
+ function sshCard(github, account) {
2104
+ const ssh = account.ssh;
2105
+ const missing = !github.tools.ssh || !github.tools.sshKeygen;
2106
+ const step = (done, text) => el('li', done ? 'done' : null, text);
2107
+ const parts = [
2108
+ el('h3', null, `Set up SSH for @${account.login}`),
2109
+ el('p', null, 'Agent Guild clones over SSH with a key of its own for each account, so your other SSH keys and settings are never used or changed.'),
2110
+ el('ol', 'github-steps',
2111
+ step(Boolean(ssh.key), 'Create a key for this account in the Agent Guild data folder'),
2112
+ step(false, 'Add its public key to your GitHub account'),
2113
+ step(false, `Check that GitHub signs in as @${account.login}`)),
2114
+ ];
2115
+ if (missing) {
2116
+ parts.push(el('p', 'github-error', 'OpenSSH (ssh and ssh-keygen) was not found. On Windows, add the "OpenSSH Client" optional feature in Settings, or install Git for Windows; on Linux, install the openssh-client package. Then check again.'));
2117
+ } else if (ssh.error) {
2118
+ parts.push(el('p', 'github-error', ssh.error.message));
2119
+ }
2120
+ const setup = button(ssh.settingUp ? 'Setting up…' : ssh.error || ssh.key ? 'Check again' : 'Set up SSH', () => setupGitHubSsh(account), 'btn primary');
2121
+ setup.disabled = ssh.settingUp || missing;
2122
+ const actions = el('div', 'github-actions', setup);
2123
+ if (ssh.error?.manual && ssh.publicKey) {
2124
+ actions.append(button('Copy public key', () => copyText(ssh.publicKey, 'the public key')), externalLink('Open GitHub SSH settings', github.newKeyUrl, 'btn'));
2125
+ }
2126
+ parts.push(actions);
2127
+ const approve = externalLink('request access', github.appUrl);
2128
+ parts.push(el('p', 'github-small', 'Organizations that restrict third-party apps accept this key only once an owner approves Agent Guild: ', approve, '.'));
2129
+ return parts;
2130
+ }
2131
+
2132
+ function renderGitHubCard(github, account) {
2133
+ const card = $('github-card');
2134
+ let kind = null;
2135
+ let build = null;
2136
+ const signIn = github?.signIn;
2137
+ if (!github) kind = null;
2138
+ else if (signIn?.status === 'pending') [kind, build] = [`code:${signIn.userCode}:${minutesLeft(signIn.expiresAt)}`, () => codeCard(signIn)];
2139
+ else if (signIn && signIn.status !== 'done') [kind, build] = [`result:${signIn.status}`, () => signInResultCard(signIn)];
2140
+ else if (!account) [kind, build] = ['welcome', () => signInCard(github)];
2141
+ else if (account.needsSignIn) [kind, build] = [`again:${account.id}`, () => signInAgainCard(account)];
2142
+ else if (account.ssh.status !== 'ready') {
2143
+ const { settingUp, key, error } = account.ssh;
2144
+ [kind, build] = [`ssh:${account.id}:${settingUp}:${Boolean(key)}:${error?.message}:${github.tools.ssh && github.tools.sshKeygen}`, () => sshCard(github, account)];
2145
+ }
2146
+ card.hidden = !kind;
2147
+ if (kind === githubView.card) return;
2148
+ const hadFocus = card.contains(document.activeElement);
2149
+ githubView.card = kind;
2150
+ card.replaceChildren(...(build ? build() : []));
2151
+ if (hadFocus) (card.querySelector('.btn.primary:not(:disabled)') ?? card.querySelector('button, a'))?.focus({ preventScroll: true });
2152
+ }
2153
+
2154
+ function renderGitHubStatus(github, account) {
2155
+ const strip = $('github-status');
2156
+ strip.hidden = !account || account.needsSignIn;
2157
+ if (strip.hidden) return strip.replaceChildren();
2158
+ const ssh = account.ssh;
2159
+ const text = ssh.status === 'ready'
2160
+ ? el('span', 'github-ready', 'SSH ready', el('span', 'github-small', ` · checked ${relativeTime(ssh.verifiedAt)}`))
2161
+ : el('span', 'github-small', 'SSH not set up yet');
2162
+ const actions = el('span', 'github-status-actions');
2163
+ if (ssh.status === 'ready') {
2164
+ const check = button(ssh.settingUp ? 'Checking…' : 'Check SSH', () => setupGitHubSsh(account));
2165
+ check.disabled = ssh.settingUp;
2166
+ check.title = `Check that GitHub still signs in as @${account.login} with Agent Guild's key`;
2167
+ actions.append(check);
2168
+ }
2169
+ actions.append(button('Sign out', () => signOutGitHub(account)));
2170
+ const focused = strip.contains(document.activeElement) ? document.activeElement.textContent : null;
2171
+ strip.replaceChildren(text, actions);
2172
+ if (focused) [...strip.querySelectorAll('button')].find((b) => b.textContent === focused)?.focus({ preventScroll: true });
2173
+ }
2174
+
2175
+ function runningClone(target) {
2176
+ return [...state.sessions.values()].find((s) => s.task === 'clone' && s.status === 'running' && s.clone?.path === target) ?? null;
2177
+ }
2178
+
2179
+ function cloneBlocker(github, account) {
2180
+ if (!github.tools.git) return 'Git was not found. Install it from git-scm.com, then reopen this dialog.';
2181
+ if (account.ssh.status !== 'ready') return `Set up SSH for @${account.login} first.`;
2182
+ if (githubView.parentError) return githubView.parentError;
2183
+ if (!cloneParent()) return 'Choose the folder to clone into below.';
2184
+ return null;
2185
+ }
2186
+
2187
+ function buildRepoRow(fullName) {
2188
+ const node = $('github-repo-template').content.firstElementChild.cloneNode(true);
2189
+ node.dataset.repo = fullName;
2190
+ node.querySelector('.github-action').addEventListener('click', (event) => {
2191
+ const repo = githubView.repos?.repos.find((r) => r.fullName === fullName);
2192
+ if (repo) repoAction(repo, event.currentTarget);
2193
+ });
2194
+ return node;
2195
+ }
2196
+
2197
+ function repoAction(repo, control) {
2198
+ const running = runningClone(repo.target);
2199
+ if (running) {
2200
+ closeGitHub({ focusOpener: false });
2201
+ openPanel(running.id);
2202
+ } else if (repo.local === 'cloned') {
2203
+ useFolder(repo.target);
2204
+ } else {
2205
+ cloneRepo(repo, control);
2206
+ }
2207
+ }
2208
+
2209
+ function updateRepoRow(node, repo, blocker) {
2210
+ const name = node.querySelector('.repo-name');
2211
+ name.replaceChildren(el('span', 'repo-owner', `${repo.owner}/`), repo.name);
2212
+ name.title = repo.url;
2213
+ const title = node.querySelector('.history-title');
2214
+ title.replaceChildren(name,
2215
+ ...(repo.private ? [badge('', 'Private', 'Only people with access can see it')] : []),
2216
+ ...(repo.fork ? [badge('', 'Fork', 'A fork of another repository')] : []),
2217
+ ...(repo.archived ? [badge('', 'Archived', 'Read-only on GitHub')] : []));
2218
+ const running = runningClone(repo.target);
2219
+ const conflict = repo.local === 'conflict';
2220
+ const meta = node.querySelector('.history-meta');
2221
+ meta.textContent = conflict
2222
+ ? `${repo.target} already exists and is not a clone of ${repo.fullName}`
2223
+ : [running && 'Cloning…', repo.local === 'cloned' && `Cloned in ${repo.target}`, repo.description, repo.language, repo.pushedAt && `pushed ${relativeTime(repo.pushedAt)}`].filter(Boolean).join(' · ');
2224
+ meta.title = [repo.description, repo.target].filter(Boolean).join('\n');
2225
+ node.classList.toggle('conflict', conflict);
2226
+ node.classList.toggle('running', Boolean(running) || repo.local === 'cloned');
2227
+ const action = node.querySelector('.github-action');
2228
+ action.hidden = conflict && !running;
2229
+ action.className = `btn github-action${running ? '' : ' primary'}`;
2230
+ action.textContent = running ? 'Show' : repo.local === 'cloned' ? 'Use folder' : 'Clone';
2231
+ action.disabled = !running && repo.local !== 'cloned' && Boolean(blocker);
2232
+ action.title = running ? `Show the session cloning ${repo.fullName}`
2233
+ : repo.local === 'cloned' ? `Make ${repo.target} the working folder, so new sessions start there`
2234
+ : blocker || `Clone ${repo.fullName} into ${repo.target} over SSH`;
2235
+ action.setAttribute('aria-label', `${action.textContent} ${repo.fullName}`);
2236
+ }
2237
+
2238
+ function renderGitHubRepos(github, account, repos) {
2239
+ const ready = Boolean(github && account && !account.needsSignIn);
2240
+ $('github-tools').hidden = !ready;
2241
+ $('github-parent').closest('.github-form').hidden = !ready;
2242
+ const list = $('github-list');
2243
+ const filter = $('github-filter').value.trim().toLowerCase();
2244
+ const shown = ready && repos ? repos.repos.filter((repo) => !filter || `${repo.fullName}\n${repo.description ?? ''}`.toLowerCase().includes(filter)) : [];
2245
+ const blocker = ready ? cloneBlocker(github, account) : null;
2246
+ const rows = new Map([...list.children].map((node) => [node.dataset.repo, node]));
2247
+ const wanted = new Set(shown.map((repo) => repo.fullName));
2248
+ for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
2249
+ shown.forEach((repo, index) => {
2250
+ const node = rows.get(repo.fullName) ?? buildRepoRow(repo.fullName);
2251
+ updateRepoRow(node, repo, blocker);
2252
+ if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
2253
+ });
2254
+ const note = $('github-note');
2255
+ const lines = [];
2256
+ if (ready) {
2257
+ if (githubView.loading && !repos) lines.push('Loading repositories…');
2258
+ else if (githubView.error) lines.push(`Repositories could not be loaded: ${githubView.error}`);
2259
+ else if (repos && repos.repos.length === 0) lines.push(`@${account.login} has no repositories yet.`);
2260
+ else if (repos && shown.length === 0) lines.push('No repository matches the filter.');
2261
+ if (githubView.parentError) lines.push(githubView.parentError);
2262
+ if (repos?.truncated) lines.push(`Showing the ${repos.repos.length} most recently pushed repositories.`);
2263
+ if (repos) lines.push(['Missing an organization\'s repositories? Its owners may need to approve Agent Guild: ', externalLink('request access', github.appUrl), '.']);
2264
+ }
2265
+ note.replaceChildren(...lines.map((line) => el('span', null, ...[line].flat())));
2266
+ note.hidden = lines.length === 0;
2267
+ $('github-refresh').disabled = Boolean(githubView.loading);
2268
+ if (!ready) showCreate(false);
2269
+ renderCreate();
2270
+ }
2271
+
2272
+ async function startClone(account, fullName) {
2273
+ const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
2274
+ githubView.started.add(session.id);
2275
+ upsertSession(session);
2276
+ closeGitHub({ focusOpener: false });
2277
+ openPanel(session.id);
2278
+ }
2279
+
2280
+ async function cloneRepo(repo, control) {
2281
+ const account = githubAccount();
2282
+ if (!account) return;
2283
+ control.disabled = true;
2284
+ try {
2285
+ await startClone(account, repo.fullName);
2286
+ } catch (err) {
2287
+ if (err instanceof AuthError) return showAuth(err.message);
2288
+ toast(err.message, 8000);
2289
+ if (err.code === 'clone_exists' || err.code === 'folder_conflict') loadRepos();
2290
+ } finally {
2291
+ control.disabled = false;
2292
+ }
2293
+ }
2294
+
2295
+ function showCreate(open) {
2296
+ const form = $('github-create');
2297
+ form.hidden = !open;
2298
+ $('github-new').setAttribute('aria-expanded', String(open));
2299
+ if (!open) return;
2300
+ $('github-create-name').value = '';
2301
+ $('github-create-description').value = '';
2302
+ $('github-create-error').hidden = true;
2303
+ renderCreate();
2304
+ $('github-create-name').focus();
2305
+ }
2306
+
2307
+ function renderCreate() {
2308
+ const form = $('github-create');
2309
+ const account = githubAccount();
2310
+ if (form.hidden || !account) return;
2311
+ const repos = githubView.repos?.accountId === account.id ? githubView.repos : null;
2312
+ const owners = repos?.owners ?? [account.login];
2313
+ const select = $('github-create-owner');
2314
+ const chosen = select.value;
2315
+ if ([...select.options].map((o) => o.value).join('\n') !== owners.join('\n')) {
2316
+ select.replaceChildren(...owners.map((owner) => {
2317
+ const option = el('option', null, owner === account.login ? `${owner} (you)` : owner);
2318
+ option.value = owner;
2319
+ return option;
2320
+ }));
2321
+ select.value = owners.includes(chosen) ? chosen : owners[0];
2322
+ }
2323
+ const blocker = cloneBlocker(state.github, account);
2324
+ const clone = $('github-create-clone');
2325
+ clone.disabled = Boolean(blocker);
2326
+ if (blocker) clone.checked = false;
2327
+ const name = $('github-create-name').value.trim();
2328
+ $('github-create-clone-label').textContent = blocker ? 'Clone it' : `Clone it into ${cloneParent()}${name ? `/${name}` : ''}`;
2329
+ clone.parentElement.title = blocker ?? '';
2330
+ }
2331
+
2332
+ async function createRepo(event) {
2333
+ event.preventDefault();
2334
+ const account = githubAccount();
2335
+ if (!account) return;
2336
+ const submit = $('github-create-submit');
2337
+ const error = $('github-create-error');
2338
+ const owner = $('github-create-owner').value;
2339
+ const name = $('github-create-name').value.trim();
2340
+ const clone = $('github-create-clone').checked && !$('github-create-clone').disabled;
2341
+ submit.disabled = true;
2342
+ error.hidden = true;
2343
+ try {
2344
+ const { repo } = await api('POST', `/github/accounts/${account.id}/repos`, {
2345
+ owner,
2346
+ name,
2347
+ description: $('github-create-description').value.trim() || null,
2348
+ private: $('github-create-private').checked,
2349
+ readme: $('github-create-readme').checked,
2350
+ });
2351
+ showCreate(false);
2352
+ githubView.reposFor = null;
2353
+ if (clone) {
2354
+ toast(`Created ${repo.fullName} on GitHub.`, 4000);
2355
+ await startClone(account, repo.fullName);
2356
+ } else {
2357
+ toast(`Created ${repo.fullName} on GitHub.`, 6000);
2358
+ await loadRepos();
2359
+ }
2360
+ } catch (err) {
2361
+ if (err instanceof AuthError) return showAuth(err.message);
2362
+ if ($('github-create').hidden) {
2363
+ toast(err.message, 8000);
2364
+ loadRepos();
2365
+ } else {
2366
+ error.textContent = err.message;
2367
+ error.hidden = false;
2368
+ }
2369
+ } finally {
2370
+ submit.disabled = false;
2371
+ }
2372
+ }
2373
+
2374
+ function clonedPath(s) {
2375
+ return s.task === 'clone' && s.status === 'exited' && s.exitCode === 0 && s.clone?.path ? s.clone.path : null;
2376
+ }
2377
+
2378
+ function noticeClone(s) {
2379
+ if (s.task !== 'clone' || s.status !== 'exited' || githubView.announced.has(s.id)) return;
2380
+ githubView.announced.add(s.id);
2381
+ githubView.reposFor = null;
2382
+ if ($('github').open) loadRepos();
2383
+ if (!githubView.started.has(s.id)) return;
2384
+ if (clonedPath(s)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
2385
+ else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
2386
+ }
2387
+
2388
+ function useFolder(dir) {
2389
+ $('cwd').value = dir;
2390
+ save(CWD_KEY, dir);
2391
+ if ($('github').open) renderGitHub();
2392
+ toast(`New sessions start in ${dir}.`, 4000);
2393
+ }
2394
+
1510
2395
  // ---- session cards --------------------------------------------------------
1511
2396
 
1512
2397
  const cards = new Map();
2398
+ let sessionsShown = false;
1513
2399
 
1514
2400
  const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
1515
2401
 
@@ -1548,11 +2434,19 @@ function buildCard(session) {
1548
2434
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
1549
2435
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
1550
2436
  node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
2437
+ node.querySelector('.use-folder').addEventListener('click', () => {
2438
+ const path = state.sessions.get(session.id)?.clone?.path;
2439
+ if (path) useFolder(path);
2440
+ });
1551
2441
  node.querySelector('.session-id').addEventListener('click', () => {
1552
2442
  const id = toolSessionId(state.sessions.get(session.id) ?? session);
1553
2443
  if (id) copyId(id);
1554
2444
  });
1555
2445
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
2446
+ node.addEventListener('animationend', (e) => {
2447
+ if (e.animationName === 'level-up') e.target.classList.remove('level-up');
2448
+ else if (e.animationName === 'card-enter' && e.target === node) node.classList.remove('enter');
2449
+ });
1556
2450
  return node;
1557
2451
  }
1558
2452
 
@@ -1584,6 +2478,7 @@ function updateCard(node, s) {
1584
2478
  paintProviderIcon(node.querySelector('.provider-icon'), s.provider);
1585
2479
  const level = sessionLevel(s);
1586
2480
  const badge = node.querySelector('.level-badge');
2481
+ if (badge.textContent && Number(badge.textContent) < level) badge.classList.add('level-up');
1587
2482
  badge.textContent = level;
1588
2483
  badge.title = `Level ${level}`;
1589
2484
  node.querySelector('.name').textContent = s.name;
@@ -1608,6 +2503,9 @@ function updateCard(node, s) {
1608
2503
  node.classList.toggle('exited', s.status === 'exited');
1609
2504
  node.querySelector('.stop').hidden = s.status !== 'running';
1610
2505
  node.querySelector('.remove').hidden = s.status === 'running';
2506
+ const useButton = node.querySelector('.use-folder');
2507
+ useButton.hidden = !clonedPath(s);
2508
+ useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
1611
2509
  const resume = node.querySelector('.resume');
1612
2510
  resume.hidden = !resumable(s);
1613
2511
  resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
@@ -1624,7 +2522,11 @@ function renderSessions() {
1624
2522
  }
1625
2523
  sessions.forEach((s, index) => {
1626
2524
  let node = cards.get(s.id);
1627
- if (!node) { node = buildCard(s); cards.set(s.id, node); }
2525
+ if (!node) {
2526
+ node = buildCard(s);
2527
+ cards.set(s.id, node);
2528
+ if (sessionsShown) node.classList.add('enter');
2529
+ }
1628
2530
  updateCard(node, s);
1629
2531
  // Move a card only when it is out of place: re-inserting a node drops
1630
2532
  // keyboard focus and can swallow a click that is in progress.
@@ -1633,14 +2535,27 @@ function renderSessions() {
1633
2535
  const running = sessions.filter((s) => s.status === 'running').length;
1634
2536
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
1635
2537
  $('empty').hidden = sessions.length > 0;
2538
+ guardLeaving();
1636
2539
  if (state.activeId) updatePanel();
1637
2540
  if ($('history').open) renderHistory();
1638
2541
  if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
1639
2542
  }
1640
2543
 
2544
+ function confirmLeaving(event) {
2545
+ event.preventDefault();
2546
+ event.returnValue = true;
2547
+ }
2548
+
2549
+ function guardLeaving() {
2550
+ const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
2551
+ if (running) addEventListener('beforeunload', confirmLeaving);
2552
+ else removeEventListener('beforeunload', confirmLeaving);
2553
+ }
2554
+
1641
2555
  function upsertSession(session) {
1642
2556
  state.sessions.set(session.id, session);
1643
2557
  renderSessions();
2558
+ noticeClone(session);
1644
2559
  }
1645
2560
 
1646
2561
  function dropSession(id) {
@@ -1908,7 +2823,9 @@ function enterStopping(running = 0, restart = false) {
1908
2823
  closePanel();
1909
2824
  closeModels();
1910
2825
  closeNews();
2826
+ closeChangelog();
1911
2827
  closeHistory();
2828
+ closeGitHub();
1912
2829
  for (const view of state.views.values()) view.dispose();
1913
2830
  state.views.clear();
1914
2831
  state.sessions.clear();
@@ -2022,10 +2939,18 @@ function connectEvents() {
2022
2939
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
2023
2940
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
2024
2941
  renderSessions();
2942
+ sessionsShown = true;
2025
2943
  setUpgrade(msg.upgrade);
2026
2944
  loadNews();
2945
+ // A changelog.updated sent while the socket was down is lost; catch up the open panel.
2946
+ if ($('changelog').open) loadChangelog();
2947
+ if ($('github').open) loadGitHub();
2027
2948
  } else if (msg.type === 'news.updated') {
2028
2949
  loadNews();
2950
+ } else if (msg.type === 'github.updated') {
2951
+ if ($('github').open) loadGitHub();
2952
+ } else if (msg.type === 'changelog.updated') {
2953
+ if ($('changelog').open) loadChangelog();
2029
2954
  } else if (msg.type === 'manager.upgrade') {
2030
2955
  setUpgrade(msg.upgrade);
2031
2956
  } else if (msg.type === 'manager.stopping') {
@@ -2085,7 +3010,9 @@ let newsTimer;
2085
3010
  function showAuth(message = '') {
2086
3011
  closeModels();
2087
3012
  closeNews();
3013
+ closeChangelog();
2088
3014
  closeHistory();
3015
+ closeGitHub();
2089
3016
  $('app').hidden = true;
2090
3017
  $('terminal-panel').hidden = true;
2091
3018
  $('stopped').hidden = true;
@@ -2144,6 +3071,30 @@ $('history').addEventListener('close', () => {
2144
3071
  historyOpener = null;
2145
3072
  });
2146
3073
  $('history-filter').addEventListener('input', renderHistory);
3074
+ $('github-open').addEventListener('click', openGitHub);
3075
+ $('github-close').addEventListener('click', () => closeGitHub());
3076
+ $('github').addEventListener('click', (e) => { if (e.target === $('github')) closeGitHub(); });
3077
+ $('github').addEventListener('close', () => {
3078
+ if (githubView.opener !== false) (githubView.opener?.isConnected && !githubView.opener.closest('[hidden]') ? githubView.opener : $('github-open')).focus();
3079
+ githubView.opener = null;
3080
+ });
3081
+ $('github-add').addEventListener('click', startGitHubSignIn);
3082
+ $('github-filter').addEventListener('input', () => renderGitHub());
3083
+ $('github-refresh').addEventListener('click', () => loadRepos({ refresh: true }));
3084
+ $('github-new').addEventListener('click', () => showCreate($('github-create').hidden));
3085
+ $('github-create').addEventListener('submit', createRepo);
3086
+ $('github-create-cancel').addEventListener('click', () => {
3087
+ showCreate(false);
3088
+ $('github-new').focus();
3089
+ });
3090
+ $('github-create').addEventListener('input', () => {
3091
+ $('github-create-error').hidden = true;
3092
+ renderCreate();
3093
+ });
3094
+ $('github-parent').addEventListener('change', () => {
3095
+ save(CLONE_PARENT_KEY, cloneParent());
3096
+ loadRepos();
3097
+ });
2147
3098
  $('history-here').addEventListener('change', renderHistory);
2148
3099
  $('history-form').addEventListener('submit', resumeById);
2149
3100
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
@@ -2192,6 +3143,23 @@ $('news').addEventListener('close', () => {
2192
3143
  document.addEventListener('visibilitychange', () => {
2193
3144
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
2194
3145
  });
3146
+ $('version').addEventListener('click', openChangelog);
3147
+ $('changelog-close').addEventListener('click', closeChangelog);
3148
+ $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
3149
+ $('changelog').addEventListener('close', () => {
3150
+ const opener = changelogView.opener?.isConnected && !changelogView.opener.closest('[hidden]') ? changelogView.opener : $('version');
3151
+ opener.focus();
3152
+ changelogView.opener = null;
3153
+ });
3154
+ $('changelog-upgrade').addEventListener('click', () => {
3155
+ closeChangelog();
3156
+ upgradeManager();
3157
+ });
3158
+ $('changelog-restart').addEventListener('click', () => {
3159
+ closeChangelog();
3160
+ stopManager({ restart: true });
3161
+ });
3162
+ addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
2195
3163
  $('models-more').addEventListener('click', () => {
2196
3164
  const before = $('models-list').childElementCount;
2197
3165
  modelsView.all = true;
@@ -2203,6 +3171,30 @@ $('restart-manager').addEventListener('click', () => stopManager({ restart: true
2203
3171
  $('copy-command').addEventListener('click', copyCommand);
2204
3172
  $('upgrade').addEventListener('click', upgradeManager);
2205
3173
  $('theme-toggle').addEventListener('click', toggleTheme);
3174
+ $('providers').addEventListener('animationend', (e) => {
3175
+ if (e.animationName === 'deal' && e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
3176
+ });
3177
+ let tiltFrame = 0;
3178
+ $('providers').addEventListener('pointermove', (e) => {
3179
+ const card = e.target.closest('.provider');
3180
+ if (!card || !finePointer.matches || reducedMotion.matches) return;
3181
+ const box = card.getBoundingClientRect();
3182
+ const x = ((e.clientX - box.left) / box.width) * 2 - 1;
3183
+ const y = ((e.clientY - box.top) / box.height) * 2 - 1;
3184
+ cancelAnimationFrame(tiltFrame);
3185
+ tiltFrame = requestAnimationFrame(() => {
3186
+ card.style.setProperty('--px', x.toFixed(3));
3187
+ card.style.setProperty('--py', y.toFixed(3));
3188
+ });
3189
+ });
3190
+ $('providers').addEventListener('pointerout', (e) => {
3191
+ const card = e.target.closest('.provider');
3192
+ if (card && !card.contains(e.relatedTarget)) {
3193
+ cancelAnimationFrame(tiltFrame);
3194
+ card.style.removeProperty('--px');
3195
+ card.style.removeProperty('--py');
3196
+ }
3197
+ });
2206
3198
  applyTheme(currentTheme());
2207
3199
  // Follow the system setting until the user picks a theme.
2208
3200
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
@@ -2216,9 +3208,11 @@ $('panel-stop').addEventListener('click', () => {
2216
3208
  });
2217
3209
  $('cwd').value = load(CWD_KEY) || '';
2218
3210
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
3211
+ githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
2219
3212
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
2220
3213
  setInterval(renderSessions, 30000);
2221
3214
  setInterval(tickNews, 30000);
3215
+ setInterval(() => { if ($('github').open && state.github) renderGitHub(); }, 30000);
2222
3216
 
2223
3217
  // The terminal panel sits below the top bar, which wraps onto two rows on
2224
3218
  // narrow screens; publish its height so the panel never covers its controls.