@oddessentials/agent-guild 0.3.0 → 0.5.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.
package/web/app.js CHANGED
@@ -5,6 +5,8 @@ const TOKEN_KEY = 'agentGuild.token';
5
5
  const CWD_KEY = 'agentGuild.cwd';
6
6
  const ACCOUNTS_KEY = 'agentGuild.accounts';
7
7
  const THEME_KEY = 'agentGuild.theme';
8
+ const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
+ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
8
10
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
9
11
 
10
12
  const $ = (id) => document.getElementById(id);
@@ -15,6 +17,7 @@ const state = {
15
17
  accounts: {},
16
18
  stats: null,
17
19
  statsFor: new Map(),
20
+ news: null,
18
21
  sessions: new Map(),
19
22
  views: new Map(),
20
23
  activeId: null,
@@ -24,8 +27,17 @@ const state = {
24
27
  connected: false,
25
28
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
26
29
  upgrade: null,
30
+ /** The running manager's version and pid, from `hello`. */
31
+ version: null,
32
+ pid: null,
33
+ /** False for a manager from before restarts, which only stops: `agent-guild restart` replaces it. */
34
+ restartable: false,
35
+ /** The double-click launcher file on this computer, or null when the install has none. */
36
+ launcher: null,
27
37
  /** True from a shutdown request until the manager is reachable again. */
28
38
  stopping: false,
39
+ /** True while the stop is a restart: a new manager is expected to take over. */
40
+ restarting: false,
29
41
  /** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
30
42
  stopRemaining: null,
31
43
  };
@@ -50,12 +62,31 @@ function setConnection(kind, label) {
50
62
  const el = $('connection');
51
63
  el.className = `connection ${kind}`;
52
64
  el.querySelector('.label').textContent = label;
53
- // The manager can only be stopped or upgraded while the page can reach it.
65
+ // The manager can only be stopped, restarted or upgraded while the page can reach it.
54
66
  state.connected = kind === 'ok';
55
67
  $('stop-manager').hidden = !state.connected;
68
+ $('restart-manager').hidden = !state.connected || !state.restartable;
56
69
  renderUpgrade();
57
70
  }
58
71
 
72
+ // ---- the running version --------------------------------------------------
73
+
74
+ /** A development checkout runs as 0.0.0-development; releases carry a real version. */
75
+ function isDevelopmentBuild(version) {
76
+ return !version || /^0\.0\.0(?:-|$)/.test(String(version));
77
+ }
78
+
79
+ function renderVersion() {
80
+ const badge = $('version');
81
+ const v = state.version;
82
+ badge.hidden = !v;
83
+ if (!v) return;
84
+ const dev = isDevelopmentBuild(v);
85
+ 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}`);
88
+ }
89
+
59
90
  // ---- theme ----------------------------------------------------------------
60
91
 
61
92
  /** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
@@ -90,15 +121,24 @@ function renderUpgrade() {
90
121
  button.textContent = `Upgrade to ${u.latestVersion}`;
91
122
  button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
92
123
  }
124
+ // A newer version on disk is used by the next manager, so the restart
125
+ // button becomes the way to pick it up.
126
+ const restart = $('restart-manager');
127
+ const pending = u?.pendingVersion;
128
+ restart.classList.toggle('pending', Boolean(pending));
129
+ restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
130
+ restart.title = pending
131
+ ? `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
+ : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
93
133
  let text = '';
94
134
  let title = '';
95
135
  const last = u?.lastInstall;
96
136
  if (u?.installing) {
97
137
  text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
98
138
  title = 'npm is running in a session. Keep the manager running until it finishes.';
99
- } else if (u?.pendingVersion) {
100
- text = `v${u.pendingVersion} installed · restart to use it`;
101
- 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.`;
139
+ } else if (pending && !state.restartable) {
140
+ text = `v${pending} installed · run "agent-guild restart" to use it`;
141
+ 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.`;
102
142
  } else if (last?.outcome === 'failed') {
103
143
  text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
104
144
  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.';
@@ -120,7 +160,9 @@ function setUpgrade(upgrade) {
120
160
  renderUpgrade();
121
161
  const pending = state.upgrade?.pendingVersion;
122
162
  if (pending && pending !== before?.pendingVersion) {
123
- toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
163
+ toast(state.restartable
164
+ ? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
165
+ : `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
124
166
  }
125
167
  }
126
168
 
@@ -307,8 +349,10 @@ function renderProviders() {
307
349
  start.hidden = !provider.available;
308
350
  start.addEventListener('click', () => startSession(provider, node));
309
351
  existing.hidden = !provider.available || !provider.resumable;
310
- existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
311
- existing.addEventListener('click', () => resumeSession(provider, node));
352
+ existing.title = provider.historySource
353
+ ? `Resume one of ${provider.tool}'s own earlier sessions`
354
+ : `Resume one of ${provider.tool}'s own sessions by its id`;
355
+ existing.addEventListener('click', () => showHistory(provider));
312
356
  const install = node.querySelector('.install');
313
357
  install.hidden = provider.available || !provider.installable;
314
358
  install.title = `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
@@ -900,6 +944,291 @@ function closeModels() {
900
944
  if ($('models').open) $('models').close();
901
945
  }
902
946
 
947
+ const NEWS_LATEST = 5;
948
+ const NEWS_POLL_MS = 10 * 60 * 1000;
949
+ const NEWS_FILTERS = [['all', 'All'], ['news', 'News'], ['releases', 'Releases'], ['research', 'Research']];
950
+ const newsView = { shown: null, since: Infinity, filter: 'all', opener: null };
951
+ let newsLoading = null;
952
+ let newsAgain = false;
953
+ let newsLoadedAt = 0;
954
+
955
+ function newsSeen() {
956
+ const seen = Date.parse(load(NEWS_SEEN_KEY));
957
+ return Number.isFinite(seen) ? seen : null;
958
+ }
959
+
960
+ function newestTime(items) {
961
+ return items.reduce((newest, item) => Math.max(newest, Date.parse(item.publishedAt) || 0), 0);
962
+ }
963
+
964
+ function loadNews() {
965
+ if (newsLoading) {
966
+ newsAgain = true;
967
+ return newsLoading;
968
+ }
969
+ newsLoading = api('GET', '/news').then(setNews, () => {}).finally(() => {
970
+ newsLoading = null;
971
+ newsLoadedAt = Date.now();
972
+ if (newsAgain) {
973
+ newsAgain = false;
974
+ loadNews();
975
+ }
976
+ });
977
+ return newsLoading;
978
+ }
979
+
980
+ function setNews(news) {
981
+ state.news = news;
982
+ if (newsSeen() === null && news.items.length) save(NEWS_SEEN_KEY, new Date(newestTime(news.items)).toISOString());
983
+ renderLatestNews();
984
+ if ($('news').open) updateNewsPanel();
985
+ }
986
+
987
+ function webHref(value) {
988
+ try {
989
+ const url = new URL(value);
990
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
991
+ } catch {
992
+ return null;
993
+ }
994
+ }
995
+
996
+ function newsTitle(item) {
997
+ const href = webHref(item.url);
998
+ const title = document.createElement(href ? 'a' : 'span');
999
+ title.className = 'news-link';
1000
+ title.textContent = item.title;
1001
+ if (href) {
1002
+ title.href = href;
1003
+ title.target = '_blank';
1004
+ title.rel = 'noopener noreferrer';
1005
+ title.setAttribute('aria-description', 'Opens in a new tab');
1006
+ }
1007
+ return title;
1008
+ }
1009
+
1010
+ function newsMeta(item) {
1011
+ const meta = document.createElement('span');
1012
+ meta.className = 'news-meta';
1013
+ const time = document.createElement('time');
1014
+ time.dateTime = item.publishedAt;
1015
+ time.textContent = relativeTime(item.publishedAt);
1016
+ time.title = new Date(item.publishedAt).toLocaleString();
1017
+ meta.append(`${item.source} · `, time);
1018
+ return meta;
1019
+ }
1020
+
1021
+ function isNew(item, since) {
1022
+ return Date.parse(item.publishedAt) > since;
1023
+ }
1024
+
1025
+ function newBadge() {
1026
+ return badge('new', 'New', 'Published since you last opened the news');
1027
+ }
1028
+
1029
+ function latestNews(items) {
1030
+ const sources = new Set();
1031
+ const latest = [];
1032
+ for (const item of items) {
1033
+ if (item.category !== 'news' || sources.has(item.source)) continue;
1034
+ sources.add(item.source);
1035
+ latest.push(item);
1036
+ if (latest.length === NEWS_LATEST) break;
1037
+ }
1038
+ return latest;
1039
+ }
1040
+
1041
+ function failureLines(news) {
1042
+ const groups = new Map();
1043
+ for (const source of news.sources) if (source.error) groups.set(source.error, [...(groups.get(source.error) ?? []), source]);
1044
+ return [...groups].map(([error, sources]) => {
1045
+ if (sources.length === 1) {
1046
+ const [source] = sources;
1047
+ return `${source.name}: ${error} · ${source.okAt ? `showing items fetched ${relativeTime(source.okAt)}` : 'nothing fetched yet'}`;
1048
+ }
1049
+ const names = [...new Set(sources.map((source) => source.name))];
1050
+ return `${names.length <= 3 ? listJoin(names) : `${sources.length} sources`}: ${error}`;
1051
+ });
1052
+ }
1053
+
1054
+ function newsNote(news) {
1055
+ if (!news || (news.refreshing && news.items.length === 0)) return 'Loading news…';
1056
+ const failed = news.sources.filter((s) => s.error);
1057
+ if (news.items.length === 0 && failed.length > 0 && failed.length === news.sources.length) {
1058
+ return `News could not be loaded. ${failureLines(news)[0]}. Agent Guild tries again in a few minutes.`;
1059
+ }
1060
+ return 'No news from the last 30 days.';
1061
+ }
1062
+
1063
+ function renderLatestNews() {
1064
+ const news = state.news;
1065
+ const list = $('news-latest');
1066
+ const seen = newsSeen() ?? Infinity;
1067
+ const items = news ? latestNews(news.items) : [];
1068
+ const focused = document.activeElement?.closest?.('#news-latest .news-row')?.dataset.id;
1069
+ list.replaceChildren(...items.map((item) => {
1070
+ const row = document.createElement('li');
1071
+ row.className = 'news-row';
1072
+ row.dataset.id = item.id;
1073
+ row.append(newsTitle(item), ...(isNew(item, seen) ? [newBadge()] : []), newsMeta(item));
1074
+ return row;
1075
+ }));
1076
+ if (focused) [...list.children].find((row) => row.dataset.id === focused)?.querySelector('.news-link')?.focus({ preventScroll: true });
1077
+ list.hidden = items.length === 0;
1078
+ const fresh = news ? news.items.filter((item) => item.category === 'news' && isNew(item, seen)).length : 0;
1079
+ $('news-count').textContent = fresh ? `· ${fresh} new` : '';
1080
+ $('news-note').textContent = items.length ? '' : newsNote(news);
1081
+ $('news-note').hidden = items.length > 0;
1082
+ }
1083
+
1084
+ function dayLabel(time) {
1085
+ const date = new Date(time);
1086
+ const today = new Date();
1087
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
1088
+ if (date.toDateString() === today.toDateString()) return 'Today';
1089
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
1090
+ return date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
1091
+ }
1092
+
1093
+ function newsEntry(item) {
1094
+ const entry = document.createElement('li');
1095
+ entry.className = 'news-item';
1096
+ entry.dataset.id = item.id;
1097
+ const head = document.createElement('div');
1098
+ head.className = 'news-head';
1099
+ head.append(newsTitle(item), ...(isNew(item, newsView.since) ? [newBadge()] : []));
1100
+ entry.append(head, newsMeta(item));
1101
+ if (item.summary) {
1102
+ const summary = document.createElement('p');
1103
+ summary.className = 'news-summary';
1104
+ const discussion = webHref(item.discussion);
1105
+ if (discussion) {
1106
+ const link = document.createElement('a');
1107
+ link.className = 'news-discussion';
1108
+ link.href = discussion;
1109
+ link.target = '_blank';
1110
+ link.rel = 'noopener noreferrer';
1111
+ link.textContent = item.summary;
1112
+ link.setAttribute('aria-description', `Opens the discussion on ${item.source} in a new tab`);
1113
+ summary.append(link);
1114
+ } else {
1115
+ summary.textContent = item.summary;
1116
+ }
1117
+ entry.append(summary);
1118
+ }
1119
+ return entry;
1120
+ }
1121
+
1122
+ function renderNewsFilters() {
1123
+ $('news-filters').replaceChildren(...NEWS_FILTERS.map(([id, label]) => {
1124
+ const chip = document.createElement('button');
1125
+ chip.type = 'button';
1126
+ chip.className = 'news-filter';
1127
+ chip.textContent = label;
1128
+ chip.setAttribute('aria-pressed', String(id === newsView.filter));
1129
+ chip.addEventListener('click', () => {
1130
+ newsView.filter = id;
1131
+ save(NEWS_FILTER_KEY, id);
1132
+ for (const other of $('news-filters').children) other.setAttribute('aria-pressed', String(other === chip));
1133
+ newsView.shown = state.news;
1134
+ renderNewsList();
1135
+ document.querySelector('.news-body').scrollTop = 0;
1136
+ });
1137
+ return chip;
1138
+ }));
1139
+ }
1140
+
1141
+ function renderNewsList() {
1142
+ const shown = newsView.shown;
1143
+ const items = (shown?.items ?? []).filter((item) => newsView.filter === 'all' || item.category === newsView.filter);
1144
+ const groups = [];
1145
+ for (const item of items) {
1146
+ const label = dayLabel(Date.parse(item.publishedAt));
1147
+ if (groups.at(-1)?.label !== label) groups.push({ label, items: [] });
1148
+ groups.at(-1).items.push(item);
1149
+ }
1150
+ $('news-list').replaceChildren(...groups.map((group) => {
1151
+ const section = document.createElement('section');
1152
+ section.className = 'news-group';
1153
+ const heading = document.createElement('h3');
1154
+ heading.className = 'news-day';
1155
+ heading.textContent = group.label;
1156
+ const list = document.createElement('ul');
1157
+ list.className = 'news-items';
1158
+ list.append(...group.items.map(newsEntry));
1159
+ section.append(heading, list);
1160
+ return section;
1161
+ }));
1162
+ const label = NEWS_FILTERS.find(([id]) => id === newsView.filter)[1];
1163
+ $('news-empty').textContent = items.length ? '' : newsView.filter === 'all' || !shown ? newsNote(shown) : `Nothing in ${label} from the last 30 days.`;
1164
+ $('news-empty').hidden = items.length > 0;
1165
+ $('news-fresh').hidden = true;
1166
+ const fresh = (shown?.items ?? []).filter((item) => isNew(item, newsView.since)).length;
1167
+ $('news-sub').textContent = fresh ? `${fresh} new since your last visit` : 'Newest first';
1168
+ }
1169
+
1170
+ function renderNewsStatus() {
1171
+ const news = state.news;
1172
+ const status = $('news-status');
1173
+ status.hidden = !news;
1174
+ if (!news) return;
1175
+ const updated = news.refreshing ? 'checking for news…' : news.refreshedAt ? `updated ${relativeTime(news.refreshedAt)}` : '';
1176
+ const lines = [[`${news.sources.length} source${news.sources.length === 1 ? '' : 's'}`, updated].filter(Boolean).join(' · '), ...failureLines(news)];
1177
+ status.replaceChildren(...lines.map((text) => {
1178
+ const line = document.createElement('span');
1179
+ line.textContent = text;
1180
+ return line;
1181
+ }));
1182
+ }
1183
+
1184
+ function updateNewsPanel() {
1185
+ renderNewsStatus();
1186
+ if (!newsView.shown?.items.length) {
1187
+ newsView.shown = state.news;
1188
+ renderNewsList();
1189
+ return;
1190
+ }
1191
+ const shownIds = new Set(newsView.shown.items.map((item) => item.id));
1192
+ const added = state.news.items.filter((item) => !shownIds.has(item.id) && (newsView.filter === 'all' || item.category === newsView.filter)).length;
1193
+ const fresh = $('news-fresh');
1194
+ fresh.hidden = added === 0;
1195
+ fresh.textContent = `Show ${added} new item${added === 1 ? '' : 's'}`;
1196
+ }
1197
+
1198
+ function showFreshNews() {
1199
+ const before = new Set(newsView.shown.items.map((item) => item.id));
1200
+ newsView.shown = state.news;
1201
+ renderNewsList();
1202
+ const first = [...$('news-list').querySelectorAll('.news-item')].find((entry) => !before.has(entry.dataset.id));
1203
+ (first?.querySelector('.news-link') ?? $('news-close')).focus();
1204
+ }
1205
+
1206
+ function openNews() {
1207
+ const dialog = $('news');
1208
+ const saved = load(NEWS_FILTER_KEY);
1209
+ newsView.filter = NEWS_FILTERS.some(([id]) => id === saved) ? saved : 'all';
1210
+ newsView.since = newsSeen() ?? Infinity;
1211
+ newsView.shown = state.news;
1212
+ renderNewsFilters();
1213
+ renderNewsList();
1214
+ renderNewsStatus();
1215
+ if (!dialog.open) {
1216
+ newsView.opener = document.activeElement;
1217
+ dialog.showModal();
1218
+ }
1219
+ ($('news-list').querySelector('.news-link') ?? $('news-close')).focus();
1220
+ if (!state.news) loadNews();
1221
+ }
1222
+
1223
+ function closeNews() {
1224
+ if ($('news').open) $('news').close();
1225
+ }
1226
+
1227
+ function tickNews() {
1228
+ for (const time of document.querySelectorAll('#news-latest time, #news-list time')) time.textContent = relativeTime(time.dateTime);
1229
+ if ($('news').open) renderNewsStatus();
1230
+ }
1231
+
903
1232
  const CHANNEL_LABELS = {
904
1233
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
905
1234
  };
@@ -959,27 +1288,223 @@ async function installProvider(provider, card, { force = false } = {}) {
959
1288
  }
960
1289
  }
961
1290
 
962
- async function startSession(provider, card, { resume } = {}) {
963
- const cwd = $('cwd').value.trim();
964
- save(CWD_KEY, cwd);
965
- card.classList.add('busy');
1291
+ /**
1292
+ * Start a session. A resumed session starts in the folder its transcript
1293
+ * names, since Claude Code and Gemini CLI only find a session from there;
1294
+ * when that folder is gone, the working folder is used instead.
1295
+ */
1296
+ async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
1297
+ const working = $('cwd').value.trim();
1298
+ save(CWD_KEY, working);
1299
+ card?.classList.add('busy');
966
1300
  try {
967
- const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
968
- const { session } = await api('POST', '/sessions', body);
1301
+ const body = { providerId: provider.id, account, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
1302
+ let session;
1303
+ try {
1304
+ ({ session } = await api('POST', '/sessions', body));
1305
+ } catch (err) {
1306
+ if (err.code !== 'bad_cwd' || !cwd) throw err;
1307
+ toast(`${cwd} no longer exists; starting in the working folder instead.`, 8000);
1308
+ ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
1309
+ }
969
1310
  upsertSession(session);
1311
+ closeHistory({ focusOpener: false });
970
1312
  openPanel(session.id);
971
1313
  } catch (err) {
972
1314
  if (err instanceof AuthError) return showAuth(err.message);
973
1315
  toast(err.message, 8000);
974
1316
  } finally {
975
- card.classList.remove('busy');
1317
+ card?.classList.remove('busy');
976
1318
  }
977
1319
  }
978
1320
 
979
- function resumeSession(provider, card) {
980
- const id = prompt(`${provider.tool} session id or name to resume`);
981
- if (id === null || !id.trim()) return;
982
- startSession(provider, card, { resume: id.trim() });
1321
+ // ---- session history ------------------------------------------------------
1322
+
1323
+ const HISTORY_LIMIT = 200;
1324
+ const historyView = { providerId: null, accountId: null, snapshot: null, loading: false };
1325
+ let historyOpener = null;
1326
+
1327
+ function historyProvider() {
1328
+ return state.providers.find((p) => p.id === historyView.providerId) ?? null;
1329
+ }
1330
+
1331
+ function toolSessionId(s) {
1332
+ return s.toolSessionId || s.resume || null;
1333
+ }
1334
+
1335
+ function shortId(id) {
1336
+ return id.length > 12 ? id.slice(0, 8) : id;
1337
+ }
1338
+
1339
+ function copyId(id) {
1340
+ navigator.clipboard?.writeText(id).then(() => toast(`Copied ${id}`, 2500), () => toast(id, 8000));
1341
+ }
1342
+
1343
+ function paintIdButton(button, id) {
1344
+ button.hidden = !id;
1345
+ if (!id) return;
1346
+ button.textContent = shortId(id);
1347
+ button.title = `Session id ${id}. Click to copy it.`;
1348
+ button.setAttribute('aria-label', `Copy session id ${id}`);
1349
+ }
1350
+
1351
+ function runningOn(providerId, accountId, id) {
1352
+ return [...state.sessions.values()].find((s) =>
1353
+ s.status === 'running' && s.task === null && s.provider.id === providerId && (s.account?.id ?? 'default') === accountId && toolSessionId(s) === id) ?? null;
1354
+ }
1355
+
1356
+ function showHistory(provider) {
1357
+ const account = selectedAccount(provider);
1358
+ const same = historyView.providerId === provider.id && historyView.accountId === account.id;
1359
+ if (!same) Object.assign(historyView, { providerId: provider.id, accountId: account.id, snapshot: null, loading: false });
1360
+ const dialog = $('history');
1361
+ $('history-filter').value = '';
1362
+ $('history-id').value = '';
1363
+ renderHistory();
1364
+ if (!dialog.open) {
1365
+ historyOpener = document.activeElement;
1366
+ dialog.showModal();
1367
+ }
1368
+ if (provider.historySource) loadHistory();
1369
+ else $('history-id').focus();
1370
+ }
1371
+
1372
+ async function loadHistory() {
1373
+ const provider = historyProvider();
1374
+ if (!provider || historyView.loading) return;
1375
+ const { providerId, accountId } = historyView;
1376
+ historyView.loading = true;
1377
+ renderHistory();
1378
+ try {
1379
+ const { history } = await api('GET', `/providers/${providerId}/history?account=${encodeURIComponent(accountId)}&limit=${HISTORY_LIMIT}`);
1380
+ if (historyView.providerId === providerId && historyView.accountId === accountId) historyView.snapshot = history;
1381
+ } catch (err) {
1382
+ if (err instanceof AuthError) return showAuth(err.message);
1383
+ if (historyView.providerId === providerId) historyView.snapshot = { sessions: [], total: 0, error: err.message };
1384
+ } finally {
1385
+ historyView.loading = false;
1386
+ renderHistory();
1387
+ }
1388
+ }
1389
+
1390
+ function historyText(entry) {
1391
+ return `${entry.title ?? ''}\n${entry.cwd ?? ''}\n${entry.id}`.toLowerCase();
1392
+ }
1393
+
1394
+ function folderName(dir) {
1395
+ const parts = dir.replace(/[\\/]+$/, '').split(/[\\/]/);
1396
+ return parts.at(-1) || dir;
1397
+ }
1398
+
1399
+ function sameFolder(a, b) {
1400
+ const clean = (dir) => (dir || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();
1401
+ return clean(a) === clean(b);
1402
+ }
1403
+
1404
+ function historyEntry(id) {
1405
+ return historyView.snapshot?.sessions.find((entry) => entry.id === id) ?? null;
1406
+ }
1407
+
1408
+ function resumeFromHistory(provider, id, cwd) {
1409
+ const running = runningOn(provider.id, historyView.accountId, id);
1410
+ if (running) {
1411
+ closeHistory({ focusOpener: false });
1412
+ openPanel(running.id);
1413
+ } else startSession(provider, null, { resume: id, cwd: cwd || undefined, account: historyView.accountId });
1414
+ }
1415
+
1416
+ function buildHistoryRow(id) {
1417
+ const node = $('history-template').content.firstElementChild.cloneNode(true);
1418
+ node.dataset.id = id;
1419
+ const idButton = node.querySelector('.session-id');
1420
+ paintIdButton(idButton, id);
1421
+ idButton.addEventListener('click', () => copyId(id));
1422
+ node.querySelector('.history-resume').addEventListener('click', () => {
1423
+ const provider = historyProvider();
1424
+ const entry = historyEntry(id);
1425
+ if (provider && entry) resumeFromHistory(provider, id, entry.cwd);
1426
+ });
1427
+ return node;
1428
+ }
1429
+
1430
+ function updateHistoryRow(node, provider, entry) {
1431
+ const running = runningOn(provider.id, historyView.accountId, entry.id);
1432
+ node.classList.toggle('untitled', !entry.title);
1433
+ node.classList.toggle('running', Boolean(running));
1434
+ node.querySelector('.history-title').textContent = entry.title ?? 'Untitled session';
1435
+ node.querySelector('.history-title').title = entry.title ?? '';
1436
+ const meta = node.querySelector('.history-meta');
1437
+ const when = entry.updatedAt ? `updated ${relativeTime(entry.updatedAt)}` : '';
1438
+ meta.textContent = [entry.cwd && folderName(entry.cwd), when, running && `open in Agent Guild as ${running.name}`].filter(Boolean).join(' · ');
1439
+ meta.title = [entry.cwd, entry.startedAt && `started ${new Date(entry.startedAt).toLocaleString()}`].filter(Boolean).join('\n');
1440
+ const action = node.querySelector('.history-resume');
1441
+ action.textContent = running ? 'Open' : 'Resume';
1442
+ action.title = running
1443
+ ? `This session is running in Agent Guild as "${running.name}". Open it instead of resuming it twice.`
1444
+ : `Resume this ${provider.tool} session${entry.cwd ? ` in ${entry.cwd}` : ''}`;
1445
+ action.setAttribute('aria-label', `${action.textContent} ${entry.title ?? entry.id}`);
1446
+ }
1447
+
1448
+ /** Rows are kept and updated in place, so a refresh never drops keyboard focus from a row's buttons. */
1449
+ function renderHistoryRows(provider, shown) {
1450
+ const list = $('history-list');
1451
+ const rows = new Map([...list.children].map((node) => [node.dataset.id, node]));
1452
+ const wanted = new Set(shown.map((entry) => entry.id));
1453
+ for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
1454
+ shown.forEach((entry, index) => {
1455
+ let node = rows.get(entry.id);
1456
+ if (!node) node = buildHistoryRow(entry.id);
1457
+ updateHistoryRow(node, provider, entry);
1458
+ if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
1459
+ });
1460
+ }
1461
+
1462
+ function renderHistory() {
1463
+ const provider = historyProvider();
1464
+ if (!provider) return closeHistory();
1465
+ const account = provider.accounts?.find((a) => a.id === historyView.accountId);
1466
+ paintProviderIcon($('history-icon'), provider);
1467
+ $('history-title').textContent = `${provider.tool} sessions`;
1468
+ const snapshot = historyView.snapshot;
1469
+ const filter = $('history-filter').value.trim().toLowerCase();
1470
+ const working = $('cwd').value.trim();
1471
+ const here = $('history-here');
1472
+ here.disabled = !working;
1473
+ here.parentElement.title = working ? `Only sessions started in ${working}` : 'Set a working folder above to filter by it';
1474
+ const all = snapshot?.sessions ?? [];
1475
+ const shown = all.filter((entry) => (!filter || historyText(entry).includes(filter)) && (!here.checked || here.disabled || sameFolder(entry.cwd, working)));
1476
+ const parts = [];
1477
+ if ((provider.accounts?.length ?? 0) > 1 && account) parts.push(`${account.label} account`);
1478
+ if (snapshot && !snapshot.error) {
1479
+ parts.push(snapshot.total === 0 ? 'no sessions found' : `${snapshot.total} session${snapshot.total === 1 ? '' : 's'}, newest first`);
1480
+ if (shown.length !== all.length) parts.push(`${shown.length} shown`);
1481
+ }
1482
+ $('history-sub').textContent = parts.join(' · ');
1483
+ renderHistoryRows(provider, shown);
1484
+ let note = '';
1485
+ if (!provider.historySource) note = `Agent Guild cannot list ${provider.tool}'s sessions. Enter the id of one to resume it.`;
1486
+ else if (historyView.loading && !snapshot) note = `Reading ${provider.tool}'s sessions…`;
1487
+ else if (snapshot?.error) note = `Sessions could not be read: ${snapshot.error}`;
1488
+ else if (snapshot && all.length === 0) note = `No ${provider.tool} sessions were found${account && account.id !== 'default' ? ` for the ${account.label} account` : ''}.`;
1489
+ else if (snapshot && shown.length === 0) note = 'No session matches the filter.';
1490
+ $('history-note').textContent = note;
1491
+ $('history-note').hidden = !note;
1492
+ $('history-filter').disabled = !provider.historySource;
1493
+ here.parentElement.hidden = !provider.historySource;
1494
+ }
1495
+
1496
+ /** Closing to open a session leaves focus with the terminal; otherwise it returns to the opener. */
1497
+ function closeHistory({ focusOpener = true } = {}) {
1498
+ if (!$('history').open) return;
1499
+ if (!focusOpener) historyOpener = false;
1500
+ $('history').close();
1501
+ }
1502
+
1503
+ function resumeById(event) {
1504
+ event.preventDefault();
1505
+ const provider = historyProvider();
1506
+ const id = $('history-id').value.trim();
1507
+ if (provider && id) resumeFromHistory(provider, id, null);
983
1508
  }
984
1509
 
985
1510
  // ---- session cards --------------------------------------------------------
@@ -1022,10 +1547,30 @@ function buildCard(session) {
1022
1547
  node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
1023
1548
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
1024
1549
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
1550
+ node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
1551
+ node.querySelector('.session-id').addEventListener('click', () => {
1552
+ const id = toolSessionId(state.sessions.get(session.id) ?? session);
1553
+ if (id) copyId(id);
1554
+ });
1025
1555
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
1026
1556
  return node;
1027
1557
  }
1028
1558
 
1559
+ function resumable(s) {
1560
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1561
+ const id = toolSessionId(s);
1562
+ return Boolean(s.status === 'exited' && s.task === null && id && provider?.available && provider.resumable
1563
+ && !runningOn(s.provider.id, s.account?.id ?? 'default', id));
1564
+ }
1565
+
1566
+ function resumeCard(id) {
1567
+ const s = state.sessions.get(id);
1568
+ if (!s || !resumable(s)) return;
1569
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1570
+ const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
1571
+ startSession(provider, cards.get(id), { resume: toolSessionId(s), cwd: s.cwd, account });
1572
+ }
1573
+
1029
1574
  function sessionLevel(s) {
1030
1575
  const end = s.status === 'exited'
1031
1576
  ? Date.parse(s.exitedAt ?? s.lastOutputAt ?? s.createdAt)
@@ -1042,8 +1587,10 @@ function updateCard(node, s) {
1042
1587
  badge.textContent = level;
1043
1588
  badge.title = `Level ${level}`;
1044
1589
  node.querySelector('.name').textContent = s.name;
1045
- const resumed = s.resume ? ` · resumed ${s.resume}` : '';
1046
- node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1590
+ const id = toolSessionId(s);
1591
+ const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
1592
+ node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1593
+ paintIdButton(node.querySelector('.session-id'), id);
1047
1594
  const pill = node.querySelector('.status-pill');
1048
1595
  pill.textContent = statusText(s);
1049
1596
  pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
@@ -1061,6 +1608,9 @@ function updateCard(node, s) {
1061
1608
  node.classList.toggle('exited', s.status === 'exited');
1062
1609
  node.querySelector('.stop').hidden = s.status !== 'running';
1063
1610
  node.querySelector('.remove').hidden = s.status === 'running';
1611
+ const resume = node.querySelector('.resume');
1612
+ resume.hidden = !resumable(s);
1613
+ resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
1064
1614
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
1065
1615
  const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
1066
1616
  node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
@@ -1084,6 +1634,7 @@ function renderSessions() {
1084
1634
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
1085
1635
  $('empty').hidden = sessions.length > 0;
1086
1636
  if (state.activeId) updatePanel();
1637
+ if ($('history').open) renderHistory();
1087
1638
  if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
1088
1639
  }
1089
1640
 
@@ -1299,7 +1850,8 @@ function updatePanel() {
1299
1850
  if (!s) return;
1300
1851
  paintProviderIcon($('panel-icon'), s.provider);
1301
1852
  $('panel-title').textContent = s.name;
1302
- $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1853
+ const id = toolSessionId(s);
1854
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd, id && `session ${id}`].filter(Boolean).join(' · ');
1303
1855
  $('panel-sub').title = modelTitle(s);
1304
1856
  renderAgents($('panel-agents'), s.agents);
1305
1857
  const stop = $('panel-stop');
@@ -1309,73 +1861,142 @@ function updatePanel() {
1309
1861
  // ---- stopping the manager -------------------------------------------------
1310
1862
 
1311
1863
  /**
1312
- * Stop the session manager. The manager refuses while sessions are running
1313
- * unless told to force, so the warning is enforced for every client and the
1314
- * count in the dialog is the manager's, not this page's possibly stale list.
1864
+ * Stop the session manager, or stop it and start it again. The manager
1865
+ * refuses while sessions are running unless told to force, so the warning is
1866
+ * enforced for every client and the count in the dialog is the manager's,
1867
+ * not this page's possibly stale list.
1315
1868
  */
1316
- async function stopManager({ force = false } = {}) {
1317
- const button = $('stop-manager');
1318
- button.disabled = true;
1869
+ async function stopManager({ force = false, restart = false } = {}) {
1870
+ const buttons = [$('stop-manager'), $('restart-manager')];
1871
+ for (const button of buttons) button.disabled = true;
1319
1872
  try {
1320
- const { running } = await api('POST', '/shutdown', force ? { force: true } : undefined);
1321
- enterStopping(running);
1873
+ const body = force || restart ? { ...(force && { force: true }), ...(restart && { restart: true }) } : undefined;
1874
+ const answer = await api('POST', '/shutdown', body);
1875
+ // A manager that does not say it will restart only stops.
1876
+ enterStopping(answer.running, restart && answer.restart === true);
1877
+ if (restart && answer.restart !== true) {
1878
+ toast('This manager cannot restart itself. Run "agent-guild restart" in a terminal to start the new one.', 12000);
1879
+ }
1322
1880
  } catch (err) {
1323
1881
  if (err instanceof AuthError) return showAuth(err.message);
1324
1882
  if (err.code === 'sessions_running') {
1325
1883
  const n = err.running;
1326
1884
  const what = `${n} session${n === 1 ? ' is' : 's are'} still running`;
1327
1885
  const them = n === 1 ? 'it' : 'all of them';
1328
- if (confirm(`${what}. Stopping the session manager ends ${them}. Stop anyway?`)) {
1329
- return stopManager({ force: true });
1886
+ const verb = restart ? 'Restarting' : 'Stopping';
1887
+ if (confirm(`${what}. ${verb} the session manager ends ${them}. ${restart ? 'Restart' : 'Stop'} anyway?`)) {
1888
+ return stopManager({ force: true, restart });
1330
1889
  }
1331
1890
  return;
1332
1891
  }
1333
1892
  toast(err.message, 8000);
1334
1893
  } finally {
1335
- button.disabled = false;
1894
+ for (const button of buttons) button.disabled = false;
1336
1895
  }
1337
1896
  }
1338
1897
 
1898
+ /** How long to wait for a restarted manager before telling the user how to start one by hand. */
1899
+ const RESTART_WAIT_MS = 30000;
1900
+ let restartTimer;
1901
+
1339
1902
  /** The manager is going down, by this page's request or another client's. */
1340
- function enterStopping(running = 0) {
1903
+ function enterStopping(running = 0, restart = false) {
1904
+ if (restart) state.restarting = true;
1341
1905
  if (state.stopping) return;
1342
1906
  state.stopping = true;
1343
1907
  state.stopRemaining = null;
1344
1908
  closePanel();
1345
1909
  closeModels();
1910
+ closeNews();
1911
+ closeHistory();
1346
1912
  for (const view of state.views.values()) view.dispose();
1347
1913
  state.views.clear();
1348
1914
  state.sessions.clear();
1349
1915
  renderSessions();
1350
1916
  $('app').hidden = true;
1351
1917
  const n = Number(running) || 0;
1352
- showStopped('stopping', 'Stopping the session manager…',
1353
- n ? `Ending ${n} running session${n === 1 ? '' : 's'}. This can take a few seconds.` : 'This can take a few seconds.');
1354
- setConnection('down', 'Stopping the session manager…');
1918
+ const ending = n ? `Ending ${n} running session${n === 1 ? '' : 's'}. ` : '';
1919
+ if (state.restarting) {
1920
+ showStopped('restarting', 'Restarting the session manager…', `${ending}A new manager starts in a moment and this page reconnects to it by itself.`);
1921
+ clearTimeout(restartTimer);
1922
+ restartTimer = setTimeout(restartGaveUp, RESTART_WAIT_MS);
1923
+ } else {
1924
+ showStopped('stopping', 'Stopping the session manager…', `${ending}This can take a few seconds.`);
1925
+ }
1926
+ setConnection('down', state.restarting ? 'Restarting the session manager…' : 'Stopping the session manager…');
1927
+ }
1928
+
1929
+ /** The manager is back: a `hello` arrived while the page was waiting out a stop. */
1930
+ function leaveStopping() {
1931
+ state.stopping = false;
1932
+ state.restarting = false;
1933
+ clearTimeout(restartTimer);
1934
+ $('stopped').hidden = true;
1935
+ $('app').hidden = false;
1936
+ }
1937
+
1938
+ /** A restarted manager did not come back in time; the user has to start one by hand. */
1939
+ function restartGaveUp() {
1940
+ if (!state.stopping || !state.restarting) return;
1941
+ state.restarting = false;
1942
+ setConnection('down', 'Session manager stopped');
1943
+ showStopped('stopped', 'The session manager did not come back',
1944
+ `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.`);
1355
1945
  }
1356
1946
 
1357
1947
  function showStopped(phase, title, text) {
1358
1948
  const el = $('stopped');
1359
1949
  el.classList.toggle('stopping', phase === 'stopping');
1950
+ el.classList.toggle('restarting', phase === 'restarting');
1360
1951
  $('stopped-title').textContent = title;
1361
1952
  $('stopped-text').textContent = text;
1953
+ // How to start again is only useful once the manager is really gone; a
1954
+ // restart brings it back without the user doing anything.
1955
+ $('stopped-help').hidden = phase !== 'stopped';
1956
+ if (phase === 'stopped') renderStoppedHelp();
1362
1957
  el.hidden = false;
1363
1958
  }
1364
1959
 
1960
+ /** Instructions for starting the manager again, worded for this computer. */
1961
+ function renderStoppedHelp() {
1962
+ const windows = /Win/.test(navigator.platform || navigator.userAgent);
1963
+ $('stopped-how').textContent = isMac
1964
+ ? 'To start again, open Terminal (search for it with Spotlight) and run:'
1965
+ : windows
1966
+ ? 'To start again, open Windows Terminal or PowerShell (search for it in the Start menu) and run:'
1967
+ : 'To start again, open a terminal and run:';
1968
+ const launcher = state.launcher;
1969
+ $('stopped-launcher').hidden = !launcher;
1970
+ $('stopped-launcher-path').textContent = launcher || '';
1971
+ }
1972
+
1973
+ async function copyCommand() {
1974
+ const button = $('copy-command');
1975
+ try {
1976
+ await navigator.clipboard.writeText('agent-guild open');
1977
+ button.textContent = 'Copied';
1978
+ setTimeout(() => { button.textContent = 'Copy'; }, 1500);
1979
+ } catch {
1980
+ toast('Could not copy. Select the command and copy it yourself.');
1981
+ }
1982
+ }
1983
+
1365
1984
  /**
1366
1985
  * The manager has stopped. Only a manager.stopped event with nothing
1367
1986
  * remaining confirms that every process exited; a timeout, or a socket that
1368
1987
  * dropped without the event, must not be announced as a clean stop.
1369
1988
  */
1370
1989
  function showManagerStopped() {
1371
- setConnection('down', 'Session manager stopped');
1372
1990
  const n = state.stopRemaining;
1373
- if (n === 0) return showStopped('stopped', 'Session manager stopped', 'Every session has ended.');
1374
- if (n > 0) {
1375
- return showStopped('stopped', 'Session manager stopped',
1376
- `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`);
1991
+ const sessions = n === 0 ? 'Every session has ended.'
1992
+ : 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.`
1993
+ : 'The manager went away before confirming that every session had ended.';
1994
+ if (state.restarting) {
1995
+ setConnection('down', 'Restarting the session manager…');
1996
+ return showStopped('restarting', 'Restarting the session manager…', `${sessions} Waiting for the new manager; this page reconnects to it by itself.`);
1377
1997
  }
1378
- showStopped('stopped', 'Session manager stopped', 'The manager went away before confirming that every session had ended.');
1998
+ setConnection('down', 'Session manager stopped');
1999
+ showStopped('stopped', 'Session manager stopped', sessions);
1379
2000
  }
1380
2001
 
1381
2002
  // ---- events ---------------------------------------------------------------
@@ -1390,22 +2011,27 @@ function connectEvents() {
1390
2011
  ws.onmessage = (event) => {
1391
2012
  const msg = JSON.parse(event.data);
1392
2013
  if (msg.type === 'hello') {
1393
- if (state.stopping) {
1394
- // The manager is back after a stop; the page picks up where it was.
1395
- state.stopping = false;
1396
- $('stopped').hidden = true;
1397
- $('app').hidden = false;
1398
- }
2014
+ // The manager is back after a stop or restart; the page picks up where it was.
2015
+ if (state.stopping) leaveStopping();
2016
+ state.version = msg.version || null;
2017
+ state.pid = msg.pid || null;
2018
+ state.restartable = typeof msg.pid === 'number';
2019
+ state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
2020
+ renderVersion();
2021
+ setConnection('ok', 'Connected to session manager');
1399
2022
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
1400
2023
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
1401
2024
  renderSessions();
1402
2025
  setUpgrade(msg.upgrade);
2026
+ loadNews();
2027
+ } else if (msg.type === 'news.updated') {
2028
+ loadNews();
1403
2029
  } else if (msg.type === 'manager.upgrade') {
1404
2030
  setUpgrade(msg.upgrade);
1405
2031
  } else if (msg.type === 'manager.stopping') {
1406
- enterStopping(msg.running);
2032
+ enterStopping(msg.running, msg.restart === true);
1407
2033
  } else if (msg.type === 'manager.stopped') {
1408
- enterStopping();
2034
+ enterStopping(0, msg.restart === true);
1409
2035
  state.stopRemaining = Number(msg.remaining) || 0;
1410
2036
  showManagerStopped();
1411
2037
  } else if (msg.type === 'session.created' || msg.type === 'session.updated') {
@@ -1415,6 +2041,7 @@ function connectEvents() {
1415
2041
  } else if (msg.type === 'providers.updated') {
1416
2042
  state.providers = msg.providers;
1417
2043
  renderProviders();
2044
+ if ($('history').open) renderHistory();
1418
2045
  scheduleStats();
1419
2046
  }
1420
2047
  };
@@ -1453,13 +2080,18 @@ function readTokenFromHash() {
1453
2080
 
1454
2081
  let usageTimer;
1455
2082
  let statsInterval;
2083
+ let newsTimer;
1456
2084
 
1457
2085
  function showAuth(message = '') {
1458
2086
  closeModels();
2087
+ closeNews();
2088
+ closeHistory();
1459
2089
  $('app').hidden = true;
1460
2090
  $('terminal-panel').hidden = true;
1461
2091
  $('stopped').hidden = true;
1462
2092
  state.stopping = false;
2093
+ state.restarting = false;
2094
+ clearTimeout(restartTimer);
1463
2095
  $('auth').hidden = false;
1464
2096
  $('auth-error').textContent = message;
1465
2097
  setConnection('down', 'Not connected');
@@ -1488,6 +2120,8 @@ async function boot() {
1488
2120
  loadStats();
1489
2121
  clearInterval(statsInterval);
1490
2122
  statsInterval = setInterval(loadStats, 60 * 60 * 1000);
2123
+ clearInterval(newsTimer);
2124
+ newsTimer = setInterval(() => { if (document.visibilityState === 'visible') loadNews(); }, NEWS_POLL_MS);
1491
2125
  }
1492
2126
 
1493
2127
  $('auth-form').addEventListener('submit', (e) => {
@@ -1499,6 +2133,19 @@ $('auth-form').addEventListener('submit', (e) => {
1499
2133
  });
1500
2134
  $('panel-close').addEventListener('click', closePanel);
1501
2135
  $('models-close').addEventListener('click', closeModels);
2136
+ $('history-close').addEventListener('click', closeHistory);
2137
+ $('history').addEventListener('click', (e) => { if (e.target === $('history')) closeHistory(); });
2138
+ $('history').addEventListener('close', () => {
2139
+ if (historyOpener !== false) {
2140
+ const opener = historyOpener?.isConnected ? historyOpener
2141
+ : $('providers').querySelector(`.provider[data-id="${historyView.providerId}"] .existing`);
2142
+ opener?.focus();
2143
+ }
2144
+ historyOpener = null;
2145
+ });
2146
+ $('history-filter').addEventListener('input', renderHistory);
2147
+ $('history-here').addEventListener('change', renderHistory);
2148
+ $('history-form').addEventListener('submit', resumeById);
1502
2149
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
1503
2150
  $('models').addEventListener('close', () => {
1504
2151
  hideTip();
@@ -1530,6 +2177,21 @@ document.addEventListener('keydown', (e) => {
1530
2177
  }, true);
1531
2178
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
1532
2179
  addEventListener('resize', () => { if (tipFor) hideTip(); });
2180
+ $('news-all').addEventListener('click', openNews);
2181
+ $('news-close').addEventListener('click', closeNews);
2182
+ $('news-fresh').addEventListener('click', showFreshNews);
2183
+ $('news').addEventListener('click', (e) => { if (e.target === $('news')) closeNews(); });
2184
+ $('news').addEventListener('close', () => {
2185
+ const newest = newestTime(newsView.shown?.items ?? []);
2186
+ if (newest > (newsSeen() ?? 0)) save(NEWS_SEEN_KEY, new Date(newest).toISOString());
2187
+ renderLatestNews();
2188
+ const opener = newsView.opener?.isConnected && !newsView.opener.closest('[hidden]') ? newsView.opener : $('news-all');
2189
+ opener.focus();
2190
+ newsView.opener = null;
2191
+ });
2192
+ document.addEventListener('visibilitychange', () => {
2193
+ if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
2194
+ });
1533
2195
  $('models-more').addEventListener('click', () => {
1534
2196
  const before = $('models-list').childElementCount;
1535
2197
  modelsView.all = true;
@@ -1537,6 +2199,8 @@ $('models-more').addEventListener('click', () => {
1537
2199
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1538
2200
  });
1539
2201
  $('stop-manager').addEventListener('click', () => stopManager());
2202
+ $('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
2203
+ $('copy-command').addEventListener('click', copyCommand);
1540
2204
  $('upgrade').addEventListener('click', upgradeManager);
1541
2205
  $('theme-toggle').addEventListener('click', toggleTheme);
1542
2206
  applyTheme(currentTheme());
@@ -1554,6 +2218,7 @@ $('cwd').value = load(CWD_KEY) || '';
1554
2218
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
1555
2219
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
1556
2220
  setInterval(renderSessions, 30000);
2221
+ setInterval(tickNews, 30000);
1557
2222
 
1558
2223
  // The terminal panel sits below the top bar, which wraps onto two rows on
1559
2224
  // narrow screens; publish its height so the panel never covers its controls.