@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/README.md +12 -7
- package/bin/agent-guild-report.mjs +14 -1
- package/bin/agent-guild.mjs +85 -46
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/launch.mjs +64 -0
- package/src/manager/main.mjs +27 -4
- package/src/manager/news.mjs +470 -0
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +30 -8
- package/src/manager/session-history.mjs +496 -0
- package/src/manager/session-manager.mjs +4 -0
- package/src/manager/session.mjs +16 -0
- package/src/report/hooks.mjs +4 -0
- package/web/app.js +715 -50
- package/web/art/art.css +20 -0
- package/web/index.html +74 -4
- package/web/styles.css +104 -4
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 (
|
|
100
|
-
text = `v${
|
|
101
|
-
title = `Agent Guild ${
|
|
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(
|
|
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 =
|
|
311
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
|
968
|
-
|
|
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
|
|
1317
|
+
card?.classList.remove('busy');
|
|
976
1318
|
}
|
|
977
1319
|
}
|
|
978
1320
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
|
1313
|
-
* unless told to force, so the warning is
|
|
1314
|
-
* count in the dialog is the manager's,
|
|
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
|
|
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
|
|
1321
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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.
|