@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/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 (u?.pendingVersion) {
103
- text = `v${u.pendingVersion} installed · restart to use it`;
104
- title = `Agent Guild ${u.pendingVersion} is installed, but this manager is still ${u.version}. Stop the manager and run "agent-guild open" to use it.`;
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(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
163
+ toast(state.restartable
164
+ ? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
165
+ : `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
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 = `Resume one of ${provider.tool}'s own sessions by its id`;
314
- existing.addEventListener('click', () => resumeSession(provider, node));
352
+ existing.title = provider.historySource
353
+ ? `Resume one of ${provider.tool}'s own earlier sessions`
354
+ : `Resume one of ${provider.tool}'s own sessions by its id`;
355
+ existing.addEventListener('click', () => showHistory(provider));
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
- async function startSession(provider, card, { resume } = {}) {
1251
- const cwd = $('cwd').value.trim();
1252
- save(CWD_KEY, cwd);
1253
- card.classList.add('busy');
1291
+ /**
1292
+ * Start a session. A resumed session starts in the folder its transcript
1293
+ * names, since Claude Code and Gemini CLI only find a session from there;
1294
+ * when that folder is gone, the working folder is used instead.
1295
+ */
1296
+ async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
1297
+ const working = $('cwd').value.trim();
1298
+ save(CWD_KEY, working);
1299
+ card?.classList.add('busy');
1254
1300
  try {
1255
- const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
1256
- const { session } = await api('POST', '/sessions', body);
1301
+ const body = { providerId: provider.id, account, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
1302
+ let session;
1303
+ try {
1304
+ ({ session } = await api('POST', '/sessions', body));
1305
+ } catch (err) {
1306
+ if (err.code !== 'bad_cwd' || !cwd) throw err;
1307
+ toast(`${cwd} no longer exists; starting in the working folder instead.`, 8000);
1308
+ ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
1309
+ }
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.classList.remove('busy');
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 resumeSession(provider, card) {
1268
- const id = prompt(`${provider.tool} session id or name to resume`);
1269
- if (id === null || !id.trim()) return;
1270
- startSession(provider, card, { resume: id.trim() });
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 resumed = s.resume ? ` · resumed ${s.resume}` : '';
1334
- node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1590
+ const id = toolSessionId(s);
1591
+ const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
1592
+ node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
1593
+ paintIdButton(node.querySelector('.session-id'), id);
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
- $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1853
+ const id = toolSessionId(s);
1854
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd, id && `session ${id}`].filter(Boolean).join(' · ');
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. The manager refuses while sessions are running
1601
- * unless told to force, so the warning is enforced for every client and the
1602
- * count in the dialog is the manager's, not this page's possibly stale list.
1864
+ * Stop the session manager, or stop it and start it again. The manager
1865
+ * refuses while sessions are running unless told to force, so the warning is
1866
+ * enforced for every client and the count in the dialog is the manager's,
1867
+ * not this page's possibly stale list.
1603
1868
  */
1604
- async function stopManager({ force = false } = {}) {
1605
- const button = $('stop-manager');
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 { running } = await api('POST', '/shutdown', force ? { force: true } : undefined);
1609
- enterStopping(running);
1873
+ const body = force || restart ? { ...(force && { force: true }), ...(restart && { restart: true }) } : undefined;
1874
+ const answer = await api('POST', '/shutdown', body);
1875
+ // A manager that does not say it will restart only stops.
1876
+ enterStopping(answer.running, restart && answer.restart === true);
1877
+ if (restart && answer.restart !== true) {
1878
+ toast('This manager cannot restart itself. Run "agent-guild restart" in a terminal to start the new one.', 12000);
1879
+ }
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
- if (confirm(`${what}. Stopping the session manager ends ${them}. Stop anyway?`)) {
1617
- return stopManager({ force: true });
1886
+ const verb = restart ? 'Restarting' : 'Stopping';
1887
+ if (confirm(`${what}. ${verb} the session manager ends ${them}. ${restart ? 'Restart' : 'Stop'} anyway?`)) {
1888
+ return stopManager({ force: true, restart });
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
- showStopped('stopping', 'Stopping the session manager…',
1642
- n ? `Ending ${n} running session${n === 1 ? '' : 's'}. This can take a few seconds.` : 'This can take a few seconds.');
1643
- setConnection('down', 'Stopping the session manager…');
1918
+ const ending = n ? `Ending ${n} running session${n === 1 ? '' : 's'}. ` : '';
1919
+ if (state.restarting) {
1920
+ showStopped('restarting', 'Restarting the session manager…', `${ending}A new manager starts in a moment and this page reconnects to it by itself.`);
1921
+ clearTimeout(restartTimer);
1922
+ restartTimer = setTimeout(restartGaveUp, RESTART_WAIT_MS);
1923
+ } else {
1924
+ showStopped('stopping', 'Stopping the session manager…', `${ending}This can take a few seconds.`);
1925
+ }
1926
+ setConnection('down', state.restarting ? 'Restarting the session manager…' : 'Stopping the session manager…');
1927
+ }
1928
+
1929
+ /** The manager is back: a `hello` arrived while the page was waiting out a stop. */
1930
+ function leaveStopping() {
1931
+ state.stopping = false;
1932
+ state.restarting = false;
1933
+ clearTimeout(restartTimer);
1934
+ $('stopped').hidden = true;
1935
+ $('app').hidden = false;
1936
+ }
1937
+
1938
+ /** A restarted manager did not come back in time; the user has to start one by hand. */
1939
+ function restartGaveUp() {
1940
+ if (!state.stopping || !state.restarting) return;
1941
+ state.restarting = false;
1942
+ setConnection('down', 'Session manager stopped');
1943
+ showStopped('stopped', 'The session manager did not come back',
1944
+ `Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
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
- if (n === 0) return showStopped('stopped', 'Session manager stopped', 'Every session has ended.');
1663
- if (n > 0) {
1664
- return showStopped('stopped', 'Session manager stopped',
1665
- `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`);
1991
+ const sessions = n === 0 ? 'Every session has ended.'
1992
+ : n > 0 ? `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`
1993
+ : 'The manager went away before confirming that every session had ended.';
1994
+ if (state.restarting) {
1995
+ setConnection('down', 'Restarting the session manager…');
1996
+ return showStopped('restarting', 'Restarting the session manager…', `${sessions} Waiting for the new manager; this page reconnects to it by itself.`);
1666
1997
  }
1667
- showStopped('stopped', 'Session manager stopped', 'The manager went away before confirming that every session had ended.');
1998
+ setConnection('down', 'Session manager stopped');
1999
+ showStopped('stopped', 'Session manager stopped', sessions);
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
- if (state.stopping) {
1683
- // The manager is back after a stop; the page picks up where it was.
1684
- state.stopping = false;
1685
- $('stopped').hidden = true;
1686
- $('app').hidden = false;
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());