@oddessentials/agent-guild 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +13 -8
  2. package/bin/agent-guild-report.mjs +14 -1
  3. package/bin/agent-guild.mjs +85 -46
  4. package/config/providers.default.json +4 -0
  5. package/package.json +1 -1
  6. package/src/manager/changelog.mjs +195 -0
  7. package/src/manager/launch.mjs +64 -0
  8. package/src/manager/main.mjs +27 -4
  9. package/src/manager/news.mjs +5 -5
  10. package/src/manager/providers.mjs +11 -0
  11. package/src/manager/server.mjs +52 -16
  12. package/src/manager/session-history.mjs +496 -0
  13. package/src/manager/session-manager.mjs +4 -0
  14. package/src/manager/session.mjs +16 -0
  15. package/src/report/hooks.mjs +4 -0
  16. package/web/app.js +754 -56
  17. package/web/art/art.css +372 -159
  18. package/web/art/characters/anthropic/idle.avif +0 -0
  19. package/web/art/characters/anthropic/idle.webp +0 -0
  20. package/web/art/characters/anthropic/locked.avif +0 -0
  21. package/web/art/characters/anthropic/locked.webp +0 -0
  22. package/web/art/characters/anthropic/working.avif +0 -0
  23. package/web/art/characters/anthropic/working.webp +0 -0
  24. package/web/art/characters/google/idle.avif +0 -0
  25. package/web/art/characters/google/idle.webp +0 -0
  26. package/web/art/characters/google/locked.avif +0 -0
  27. package/web/art/characters/google/locked.webp +0 -0
  28. package/web/art/characters/google/working.avif +0 -0
  29. package/web/art/characters/google/working.webp +0 -0
  30. package/web/art/characters/openai/idle.avif +0 -0
  31. package/web/art/characters/openai/idle.webp +0 -0
  32. package/web/art/characters/openai/locked.avif +0 -0
  33. package/web/art/characters/openai/locked.webp +0 -0
  34. package/web/art/characters/openai/working.avif +0 -0
  35. package/web/art/characters/openai/working.webp +0 -0
  36. package/web/art/characters/shell/idle.avif +0 -0
  37. package/web/art/characters/shell/idle.webp +0 -0
  38. package/web/art/characters/shell/locked.avif +0 -0
  39. package/web/art/characters/shell/locked.webp +0 -0
  40. package/web/art/characters/shell/working.avif +0 -0
  41. package/web/art/characters/shell/working.webp +0 -0
  42. package/web/art/characters/xai/idle.avif +0 -0
  43. package/web/art/characters/xai/idle.webp +0 -0
  44. package/web/art/characters/xai/locked.avif +0 -0
  45. package/web/art/characters/xai/locked.webp +0 -0
  46. package/web/art/characters/xai/working.avif +0 -0
  47. package/web/art/characters/xai/working.webp +0 -0
  48. package/web/art/icons/anthropic.avif +0 -0
  49. package/web/art/icons/anthropic.webp +0 -0
  50. package/web/art/icons/google.avif +0 -0
  51. package/web/art/icons/google.webp +0 -0
  52. package/web/art/icons/openai.avif +0 -0
  53. package/web/art/icons/openai.webp +0 -0
  54. package/web/art/icons/shell.avif +0 -0
  55. package/web/art/icons/shell.webp +0 -0
  56. package/web/art/icons/xai.avif +0 -0
  57. package/web/art/icons/xai.webp +0 -0
  58. package/web/art/page-light.avif +0 -0
  59. package/web/art/page-light.webp +0 -0
  60. package/web/art/page.avif +0 -0
  61. package/web/art/page.webp +0 -0
  62. package/web/art/ui/empty-state.avif +0 -0
  63. package/web/art/ui/empty-state.webp +0 -0
  64. package/web/art/ui/favicon.png +0 -0
  65. package/web/art/ui/frame-ornate.png +0 -0
  66. package/web/art/ui/guild-crest.png +0 -0
  67. package/web/fonts/OFL.txt +93 -0
  68. package/web/fonts/cinzel.woff2 +0 -0
  69. package/web/index.html +82 -14
  70. package/web/styles.css +208 -68
  71. package/web/art/characters/anthropic/idle.png +0 -0
  72. package/web/art/characters/anthropic/locked.png +0 -0
  73. package/web/art/characters/anthropic/working.png +0 -0
  74. package/web/art/characters/google/idle.png +0 -0
  75. package/web/art/characters/google/locked.png +0 -0
  76. package/web/art/characters/google/working.png +0 -0
  77. package/web/art/characters/openai/idle.png +0 -0
  78. package/web/art/characters/openai/locked.png +0 -0
  79. package/web/art/characters/openai/working.png +0 -0
  80. package/web/art/characters/shell/idle.png +0 -0
  81. package/web/art/characters/shell/locked.png +0 -0
  82. package/web/art/characters/shell/working.png +0 -0
  83. package/web/art/characters/xai/idle.png +0 -0
  84. package/web/art/characters/xai/locked.png +0 -0
  85. package/web/art/characters/xai/working.png +0 -0
  86. package/web/art/icons/anthropic.png +0 -0
  87. package/web/art/icons/google.png +0 -0
  88. package/web/art/icons/openai.png +0 -0
  89. package/web/art/icons/shell.png +0 -0
  90. package/web/art/icons/xai.png +0 -0
  91. package/web/art/page.png +0 -0
package/web/app.js CHANGED
@@ -7,7 +7,11 @@ 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 RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
10
12
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
13
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
14
+ const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
11
15
 
12
16
  const $ = (id) => document.getElementById(id);
