@oddessentials/agent-guild 0.2.0 → 0.4.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
@@ -3,6 +3,10 @@
3
3
 
4
4
  const TOKEN_KEY = 'agentGuild.token';
5
5
  const CWD_KEY = 'agentGuild.cwd';
6
+ const ACCOUNTS_KEY = 'agentGuild.accounts';
7
+ const THEME_KEY = 'agentGuild.theme';
8
+ const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
+ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
6
10
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
7
11
 
8
12
  const $ = (id) => document.getElementById(id);
@@ -10,13 +14,19 @@ const state = {
10
14
  token: null,
11
15
  providers: [],
12
16
  usage: new Map(),
17
+ accounts: {},
13
18
  stats: null,
14
19
  statsFor: new Map(),
20
+ news: null,
15
21
  sessions: new Map(),
16
22
  views: new Map(),
17
23
  activeId: null,
18
24
  eventsSocket: null,
19
25
  eventsRetry: 0,
26
+ /** True while the events socket is open. */
27
+ connected: false,
28
+ /** The manager's own version check, from `hello` and `manager.upgrade`. */
29
+ upgrade: null,
20
30
  /** True from a shutdown request until the manager is reachable again. */
21
31
  stopping: false,
22
32
  /** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
@@ -43,8 +53,93 @@ function setConnection(kind, label) {
43
53
  const el = $('connection');
44
54
  el.className = `connection ${kind}`;
45
55
  el.querySelector('.label').textContent = label;
46
- // The manager can only be stopped while the page can reach it.
47
- $('stop-manager').hidden = kind !== 'ok';
56
+ // The manager can only be stopped or upgraded while the page can reach it.
57
+ state.connected = kind === 'ok';
58
+ $('stop-manager').hidden = !state.connected;
59
+ renderUpgrade();
60
+ }
61
+
62
+ // ---- theme ----------------------------------------------------------------
63
+
64
+ /** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
65
+ function applyTheme(theme) {
66
+ document.documentElement.dataset.theme = theme;
67
+ const button = $('theme-toggle');
68
+ const other = theme === 'dark' ? 'light' : 'dark';
69
+ button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
70
+ button.title = `Switch to the ${other} theme`;
71
+ button.setAttribute('aria-label', `Switch to the ${other} theme`);
72
+ }
73
+
74
+ function currentTheme() {
75
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
76
+ }
77
+
78
+ function toggleTheme() {
79
+ const theme = currentTheme() === 'dark' ? 'light' : 'dark';
80
+ save(THEME_KEY, theme);
81
+ applyTheme(theme);
82
+ }
83
+
84
+ // ---- upgrading the manager ------------------------------------------------
85
+
86
+ function renderUpgrade() {
87
+ const u = state.upgrade;
88
+ const button = $('upgrade');
89
+ const note = $('upgrade-note');
90
+ const offer = Boolean(state.connected && u?.available && u.command);
91
+ button.hidden = !offer;
92
+ if (offer) {
93
+ button.textContent = `Upgrade to ${u.latestVersion}`;
94
+ button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
95
+ }
96
+ let text = '';
97
+ let title = '';
98
+ const last = u?.lastInstall;
99
+ if (u?.installing) {
100
+ text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
101
+ 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.`;
105
+ } else if (last?.outcome === 'failed') {
106
+ text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
107
+ 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.';
108
+ } else if (last?.outcome === 'unchanged') {
109
+ text = 'Upgrade finished, but this copy was not replaced';
110
+ title = `npm did not replace the files this manager runs from. Run${u.command ? ` "${u.command}"` : ' the npm install'} where Agent Guild is installed.`;
111
+ } else if (u?.available && !u.command) {
112
+ text = `v${u.latestVersion} available`;
113
+ title = u.guidance || '';
114
+ }
115
+ note.hidden = !state.connected || !text;
116
+ note.textContent = text;
117
+ note.title = title;
118
+ }
119
+
120
+ function setUpgrade(upgrade) {
121
+ const before = state.upgrade;
122
+ state.upgrade = upgrade || null;
123
+ renderUpgrade();
124
+ const pending = state.upgrade?.pendingVersion;
125
+ if (pending && pending !== before?.pendingVersion) {
126
+ toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
127
+ }
128
+ }
129
+
130
+ async function upgradeManager() {
131
+ const button = $('upgrade');
132
+ button.disabled = true;
133
+ try {
134
+ const { session } = await api('POST', '/upgrade');
135
+ upsertSession(session);
136
+ openPanel(session.id);
137
+ } catch (err) {
138
+ if (err instanceof AuthError) return showAuth(err.message);
139
+ toast(err.message, 8000);
140
+ } finally {
141
+ button.disabled = false;
142
+ }
48
143
  }
49
144
 
50
145
  function relativeTime(iso) {
@@ -148,6 +243,51 @@ function wsUrl(path) {
148
243
 
149
244
  // ---- providers ------------------------------------------------------------
150
245
 
246
+ function selectedAccount(provider) {
247
+ const accounts = provider.accounts || [];
248
+ return accounts.find((a) => a.id === state.accounts[provider.id]) || accounts[0] || { id: 'default', label: 'Default' };
249
+ }
250
+
251
+ function selectAccount(provider, id) {
252
+ state.accounts[provider.id] = id;
253
+ save(ACCOUNTS_KEY, JSON.stringify(state.accounts));
254
+ }
255
+
256
+ function usageFor(provider, account = selectedAccount(provider)) {
257
+ return state.usage.get(`${provider.id}/${account.id}`);
258
+ }
259
+
260
+ function renderAccounts(card, provider) {
261
+ const host = card.querySelector('.accounts');
262
+ const accounts = provider.accounts || [];
263
+ host.hidden = accounts.length < 2;
264
+ if (host.hidden) return host.replaceChildren();
265
+ const selected = selectedAccount(provider).id;
266
+ const same = host.children.length === accounts.length && accounts.every((a, i) => host.children[i].dataset.account === a.id);
267
+ if (!same) {
268
+ host.replaceChildren(...accounts.map((account) => {
269
+ const chip = document.createElement('button');
270
+ chip.type = 'button';
271
+ chip.className = 'account-chip';
272
+ chip.setAttribute('role', 'tab');
273
+ chip.dataset.account = account.id;
274
+ chip.addEventListener('click', () => {
275
+ selectAccount(provider, account.id);
276
+ renderAccounts(card, provider);
277
+ renderUsage(card, provider);
278
+ });
279
+ return chip;
280
+ }));
281
+ }
282
+ accounts.forEach((account, i) => {
283
+ const chip = host.children[i];
284
+ chip.classList.toggle('unsigned', usageFor(provider, account)?.signedIn === false);
285
+ chip.setAttribute('aria-selected', String(account.id === selected));
286
+ chip.textContent = account.label;
287
+ chip.title = `Start new ${provider.tool} sessions as the ${account.label} account`;
288
+ });
289
+ }
290
+
151
291
  function renderProviders() {
152
292
  const list = $('providers');
153
293
  const tpl = $('provider-template');
@@ -168,7 +308,6 @@ function renderProviders() {
168
308
  const existing = node.querySelector('.existing');
169
309
  const hint = node.querySelector('.hint');
170
310
  start.hidden = !provider.available;
171
- start.title = `Start a new ${provider.tool} session`;
172
311
  start.addEventListener('click', () => startSession(provider, node));
173
312
  existing.hidden = !provider.available || !provider.resumable;
174
313
  existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
@@ -186,6 +325,7 @@ function renderProviders() {
186
325
  renderHint(hint, provider);
187
326
  renderCopies(node.querySelector('.copies'), provider);
188
327
  renderConsoleLinks(node, provider);
328
+ renderAccounts(node, provider);
189
329
  renderUsage(node, provider);
190
330
  renderModelStats(node, provider);
191
331
  return node;
@@ -284,14 +424,21 @@ function creditsNote(usage) {
284
424
 
285
425
  function renderUsage(card, provider) {
286
426
  const host = card.querySelector('.usage');
287
- const usage = state.usage.get(provider.id);
427
+ const account = selectedAccount(provider);
428
+ const usage = usageFor(provider, account);
288
429
  renderTier(card, provider, provider.usageSource ? usage : null);
430
+ const start = card.querySelector('.new');
431
+ const unsigned = Boolean(provider.available && usage?.signedIn === false);
432
+ start.textContent = unsigned ? 'Sign in' : 'New';
433
+ start.title = unsigned
434
+ ? `Start a ${provider.tool} session and sign in as the ${account.label} account`
435
+ : `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}`;
289
436
  if (!provider.available || !provider.usageSource || !usage) return host.replaceChildren();
290
437
  if (usage.error || usage.windows.length === 0) {
291
438
  const note = document.createElement('div');
292
439
  note.className = 'usage-note';
293
- note.textContent = `Usage: ${usage.error || 'no limits reported'}`;
294
- note.title = note.textContent;
440
+ note.textContent = unsigned ? 'Not signed in yet' : `Usage: ${usage.error || 'no limits reported'}`;
441
+ note.title = unsigned ? usage.error : note.textContent;
295
442
  return host.replaceChildren(note, ...creditsNote(usage));
296
443
  }
297
444
  host.replaceChildren(...usage.windows.map((w) => {
@@ -313,10 +460,12 @@ function renderUsage(card, provider) {
313
460
  async function loadUsage() {
314
461
  let usage;
315
462
  try { ({ usage } = await api('GET', '/usage')); } catch { return; }
316
- state.usage = new Map(usage.map((u) => [u.providerId, u]));
463
+ state.usage = new Map(usage.map((u) => [`${u.providerId}/${u.accountId ?? 'default'}`, u]));
317
464
  for (const card of $('providers').children) {
318
465
  const provider = state.providers.find((p) => p.id === card.dataset.id);
319
- if (provider) renderUsage(card, provider);
466
+ if (!provider) continue;
467
+ renderAccounts(card, provider);
468
+ renderUsage(card, provider);
320
469
  }
321
470
  }
322
471
 
@@ -754,6 +903,291 @@ function closeModels() {
754
903
  if ($('models').open) $('models').close();
755
904
  }
756
905
 
906
+ const NEWS_LATEST = 5;
907
+ const NEWS_POLL_MS = 10 * 60 * 1000;
908
+ const NEWS_FILTERS = [['all', 'All'], ['news', 'News'], ['releases', 'Releases'], ['research', 'Research']];
909
+ const newsView = { shown: null, since: Infinity, filter: 'all', opener: null };
910
+ let newsLoading = null;
911
+ let newsAgain = false;
912
+ let newsLoadedAt = 0;
913
+
914
+ function newsSeen() {
915
+ const seen = Date.parse(load(NEWS_SEEN_KEY));
916
+ return Number.isFinite(seen) ? seen : null;
917
+ }
918
+
919
+ function newestTime(items) {
920
+ return items.reduce((newest, item) => Math.max(newest, Date.parse(item.publishedAt) || 0), 0);
921
+ }
922
+
923
+ function loadNews() {
924
+ if (newsLoading) {
925
+ newsAgain = true;
926
+ return newsLoading;
927
+ }
928
+ newsLoading = api('GET', '/news').then(setNews, () => {}).finally(() => {
929
+ newsLoading = null;
930
+ newsLoadedAt = Date.now();
931
+ if (newsAgain) {
932
+ newsAgain = false;
933
+ loadNews();
934
+ }
935
+ });
936
+ return newsLoading;
937
+ }
938
+
939
+ function setNews(news) {
940
+ state.news = news;
941
+ if (newsSeen() === null && news.items.length) save(NEWS_SEEN_KEY, new Date(newestTime(news.items)).toISOString());
942
+ renderLatestNews();
943
+ if ($('news').open) updateNewsPanel();
944
+ }
945
+
946
+ function webHref(value) {
947
+ try {
948
+ const url = new URL(value);
949
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
950
+ } catch {
951
+ return null;
952
+ }
953
+ }
954
+
955
+ function newsTitle(item) {
956
+ const href = webHref(item.url);
957
+ const title = document.createElement(href ? 'a' : 'span');
958
+ title.className = 'news-link';
959
+ title.textContent = item.title;
960
+ if (href) {
961
+ title.href = href;
962
+ title.target = '_blank';
963
+ title.rel = 'noopener noreferrer';
964
+ title.setAttribute('aria-description', 'Opens in a new tab');
965
+ }
966
+ return title;
967
+ }
968
+
969
+ function newsMeta(item) {
970
+ const meta = document.createElement('span');
971
+ meta.className = 'news-meta';
972
+ const time = document.createElement('time');
973
+ time.dateTime = item.publishedAt;
974
+ time.textContent = relativeTime(item.publishedAt);
975
+ time.title = new Date(item.publishedAt).toLocaleString();
976
+ meta.append(`${item.source} · `, time);
977
+ return meta;
978
+ }
979
+
980
+ function isNew(item, since) {
981
+ return Date.parse(item.publishedAt) > since;
982
+ }
983
+
984
+ function newBadge() {
985
+ return badge('new', 'New', 'Published since you last opened the news');
986
+ }
987
+
988
+ function latestNews(items) {
989
+ const sources = new Set();
990
+ const latest = [];
991
+ for (const item of items) {
992
+ if (item.category !== 'news' || sources.has(item.source)) continue;
993
+ sources.add(item.source);
994
+ latest.push(item);
995
+ if (latest.length === NEWS_LATEST) break;
996
+ }
997
+ return latest;
998
+ }
999
+
1000
+ function failureLines(news) {
1001
+ const groups = new Map();
1002
+ for (const source of news.sources) if (source.error) groups.set(source.error, [...(groups.get(source.error) ?? []), source]);
1003
+ return [...groups].map(([error, sources]) => {
1004
+ if (sources.length === 1) {
1005
+ const [source] = sources;
1006
+ return `${source.name}: ${error} · ${source.okAt ? `showing items fetched ${relativeTime(source.okAt)}` : 'nothing fetched yet'}`;
1007
+ }
1008
+ const names = [...new Set(sources.map((source) => source.name))];
1009
+ return `${names.length <= 3 ? listJoin(names) : `${sources.length} sources`}: ${error}`;
1010
+ });
1011
+ }
1012
+
1013
+ function newsNote(news) {
1014
+ if (!news || (news.refreshing && news.items.length === 0)) return 'Loading news…';
1015
+ const failed = news.sources.filter((s) => s.error);
1016
+ if (news.items.length === 0 && failed.length > 0 && failed.length === news.sources.length) {
1017
+ return `News could not be loaded. ${failureLines(news)[0]}. Agent Guild tries again in a few minutes.`;
1018
+ }
1019
+ return 'No news from the last 30 days.';
1020
+ }
1021
+
1022
+ function renderLatestNews() {
1023
+ const news = state.news;
1024
+ const list = $('news-latest');
1025
+ const seen = newsSeen() ?? Infinity;
1026
+ const items = news ? latestNews(news.items) : [];
1027
+ const focused = document.activeElement?.closest?.('#news-latest .news-row')?.dataset.id;
1028
+ list.replaceChildren(...items.map((item) => {
1029
+ const row = document.createElement('li');
1030
+ row.className = 'news-row';
1031
+ row.dataset.id = item.id;
1032
+ row.append(newsTitle(item), ...(isNew(item, seen) ? [newBadge()] : []), newsMeta(item));
1033
+ return row;
1034
+ }));
1035
+ if (focused) [...list.children].find((row) => row.dataset.id === focused)?.querySelector('.news-link')?.focus({ preventScroll: true });
1036
+ list.hidden = items.length === 0;
1037
+ const fresh = news ? news.items.filter((item) => item.category === 'news' && isNew(item, seen)).length : 0;
1038
+ $('news-count').textContent = fresh ? `· ${fresh} new` : '';
1039
+ $('news-note').textContent = items.length ? '' : newsNote(news);
1040
+ $('news-note').hidden = items.length > 0;
1041
+ }
1042
+
1043
+ function dayLabel(time) {
1044
+ const date = new Date(time);
1045
+ const today = new Date();
1046
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
1047
+ if (date.toDateString() === today.toDateString()) return 'Today';
1048
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
1049
+ return date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
1050
+ }
1051
+
1052
+ function newsEntry(item) {
1053
+ const entry = document.createElement('li');
1054
+ entry.className = 'news-item';
1055
+ entry.dataset.id = item.id;
1056
+ const head = document.createElement('div');
1057
+ head.className = 'news-head';
1058
+ head.append(newsTitle(item), ...(isNew(item, newsView.since) ? [newBadge()] : []));
1059
+ entry.append(head, newsMeta(item));
1060
+ if (item.summary) {
1061
+ const summary = document.createElement('p');
1062
+ summary.className = 'news-summary';
1063
+ const discussion = webHref(item.discussion);
1064
+ if (discussion) {
1065
+ const link = document.createElement('a');
1066
+ link.className = 'news-discussion';
1067
+ link.href = discussion;
1068
+ link.target = '_blank';
1069
+ link.rel = 'noopener noreferrer';
1070
+ link.textContent = item.summary;
1071
+ link.setAttribute('aria-description', `Opens the discussion on ${item.source} in a new tab`);
1072
+ summary.append(link);
1073
+ } else {
1074
+ summary.textContent = item.summary;
1075
+ }
1076
+ entry.append(summary);
1077
+ }
1078
+ return entry;
1079
+ }
1080
+
1081
+ function renderNewsFilters() {
1082
+ $('news-filters').replaceChildren(...NEWS_FILTERS.map(([id, label]) => {
1083
+ const chip = document.createElement('button');
1084
+ chip.type = 'button';
1085
+ chip.className = 'news-filter';
1086
+ chip.textContent = label;
1087
+ chip.setAttribute('aria-pressed', String(id === newsView.filter));
1088
+ chip.addEventListener('click', () => {
1089
+ newsView.filter = id;
1090
+ save(NEWS_FILTER_KEY, id);
1091
+ for (const other of $('news-filters').children) other.setAttribute('aria-pressed', String(other === chip));
1092
+ newsView.shown = state.news;
1093
+ renderNewsList();
1094
+ document.querySelector('.news-body').scrollTop = 0;
1095
+ });
1096
+ return chip;
1097
+ }));
1098
+ }
1099
+
1100
+ function renderNewsList() {
1101
+ const shown = newsView.shown;
1102
+ const items = (shown?.items ?? []).filter((item) => newsView.filter === 'all' || item.category === newsView.filter);
1103
+ const groups = [];
1104
+ for (const item of items) {
1105
+ const label = dayLabel(Date.parse(item.publishedAt));
1106
+ if (groups.at(-1)?.label !== label) groups.push({ label, items: [] });
1107
+ groups.at(-1).items.push(item);
1108
+ }
1109
+ $('news-list').replaceChildren(...groups.map((group) => {
1110
+ const section = document.createElement('section');
1111
+ section.className = 'news-group';
1112
+ const heading = document.createElement('h3');
1113
+ heading.className = 'news-day';
1114
+ heading.textContent = group.label;
1115
+ const list = document.createElement('ul');
1116
+ list.className = 'news-items';
1117
+ list.append(...group.items.map(newsEntry));
1118
+ section.append(heading, list);
1119
+ return section;
1120
+ }));
1121
+ const label = NEWS_FILTERS.find(([id]) => id === newsView.filter)[1];
1122
+ $('news-empty').textContent = items.length ? '' : newsView.filter === 'all' || !shown ? newsNote(shown) : `Nothing in ${label} from the last 30 days.`;
1123
+ $('news-empty').hidden = items.length > 0;
1124
+ $('news-fresh').hidden = true;
1125
+ const fresh = (shown?.items ?? []).filter((item) => isNew(item, newsView.since)).length;
1126
+ $('news-sub').textContent = fresh ? `${fresh} new since your last visit` : 'Newest first';
1127
+ }
1128
+
1129
+ function renderNewsStatus() {
1130
+ const news = state.news;
1131
+ const status = $('news-status');
1132
+ status.hidden = !news;
1133
+ if (!news) return;
1134
+ const updated = news.refreshing ? 'checking for news…' : news.refreshedAt ? `updated ${relativeTime(news.refreshedAt)}` : '';
1135
+ const lines = [[`${news.sources.length} source${news.sources.length === 1 ? '' : 's'}`, updated].filter(Boolean).join(' · '), ...failureLines(news)];
1136
+ status.replaceChildren(...lines.map((text) => {
1137
+ const line = document.createElement('span');
1138
+ line.textContent = text;
1139
+ return line;
1140
+ }));
1141
+ }
1142
+
1143
+ function updateNewsPanel() {
1144
+ renderNewsStatus();
1145
+ if (!newsView.shown?.items.length) {
1146
+ newsView.shown = state.news;
1147
+ renderNewsList();
1148
+ return;
1149
+ }
1150
+ const shownIds = new Set(newsView.shown.items.map((item) => item.id));
1151
+ const added = state.news.items.filter((item) => !shownIds.has(item.id) && (newsView.filter === 'all' || item.category === newsView.filter)).length;
1152
+ const fresh = $('news-fresh');
1153
+ fresh.hidden = added === 0;
1154
+ fresh.textContent = `Show ${added} new item${added === 1 ? '' : 's'}`;
1155
+ }
1156
+
1157
+ function showFreshNews() {
1158
+ const before = new Set(newsView.shown.items.map((item) => item.id));
1159
+ newsView.shown = state.news;
1160
+ renderNewsList();
1161
+ const first = [...$('news-list').querySelectorAll('.news-item')].find((entry) => !before.has(entry.dataset.id));
1162
+ (first?.querySelector('.news-link') ?? $('news-close')).focus();
1163
+ }
1164
+
1165
+ function openNews() {
1166
+ const dialog = $('news');
1167
+ const saved = load(NEWS_FILTER_KEY);
1168
+ newsView.filter = NEWS_FILTERS.some(([id]) => id === saved) ? saved : 'all';
1169
+ newsView.since = newsSeen() ?? Infinity;
1170
+ newsView.shown = state.news;
1171
+ renderNewsFilters();
1172
+ renderNewsList();
1173
+ renderNewsStatus();
1174
+ if (!dialog.open) {
1175
+ newsView.opener = document.activeElement;
1176
+ dialog.showModal();
1177
+ }
1178
+ ($('news-list').querySelector('.news-link') ?? $('news-close')).focus();
1179
+ if (!state.news) loadNews();
1180
+ }
1181
+
1182
+ function closeNews() {
1183
+ if ($('news').open) $('news').close();
1184
+ }
1185
+
1186
+ function tickNews() {
1187
+ for (const time of document.querySelectorAll('#news-latest time, #news-list time')) time.textContent = relativeTime(time.dateTime);
1188
+ if ($('news').open) renderNewsStatus();
1189
+ }
1190
+
757
1191
  const CHANNEL_LABELS = {
758
1192
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
759
1193
  };
@@ -818,7 +1252,7 @@ async function startSession(provider, card, { resume } = {}) {
818
1252
  save(CWD_KEY, cwd);
819
1253
  card.classList.add('busy');
820
1254
  try {
821
- const body = { providerId: provider.id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
1255
+ const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
822
1256
  const { session } = await api('POST', '/sessions', body);
823
1257
  upsertSession(session);
824
1258
  openPanel(session.id);
@@ -852,6 +1286,12 @@ function modelTitle(s) {
852
1286
  return `Model ${modelText(s)}${id}, ${MODEL_SOURCES[s.model.source] || s.model.source}`;
853
1287
  }
854
1288
 
1289
+ function accountLabel(s) {
1290
+ if (!s.account) return '';
1291
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1292
+ return (provider?.accounts?.length ?? 0) > 1 || s.account.id !== 'default' ? s.account.label : '';
1293
+ }
1294
+
855
1295
  function statusText(s) {
856
1296
  if (s.status === 'exited') {
857
1297
  if (s.signal) return `Exited (${s.signal})`;
@@ -891,7 +1331,7 @@ function updateCard(node, s) {
891
1331
  badge.title = `Level ${level}`;
892
1332
  node.querySelector('.name').textContent = s.name;
893
1333
  const resumed = s.resume ? ` · resumed ${s.resume}` : '';
894
- node.querySelector('.meta').textContent = `${s.provider.vendor} · ${s.provider.tool} · started ${relativeTime(s.createdAt)}${resumed}`;
1334
+ node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
895
1335
  const pill = node.querySelector('.status-pill');
896
1336
  pill.textContent = statusText(s);
897
1337
  pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
@@ -910,7 +1350,8 @@ function updateCard(node, s) {
910
1350
  node.querySelector('.stop').hidden = s.status !== 'running';
911
1351
  node.querySelector('.remove').hidden = s.status === 'running';
912
1352
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
913
- node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
1353
+ const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
1354
+ node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
914
1355
  }
915
1356
 
916
1357
  function renderSessions() {
@@ -1146,7 +1587,7 @@ function updatePanel() {
1146
1587
  if (!s) return;
1147
1588
  paintProviderIcon($('panel-icon'), s.provider);
1148
1589
  $('panel-title').textContent = s.name;
1149
- $('panel-sub').textContent = [s.provider.tool, modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1590
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1150
1591
  $('panel-sub').title = modelTitle(s);
1151
1592
  renderAgents($('panel-agents'), s.agents);
1152
1593
  const stop = $('panel-stop');
@@ -1190,6 +1631,7 @@ function enterStopping(running = 0) {
1190
1631
  state.stopRemaining = null;
1191
1632
  closePanel();
1192
1633
  closeModels();
1634
+ closeNews();
1193
1635
  for (const view of state.views.values()) view.dispose();
1194
1636
  state.views.clear();
1195
1637
  state.sessions.clear();
@@ -1246,6 +1688,12 @@ function connectEvents() {
1246
1688
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
1247
1689
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
1248
1690
  renderSessions();
1691
+ setUpgrade(msg.upgrade);
1692
+ loadNews();
1693
+ } else if (msg.type === 'news.updated') {
1694
+ loadNews();
1695
+ } else if (msg.type === 'manager.upgrade') {
1696
+ setUpgrade(msg.upgrade);
1249
1697
  } else if (msg.type === 'manager.stopping') {
1250
1698
  enterStopping(msg.running);
1251
1699
  } else if (msg.type === 'manager.stopped') {
@@ -1297,9 +1745,11 @@ function readTokenFromHash() {
1297
1745
 
1298
1746
  let usageTimer;
1299
1747
  let statsInterval;
1748
+ let newsTimer;
1300
1749
 
1301
1750
  function showAuth(message = '') {
1302
1751
  closeModels();
1752
+ closeNews();
1303
1753
  $('app').hidden = true;
1304
1754
  $('terminal-panel').hidden = true;
1305
1755
  $('stopped').hidden = true;
@@ -1332,6 +1782,8 @@ async function boot() {
1332
1782
  loadStats();
1333
1783
  clearInterval(statsInterval);
1334
1784
  statsInterval = setInterval(loadStats, 60 * 60 * 1000);
1785
+ clearInterval(newsTimer);
1786
+ newsTimer = setInterval(() => { if (document.visibilityState === 'visible') loadNews(); }, NEWS_POLL_MS);
1335
1787
  }
1336
1788
 
1337
1789
  $('auth-form').addEventListener('submit', (e) => {
@@ -1374,6 +1826,21 @@ document.addEventListener('keydown', (e) => {
1374
1826
  }, true);
1375
1827
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
1376
1828
  addEventListener('resize', () => { if (tipFor) hideTip(); });
1829
+ $('news-all').addEventListener('click', openNews);
1830
+ $('news-close').addEventListener('click', closeNews);
1831
+ $('news-fresh').addEventListener('click', showFreshNews);
1832
+ $('news').addEventListener('click', (e) => { if (e.target === $('news')) closeNews(); });
1833
+ $('news').addEventListener('close', () => {
1834
+ const newest = newestTime(newsView.shown?.items ?? []);
1835
+ if (newest > (newsSeen() ?? 0)) save(NEWS_SEEN_KEY, new Date(newest).toISOString());
1836
+ renderLatestNews();
1837
+ const opener = newsView.opener?.isConnected && !newsView.opener.closest('[hidden]') ? newsView.opener : $('news-all');
1838
+ opener.focus();
1839
+ newsView.opener = null;
1840
+ });
1841
+ document.addEventListener('visibilitychange', () => {
1842
+ if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
1843
+ });
1377
1844
  $('models-more').addEventListener('click', () => {
1378
1845
  const before = $('models-list').childElementCount;
1379
1846
  modelsView.all = true;
@@ -1381,6 +1848,13 @@ $('models-more').addEventListener('click', () => {
1381
1848
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1382
1849
  });
1383
1850
  $('stop-manager').addEventListener('click', () => stopManager());
1851
+ $('upgrade').addEventListener('click', upgradeManager);
1852
+ $('theme-toggle').addEventListener('click', toggleTheme);
1853
+ applyTheme(currentTheme());
1854
+ // Follow the system setting until the user picks a theme.
1855
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1856
+ if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
1857
+ });
1384
1858
  $('panel-stop').addEventListener('click', () => {
1385
1859
  const s = state.sessions.get(state.activeId);
1386
1860
  if (!s) return;
@@ -1388,7 +1862,10 @@ $('panel-stop').addEventListener('click', () => {
1388
1862
  else removeSession(s.id);
1389
1863
  });
1390
1864
  $('cwd').value = load(CWD_KEY) || '';
1865
+ try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
1866
+ if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
1391
1867
  setInterval(renderSessions, 30000);
1868
+ setInterval(tickNews, 30000);
1392
1869
 
1393
1870
  // The terminal panel sits below the top bar, which wraps onto two rows on
1394
1871
  // narrow screens; publish its height so the panel never covers its controls.