@oddessentials/agent-guild 0.4.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 +24 -4
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +25 -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 +403 -50
- package/web/index.html +48 -4
- package/web/styles.css +46 -3
package/web/app.js
CHANGED
|
@@ -27,8 +27,17 @@ const state = {
|
|
|
27
27
|
connected: false,
|
|
28
28
|
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
29
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,
|
|
30
37
|
/** True from a shutdown request until the manager is reachable again. */
|
|
31
38
|
stopping: false,
|
|
39
|
+
/** True while the stop is a restart: a new manager is expected to take over. */
|
|
40
|
+
restarting: false,
|
|
32
41
|
/** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
|
|
33
42
|
stopRemaining: null,
|
|
34
43
|
};
|
|
@@ -53,12 +62,31 @@ function setConnection(kind, label) {
|
|
|
53
62
|
const el = $('connection');
|
|
54
63
|
el.className = `connection ${kind}`;
|
|
55
64
|
el.querySelector('.label').textContent = label;
|
|
56
|
-
// 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.
|
|
57
66
|
state.connected = kind === 'ok';
|
|
58
67
|
$('stop-manager').hidden = !state.connected;
|
|
68
|
+
$('restart-manager').hidden = !state.connected || !state.restartable;
|
|
59
69
|
renderUpgrade();
|
|
60
70
|
}
|
|
61
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
|
+
|
|
62
90
|
// ---- theme ----------------------------------------------------------------
|
|
63
91
|
|
|
64
92
|
/** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
|
|
@@ -93,15 +121,24 @@ function renderUpgrade() {
|
|
|
93
121
|
button.textContent = `Upgrade to ${u.latestVersion}`;
|
|
94
122
|
button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
|
|
95
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.';
|
|
96
133
|
let text = '';
|
|
97
134
|
let title = '';
|
|
98
135
|
const last = u?.lastInstall;
|
|
99
136
|
if (u?.installing) {
|
|
100
137
|
text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
|
|
101
138
|
title = 'npm is running in a session. Keep the manager running until it finishes.';
|
|
102
|
-
} else if (
|
|
103
|
-
text = `v${
|
|
104
|
-
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.`;
|
|
105
142
|
} else if (last?.outcome === 'failed') {
|
|
106
143
|
text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
|
|
107
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.';
|
|
@@ -123,7 +160,9 @@ function setUpgrade(upgrade) {
|
|
|
123
160
|
renderUpgrade();
|
|
124
161
|
const pending = state.upgrade?.pendingVersion;
|
|
125
162
|
if (pending && pending !== before?.pendingVersion) {
|
|
126
|
-
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);
|
|
127
166
|
}
|
|
128
167
|
}
|
|
129
168
|
|
|
@@ -310,8 +349,10 @@ function renderProviders() {
|
|
|
310
349
|
start.hidden = !provider.available;
|
|
311
350
|
start.addEventListener('click', () => startSession(provider, node));
|
|
312
351
|
existing.hidden = !provider.available || !provider.resumable;
|
|
313
|
-
existing.title =
|
|
314
|
-
|
|
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));
|
|
315
356
|
const install = node.querySelector('.install');
|
|
316
357
|
install.hidden = provider.available || !provider.installable;
|
|
317
358
|
install.title = `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
|
|
@@ -1247,27 +1288,223 @@ async function installProvider(provider, card, { force = false } = {}) {
|
|
|
1247
1288
|
}
|
|
1248
1289
|
}
|
|
1249
1290
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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');
|
|
1254
1300
|
try {
|
|
1255
|
-
const body = { providerId: provider.id, account
|
|
1256
|
-
|
|
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
|
+
}
|
|
1257
1310
|
upsertSession(session);
|
|
1311
|
+
closeHistory({ focusOpener: false });
|
|
1258
1312
|
openPanel(session.id);
|
|
1259
1313
|
} catch (err) {
|
|
1260
1314
|
if (err instanceof AuthError) return showAuth(err.message);
|
|
1261
1315
|
toast(err.message, 8000);
|
|
1262
1316
|
} finally {
|
|
1263
|
-
card
|
|
1317
|
+
card?.classList.remove('busy');
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
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`);
|
|
1264
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();
|
|
1265
1501
|
}
|
|
1266
1502
|
|
|
1267
|
-
function
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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);
|
|
1271
1508
|
}
|
|
1272
1509
|
|
|
1273
1510
|
// ---- session cards --------------------------------------------------------
|
|
@@ -1310,10 +1547,30 @@ function buildCard(session) {
|
|
|
1310
1547
|
node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
|
|
1311
1548
|
node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
|
|
1312
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
|
+
});
|
|
1313
1555
|
node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
|
|
1314
1556
|
return node;
|
|
1315
1557
|
}
|
|
1316
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
|
+
|
|
1317
1574
|
function sessionLevel(s) {
|
|
1318
1575
|
const end = s.status === 'exited'
|
|
1319
1576
|
? Date.parse(s.exitedAt ?? s.lastOutputAt ?? s.createdAt)
|
|
@@ -1330,8 +1587,10 @@ function updateCard(node, s) {
|
|
|
1330
1587
|
badge.textContent = level;
|
|
1331
1588
|
badge.title = `Level ${level}`;
|
|
1332
1589
|
node.querySelector('.name').textContent = s.name;
|
|
1333
|
-
const
|
|
1334
|
-
|
|
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);
|
|
1335
1594
|
const pill = node.querySelector('.status-pill');
|
|
1336
1595
|
pill.textContent = statusText(s);
|
|
1337
1596
|
pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
|
|
@@ -1349,6 +1608,9 @@ function updateCard(node, s) {
|
|
|
1349
1608
|
node.classList.toggle('exited', s.status === 'exited');
|
|
1350
1609
|
node.querySelector('.stop').hidden = s.status !== 'running';
|
|
1351
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}`;
|
|
1352
1614
|
const modelLabel = s.model ? `, model ${modelText(s)}` : '';
|
|
1353
1615
|
const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
|
|
1354
1616
|
node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
|
|
@@ -1372,6 +1634,7 @@ function renderSessions() {
|
|
|
1372
1634
|
$('session-count').textContent = sessions.length ? `· ${running} running` : '';
|
|
1373
1635
|
$('empty').hidden = sessions.length > 0;
|
|
1374
1636
|
if (state.activeId) updatePanel();
|
|
1637
|
+
if ($('history').open) renderHistory();
|
|
1375
1638
|
if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
|
|
1376
1639
|
}
|
|
1377
1640
|
|
|
@@ -1587,7 +1850,8 @@ function updatePanel() {
|
|
|
1587
1850
|
if (!s) return;
|
|
1588
1851
|
paintProviderIcon($('panel-icon'), s.provider);
|
|
1589
1852
|
$('panel-title').textContent = s.name;
|
|
1590
|
-
|
|
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(' · ');
|
|
1591
1855
|
$('panel-sub').title = modelTitle(s);
|
|
1592
1856
|
renderAgents($('panel-agents'), s.agents);
|
|
1593
1857
|
const stop = $('panel-stop');
|
|
@@ -1597,74 +1861,142 @@ function updatePanel() {
|
|
|
1597
1861
|
// ---- stopping the manager -------------------------------------------------
|
|
1598
1862
|
|
|
1599
1863
|
/**
|
|
1600
|
-
* Stop the session manager
|
|
1601
|
-
* unless told to force, so the warning is
|
|
1602
|
-
* 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.
|
|
1603
1868
|
*/
|
|
1604
|
-
async function stopManager({ force = false } = {}) {
|
|
1605
|
-
const
|
|
1606
|
-
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;
|
|
1607
1872
|
try {
|
|
1608
|
-
const
|
|
1609
|
-
|
|
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
|
+
}
|
|
1610
1880
|
} catch (err) {
|
|
1611
1881
|
if (err instanceof AuthError) return showAuth(err.message);
|
|
1612
1882
|
if (err.code === 'sessions_running') {
|
|
1613
1883
|
const n = err.running;
|
|
1614
1884
|
const what = `${n} session${n === 1 ? ' is' : 's are'} still running`;
|
|
1615
1885
|
const them = n === 1 ? 'it' : 'all of them';
|
|
1616
|
-
|
|
1617
|
-
|
|
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 });
|
|
1618
1889
|
}
|
|
1619
1890
|
return;
|
|
1620
1891
|
}
|
|
1621
1892
|
toast(err.message, 8000);
|
|
1622
1893
|
} finally {
|
|
1623
|
-
button.disabled = false;
|
|
1894
|
+
for (const button of buttons) button.disabled = false;
|
|
1624
1895
|
}
|
|
1625
1896
|
}
|
|
1626
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
|
+
|
|
1627
1902
|
/** The manager is going down, by this page's request or another client's. */
|
|
1628
|
-
function enterStopping(running = 0) {
|
|
1903
|
+
function enterStopping(running = 0, restart = false) {
|
|
1904
|
+
if (restart) state.restarting = true;
|
|
1629
1905
|
if (state.stopping) return;
|
|
1630
1906
|
state.stopping = true;
|
|
1631
1907
|
state.stopRemaining = null;
|
|
1632
1908
|
closePanel();
|
|
1633
1909
|
closeModels();
|
|
1634
1910
|
closeNews();
|
|
1911
|
+
closeHistory();
|
|
1635
1912
|
for (const view of state.views.values()) view.dispose();
|
|
1636
1913
|
state.views.clear();
|
|
1637
1914
|
state.sessions.clear();
|
|
1638
1915
|
renderSessions();
|
|
1639
1916
|
$('app').hidden = true;
|
|
1640
1917
|
const n = Number(running) || 0;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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.`);
|
|
1644
1945
|
}
|
|
1645
1946
|
|
|
1646
1947
|
function showStopped(phase, title, text) {
|
|
1647
1948
|
const el = $('stopped');
|
|
1648
1949
|
el.classList.toggle('stopping', phase === 'stopping');
|
|
1950
|
+
el.classList.toggle('restarting', phase === 'restarting');
|
|
1649
1951
|
$('stopped-title').textContent = title;
|
|
1650
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();
|
|
1651
1957
|
el.hidden = false;
|
|
1652
1958
|
}
|
|
1653
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
|
+
|
|
1654
1984
|
/**
|
|
1655
1985
|
* The manager has stopped. Only a manager.stopped event with nothing
|
|
1656
1986
|
* remaining confirms that every process exited; a timeout, or a socket that
|
|
1657
1987
|
* dropped without the event, must not be announced as a clean stop.
|
|
1658
1988
|
*/
|
|
1659
1989
|
function showManagerStopped() {
|
|
1660
|
-
setConnection('down', 'Session manager stopped');
|
|
1661
1990
|
const n = state.stopRemaining;
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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.`);
|
|
1666
1997
|
}
|
|
1667
|
-
|
|
1998
|
+
setConnection('down', 'Session manager stopped');
|
|
1999
|
+
showStopped('stopped', 'Session manager stopped', sessions);
|
|
1668
2000
|
}
|
|
1669
2001
|
|
|
1670
2002
|
// ---- events ---------------------------------------------------------------
|
|
@@ -1679,12 +2011,14 @@ function connectEvents() {
|
|
|
1679
2011
|
ws.onmessage = (event) => {
|
|
1680
2012
|
const msg = JSON.parse(event.data);
|
|
1681
2013
|
if (msg.type === 'hello') {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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');
|
|
1688
2022
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
1689
2023
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
1690
2024
|
renderSessions();
|
|
@@ -1695,9 +2029,9 @@ function connectEvents() {
|
|
|
1695
2029
|
} else if (msg.type === 'manager.upgrade') {
|
|
1696
2030
|
setUpgrade(msg.upgrade);
|
|
1697
2031
|
} else if (msg.type === 'manager.stopping') {
|
|
1698
|
-
enterStopping(msg.running);
|
|
2032
|
+
enterStopping(msg.running, msg.restart === true);
|
|
1699
2033
|
} else if (msg.type === 'manager.stopped') {
|
|
1700
|
-
enterStopping();
|
|
2034
|
+
enterStopping(0, msg.restart === true);
|
|
1701
2035
|
state.stopRemaining = Number(msg.remaining) || 0;
|
|
1702
2036
|
showManagerStopped();
|
|
1703
2037
|
} else if (msg.type === 'session.created' || msg.type === 'session.updated') {
|
|
@@ -1707,6 +2041,7 @@ function connectEvents() {
|
|
|
1707
2041
|
} else if (msg.type === 'providers.updated') {
|
|
1708
2042
|
state.providers = msg.providers;
|
|
1709
2043
|
renderProviders();
|
|
2044
|
+
if ($('history').open) renderHistory();
|
|
1710
2045
|
scheduleStats();
|
|
1711
2046
|
}
|
|
1712
2047
|
};
|
|
@@ -1750,10 +2085,13 @@ let newsTimer;
|
|
|
1750
2085
|
function showAuth(message = '') {
|
|
1751
2086
|
closeModels();
|
|
1752
2087
|
closeNews();
|
|
2088
|
+
closeHistory();
|
|
1753
2089
|
$('app').hidden = true;
|
|
1754
2090
|
$('terminal-panel').hidden = true;
|
|
1755
2091
|
$('stopped').hidden = true;
|
|
1756
2092
|
state.stopping = false;
|
|
2093
|
+
state.restarting = false;
|
|
2094
|
+
clearTimeout(restartTimer);
|
|
1757
2095
|
$('auth').hidden = false;
|
|
1758
2096
|
$('auth-error').textContent = message;
|
|
1759
2097
|
setConnection('down', 'Not connected');
|
|
@@ -1795,6 +2133,19 @@ $('auth-form').addEventListener('submit', (e) => {
|
|
|
1795
2133
|
});
|
|
1796
2134
|
$('panel-close').addEventListener('click', closePanel);
|
|
1797
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);
|
|
1798
2149
|
$('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
|
|
1799
2150
|
$('models').addEventListener('close', () => {
|
|
1800
2151
|
hideTip();
|
|
@@ -1848,6 +2199,8 @@ $('models-more').addEventListener('click', () => {
|
|
|
1848
2199
|
$('models-list').children[before]?.querySelector('.model-toggle')?.focus();
|
|
1849
2200
|
});
|
|
1850
2201
|
$('stop-manager').addEventListener('click', () => stopManager());
|
|
2202
|
+
$('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
|
|
2203
|
+
$('copy-command').addEventListener('click', copyCommand);
|
|
1851
2204
|
$('upgrade').addEventListener('click', upgradeManager);
|
|
1852
2205
|
$('theme-toggle').addEventListener('click', toggleTheme);
|
|
1853
2206
|
applyTheme(currentTheme());
|