13
17
  const state = {
@@ -18,6 +22,7 @@ const state = {
18
22
  stats: null,
19
23
  statsFor: new Map(),
20
24
  news: null,
25
+ changelog: null,
21
26
  sessions: new Map(),
22
27
  views: new Map(),
23
28
  activeId: null,
@@ -27,8 +32,17 @@ const state = {
27
32
  connected: false,
28
33
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
29
34
  upgrade: null,
35
+ /** The running manager's version and pid, from `hello`. */
36
+ version: null,
37
+ pid: null,
38
+ /** False for a manager from before restarts, which only stops: `agent-guild restart` replaces it. */
39
+ restartable: false,
40
+ /** The double-click launcher file on this computer, or null when the install has none. */
41
+ launcher: null,
30
42
  /** True from a shutdown request until the manager is reachable again. */
31
43
  stopping: false,
44
+ /** True while the stop is a restart: a new manager is expected to take over. */
45
+ restarting: false,
32
46
  /** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
33
47
  stopRemaining: null,
34
48
  };
@@ -53,10 +67,63 @@ function setConnection(kind, label) {
53
67
  const el = $('connection');
54
68
  el.className = `connection ${kind}`;
55
69
  el.querySelector('.label').textContent = label;
56
- // The manager can only be stopped or upgraded while the page can reach it.
70
+ // The manager can only be stopped, restarted or upgraded while the page can reach it.
57
71
  state.connected = kind === 'ok';
58
72
  $('stop-manager').hidden = !state.connected;
73
+ $('restart-manager').hidden = !state.connected || !state.restartable;
59
74
  renderUpgrade();
75
+ guardLeaving();
76
+ }
77
+
78
+ // ---- the running version --------------------------------------------------
79
+
80
+ /** A development checkout runs as 0.0.0-development; releases carry a real version. */
81
+ function isDevelopmentBuild(version) {
82
+ return !version || /^0\.0\.0(?:-|$)/.test(String(version));
83
+ }
84
+
85
+ function renderVersion() {
86
+ const badge = $('version');
87
+ const v = state.version;
88
+ badge.hidden = !v;
89
+ if (!v) return;
90
+ const dev = isDevelopmentBuild(v);
91
+ const unread = unreadRelease();
92
+ const notes = unread ? `What’s new in v${unread}` : 'What’s new';
93
+ badge.textContent = dev ? 'dev' : `v${v}`;
94
+ badge.classList.toggle('unread', Boolean(unread));
95
+ badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`, notes].filter(Boolean).join(' · ');
96
+ badge.setAttribute('aria-label', `${dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`}. ${notes}${unread ? ', not read yet' : ''}`);
97
+ }
98
+
99
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+$/;
100
+
101
+ function compareReleases(a, b) {
102
+ const [x, y] = [a, b].map((version) => version.split('.').map(Number));
103
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
104
+ }
105
+
106
+ function newestRelease(versions) {
107
+ return versions.filter((v) => RELEASE_VERSION.test(v ?? '')).reduce((newest, v) => (newest && compareReleases(newest, v) >= 0 ? newest : v), null);
108
+ }
109
+
110
+ function seenRelease() {
111
+ const saved = load(CHANGELOG_SEEN_KEY);
112
+ return RELEASE_VERSION.test(saved ?? '') ? saved : changelogView.seen;
113
+ }
114
+
115
+ function markReleasesSeen(...versions) {
116
+ const newest = newestRelease([seenRelease(), ...versions]);
117
+ if (!newest) return;
118
+ changelogView.seen = newest;
119
+ save(CHANGELOG_SEEN_KEY, newest);
120
+ }
121
+
122
+ function unreadRelease() {
123
+ if (!RELEASE_VERSION.test(state.version ?? '')) return null;
124
+ if (!seenRelease()) markReleasesSeen(state.version);
125
+ const newest = newestRelease([state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion]);
126
+ return compareReleases(newest, seenRelease()) > 0 ? newest : null;
60
127
  }
61
128
 
62
129
  // ---- theme ----------------------------------------------------------------
@@ -66,7 +133,8 @@ function applyTheme(theme) {
66
133
  document.documentElement.dataset.theme = theme;
67
134
  const button = $('theme-toggle');
68
135
  const other = theme === 'dark' ? 'light' : 'dark';
69
- button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
136
+ button.textContent = theme === 'dark' ? 'Light' : 'Dark';
137
+ button.dataset.next = other;
70
138
  button.title = `Switch to the ${other} theme`;
71
139
  button.setAttribute('aria-label', `Switch to the ${other} theme`);
72
140
  }
@@ -75,10 +143,18 @@ function currentTheme() {
75
143
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
76
144
  }
77
145
 
78
- function toggleTheme() {
146
+ function toggleTheme(event) {
79
147
  const theme = currentTheme() === 'dark' ? 'light' : 'dark';
80
148
  save(THEME_KEY, theme);
81
- applyTheme(theme);
149
+ if (!document.startViewTransition || reducedMotion.matches) return applyTheme(theme);
150
+ const box = event.currentTarget.getBoundingClientRect();
151
+ const x = box.left + box.width / 2;
152
+ const y = box.top + box.height / 2;
153
+ const root = document.documentElement.style;
154
+ root.setProperty('--reveal-x', `${Math.round(x)}px`);
155
+ root.setProperty('--reveal-y', `${Math.round(y)}px`);
156
+ root.setProperty('--reveal-r', `${Math.ceil(Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)))}px`);
157
+ document.startViewTransition(() => applyTheme(theme));
82
158
  }
83
159
 
84
160
  // ---- upgrading the manager ------------------------------------------------
@@ -93,15 +169,33 @@ function renderUpgrade() {
93
169
  button.textContent = `Upgrade to ${u.latestVersion}`;
94
170
  button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
95
171
  }
172
+ // A newer version on disk is used by the next manager, so the restart
173
+ // button becomes the way to pick it up.
174
+ const restart = $('restart-manager');
175
+ const pending = u?.pendingVersion;
176
+ restart.classList.toggle('pending', Boolean(pending));
177
+ restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
178
+ restart.title = pending
179
+ ? `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.`
180
+ : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
181
+ const panelUpgrade = $('changelog-upgrade');
182
+ panelUpgrade.hidden = button.hidden;
183
+ panelUpgrade.textContent = button.textContent;
184
+ panelUpgrade.title = button.title;
185
+ const panelRestart = $('changelog-restart');
186
+ panelRestart.hidden = restart.hidden || !pending;
187
+ panelRestart.textContent = restart.textContent;
188
+ panelRestart.title = restart.title;
189
+ $('changelog-actions').hidden = panelUpgrade.hidden && panelRestart.hidden;
96
190
  let text = '';
97
191
  let title = '';
98
192
  const last = u?.lastInstall;
99
193
  if (u?.installing) {
100
194
  text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
101
195
  title = 'npm is running in a session. Keep the manager running until it finishes.';
102
- } else if (u?.pendingVersion) {
103
- text = `v${u.pendingVersion} installed · restart to use it`;
104
- title = `Agent Guild ${u.pendingVersion} is installed, but this manager is still ${u.version}. Stop the manager and run "agent-guild open" to use it.`;
196
+ } else if (pending && !state.restartable) {
197
+ text = `v${pending} installed · run "agent-guild restart" to use it`;
198
+ title = `Agent Guild ${pending} is installed, but this manager is still ${u.version} and cannot restart itself. Run "agent-guild restart" in a terminal when your sessions are done; this page reconnects by itself.`;
105
199
  } else if (last?.outcome === 'failed') {
106
200
  text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
107
201
  title = 'See the upgrade session for npm\'s output, then run the upgrade again: the files on disk may be incomplete. On Windows, files in use cannot be replaced: stop the manager first and run the command yourself.';
@@ -121,9 +215,13 @@ function setUpgrade(upgrade) {
121
215
  const before = state.upgrade;
122
216
  state.upgrade = upgrade || null;
123
217
  renderUpgrade();
218
+ renderVersion();
219
+ if ($('changelog').open) renderChangelog();
124
220
  const pending = state.upgrade?.pendingVersion;
125
221
  if (pending && pending !== before?.pendingVersion) {
126
- toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
222
+ toast(state.restartable
223
+ ? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
224
+ : `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
127
225
  }
128
226
  }
129
227
 
@@ -203,9 +301,13 @@ function paintProviderIcon(el, provider) {
203
301
  const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
204
302
 
205
303
  function renderAgents(container, agents) {
304
+ const known = container.dataset.rendered ? new Set([...container.children].map((el) => el.dataset.agent)) : null;
305
+ container.dataset.rendered = 'true';
206
306
  container.replaceChildren(...agents.map((agent) => {
207
307
  const el = document.createElement('span');
208
308
  el.className = `agent ${agent.status}`;
309
+ el.dataset.agent = agent.id;
310
+ if (known && !known.has(agent.id)) el.classList.add('summon');
209
311
  el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
210
312
  el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
211
313
  el.textContent = (agent.name || '?').charAt(0).toUpperCase();
@@ -288,6 +390,8 @@ function renderAccounts(card, provider) {
288
390
  });
289
391
  }
290
392
 
393
+ let dealt = false;
394
+
291
395
  function renderProviders() {
292
396
  const list = $('providers');
293
397
  const tpl = $('provider-template');
@@ -310,8 +414,10 @@ function renderProviders() {
310
414
  start.hidden = !provider.available;
311
415
  start.addEventListener('click', () => startSession(provider, node));
312
416
  existing.hidden = !provider.available || !provider.resumable;
313
- existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
314
- existing.addEventListener('click', () => resumeSession(provider, node));
417
+ existing.title = provider.historySource
418
+ ? `Resume one of ${provider.tool}'s own earlier sessions`
419
+ : `Resume one of ${provider.tool}'s own sessions by its id`;
420
+ existing.addEventListener('click', () => showHistory(provider));
315
421
  const install = node.querySelector('.install');
316
422
  install.hidden = provider.available || !provider.installable;
317
423
  install.title = `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
@@ -330,6 +436,10 @@ function renderProviders() {
330
436
  renderModelStats(node, provider);
331
437
  return node;
332
438
  }));
439
+ if (!dealt && state.providers.length) {
440
+ dealt = true;
441
+ if (!reducedMotion.matches) list.classList.add('deal');
442
+ }
333
443
  }
334
444
 
335
445
  const openCopies = new Set();
@@ -385,7 +495,7 @@ function renderHint(hint, provider) {
385
495
  link.href = docs;
386
496
  link.target = '_blank';
387
497
  link.rel = 'noopener noreferrer';
388
- link.textContent = 'Docs ↗';
498
+ link.textContent = 'Docs';
389
499
  hint.append(' ', link);
390
500
  }
391
501
 
@@ -769,7 +879,7 @@ function modelFacts(card) {
769
879
  link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
770
880
  link.target = '_blank';
771
881
  link.rel = 'noopener noreferrer';
772
- link.textContent = 'OpenRouter ↗';
882
+ link.textContent = 'OpenRouter';
773
883
  link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
774
884
  const facts = document.createElement('div');
775
885
  facts.className = 'model-more';
@@ -1188,6 +1298,212 @@ function tickNews() {
1188
1298
  if ($('news').open) renderNewsStatus();
1189
1299
  }
1190
1300
 
1301
+ const RELEASE_BADGES = {
1302
+ running: ['in-use', 'Running', 'The session manager runs this version'],
1303
+ installed: ['installed', 'Installed', 'Installed. Restart the session manager to use it.'],
1304
+ newer: ['new', 'New', 'Released after the version the session manager runs'],
1305
+ };
1306
+ const changelogView = { opener: null, failed: null, seen: null };
1307
+ let changelogLoading = null;
1308
+ let changelogAgain = false;
1309
+
1310
+ function loadChangelog() {
1311
+ if (changelogLoading) {
1312
+ changelogAgain = true;
1313
+ return changelogLoading;
1314
+ }
1315
+ changelogLoading = api('GET', '/changelog').then((changelog) => {
1316
+ state.changelog = changelog;
1317
+ changelogView.failed = null;
1318
+ }, (err) => {
1319
+ if (err instanceof AuthError) return showAuth(err.message);
1320
+ changelogView.failed = state.connected ? err.message : 'the session manager is not reachable';
1321
+ }).finally(() => {
1322
+ changelogLoading = null;
1323
+ if ($('changelog').open) {
1324
+ markReleasesSeen(state.changelog?.releases[0]?.version);
1325
+ renderVersion();
1326
+ renderChangelog();
1327
+ }
1328
+ if (changelogAgain) {
1329
+ changelogAgain = false;
1330
+ loadChangelog();
1331
+ }
1332
+ });
1333
+ return changelogLoading;
1334
+ }
1335
+
1336
+ function openChangelog() {
1337
+ const dialog = $('changelog');
1338
+ renderChangelog();
1339
+ if (!dialog.open) {
1340
+ changelogView.opener = document.activeElement;
1341
+ dialog.showModal();
1342
+ }
1343
+ ($('changelog-list').querySelector('a') ?? $('changelog-close')).focus();
1344
+ markReleasesSeen(state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion, state.changelog?.releases[0]?.version);
1345
+ renderVersion();
1346
+ loadChangelog();
1347
+ }
1348
+
1349
+ function closeChangelog() {
1350
+ if ($('changelog').open) $('changelog').close();
1351
+ }
1352
+
1353
+ function releasesLink(label) {
1354
+ const link = document.createElement('a');
1355
+ link.className = 'console-link';
1356
+ link.href = RELEASES_URL;
1357
+ link.target = '_blank';
1358
+ link.rel = 'noopener noreferrer';
1359
+ link.textContent = label;
1360
+ link.setAttribute('aria-description', 'Opens in a new tab');
1361
+ return link;
1362
+ }
1363
+
1364
+ function releaseStatus(version) {
1365
+ const running = state.version;
1366
+ if (!RELEASE_VERSION.test(running ?? '')) return null;
1367
+ const order = compareReleases(version, running);
1368
+ if (order <= 0) return order === 0 ? 'running' : null;
1369
+ const installed = state.upgrade?.pendingVersion;
1370
+ return RELEASE_VERSION.test(installed ?? '') && compareReleases(version, installed) <= 0 ? 'installed' : 'newer';
1371
+ }
1372
+
1373
+ function changeRun(run) {
1374
+ const href = run.url ? webHref(run.url) : null;
1375
+ let node;
1376
+ if (href) {
1377
+ node = document.createElement('a');
1378
+ node.className = 'console-link';
1379
+ node.href = href;
1380
+ node.target = '_blank';
1381
+ node.rel = 'noopener noreferrer';
1382
+ node.textContent = run.text;
1383
+ } else if (run.code) {
1384
+ node = document.createElement('code');
1385
+ node.textContent = run.text;
1386
+ } else {
1387
+ node = document.createTextNode(run.text);
1388
+ }
1389
+ if (!run.strong) return node;
1390
+ const strong = document.createElement('strong');
1391
+ strong.append(node);
1392
+ return strong;
1393
+ }
1394
+
1395
+ function changeGroup(section) {
1396
+ const group = document.createElement('div');
1397
+ group.className = 'changelog-group';
1398
+ if (section.title) {
1399
+ const title = document.createElement('h4');
1400
+ title.className = 'changelog-type';
1401
+ title.classList.toggle('breaking', /breaking/i.test(section.title));
1402
+ title.textContent = section.title;
1403
+ group.append(title);
1404
+ }
1405
+ const list = document.createElement('ul');
1406
+ list.className = 'changelog-changes';
1407
+ list.append(...section.changes.map((runs) => {
1408
+ const item = document.createElement('li');
1409
+ item.append(...runs.map(changeRun));
1410
+ return item;
1411
+ }));
1412
+ group.append(list);
1413
+ return group;
1414
+ }
1415
+
1416
+ function releaseEntry(release) {
1417
+ const entry = document.createElement('li');
1418
+ entry.className = 'changelog-release';
1419
+ entry.dataset.version = release.version;
1420
+ const heading = document.createElement('h3');
1421
+ heading.className = 'news-head';
1422
+ const href = webHref(release.url);
1423
+ const title = document.createElement(href ? 'a' : 'span');
1424
+ title.className = 'news-link';
1425
+ title.textContent = `v${release.version}`;
1426
+ if (href) {
1427
+ title.href = href;
1428
+ title.target = '_blank';
1429
+ title.rel = 'noopener noreferrer';
1430
+ title.setAttribute('aria-description', 'Opens the release on GitHub in a new tab');
1431
+ }
1432
+ heading.append(title);
1433
+ const status = releaseStatus(release.version);
1434
+ if (status) heading.append(badge(...RELEASE_BADGES[status]));
1435
+ entry.append(heading);
1436
+ if (release.publishedAt) {
1437
+ const time = document.createElement('time');
1438
+ time.className = 'news-meta';
1439
+ time.dateTime = release.publishedAt;
1440
+ time.textContent = relativeTime(release.publishedAt);
1441
+ time.title = new Date(release.publishedAt).toLocaleString();
1442
+ entry.append(time);
1443
+ }
1444
+ entry.append(...release.sections.map(changeGroup));
1445
+ return entry;
1446
+ }
1447
+
1448
+ function changelogSummary() {
1449
+ const v = state.version;
1450
+ if (!v) return 'Agent Guild releases, newest first';
1451
+ if (isDevelopmentBuild(v)) return `Development build (${v}) · releases, newest first`;
1452
+ const u = state.upgrade;
1453
+ const parts = [`Running v${v}`];
1454
+ if (u?.pendingVersion) parts.push(`v${u.pendingVersion} installed`);
1455
+ const newer = state.changelog?.releases.some((release) => compareReleases(release.version, v) > 0);
1456
+ if (u?.available && u.latestVersion) parts.push(`v${u.latestVersion} available`);
1457
+ else if (!u?.pendingVersion && u?.latestVersion === v && !newer) parts.push('the latest release');
1458
+ return parts.join(' · ');
1459
+ }
1460
+
1461
+ function changelogNote(changelog) {
1462
+ if (changelog?.releases.length) return [];
1463
+ if (changelogView.failed) return [`Release notes could not be loaded: ${changelogView.failed}. `, releasesLink('Read them on GitHub')];
1464
+ if (!changelog || changelog.refreshing) return ['Loading release notes…'];
1465
+ if (changelog.error) return [`Release notes could not be loaded. GitHub Releases: ${changelog.error}. `, releasesLink('Read them on GitHub')];
1466
+ return ['No releases yet.'];
1467
+ }
1468
+
1469
+ function renderChangelogStatus(changelog) {
1470
+ const lines = [];
1471
+ if (changelog) {
1472
+ const updated = changelog.refreshing ? 'checking for new releases…' : changelog.okAt ? `updated ${relativeTime(changelog.okAt)}` : '';
1473
+ lines.push(['From GitHub Releases', updated].filter(Boolean).join(' · '));
1474
+ if (changelog.error && changelog.releases.length) lines.push(`The last check failed (${changelog.error}); showing notes fetched ${relativeTime(changelog.okAt)}.`);
1475
+ }
1476
+ lines.push(releasesLink('All releases on GitHub'));
1477
+ $('changelog-status').replaceChildren(...lines.map((content) => {
1478
+ const line = document.createElement('span');
1479
+ line.append(content);
1480
+ return line;
1481
+ }));
1482
+ $('changelog-status').hidden = false;
1483
+ }
1484
+
1485
+ function renderChangelog() {
1486
+ const changelog = state.changelog;
1487
+ const releases = changelog?.releases ?? [];
1488
+ $('changelog-sub').textContent = changelogSummary();
1489
+ const list = $('changelog-list');
1490
+ const body = list.parentElement;
1491
+ const top = body.scrollTop;
1492
+ const entry = document.activeElement?.closest?.('#changelog-list .changelog-release');
1493
+ const focus = entry && { version: entry.dataset.version, index: [...entry.querySelectorAll('a')].indexOf(document.activeElement) };
1494
+ list.replaceChildren(...releases.map(releaseEntry));
1495
+ list.hidden = releases.length === 0;
1496
+ const note = changelogNote(changelog);
1497
+ $('changelog-note').replaceChildren(...note);
1498
+ $('changelog-note').hidden = note.length === 0;
1499
+ renderChangelogStatus(changelog);
1500
+ body.scrollTop = top;
1501
+ if (focus) {
1502
+ const again = [...list.children].find((el) => el.dataset.version === focus.version);
1503
+ (again?.querySelectorAll('a')[focus.index] ?? again?.querySelector('a') ?? $('changelog-close')).focus({ preventScroll: true });
1504
+ }
1505
+ }
1506
+
1191
1507
  const CHANNEL_LABELS = {
1192
1508
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
1193
1509
  };
@@ -1247,32 +1563,229 @@ async function installProvider(provider, card, { force = false } = {}) {
1247
1563
  }
1248
1564
  }
1249
1565
 
1250
- async function startSession(provider, card, { resume } = {}) {
1251
- const cwd = $('cwd').value.trim();
1252
- save(CWD_KEY, cwd);
1253
- card.classList.add('busy');
1566
+ /**
1567
+ * Start a session. A resumed session starts in the folder its transcript
1568
+ * names, since Claude Code and Gemini CLI only find a session from there;
1569
+ * when that folder is gone, the working folder is used instead.
1570
+ */
1571
+ async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
1572
+ const working = $('cwd').value.trim();
1573
+ save(CWD_KEY, working);
1574
+ card?.classList.add('busy');
1254
1575
  try {
1255
- const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
1256
- const { session } = await api('POST', '/sessions', body);
1576
+ const body = { providerId: provider.id, account, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
1577
+ let session;
1578
+ try {
1579
+ ({ session } = await api('POST', '/sessions', body));
1580
+ } catch (err) {
1581
+ if (err.code !== 'bad_cwd' || !cwd) throw err;
1582
+ toast(`${cwd} no longer exists; starting in the working folder instead.`, 8000);
1583
+ ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
1584
+ }
1257
1585
  upsertSession(session);
1586
+ closeHistory({ focusOpener: false });
1258
1587
  openPanel(session.id);
1259
1588
  } catch (err) {
1260
1589
  if (err instanceof AuthError) return showAuth(err.message);
1261
1590
  toast(err.message, 8000);
1262
1591
  } finally {
1263
- card.classList.remove('busy');
1592
+ card?.classList.remove('busy');
1264
1593
  }
1265
1594
  }
1266
1595
 
1267
- function resumeSession(provider, card) {
1268
- const id = prompt(`${provider.tool} session id or name to resume`);
1269
- if (id === null || !id.trim()) return;
1270
- startSession(provider, card, { resume: id.trim() });
1596
+ // ---- session history ------------------------------------------------------
1597
+
1598
+ const HISTORY_LIMIT = 200;
1599
+ const historyView = { providerId: null, accountId: null, snapshot: null, loading: false };
1600
+ let historyOpener = null;
1601
+
1602
+ function historyProvider() {
1603
+ return state.providers.find((p) => p.id === historyView.providerId) ?? null;
1604
+ }
1605
+
1606
+ function toolSessionId(s) {
1607
+ return s.toolSessionId || s.resume || null;
1608
+ }
1609
+
1610
+ function shortId(id) {
1611
+ return id.length > 12 ? id.slice(0, 8) : id;
1612
+ }
1613
+
1614
+ function copyId(id) {
1615
+ navigator.clipboard?.writeText(id).then(() => toast(`Copied ${id}`, 2500), () => toast(id, 8000));
1616
+ }
1617
+
1618
+ function paintIdButton(button, id) {
1619
+ button.hidden = !id;
1620
+ if (!id) return;
1621
+ button.textContent = shortId(id);
1622
+ button.title = `Session id ${id}. Click to copy it.`;
1623
+ button.setAttribute('aria-label', `Copy session id ${id}`);
1624
+ }
1625
+
1626
+ function runningOn(providerId, accountId, id) {
1627
+ return [...state.sessions.values()].find((s) =>
1628
+ s.status === 'running' && s.task === null && s.provider.id === providerId && (s.account?.id ?? 'default') === accountId && toolSessionId(s) === id) ?? null;
1629
+ }
1630
+
1631
+ function showHistory(provider) {
1632
+ const account = selectedAccount(provider);
1633
+ const same = historyView.providerId === provider.id && historyView.accountId === account.id;
1634
+ if (!same) Object.assign(historyView, { providerId: provider.id, accountId: account.id, snapshot: null, loading: false });
1635
+ const dialog = $('history');
1636
+ $('history-filter').value = '';
1637
+ $('history-id').value = '';
1638
+ renderHistory();
1639
+ if (!dialog.open) {
1640
+ historyOpener = document.activeElement;
1641
+ dialog.showModal();
1642
+ }
1643
+ if (provider.historySource) loadHistory();
1644
+ else $('history-id').focus();
1645
+ }
1646
+
1647
+ async function loadHistory() {
1648
+ const provider = historyProvider();
1649
+ if (!provider || historyView.loading) return;
1650
+ const { providerId, accountId } = historyView;
1651
+ historyView.loading = true;
1652
+ renderHistory();
1653
+ try {
1654
+ const { history } = await api('GET', `/providers/${providerId}/history?account=${encodeURIComponent(accountId)}&limit=${HISTORY_LIMIT}`);
1655
+ if (historyView.providerId === providerId && historyView.accountId === accountId) historyView.snapshot = history;
1656
+ } catch (err) {
1657
+ if (err instanceof AuthError) return showAuth(err.message);
1658
+ if (historyView.providerId === providerId) historyView.snapshot = { sessions: [], total: 0, error: err.message };
1659
+ } finally {
1660
+ historyView.loading = false;
1661
+ renderHistory();
1662
+ }
1663
+ }
1664
+
1665
+ function historyText(entry) {
1666
+ return `${entry.title ?? ''}\n${entry.cwd ?? ''}\n${entry.id}`.toLowerCase();
1667
+ }
1668
+
1669
+ function folderName(dir) {
1670
+ const parts = dir.replace(/[\\/]+$/, '').split(/[\\/]/);
1671
+ return parts.at(-1) || dir;
1672
+ }
1673
+
1674
+ function sameFolder(a, b) {
1675
+ const clean = (dir) => (dir || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();
1676
+ return clean(a) === clean(b);
1677
+ }
1678
+
1679
+ function historyEntry(id) {
1680
+ return historyView.snapshot?.sessions.find((entry) => entry.id === id) ?? null;
1681
+ }
1682
+
1683
+ function resumeFromHistory(provider, id, cwd) {
1684
+ const running = runningOn(provider.id, historyView.accountId, id);
1685
+ if (running) {
1686
+ closeHistory({ focusOpener: false });
1687
+ openPanel(running.id);
1688
+ } else startSession(provider, null, { resume: id, cwd: cwd || undefined, account: historyView.accountId });
1689
+ }
1690
+
1691
+ function buildHistoryRow(id) {
1692
+ const node = $('history-template').content.firstElementChild.cloneNode(true);
1693
+ node.dataset.id = id;
1694
+ const idButton = node.querySelector('.session-id');
1695
+ paintIdButton(idButton, id);
1696
+ idButton.addEventListener('click', () => copyId(id));
1697
+ node.querySelector('.history-resume').addEventListener('click', () => {
1698
+ const provider = historyProvider();
1699
+ const entry = historyEntry(id);
1700
+ if (provider && entry) resumeFromHistory(provider, id, entry.cwd);
1701
+ });
1702
+ return node;
1703
+ }
1704
+
1705
+ function updateHistoryRow(node, provider, entry) {
1706
+ const running = runningOn(provider.id, historyView.accountId, entry.id);
1707
+ node.classList.toggle('untitled', !entry.title);
1708
+ node.classList.toggle('running', Boolean(running));
1709
+ node.querySelector('.history-title').textContent = entry.title ?? 'Untitled session';
1710
+ node.querySelector('.history-title').title = entry.title ?? '';
1711
+ const meta = node.querySelector('.history-meta');
1712
+ const when = entry.updatedAt ? `updated ${relativeTime(entry.updatedAt)}` : '';
1713
+ meta.textContent = [entry.cwd && folderName(entry.cwd), when, running && `open in Agent Guild as ${running.name}`].filter(Boolean).join(' · ');
1714
+ meta.title = [entry.cwd, entry.startedAt && `started ${new Date(entry.startedAt).toLocaleString()}`].filter(Boolean).join('\n');
1715
+ const action = node.querySelector('.history-resume');
1716
+ action.textContent = running ? 'Open' : 'Resume';
1717
+ action.title = running
1718
+ ? `This session is running in Agent Guild as "${running.name}". Open it instead of resuming it twice.`
1719
+ : `Resume this ${provider.tool} session${entry.cwd ? ` in ${entry.cwd}` : ''}`;
1720
+ action.setAttribute('aria-label', `${action.textContent} ${entry.title ?? entry.id}`);
1721
+ }
1722
+
1723
+ /** Rows are kept and updated in place, so a refresh never drops keyboard focus from a row's buttons. */
1724
+ function renderHistoryRows(provider, shown) {
1725
+ const list = $('history-list');
1726
+ const rows = new Map([...list.children].map((node) => [node.dataset.id, node]));
1727
+ const wanted = new Set(shown.map((entry) => entry.id));
1728
+ for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
1729
+ shown.forEach((entry, index) => {
1730
+ let node = rows.get(entry.id);
1731
+ if (!node) node = buildHistoryRow(entry.id);
1732
+ updateHistoryRow(node, provider, entry);
1733
+ if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
1734
+ });
1735
+ }
1736
+
1737
+ function renderHistory() {
1738
+ const provider = historyProvider();
1739
+ if (!provider) return closeHistory();
1740
+ const account = provider.accounts?.find((a) => a.id === historyView.accountId);
1741
+ paintProviderIcon($('history-icon'), provider);
1742
+ $('history-title').textContent = `${provider.tool} sessions`;
1743
+ const snapshot = historyView.snapshot;
1744
+ const filter = $('history-filter').value.trim().toLowerCase();
1745
+ const working = $('cwd').value.trim();
1746
+ const here = $('history-here');
1747
+ here.disabled = !working;
1748
+ here.parentElement.title = working ? `Only sessions started in ${working}` : 'Set a working folder above to filter by it';
1749
+ const all = snapshot?.sessions ?? [];
1750
+ const shown = all.filter((entry) => (!filter || historyText(entry).includes(filter)) && (!here.checked || here.disabled || sameFolder(entry.cwd, working)));
1751
+ const parts = [];
1752
+ if ((provider.accounts?.length ?? 0) > 1 && account) parts.push(`${account.label} account`);
1753
+ if (snapshot && !snapshot.error) {
1754
+ parts.push(snapshot.total === 0 ? 'no sessions found' : `${snapshot.total} session${snapshot.total === 1 ? '' : 's'}, newest first`);
1755
+ if (shown.length !== all.length) parts.push(`${shown.length} shown`);
1756
+ }
1757
+ $('history-sub').textContent = parts.join(' · ');
1758
+ renderHistoryRows(provider, shown);
1759
+ let note = '';
1760
+ if (!provider.historySource) note = `Agent Guild cannot list ${provider.tool}'s sessions. Enter the id of one to resume it.`;
1761
+ else if (historyView.loading && !snapshot) note = `Reading ${provider.tool}'s sessions…`;
1762
+ else if (snapshot?.error) note = `Sessions could not be read: ${snapshot.error}`;
1763
+ else if (snapshot && all.length === 0) note = `No ${provider.tool} sessions were found${account && account.id !== 'default' ? ` for the ${account.label} account` : ''}.`;
1764
+ else if (snapshot && shown.length === 0) note = 'No session matches the filter.';
1765
+ $('history-note').textContent = note;
1766
+ $('history-note').hidden = !note;
1767
+ $('history-filter').disabled = !provider.historySource;
1768
+ here.parentElement.hidden = !provider.historySource;
1769
+ }
1770
+
1771
+ /** Closing to open a session leaves focus with the terminal; otherwise it returns to the opener. */
1772
+ function closeHistory({ focusOpener = true } = {}) {
1773
+ if (!$('history').open) return;
1774
+ if (!focusOpener) historyOpener = false;
1775
+ $('history').close();
1776
+ }
1777
+
1778
+ function resumeById(event) {
1779
+ event.preventDefault();
1780
+ const provider = historyProvider();
1781
+ const id = $('history-id').value.trim();
1782
+ if (provider && id) resumeFromHistory(provider, id, null);
1271
1783
  }
1272
1784
 
1273
1785
  // ---- session cards --------------------------------------------------------
1274
1786
 
1275
1787
  const cards = new Map();
1788
+ let sessionsShown = false;
1276
1789
 
1277
1790
  const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
1278
1791
 
@@ -1310,10 +1823,34 @@ function buildCard(session) {
1310
1823
  node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
1311
1824
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
1312
1825
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
1826
+ node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
1827
+ node.querySelector('.session-id').addEventListener('click', () => {
1828
+ const id = toolSessionId(state.sessions.get(session.id) ?? session);
1829
+ if (id) copyId(id);
1830
+ });
1313
1831
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
1832
+ node.addEventListener('animationend', (e) => {
1833
+ if (e.animationName === 'level-up') e.target.classList.remove('level-up');
1834
+ else if (e.animationName === 'card-enter' && e.target === node) node.classList.remove('enter');
1835
+ });
1314
1836
  return node;
1315
1837
  }
1316
1838
 
1839
+ function resumable(s) {
1840
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1841
+ const id = toolSessionId(s);
1842
+ return Boolean(s.status === 'exited' && s.task === null && id && provider?.available && provider.resumable
1843
+ && !runningOn(s.provider.id, s.account?.id ?? 'default', id));
1844
+ }
1845
+
1846
+ function resumeCard(id) {
1847
+ const s = state.sessions.get(id);
1848
+ if (!s || !resumable(s)) return;
1849
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1850
+ const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
1851
+ startSession(provider, cards.get(id), { resume: toolSessionId(s), cwd: s.cwd, account });
1852
+ }
1853
+
1317
1854
  function sessionLevel(s) {
1318
1855
  const end = s.status === 'exited'
1319
1856
  ? Date.parse(s.exitedAt ?? s.lastOutputAt ?? s.createdAt)
@@ -1327,11 +1864,14 @@ function updateCard(node, s) {
1327
1864
  paintProviderIcon(node.querySelector('.provider-icon'), s.provider);
1328
1865
  const level = sessionLevel(s);
1329
1866
  const badge = node.querySelector('.level-badge');
1867
+ if (badge.textContent && Number(badge.textContent) < level) badge.classList.add('level-up');
1330
1868
  badge.textContent = level;
1331
1869
  badge.title = `Level ${level}`;
1332
1870
  node.querySelector('.name').textContent = s.name;
1333
- const resumed = s.resume ? ` · resumed ${s.resume}` : '';
1334
- node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1871
+ const id = toolSessionId(s);
1872
+ const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
1873
+ node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1874
+ paintIdButton(node.querySelector('.session-id'), id);
1335
1875
  const pill = node.querySelector('.status-pill');
1336
1876
  pill.textContent = statusText(s);
1337
1877
  pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
@@ -1349,6 +1889,9 @@ function updateCard(node, s) {
1349
1889
  node.classList.toggle('exited', s.status === 'exited');
1350
1890
  node.querySelector('.stop').hidden = s.status !== 'running';
1351
1891
  node.querySelector('.remove').hidden = s.status === 'running';
1892
+ const resume = node.querySelector('.resume');
1893
+ resume.hidden = !resumable(s);
1894
+ resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
1352
1895
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
1353
1896
  const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
1354
1897
  node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
@@ -1362,7 +1905,11 @@ function renderSessions() {
1362
1905
  }
1363
1906
  sessions.forEach((s, index) => {
1364
1907
  let node = cards.get(s.id);
1365
- if (!node) { node = buildCard(s); cards.set(s.id, node); }
1908
+ if (!node) {
1909
+ node = buildCard(s);
1910
+ cards.set(s.id, node);
1911
+ if (sessionsShown) node.classList.add('enter');
1912
+ }
1366
1913
  updateCard(node, s);
1367
1914
  // Move a card only when it is out of place: re-inserting a node drops
1368
1915
  // keyboard focus and can swallow a click that is in progress.
@@ -1371,10 +1918,23 @@ function renderSessions() {
1371
1918
  const running = sessions.filter((s) => s.status === 'running').length;
1372
1919
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
1373
1920
  $('empty').hidden = sessions.length > 0;
1921
+ guardLeaving();
1374
1922
  if (state.activeId) updatePanel();
1923
+ if ($('history').open) renderHistory();
1375
1924
  if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
1376
1925
  }
1377
1926
 
1927
+ function confirmLeaving(event) {
1928
+ event.preventDefault();
1929
+ event.returnValue = true;
1930
+ }
1931
+
1932
+ function guardLeaving() {
1933
+ const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
1934
+ if (running) addEventListener('beforeunload', confirmLeaving);
1935
+ else removeEventListener('beforeunload', confirmLeaving);
1936
+ }
1937
+
1378
1938
  function upsertSession(session) {
1379
1939
  state.sessions.set(session.id, session);
1380
1940
  renderSessions();
@@ -1587,7 +2147,8 @@ function updatePanel() {
1587
2147
  if (!s) return;
1588
2148
  paintProviderIcon($('panel-icon'), s.provider);
1589
2149
  $('panel-title').textContent = s.name;
1590
- $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
2150
+ const id = toolSessionId(s);
2151
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd, id && `session ${id}`].filter(Boolean).join(' · ');
1591
2152
  $('panel-sub').title = modelTitle(s);
1592
2153
  renderAgents($('panel-agents'), s.agents);
1593
2154
  const stop = $('panel-stop');
@@ -1597,74 +2158,143 @@ function updatePanel() {
1597
2158
  // ---- stopping the manager -------------------------------------------------
1598
2159
 
1599
2160
  /**
1600
- * Stop the session manager. The manager refuses while sessions are running
1601
- * unless told to force, so the warning is enforced for every client and the
1602
- * count in the dialog is the manager's, not this page's possibly stale list.
2161
+ * Stop the session manager, or stop it and start it again. The manager
2162
+ * refuses while sessions are running unless told to force, so the warning is
2163
+ * enforced for every client and the count in the dialog is the manager's,
2164
+ * not this page's possibly stale list.
1603
2165
  */
1604
- async function stopManager({ force = false } = {}) {
1605
- const button = $('stop-manager');
1606
- button.disabled = true;
2166
+ async function stopManager({ force = false, restart = false } = {}) {
2167
+ const buttons = [$('stop-manager'), $('restart-manager')];
2168
+ for (const button of buttons) button.disabled = true;
1607
2169
  try {
1608
- const { running } = await api('POST', '/shutdown', force ? { force: true } : undefined);
1609
- enterStopping(running);
2170
+ const body = force || restart ? { ...(force && { force: true }), ...(restart && { restart: true }) } : undefined;
2171
+ const answer = await api('POST', '/shutdown', body);
2172
+ // A manager that does not say it will restart only stops.
2173
+ enterStopping(answer.running, restart && answer.restart === true);
2174
+ if (restart && answer.restart !== true) {
2175
+ toast('This manager cannot restart itself. Run "agent-guild restart" in a terminal to start the new one.', 12000);
2176
+ }
1610
2177
  } catch (err) {
1611
2178
  if (err instanceof AuthError) return showAuth(err.message);
1612
2179
  if (err.code === 'sessions_running') {
1613
2180
  const n = err.running;
1614
2181
  const what = `${n} session${n === 1 ? ' is' : 's are'} still running`;
1615
2182
  const them = n === 1 ? 'it' : 'all of them';
1616
- if (confirm(`${what}. Stopping the session manager ends ${them}. Stop anyway?`)) {
1617
- return stopManager({ force: true });
2183
+ const verb = restart ? 'Restarting' : 'Stopping';
2184
+ if (confirm(`${what}. ${verb} the session manager ends ${them}. ${restart ? 'Restart' : 'Stop'} anyway?`)) {
2185
+ return stopManager({ force: true, restart });
1618
2186
  }
1619
2187
  return;
1620
2188
  }
1621
2189
  toast(err.message, 8000);
1622
2190
  } finally {
1623
- button.disabled = false;
2191
+ for (const button of buttons) button.disabled = false;
1624
2192
  }
1625
2193
  }
1626
2194
 
2195
+ /** How long to wait for a restarted manager before telling the user how to start one by hand. */
2196
+ const RESTART_WAIT_MS = 30000;
2197
+ let restartTimer;
2198
+
1627
2199
  /** The manager is going down, by this page's request or another client's. */
1628
- function enterStopping(running = 0) {
2200
+ function enterStopping(running = 0, restart = false) {
2201
+ if (restart) state.restarting = true;
1629
2202
  if (state.stopping) return;
1630
2203
  state.stopping = true;
1631
2204
  state.stopRemaining = null;
1632
2205
  closePanel();
1633
2206
  closeModels();
1634
2207
  closeNews();
2208
+ closeChangelog();
2209
+ closeHistory();
1635
2210
  for (const view of state.views.values()) view.dispose();
1636
2211
  state.views.clear();
1637
2212
  state.sessions.clear();
1638
2213
  renderSessions();
1639
2214
  $('app').hidden = true;
1640
2215
  const n = Number(running) || 0;
1641
- showStopped('stopping', 'Stopping the session manager…',
1642
- n ? `Ending ${n} running session${n === 1 ? '' : 's'}. This can take a few seconds.` : 'This can take a few seconds.');
1643
- setConnection('down', 'Stopping the session manager…');
2216
+ const ending = n ? `Ending ${n} running session${n === 1 ? '' : 's'}. ` : '';
2217
+ if (state.restarting) {
2218
+ showStopped('restarting', 'Restarting the session manager…', `${ending}A new manager starts in a moment and this page reconnects to it by itself.`);
2219
+ clearTimeout(restartTimer);
2220
+ restartTimer = setTimeout(restartGaveUp, RESTART_WAIT_MS);
2221
+ } else {
2222
+ showStopped('stopping', 'Stopping the session manager…', `${ending}This can take a few seconds.`);
2223
+ }
2224
+ setConnection('down', state.restarting ? 'Restarting the session manager…' : 'Stopping the session manager…');
2225
+ }
2226
+
2227
+ /** The manager is back: a `hello` arrived while the page was waiting out a stop. */
2228
+ function leaveStopping() {
2229
+ state.stopping = false;
2230
+ state.restarting = false;
2231
+ clearTimeout(restartTimer);
2232
+ $('stopped').hidden = true;
2233
+ $('app').hidden = false;
2234
+ }
2235
+
2236
+ /** A restarted manager did not come back in time; the user has to start one by hand. */
2237
+ function restartGaveUp() {
2238
+ if (!state.stopping || !state.restarting) return;
2239
+ state.restarting = false;
2240
+ setConnection('down', 'Session manager stopped');
2241
+ showStopped('stopped', 'The session manager did not come back',
2242
+ `Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
1644
2243
  }
1645
2244
 
1646
2245
  function showStopped(phase, title, text) {
1647
2246
  const el = $('stopped');
1648
2247
  el.classList.toggle('stopping', phase === 'stopping');
2248
+ el.classList.toggle('restarting', phase === 'restarting');
1649
2249
  $('stopped-title').textContent = title;
1650
2250
  $('stopped-text').textContent = text;
2251
+ // How to start again is only useful once the manager is really gone; a
2252
+ // restart brings it back without the user doing anything.
2253
+ $('stopped-help').hidden = phase !== 'stopped';
2254
+ if (phase === 'stopped') renderStoppedHelp();
1651
2255
  el.hidden = false;
1652
2256
  }
1653
2257
 
2258
+ /** Instructions for starting the manager again, worded for this computer. */
2259
+ function renderStoppedHelp() {
2260
+ const windows = /Win/.test(navigator.platform || navigator.userAgent);
2261
+ $('stopped-how').textContent = isMac
2262
+ ? 'To start again, open Terminal (search for it with Spotlight) and run:'
2263
+ : windows
2264
+ ? 'To start again, open Windows Terminal or PowerShell (search for it in the Start menu) and run:'
2265
+ : 'To start again, open a terminal and run:';
2266
+ const launcher = state.launcher;
2267
+ $('stopped-launcher').hidden = !launcher;
2268
+ $('stopped-launcher-path').textContent = launcher || '';
2269
+ }
2270
+
2271
+ async function copyCommand() {
2272
+ const button = $('copy-command');
2273
+ try {
2274
+ await navigator.clipboard.writeText('agent-guild open');
2275
+ button.textContent = 'Copied';
2276
+ setTimeout(() => { button.textContent = 'Copy'; }, 1500);
2277
+ } catch {
2278
+ toast('Could not copy. Select the command and copy it yourself.');
2279
+ }
2280
+ }
2281
+
1654
2282
  /**
1655
2283
  * The manager has stopped. Only a manager.stopped event with nothing
1656
2284
  * remaining confirms that every process exited; a timeout, or a socket that
1657
2285
  * dropped without the event, must not be announced as a clean stop.
1658
2286
  */
1659
2287
  function showManagerStopped() {
1660
- setConnection('down', 'Session manager stopped');
1661
2288
  const n = state.stopRemaining;
1662
- if (n === 0) return showStopped('stopped', 'Session manager stopped', 'Every session has ended.');
1663
- if (n > 0) {
1664
- return showStopped('stopped', 'Session manager stopped',
1665
- `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`);
2289
+ const sessions = n === 0 ? 'Every session has ended.'
2290
+ : n > 0 ? `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`
2291
+ : 'The manager went away before confirming that every session had ended.';
2292
+ if (state.restarting) {
2293
+ setConnection('down', 'Restarting the session manager…');
2294
+ return showStopped('restarting', 'Restarting the session manager…', `${sessions} Waiting for the new manager; this page reconnects to it by itself.`);
1666
2295
  }
1667
- showStopped('stopped', 'Session manager stopped', 'The manager went away before confirming that every session had ended.');
2296
+ setConnection('down', 'Session manager stopped');
2297
+ showStopped('stopped', 'Session manager stopped', sessions);
1668
2298
  }
1669
2299
 
1670
2300
  // ---- events ---------------------------------------------------------------
@@ -1679,25 +2309,32 @@ function connectEvents() {
1679
2309
  ws.onmessage = (event) => {
1680
2310
  const msg = JSON.parse(event.data);
1681
2311
  if (msg.type === 'hello') {
1682
- if (state.stopping) {
1683
- // The manager is back after a stop; the page picks up where it was.
1684
- state.stopping = false;
1685
- $('stopped').hidden = true;
1686
- $('app').hidden = false;
1687
- }
2312
+ // The manager is back after a stop or restart; the page picks up where it was.
2313
+ if (state.stopping) leaveStopping();
2314
+ state.version = msg.version || null;
2315
+ state.pid = msg.pid || null;
2316
+ state.restartable = typeof msg.pid === 'number';
2317
+ state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
2318
+ renderVersion();
2319
+ setConnection('ok', 'Connected to session manager');
1688
2320
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
1689
2321
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
1690
2322
  renderSessions();
2323
+ sessionsShown = true;
1691
2324
  setUpgrade(msg.upgrade);
1692
2325
  loadNews();
2326
+ // A changelog.updated sent while the socket was down is lost; catch up the open panel.
2327
+ if ($('changelog').open) loadChangelog();
1693
2328
  } else if (msg.type === 'news.updated') {
1694
2329
  loadNews();
2330
+ } else if (msg.type === 'changelog.updated') {
2331
+ if ($('changelog').open) loadChangelog();
1695
2332
  } else if (msg.type === 'manager.upgrade') {
1696
2333
  setUpgrade(msg.upgrade);
1697
2334
  } else if (msg.type === 'manager.stopping') {
1698
- enterStopping(msg.running);
2335
+ enterStopping(msg.running, msg.restart === true);
1699
2336
  } else if (msg.type === 'manager.stopped') {
1700
- enterStopping();
2337
+ enterStopping(0, msg.restart === true);
1701
2338
  state.stopRemaining = Number(msg.remaining) || 0;
1702
2339
  showManagerStopped();
1703
2340
  } else if (msg.type === 'session.created' || msg.type === 'session.updated') {
@@ -1707,6 +2344,7 @@ function connectEvents() {
1707
2344
  } else if (msg.type === 'providers.updated') {
1708
2345
  state.providers = msg.providers;
1709
2346
  renderProviders();
2347
+ if ($('history').open) renderHistory();
1710
2348
  scheduleStats();
1711
2349
  }
1712
2350
  };
@@ -1750,10 +2388,14 @@ let newsTimer;
1750
2388
  function showAuth(message = '') {
1751
2389
  closeModels();
1752
2390
  closeNews();
2391
+ closeChangelog();
2392
+ closeHistory();
1753
2393
  $('app').hidden = true;
1754
2394
  $('terminal-panel').hidden = true;
1755
2395
  $('stopped').hidden = true;
1756
2396
  state.stopping = false;
2397
+ state.restarting = false;
2398
+ clearTimeout(restartTimer);
1757
2399
  $('auth').hidden = false;
1758
2400
  $('auth-error').textContent = message;
1759
2401
  setConnection('down', 'Not connected');
@@ -1795,6 +2437,19 @@ $('auth-form').addEventListener('submit', (e) => {
1795
2437
  });
1796
2438
  $('panel-close').addEventListener('click', closePanel);
1797
2439
  $('models-close').addEventListener('click', closeModels);
2440
+ $('history-close').addEventListener('click', closeHistory);
2441
+ $('history').addEventListener('click', (e) => { if (e.target === $('history')) closeHistory(); });
2442
+ $('history').addEventListener('close', () => {
2443
+ if (historyOpener !== false) {
2444
+ const opener = historyOpener?.isConnected ? historyOpener
2445
+ : $('providers').querySelector(`.provider[data-id="${historyView.providerId}"] .existing`);
2446
+ opener?.focus();
2447
+ }
2448
+ historyOpener = null;
2449
+ });
2450
+ $('history-filter').addEventListener('input', renderHistory);
2451
+ $('history-here').addEventListener('change', renderHistory);
2452
+ $('history-form').addEventListener('submit', resumeById);
1798
2453
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
1799
2454
  $('models').addEventListener('close', () => {
1800
2455
  hideTip();
@@ -1841,6 +2496,23 @@ $('news').addEventListener('close', () => {
1841
2496
  document.addEventListener('visibilitychange', () => {
1842
2497
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
1843
2498
  });
2499
+ $('version').addEventListener('click', openChangelog);
2500
+ $('changelog-close').addEventListener('click', closeChangelog);
2501
+ $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
2502
+ $('changelog').addEventListener('close', () => {
2503
+ const opener = changelogView.opener?.isConnected && !changelogView.opener.closest('[hidden]') ? changelogView.opener : $('version');
2504
+ opener.focus();
2505
+ changelogView.opener = null;
2506
+ });
2507
+ $('changelog-upgrade').addEventListener('click', () => {
2508
+ closeChangelog();
2509
+ upgradeManager();
2510
+ });
2511
+ $('changelog-restart').addEventListener('click', () => {
2512
+ closeChangelog();
2513
+ stopManager({ restart: true });
2514
+ });
2515
+ addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
1844
2516
  $('models-more').addEventListener('click', () => {
1845
2517
  const before = $('models-list').childElementCount;
1846
2518
  modelsView.all = true;
@@ -1848,8 +2520,34 @@ $('models-more').addEventListener('click', () => {
1848
2520
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1849
2521
  });
1850
2522
  $('stop-manager').addEventListener('click', () => stopManager());
2523
+ $('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
2524
+ $('copy-command').addEventListener('click', copyCommand);
1851
2525
  $('upgrade').addEventListener('click', upgradeManager);
1852
2526
  $('theme-toggle').addEventListener('click', toggleTheme);
2527
+ $('providers').addEventListener('animationend', (e) => {
2528
+ if (e.animationName === 'deal' && e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
2529
+ });
2530
+ let tiltFrame = 0;
2531
+ $('providers').addEventListener('pointermove', (e) => {
2532
+ const card = e.target.closest('.provider');
2533
+ if (!card || !finePointer.matches || reducedMotion.matches) return;
2534
+ const box = card.getBoundingClientRect();
2535
+ const x = ((e.clientX - box.left) / box.width) * 2 - 1;
2536
+ const y = ((e.clientY - box.top) / box.height) * 2 - 1;
2537
+ cancelAnimationFrame(tiltFrame);
2538
+ tiltFrame = requestAnimationFrame(() => {
2539
+ card.style.setProperty('--px', x.toFixed(3));
2540
+ card.style.setProperty('--py', y.toFixed(3));
2541
+ });
2542
+ });
2543
+ $('providers').addEventListener('pointerout', (e) => {
2544
+ const card = e.target.closest('.provider');
2545
+ if (card && !card.contains(e.relatedTarget)) {
2546
+ cancelAnimationFrame(tiltFrame);
2547
+ card.style.removeProperty('--px');
2548
+ card.style.removeProperty('--py');
2549
+ }
2550
+ });
1853
2551
  applyTheme(currentTheme());
1854
2552
  // Follow the system setting until the user picks a theme.
1855
2553
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {