@oddessentials/agent-guild 0.1.1 → 0.3.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,8 @@
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';
6
8
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
7
9
 
8
10
  const $ = (id) => document.getElementById(id);
@@ -10,11 +12,18 @@ const state = {
10
12
  token: null,
11
13
  providers: [],
12
14
  usage: new Map(),
15
+ accounts: {},
16
+ stats: null,
17
+ statsFor: new Map(),
13
18
  sessions: new Map(),
14
19
  views: new Map(),
15
20
  activeId: null,
16
21
  eventsSocket: null,
17
22
  eventsRetry: 0,
23
+ /** True while the events socket is open. */
24
+ connected: false,
25
+ /** The manager's own version check, from `hello` and `manager.upgrade`. */
26
+ upgrade: null,
18
27
  /** True from a shutdown request until the manager is reachable again. */
19
28
  stopping: false,
20
29
  /** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
@@ -41,8 +50,93 @@ function setConnection(kind, label) {
41
50
  const el = $('connection');
42
51
  el.className = `connection ${kind}`;
43
52
  el.querySelector('.label').textContent = label;
44
- // The manager can only be stopped while the page can reach it.
45
- $('stop-manager').hidden = kind !== 'ok';
53
+ // The manager can only be stopped or upgraded while the page can reach it.
54
+ state.connected = kind === 'ok';
55
+ $('stop-manager').hidden = !state.connected;
56
+ renderUpgrade();
57
+ }
58
+
59
+ // ---- theme ----------------------------------------------------------------
60
+
61
+ /** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
62
+ function applyTheme(theme) {
63
+ document.documentElement.dataset.theme = theme;
64
+ const button = $('theme-toggle');
65
+ const other = theme === 'dark' ? 'light' : 'dark';
66
+ button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
67
+ button.title = `Switch to the ${other} theme`;
68
+ button.setAttribute('aria-label', `Switch to the ${other} theme`);
69
+ }
70
+
71
+ function currentTheme() {
72
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
73
+ }
74
+
75
+ function toggleTheme() {
76
+ const theme = currentTheme() === 'dark' ? 'light' : 'dark';
77
+ save(THEME_KEY, theme);
78
+ applyTheme(theme);
79
+ }
80
+
81
+ // ---- upgrading the manager ------------------------------------------------
82
+
83
+ function renderUpgrade() {
84
+ const u = state.upgrade;
85
+ const button = $('upgrade');
86
+ const note = $('upgrade-note');
87
+ const offer = Boolean(state.connected && u?.available && u.command);
88
+ button.hidden = !offer;
89
+ if (offer) {
90
+ button.textContent = `Upgrade to ${u.latestVersion}`;
91
+ button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
92
+ }
93
+ let text = '';
94
+ let title = '';
95
+ const last = u?.lastInstall;
96
+ if (u?.installing) {
97
+ text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
98
+ 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.`;
102
+ } else if (last?.outcome === 'failed') {
103
+ text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
104
+ 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.';
105
+ } else if (last?.outcome === 'unchanged') {
106
+ text = 'Upgrade finished, but this copy was not replaced';
107
+ title = `npm did not replace the files this manager runs from. Run${u.command ? ` "${u.command}"` : ' the npm install'} where Agent Guild is installed.`;
108
+ } else if (u?.available && !u.command) {
109
+ text = `v${u.latestVersion} available`;
110
+ title = u.guidance || '';
111
+ }
112
+ note.hidden = !state.connected || !text;
113
+ note.textContent = text;
114
+ note.title = title;
115
+ }
116
+
117
+ function setUpgrade(upgrade) {
118
+ const before = state.upgrade;
119
+ state.upgrade = upgrade || null;
120
+ renderUpgrade();
121
+ const pending = state.upgrade?.pendingVersion;
122
+ if (pending && pending !== before?.pendingVersion) {
123
+ toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
124
+ }
125
+ }
126
+
127
+ async function upgradeManager() {
128
+ const button = $('upgrade');
129
+ button.disabled = true;
130
+ try {
131
+ const { session } = await api('POST', '/upgrade');
132
+ upsertSession(session);
133
+ openPanel(session.id);
134
+ } catch (err) {
135
+ if (err instanceof AuthError) return showAuth(err.message);
136
+ toast(err.message, 8000);
137
+ } finally {
138
+ button.disabled = false;
139
+ }
46
140
  }
47
141
 
48
142
  function relativeTime(iso) {
@@ -146,6 +240,51 @@ function wsUrl(path) {
146
240
 
147
241
  // ---- providers ------------------------------------------------------------
148
242
 
243
+ function selectedAccount(provider) {
244
+ const accounts = provider.accounts || [];
245
+ return accounts.find((a) => a.id === state.accounts[provider.id]) || accounts[0] || { id: 'default', label: 'Default' };
246
+ }
247
+
248
+ function selectAccount(provider, id) {
249
+ state.accounts[provider.id] = id;
250
+ save(ACCOUNTS_KEY, JSON.stringify(state.accounts));
251
+ }
252
+
253
+ function usageFor(provider, account = selectedAccount(provider)) {
254
+ return state.usage.get(`${provider.id}/${account.id}`);
255
+ }
256
+
257
+ function renderAccounts(card, provider) {
258
+ const host = card.querySelector('.accounts');
259
+ const accounts = provider.accounts || [];
260
+ host.hidden = accounts.length < 2;
261
+ if (host.hidden) return host.replaceChildren();
262
+ const selected = selectedAccount(provider).id;
263
+ const same = host.children.length === accounts.length && accounts.every((a, i) => host.children[i].dataset.account === a.id);
264
+ if (!same) {
265
+ host.replaceChildren(...accounts.map((account) => {
266
+ const chip = document.createElement('button');
267
+ chip.type = 'button';
268
+ chip.className = 'account-chip';
269
+ chip.setAttribute('role', 'tab');
270
+ chip.dataset.account = account.id;
271
+ chip.addEventListener('click', () => {
272
+ selectAccount(provider, account.id);
273
+ renderAccounts(card, provider);
274
+ renderUsage(card, provider);
275
+ });
276
+ return chip;
277
+ }));
278
+ }
279
+ accounts.forEach((account, i) => {
280
+ const chip = host.children[i];
281
+ chip.classList.toggle('unsigned', usageFor(provider, account)?.signedIn === false);
282
+ chip.setAttribute('aria-selected', String(account.id === selected));
283
+ chip.textContent = account.label;
284
+ chip.title = `Start new ${provider.tool} sessions as the ${account.label} account`;
285
+ });
286
+ }
287
+
149
288
  function renderProviders() {
150
289
  const list = $('providers');
151
290
  const tpl = $('provider-template');
@@ -166,7 +305,6 @@ function renderProviders() {
166
305
  const existing = node.querySelector('.existing');
167
306
  const hint = node.querySelector('.hint');
168
307
  start.hidden = !provider.available;
169
- start.title = `Start a new ${provider.tool} session`;
170
308
  start.addEventListener('click', () => startSession(provider, node));
171
309
  existing.hidden = !provider.available || !provider.resumable;
172
310
  existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
@@ -184,7 +322,9 @@ function renderProviders() {
184
322
  renderHint(hint, provider);
185
323
  renderCopies(node.querySelector('.copies'), provider);
186
324
  renderConsoleLinks(node, provider);
325
+ renderAccounts(node, provider);
187
326
  renderUsage(node, provider);
327
+ renderModelStats(node, provider);
188
328
  return node;
189
329
  }));
190
330
  }
@@ -281,14 +421,21 @@ function creditsNote(usage) {
281
421
 
282
422
  function renderUsage(card, provider) {
283
423
  const host = card.querySelector('.usage');
284
- const usage = state.usage.get(provider.id);
424
+ const account = selectedAccount(provider);
425
+ const usage = usageFor(provider, account);
285
426
  renderTier(card, provider, provider.usageSource ? usage : null);
427
+ const start = card.querySelector('.new');
428
+ const unsigned = Boolean(provider.available && usage?.signedIn === false);
429
+ start.textContent = unsigned ? 'Sign in' : 'New';
430
+ start.title = unsigned
431
+ ? `Start a ${provider.tool} session and sign in as the ${account.label} account`
432
+ : `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}`;
286
433
  if (!provider.available || !provider.usageSource || !usage) return host.replaceChildren();
287
434
  if (usage.error || usage.windows.length === 0) {
288
435
  const note = document.createElement('div');
289
436
  note.className = 'usage-note';
290
- note.textContent = `Usage: ${usage.error || 'no limits reported'}`;
291
- note.title = note.textContent;
437
+ note.textContent = unsigned ? 'Not signed in yet' : `Usage: ${usage.error || 'no limits reported'}`;
438
+ note.title = unsigned ? usage.error : note.textContent;
292
439
  return host.replaceChildren(note, ...creditsNote(usage));
293
440
  }
294
441
  host.replaceChildren(...usage.windows.map((w) => {
@@ -310,11 +457,447 @@ function renderUsage(card, provider) {
310
457
  async function loadUsage() {
311
458
  let usage;
312
459
  try { ({ usage } = await api('GET', '/usage')); } catch { return; }
313
- state.usage = new Map(usage.map((u) => [u.providerId, u]));
460
+ state.usage = new Map(usage.map((u) => [`${u.providerId}/${u.accountId ?? 'default'}`, u]));
314
461
  for (const card of $('providers').children) {
315
462
  const provider = state.providers.find((p) => p.id === card.dataset.id);
316
- if (provider) renderUsage(card, provider);
463
+ if (!provider) continue;
464
+ renderAccounts(card, provider);
465
+ renderUsage(card, provider);
466
+ }
467
+ }
468
+
469
+ const ARTIFICIAL_ANALYSIS = 'Artificial Analysis';
470
+ const MODELS_SHOWN = 8;
471
+ const modelsView = { providerId: null, sessionId: null, focus: null, all: false, expanded: new Set() };
472
+ let modelsOpener = null;
473
+ let statsLoading = null;
474
+ let statsAgain = false;
475
+ let statsTimer;
476
+
477
+ function modelKey(s) {
478
+ return s.model ? `${s.model.name}\n${s.model.displayName ?? ''}` : '';
479
+ }
480
+
481
+ function sessionModelId(s) {
482
+ return state.statsFor.get(s.id) === modelKey(s) ? state.stats?.sessions[s.id] ?? null : null;
483
+ }
484
+
485
+ function loadStats() {
486
+ if (statsLoading) {
487
+ statsAgain = true;
488
+ return statsLoading;
489
+ }
490
+ const asked = new Map([...state.sessions.values()].map((s) => [s.id, modelKey(s)]));
491
+ statsLoading = api('GET', '/model-stats').then((stats) => {
492
+ state.stats = stats;
493
+ state.statsFor = asked;
494
+ for (const card of $('providers').children) {
495
+ const provider = state.providers.find((p) => p.id === card.dataset.id);
496
+ if (provider) renderModelStats(card, provider);
497
+ }
498
+ renderSessions();
499
+ if ($('models').open) {
500
+ const body = document.querySelector('.models-body');
501
+ const top = body.scrollTop;
502
+ const focus = modelsFocus();
503
+ const withTip = Boolean(tipFor?.closest('#models-list'));
504
+ renderModels();
505
+ body.scrollTop = top;
506
+ restoreModelsFocus(focus, withTip);
507
+ }
508
+ if (tipFor && !tipFor.isConnected) hideTip();
509
+ }, () => {}).finally(() => {
510
+ statsLoading = null;
511
+ if (statsAgain) {
512
+ statsAgain = false;
513
+ loadStats();
514
+ }
515
+ });
516
+ return statsLoading;
517
+ }
518
+
519
+ function scheduleStats() {
520
+ clearTimeout(statsTimer);
521
+ statsTimer = setTimeout(loadStats, 300);
522
+ }
523
+
524
+ function indexStats() {
525
+ return state.stats?.stats.filter((stat) => stat.group === ARTIFICIAL_ANALYSIS) ?? [];
526
+ }
527
+
528
+ function ordinal(n) {
529
+ const suffixes = ['th', 'st', 'nd', 'rd'];
530
+ const v = n % 100;
531
+ return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
532
+ }
533
+
534
+ function listJoin(items) {
535
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
536
+ }
537
+
538
+ function formatTokens(n) {
539
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '—';
540
+ if (n >= 1e6) return `${Number((n / 1e6).toFixed(2))}M`;
541
+ if (n >= 1e3) return `${Math.round(n / 1e3)}K`;
542
+ return String(n);
543
+ }
544
+
545
+ function formatPrice(n) {
546
+ return `$${Number(n.toFixed(n < 1 ? 3 : 2))}`;
547
+ }
548
+
549
+ function formatDate(iso, options = { year: 'numeric', month: 'short', day: 'numeric' }) {
550
+ const date = new Date(iso);
551
+ return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString(undefined, options);
552
+ }
553
+
554
+ function statValue(stat, entry) {
555
+ return stat.group === ARTIFICIAL_ANALYSIS ? String(entry.value) : `Elo ${entry.value}`;
556
+ }
557
+
558
+ function statPlace(entry) {
559
+ return `${entry.tied ? 'tied ' : ''}${ordinal(entry.place)} of ${entry.of}`;
560
+ }
561
+
562
+ function statSummary(stat, entry) {
563
+ if (!entry) return `${stat.label}: no published result`;
564
+ const value = `${stat.label} ${statValue(stat, entry)}`;
565
+ if (entry.level === null) return `${value}: no other model has this result to compare with`;
566
+ const rank = entry.rank === null ? '' : `, Design Arena rank ${entry.rank}`;
567
+ return `${value}: level ${entry.level}, tier ${entry.tier}, ${statPlace(entry)}${rank}`;
568
+ }
569
+
570
+ function levelValue(entry) {
571
+ if (!entry) return ['—'];
572
+ if (entry.level === null) return [String(entry.value)];
573
+ const tier = document.createElement('span');
574
+ tier.className = 'stat-tier';
575
+ tier.textContent = entry.tier;
576
+ const level = document.createElement('span');
577
+ level.className = 'level';
578
+ level.textContent = entry.level;
579
+ return [tier, level];
580
+ }
581
+
582
+ function tierClass(entry) {
583
+ return entry?.tier ? `tier-${entry.tier.toLowerCase()}` : '';
584
+ }
585
+
586
+ function statRow(stat, card, { detail = false } = {}) {
587
+ const node = $('stat-template').content.firstElementChild.cloneNode(true);
588
+ const entry = card.stats[stat.id];
589
+ node.querySelector('.stat-name').textContent = detail ? stat.label : stat.short;
590
+ const info = node.querySelector('.info');
591
+ info.dataset.stat = stat.id;
592
+ info.setAttribute('aria-label', `${stat.label}: ${stat.about}`);
593
+ const reading = node.querySelector('.stat-reading');
594
+ reading.querySelector('.stat-value').replaceChildren(...levelValue(entry));
595
+ node.classList.toggle('unmeasured', !entry);
596
+ if (entry?.tier) {
597
+ node.classList.add(tierClass(entry));
598
+ reading.querySelector('.stat-fill').style.width = `${entry.level}%`;
599
+ }
600
+ if (detail) {
601
+ const rank = entry?.rank == null ? '' : ` · Rank ${entry.rank}`;
602
+ reading.querySelector('.stat-note').textContent = !entry ? 'No published result'
603
+ : entry.level === null ? statValue(stat, entry) : `${statPlace(entry)} · ${statValue(stat, entry)}${rank}`;
604
+ }
605
+ reading.title = statSummary(stat, entry);
606
+ reading.setAttribute('aria-label', reading.title);
607
+ return node;
608
+ }
609
+
610
+ let tipFor = null;
611
+ let quietFocus = false;
612
+
613
+ function showTip(button) {
614
+ const stat = state.stats?.stats.find((s) => s.id === button.dataset.stat);
615
+ if (!stat) return;
616
+ const tip = $('tip');
617
+ $('tip-title').textContent = stat.label;
618
+ $('tip-source').textContent = stat.group;
619
+ $('tip-text').textContent = stat.about;
620
+ const host = $('models').open ? $('models') : document.body;
621
+ if (tip.parentElement !== host) host.append(tip);
622
+ tip.hidden = false;
623
+ const box = button.getBoundingClientRect();
624
+ const left = Math.min(Math.max(8, box.left + box.width / 2 - tip.offsetWidth / 2), innerWidth - tip.offsetWidth - 8);
625
+ const below = box.bottom + 8 + tip.offsetHeight <= innerHeight;
626
+ tip.style.left = `${Math.round(left)}px`;
627
+ tip.style.top = `${Math.round(below ? box.bottom + 8 : box.top - tip.offsetHeight - 8)}px`;
628
+ tipFor = button;
629
+ }
630
+
631
+ function hideTip() {
632
+ $('tip').hidden = true;
633
+ tipFor = null;
634
+ }
635
+
636
+ function renderModelStats(card, provider) {
637
+ const host = card.querySelector('.model-stats');
638
+ const stats = state.stats;
639
+ const entry = stats?.providers[provider.id];
640
+ if (!entry) {
641
+ if (!stats?.error || !provider.modelPattern) return host.replaceChildren();
642
+ const note = document.createElement('div');
643
+ note.className = 'usage-note';
644
+ note.textContent = `Benchmarks: ${stats.error}`;
645
+ note.title = note.textContent;
646
+ return host.replaceChildren(note);
647
+ }
648
+ const featured = stats.models[entry.featured];
649
+ const count = entry.models.length;
650
+ const complete = indexStats().every((stat) => featured.stats[stat.id]);
651
+ const head = document.createElement('button');
652
+ head.type = 'button';
653
+ head.className = 'model-stats-head';
654
+ head.textContent = `${featured.name} · ${count} model${count === 1 ? '' : 's'}`;
655
+ head.setAttribute('aria-label', head.textContent);
656
+ head.title = `${featured.name} is the newest ${provider.tool} model with ${complete ? 'all three Artificial Analysis indexes' : 'published benchmarks'}. Open to compare ${count === 1 ? 'it' : `all ${count}`}.`;
657
+ head.addEventListener('click', () => showModels({ providerId: provider.id, focus: featured.id }));
658
+ host.replaceChildren(head, ...indexStats().map((stat) => statRow(stat, featured)));
659
+ }
660
+
661
+ function modelStatsLine(s) {
662
+ const id = sessionModelId(s);
663
+ const card = id ? state.stats.models[id] : null;
664
+ if (!card) return '';
665
+ return indexStats().map((stat) => {
666
+ const entry = card.stats[stat.id];
667
+ return `${stat.short} ${entry?.tier ? `${entry.tier} ${entry.level}` : '—'}`;
668
+ }).join(' · ');
669
+ }
670
+
671
+ function modelsFocus() {
672
+ const active = document.activeElement;
673
+ const row = active?.closest?.('#models-list .model');
674
+ return row ? { id: row.dataset.id, stat: active.dataset.stat ?? null } : null;
675
+ }
676
+
677
+ function restoreModelsFocus(focus, withTip) {
678
+ const row = focus && [...$('models-list').children].find((el) => el.dataset.id === focus.id);
679
+ if (!row) return;
680
+ const info = focus.stat && row.querySelector(`.info[data-stat="${focus.stat}"]`);
681
+ quietFocus = true;
682
+ (info || row.querySelector('.model-toggle')).focus({ preventScroll: true });
683
+ quietFocus = false;
684
+ if (withTip && info) showTip(info);
685
+ }
686
+
687
+ function showModels({ providerId = null, sessionId = null, focus = null }) {
688
+ Object.assign(modelsView, { providerId, sessionId, focus, all: false, expanded: new Set(focus ? [focus] : []) });
689
+ const dialog = $('models');
690
+ renderModels();
691
+ if (!dialog.open) {
692
+ modelsOpener = document.activeElement;
693
+ dialog.showModal();
317
694
  }
695
+ const toggle = $('models-list').querySelector('[aria-expanded="true"]');
696
+ toggle?.focus();
697
+ toggle?.scrollIntoView({ block: 'nearest' });
698
+ }
699
+
700
+ function openSessionModel(id) {
701
+ const s = state.sessions.get(id);
702
+ if (!s?.model) return;
703
+ if (!state.stats || state.statsFor.get(id) !== modelKey(s)) scheduleStats();
704
+ showModels({ sessionId: id, focus: sessionModelId(s) });
705
+ }
706
+
707
+ function unmatchedText(s) {
708
+ const stats = state.stats;
709
+ if (!stats) return 'Loading benchmarks from OpenRouter…';
710
+ if (!stats.pool) return `Benchmarks are unavailable: ${stats.error}.`;
711
+ if (!(s.id in stats.sessions) || state.statsFor.get(s.id) !== modelKey(s)) return 'Looking this model up in OpenRouter’s catalog…';
712
+ const names = [s.model.name, s.model.displayName].filter((name, i, all) => name && all.indexOf(name) === i);
713
+ return `OpenRouter’s catalog has no model named ${names.map((name) => `“${name}”`).join(' or ')}.`;
714
+ }
715
+
716
+ function sourceText(stats, { levels = true } = {}) {
717
+ if (!stats?.retrievedAt) return '';
718
+ const parts = [`Benchmarks from Artificial Analysis and Design Arena via OpenRouter, fetched ${relativeTime(stats.retrievedAt)}.`];
719
+ if (stats.stale) parts.push(`The last refresh failed (${stats.error}), so these results may be out of date.`);
720
+ if (stats.pool && levels) {
721
+ parts.push('Artificial Analysis results are index scores. Design Arena results are Elo ratings from real users\' head-to-head votes, and Rank is the model\'s place on Design Arena\'s own leaderboard.');
722
+ parts.push(`Level 0–100 is a model's standing on each benchmark among the models ${listJoin(stats.pool.tools)} run; 100 is the best result.`);
723
+ parts.push('Tier: S 90+, A 75+, B 50+, C 25+, D below 25.');
724
+ }
725
+ return parts.join(' ');
726
+ }
727
+
728
+ function usedBy(id) {
729
+ return [...state.sessions.values()]
730
+ .filter((s) => s.status === 'running' && sessionModelId(s) === id)
731
+ .map((s) => s.name);
732
+ }
733
+
734
+ function badge(kind, label, title) {
735
+ const el = document.createElement('span');
736
+ el.className = `badge ${kind}`;
737
+ el.textContent = label;
738
+ el.title = title;
739
+ return el;
740
+ }
741
+
742
+ function modelFacts(card) {
743
+ const list = document.createElement('dl');
744
+ list.className = 'model-facts';
745
+ const fact = (term, value) => {
746
+ if (!value) return;
747
+ const row = document.createElement('div');
748
+ const dt = document.createElement('dt');
749
+ const dd = document.createElement('dd');
750
+ dt.textContent = term;
751
+ dd.textContent = value;
752
+ row.append(dt, dd);
753
+ list.append(row);
754
+ };
755
+ fact('Context', card.context ? `${formatTokens(card.context)} tokens` : '');
756
+ fact('Max output', card.maxOutput ? `${formatTokens(card.maxOutput)} tokens` : '');
757
+ fact('Input', card.input.join(', '));
758
+ fact('Reasoning effort', card.reasoning.join(', '));
759
+ if (card.price.input !== null && card.price.output !== null) {
760
+ fact('Price', `${formatPrice(card.price.input)} input · ${formatPrice(card.price.output)} output per 1M tokens`);
761
+ }
762
+ fact('Created', card.created ? formatDate(card.created) : '');
763
+ fact('Expires', card.expires ? formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '');
764
+ const link = document.createElement('a');
765
+ link.className = 'console-link';
766
+ link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
767
+ link.target = '_blank';
768
+ link.rel = 'noopener noreferrer';
769
+ link.textContent = 'OpenRouter ↗';
770
+ link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
771
+ const facts = document.createElement('div');
772
+ facts.className = 'model-more';
773
+ facts.append(list, link);
774
+ return facts;
775
+ }
776
+
777
+ function modelDetail(card) {
778
+ const groups = [];
779
+ for (const stat of state.stats.stats) {
780
+ if (groups.at(-1)?.name !== stat.group) groups.push({ name: stat.group, stats: [] });
781
+ groups.at(-1).stats.push(stat);
782
+ }
783
+ const sections = groups.map(({ name, stats }) => {
784
+ const section = document.createElement('div');
785
+ section.className = 'model-group';
786
+ const heading = document.createElement('h4');
787
+ heading.textContent = name;
788
+ section.append(heading);
789
+ if (stats.some((stat) => card.stats[stat.id])) {
790
+ section.append(...stats.map((stat) => statRow(stat, card, { detail: true })));
791
+ } else {
792
+ const none = document.createElement('p');
793
+ none.className = 'model-none';
794
+ none.textContent = 'No published results';
795
+ section.append(none);
796
+ }
797
+ return section;
798
+ });
799
+ return [...sections, modelFacts(card)];
800
+ }
801
+
802
+ function modelRow(card) {
803
+ const node = $('model-template').content.firstElementChild.cloneNode(true);
804
+ node.dataset.id = card.id;
805
+ const toggle = node.querySelector('.model-toggle');
806
+ const detail = node.querySelector('.model-detail');
807
+ node.querySelector('.model-name').textContent = card.name;
808
+ const users = usedBy(card.id);
809
+ const badges = [];
810
+ if (users.length) badges.push(badge('in-use', 'In use', `Used by ${listJoin(users)}`));
811
+ if (card.new) badges.push(badge('new', 'New', `Created ${formatDate(card.created)}`));
812
+ if (card.expires) {
813
+ const day = formatDate(card.expires, { month: 'short', day: 'numeric', timeZone: 'UTC' });
814
+ badges.push(badge('expires', `Expires ${day}`, `OpenRouter lists this model as expiring on ${formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })}`));
815
+ }
816
+ node.querySelector('.model-title').append(...badges);
817
+ for (const stat of indexStats()) {
818
+ const entry = card.stats[stat.id];
819
+ const cell = document.createElement('span');
820
+ cell.className = ['model-index', tierClass(entry), entry ? '' : 'unmeasured'].filter(Boolean).join(' ');
821
+ cell.replaceChildren(...levelValue(entry));
822
+ cell.title = statSummary(stat, entry);
823
+ toggle.append(cell);
824
+ }
825
+ const context = document.createElement('span');
826
+ context.className = 'model-context';
827
+ context.textContent = formatTokens(card.context);
828
+ context.title = card.context ? `Context: ${card.context.toLocaleString()} tokens` : 'Context: not listed';
829
+ toggle.append(context);
830
+ const summary = indexStats().map((stat) => statSummary(stat, card.stats[stat.id])).join('. ');
831
+ toggle.setAttribute('aria-description', summary);
832
+ const expand = (open) => {
833
+ toggle.setAttribute('aria-expanded', String(open));
834
+ if (open && !detail.childElementCount) detail.replaceChildren(...modelDetail(card));
835
+ detail.hidden = !open;
836
+ };
837
+ expand(modelsView.expanded.has(card.id));
838
+ toggle.addEventListener('click', () => {
839
+ const open = toggle.getAttribute('aria-expanded') !== 'true';
840
+ if (open) modelsView.expanded.add(card.id);
841
+ else modelsView.expanded.delete(card.id);
842
+ expand(open);
843
+ });
844
+ return node;
845
+ }
846
+
847
+ function renderModels() {
848
+ const stats = state.stats;
849
+ const session = modelsView.sessionId ? state.sessions.get(modelsView.sessionId) : null;
850
+ const provider = session?.provider ?? state.providers.find((p) => p.id === modelsView.providerId);
851
+ if (!provider || (modelsView.sessionId && !session?.model)) return $('models').close();
852
+ paintProviderIcon($('models-icon'), provider);
853
+ const list = stats?.providers[provider.id]?.models ?? [];
854
+ let ids = list;
855
+ let title = `${provider.tool} models`;
856
+ let note = '';
857
+ if (session) {
858
+ const id = sessionModelId(session);
859
+ if (id && !modelsView.focus) {
860
+ modelsView.focus = id;
861
+ modelsView.expanded.add(id);
862
+ }
863
+ if (!id) {
864
+ ids = [];
865
+ title = `${provider.tool} · ${modelText(session)}`;
866
+ note = unmatchedText(session);
867
+ } else if (!list.includes(id)) {
868
+ ids = [id];
869
+ title = `${provider.tool} · ${stats.models[id].name}`;
870
+ }
871
+ } else if (!stats) {
872
+ note = 'Loading benchmarks from OpenRouter…';
873
+ } else if (list.length === 0) {
874
+ note = stats.error ? `Benchmarks are unavailable: ${stats.error}.` : `OpenRouter lists no benchmarked ${provider.tool} models.`;
875
+ }
876
+ $('models-title').textContent = title;
877
+ $('models-sub').textContent = ids.length > 1
878
+ ? `${ids.length} models with published benchmarks, newest first`
879
+ : session ? `Reported by ${session.name} as “${session.model.name}”` : '';
880
+ const all = modelsView.all || ids.length <= MODELS_SHOWN || ids.indexOf(modelsView.focus) >= MODELS_SHOWN;
881
+ const shown = all ? ids : ids.slice(0, MODELS_SHOWN);
882
+ const columns = $('models-columns');
883
+ columns.hidden = shown.length === 0;
884
+ columns.replaceChildren(...['Model', ...indexStats().map((stat) => stat.short), 'Context'].map((label) => {
885
+ const span = document.createElement('span');
886
+ span.textContent = label;
887
+ return span;
888
+ }));
889
+ $('models-list').replaceChildren(...shown.map((id) => modelRow(stats.models[id])));
890
+ const more = $('models-more');
891
+ more.hidden = all;
892
+ more.textContent = `Show ${ids.length - shown.length} older models`;
893
+ $('models-note').textContent = note;
894
+ $('models-note').hidden = !note;
895
+ $('models-source').textContent = sourceText(stats, { levels: shown.length > 0 });
896
+ $('models-source').hidden = !$('models-source').textContent;
897
+ }
898
+
899
+ function closeModels() {
900
+ if ($('models').open) $('models').close();
318
901
  }
319
902
 
320
903
  const CHANNEL_LABELS = {
@@ -381,7 +964,7 @@ async function startSession(provider, card, { resume } = {}) {
381
964
  save(CWD_KEY, cwd);
382
965
  card.classList.add('busy');
383
966
  try {
384
- const body = { providerId: provider.id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
967
+ const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
385
968
  const { session } = await api('POST', '/sessions', body);
386
969
  upsertSession(session);
387
970
  openPanel(session.id);
@@ -415,6 +998,12 @@ function modelTitle(s) {
415
998
  return `Model ${modelText(s)}${id}, ${MODEL_SOURCES[s.model.source] || s.model.source}`;
416
999
  }
417
1000
 
1001
+ function accountLabel(s) {
1002
+ if (!s.account) return '';
1003
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1004
+ return (provider?.accounts?.length ?? 0) > 1 || s.account.id !== 'default' ? s.account.label : '';
1005
+ }
1006
+
418
1007
  function statusText(s) {
419
1008
  if (s.status === 'exited') {
420
1009
  if (s.signal) return `Exited (${s.signal})`;
@@ -433,6 +1022,7 @@ function buildCard(session) {
433
1022
  node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
434
1023
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
435
1024
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
1025
+ node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
436
1026
  return node;
437
1027
  }
438
1028
 
@@ -453,14 +1043,15 @@ function updateCard(node, s) {
453
1043
  badge.title = `Level ${level}`;
454
1044
  node.querySelector('.name').textContent = s.name;
455
1045
  const resumed = s.resume ? ` · resumed ${s.resume}` : '';
456
- node.querySelector('.meta').textContent = `${s.provider.vendor} · ${s.provider.tool} · started ${relativeTime(s.createdAt)}${resumed}`;
1046
+ node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
457
1047
  const pill = node.querySelector('.status-pill');
458
1048
  pill.textContent = statusText(s);
459
1049
  pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
460
1050
  const model = node.querySelector('.model-pill');
461
1051
  model.hidden = !s.model;
462
1052
  model.textContent = modelText(s);
463
- model.title = modelTitle(s);
1053
+ model.title = [modelTitle(s), modelStatsLine(s)].filter(Boolean).join('\n');
1054
+ model.setAttribute('aria-label', `Benchmarks for ${modelText(s)}`);
464
1055
  model.className = `model-pill ${s.model?.source || ''}`;
465
1056
  const cwd = node.querySelector('.cwd-line');
466
1057
  // The LRM keeps a leading "/" in place under the right-to-left truncation style.
@@ -471,7 +1062,8 @@ function updateCard(node, s) {
471
1062
  node.querySelector('.stop').hidden = s.status !== 'running';
472
1063
  node.querySelector('.remove').hidden = s.status === 'running';
473
1064
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
474
- node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
1065
+ const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
1066
+ node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
475
1067
  }
476
1068
 
477
1069
  function renderSessions() {
@@ -492,6 +1084,7 @@ function renderSessions() {
492
1084
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
493
1085
  $('empty').hidden = sessions.length > 0;
494
1086
  if (state.activeId) updatePanel();
1087
+ if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
495
1088
  }
496
1089
 
497
1090
  function upsertSession(session) {
@@ -706,7 +1299,7 @@ function updatePanel() {
706
1299
  if (!s) return;
707
1300
  paintProviderIcon($('panel-icon'), s.provider);
708
1301
  $('panel-title').textContent = s.name;
709
- $('panel-sub').textContent = [s.provider.tool, modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1302
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
710
1303
  $('panel-sub').title = modelTitle(s);
711
1304
  renderAgents($('panel-agents'), s.agents);
712
1305
  const stop = $('panel-stop');
@@ -749,6 +1342,7 @@ function enterStopping(running = 0) {
749
1342
  state.stopping = true;
750
1343
  state.stopRemaining = null;
751
1344
  closePanel();
1345
+ closeModels();
752
1346
  for (const view of state.views.values()) view.dispose();
753
1347
  state.views.clear();
754
1348
  state.sessions.clear();
@@ -805,6 +1399,9 @@ function connectEvents() {
805
1399
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
806
1400
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
807
1401
  renderSessions();
1402
+ setUpgrade(msg.upgrade);
1403
+ } else if (msg.type === 'manager.upgrade') {
1404
+ setUpgrade(msg.upgrade);
808
1405
  } else if (msg.type === 'manager.stopping') {
809
1406
  enterStopping(msg.running);
810
1407
  } else if (msg.type === 'manager.stopped') {
@@ -818,6 +1415,7 @@ function connectEvents() {
818
1415
  } else if (msg.type === 'providers.updated') {
819
1416
  state.providers = msg.providers;
820
1417
  renderProviders();
1418
+ scheduleStats();
821
1419
  }
822
1420
  };
823
1421
  ws.onclose = () => {
@@ -854,8 +1452,10 @@ function readTokenFromHash() {
854
1452
  }
855
1453
 
856
1454
  let usageTimer;
1455
+ let statsInterval;
857
1456
 
858
1457
  function showAuth(message = '') {
1458
+ closeModels();
859
1459
  $('app').hidden = true;
860
1460
  $('terminal-panel').hidden = true;
861
1461
  $('stopped').hidden = true;
@@ -885,6 +1485,9 @@ async function boot() {
885
1485
  loadUsage();
886
1486
  clearInterval(usageTimer);
887
1487
  usageTimer = setInterval(loadUsage, 60000);
1488
+ loadStats();
1489
+ clearInterval(statsInterval);
1490
+ statsInterval = setInterval(loadStats, 60 * 60 * 1000);
888
1491
  }
889
1492
 
890
1493
  $('auth-form').addEventListener('submit', (e) => {
@@ -895,7 +1498,52 @@ $('auth-form').addEventListener('submit', (e) => {
895
1498
  boot();
896
1499
  });
897
1500
  $('panel-close').addEventListener('click', closePanel);
1501
+ $('models-close').addEventListener('click', closeModels);
1502
+ $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
1503
+ $('models').addEventListener('close', () => {
1504
+ hideTip();
1505
+ const opener = modelsOpener?.isConnected ? modelsOpener
1506
+ : modelsView.sessionId ? cards.get(modelsView.sessionId)?.querySelector('.model-pill')
1507
+ : $('providers').querySelector(`.provider[data-id="${modelsView.providerId}"] .model-stats-head`);
1508
+ opener?.focus();
1509
+ modelsOpener = null;
1510
+ });
1511
+ document.addEventListener('pointerover', (e) => {
1512
+ const info = e.target.closest?.('.info');
1513
+ if (info && e.pointerType !== 'touch') showTip(info);
1514
+ });
1515
+ document.addEventListener('pointerout', (e) => {
1516
+ if (tipFor && e.target === tipFor && document.activeElement !== tipFor) hideTip();
1517
+ });
1518
+ document.addEventListener('pointerdown', (e) => { if (tipFor && !e.target.closest?.('.info')) hideTip(); });
1519
+ document.addEventListener('click', (e) => {
1520
+ const info = e.target.closest?.('.info');
1521
+ if (info) showTip(info);
1522
+ });
1523
+ document.addEventListener('focusin', (e) => { if (!quietFocus && e.target.matches?.('.info')) showTip(e.target); });
1524
+ document.addEventListener('focusout', (e) => { if (e.target === tipFor) hideTip(); });
1525
+ document.addEventListener('keydown', (e) => {
1526
+ if (e.key !== 'Escape' || !tipFor) return;
1527
+ hideTip();
1528
+ e.preventDefault();
1529
+ e.stopPropagation();
1530
+ }, true);
1531
+ addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
1532
+ addEventListener('resize', () => { if (tipFor) hideTip(); });
1533
+ $('models-more').addEventListener('click', () => {
1534
+ const before = $('models-list').childElementCount;
1535
+ modelsView.all = true;
1536
+ renderModels();
1537
+ $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1538
+ });
898
1539
  $('stop-manager').addEventListener('click', () => stopManager());
1540
+ $('upgrade').addEventListener('click', upgradeManager);
1541
+ $('theme-toggle').addEventListener('click', toggleTheme);
1542
+ applyTheme(currentTheme());
1543
+ // Follow the system setting until the user picks a theme.
1544
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1545
+ if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
1546
+ });
899
1547
  $('panel-stop').addEventListener('click', () => {
900
1548
  const s = state.sessions.get(state.activeId);
901
1549
  if (!s) return;
@@ -903,6 +1551,8 @@ $('panel-stop').addEventListener('click', () => {
903
1551
  else removeSession(s.id);
904
1552
  });
905
1553
  $('cwd').value = load(CWD_KEY) || '';
1554
+ try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
1555
+ if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
906
1556
  setInterval(renderSessions, 30000);
907
1557
 
908
1558
  // The terminal panel sits below the top bar, which wraps onto two rows on