@oddessentials/agent-guild 0.33.2 → 0.35.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 +126 -359
- package/bin/agent-guild.mjs +29 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +104 -20
- package/src/manager/environment-pins.mjs +289 -0
- package/src/manager/environment-probe.mjs +11 -0
- package/src/manager/environment.mjs +320 -15
- package/src/manager/github-views.mjs +34 -2
- package/src/manager/github.mjs +3 -1
- package/src/manager/launch.mjs +93 -1
- package/src/manager/main.mjs +25 -12
- package/src/manager/server.mjs +37 -6
- package/src/manager/systemd-service.mjs +312 -0
- package/web/app.js +163 -9
- package/web/environment.js +239 -34
- package/web/index.html +24 -8
- package/web/styles.css +39 -0
package/web/app.js
CHANGED
|
@@ -315,9 +315,87 @@ function autostartRun(autostart) {
|
|
|
315
315
|
return `Last ran at sign-in on ${at}, but the session manager did not start.${autostart.log ? ` See ${autostart.log}.` : ''}`;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
|
|
318
|
+
const STARTUP_MODES = ['off', 'sign-in', 'boot'];
|
|
319
|
+
const STARTUP_OFF_NOTE = 'The session manager starts when you run agent-guild open, and stops when you stop it or the computer shuts down.';
|
|
320
|
+
const startupTime = (iso) => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
|
321
|
+
|
|
322
|
+
/** What the boot service is doing, as systemd reports it, and how loudly to say it. */
|
|
323
|
+
function startupBootStatus(boot) {
|
|
324
|
+
const s = boot?.state;
|
|
325
|
+
if (!s) return { text: '', outcome: '' };
|
|
326
|
+
const on = (iso) => (iso ? ` on ${startupTime(iso)}` : '');
|
|
327
|
+
const text = {
|
|
328
|
+
running: `Running under systemd${s.since ? ` since ${startupTime(s.since)}` : ''}.`,
|
|
329
|
+
other: `systemd is running a different session manager (process ${s.pid}), not this one.`,
|
|
330
|
+
starting: 'systemd is starting the session manager…',
|
|
331
|
+
pending: 'Saved. This session manager was started without systemd; systemd takes over at its next restart or when the computer starts.',
|
|
332
|
+
stopped: 'systemd has stopped the session manager; it starts again when the computer starts.',
|
|
333
|
+
'port-in-use': `Did not start${on(s.at)}: another session manager was using port ${s.port}.`,
|
|
334
|
+
failed: `Stopped after failing to start${on(s.at)}. Its log says why:`,
|
|
335
|
+
}[s.kind] || '';
|
|
336
|
+
const linger = boot.linger ? '' : ' Until you allow it to run without a sign-in, it starts when you first sign in, not when the computer starts. Run this once as an administrator:';
|
|
337
|
+
const failed = s.kind === 'failed' || s.kind === 'port-in-use';
|
|
338
|
+
return { text: `${text}${linger}`, outcome: failed ? 'failed' : boot.linger ? 'started' : 'warn' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Commands the state names as the next step, each with a Copy button. */
|
|
342
|
+
function renderStartupCommands(commands = []) {
|
|
343
|
+
const box = $('startup-commands');
|
|
344
|
+
box.replaceChildren(...commands.map((command) => {
|
|
345
|
+
const row = document.createElement('div');
|
|
346
|
+
row.className = 'command';
|
|
347
|
+
const pre = document.createElement('pre');
|
|
348
|
+
pre.textContent = command;
|
|
349
|
+
const copy = document.createElement('button');
|
|
350
|
+
copy.className = 'btn';
|
|
351
|
+
copy.type = 'button';
|
|
352
|
+
copy.textContent = 'Copy';
|
|
353
|
+
copy.title = 'Copy the command';
|
|
354
|
+
copy.addEventListener('click', () => copyText(command, 'the command'));
|
|
355
|
+
row.append(pre, copy);
|
|
356
|
+
return row;
|
|
357
|
+
}));
|
|
358
|
+
box.hidden = commands.length === 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Linux: one choice of off, sign-in or boot, with what the chosen one is doing now. */
|
|
362
|
+
function renderStartupModes(autostart) {
|
|
363
|
+
const { mode, boot } = autostart;
|
|
364
|
+
for (const value of STARTUP_MODES) {
|
|
365
|
+
const input = $(`startup-${value}`);
|
|
366
|
+
input.checked = value === mode;
|
|
367
|
+
input.disabled = !autostart.available || (value === 'boot' && !boot?.available);
|
|
368
|
+
}
|
|
369
|
+
let note;
|
|
370
|
+
let run = { text: '', outcome: '' };
|
|
371
|
+
if (mode === 'both') {
|
|
372
|
+
note = 'A sign-in entry and the systemd service are both on. Choose one.';
|
|
373
|
+
} else if (mode === 'boot') {
|
|
374
|
+
note = boot.reason || boot.note;
|
|
375
|
+
run = startupBootStatus(boot);
|
|
376
|
+
} else if (mode === 'sign-in') {
|
|
377
|
+
note = autostart.reason || autostart.note || AUTOSTART_NOTE;
|
|
378
|
+
run = { text: autostartRun(autostart), outcome: autostart.lastRun?.outcome ?? 'none' };
|
|
379
|
+
} else {
|
|
380
|
+
note = STARTUP_OFF_NOTE;
|
|
381
|
+
}
|
|
382
|
+
// Why the boot choice is greyed out, whichever mode is chosen.
|
|
383
|
+
if (boot && !boot.available && mode !== 'boot') run = { text: boot.reason, outcome: '' };
|
|
384
|
+
$('autostart-note').textContent = note;
|
|
385
|
+
$('autostart-run').textContent = run.text;
|
|
386
|
+
$('autostart-run').hidden = !run.text;
|
|
387
|
+
$('autostart-run').dataset.outcome = run.outcome;
|
|
388
|
+
renderStartupCommands(mode === 'boot' || mode === 'both' ? boot?.commands : []);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The manager's startup setting; hidden when the manager has none. */
|
|
319
392
|
function renderAutostart(autostart) {
|
|
320
393
|
$('autostart-choice').hidden = !autostart;
|
|
394
|
+
const modes = Boolean(autostart?.mode);
|
|
395
|
+
$('autostart-single').hidden = modes;
|
|
396
|
+
$('startup-modes').hidden = !modes;
|
|
397
|
+
if (modes) return renderStartupModes(autostart);
|
|
398
|
+
renderStartupCommands([]);
|
|
321
399
|
$('autostart').checked = Boolean(autostart?.enabled);
|
|
322
400
|
$('autostart').disabled = !autostart?.available;
|
|
323
401
|
$('autostart-note').textContent = autostart?.reason || autostart?.note || AUTOSTART_NOTE;
|
|
@@ -327,10 +405,16 @@ function renderAutostart(autostart) {
|
|
|
327
405
|
$('autostart-run').dataset.outcome = autostart?.enabled ? autostart.lastRun?.outcome ?? 'none' : '';
|
|
328
406
|
}
|
|
329
407
|
|
|
408
|
+
/** Every startup control, disabled while a read or change is in flight. */
|
|
409
|
+
function disableStartup() {
|
|
410
|
+
$('autostart').disabled = true;
|
|
411
|
+
for (const value of STARTUP_MODES) $(`startup-${value}`).disabled = true;
|
|
412
|
+
}
|
|
413
|
+
|
|
330
414
|
async function loadAutostart({ afterChange = false } = {}) {
|
|
331
415
|
if (!state.connected || (autostartChanging && !afterChange)) return;
|
|
332
416
|
const request = ++autostartRequest;
|
|
333
|
-
|
|
417
|
+
disableStartup();
|
|
334
418
|
try {
|
|
335
419
|
const { autostart } = await api('GET', '/autostart');
|
|
336
420
|
if (request === autostartRequest) renderAutostart(autostart);
|
|
@@ -342,14 +426,14 @@ async function loadAutostart({ afterChange = false } = {}) {
|
|
|
342
426
|
}
|
|
343
427
|
}
|
|
344
428
|
|
|
345
|
-
|
|
429
|
+
/** Sends one change, shows what the manager reports after it, and reads back the actual state when it fails. */
|
|
430
|
+
async function sendStartup(body) {
|
|
346
431
|
if (autostartChanging) return;
|
|
347
432
|
autostartChanging = true;
|
|
348
433
|
++autostartRequest;
|
|
349
|
-
|
|
350
|
-
input.disabled = true;
|
|
434
|
+
disableStartup();
|
|
351
435
|
try {
|
|
352
|
-
renderAutostart((await api('PUT', '/autostart',
|
|
436
|
+
renderAutostart((await api('PUT', '/autostart', body)).autostart);
|
|
353
437
|
} catch (err) {
|
|
354
438
|
if (err instanceof AuthError) showAuth(err.message);
|
|
355
439
|
else {
|
|
@@ -362,6 +446,14 @@ async function changeAutostart(input) {
|
|
|
362
446
|
}
|
|
363
447
|
}
|
|
364
448
|
|
|
449
|
+
function changeAutostart(input) {
|
|
450
|
+
return sendStartup({ enabled: input.checked });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function changeStartup(input) {
|
|
454
|
+
return sendStartup({ mode: input.value });
|
|
455
|
+
}
|
|
456
|
+
|
|
365
457
|
function changeSound(input) {
|
|
366
458
|
save(SOUND_KEY, input.checked ? 'on' : null);
|
|
367
459
|
prepareSounds();
|
|
@@ -1695,10 +1787,43 @@ function creditsNote(usage) {
|
|
|
1695
1787
|
return [note];
|
|
1696
1788
|
}
|
|
1697
1789
|
|
|
1790
|
+
/** The same rounding the meter prints as "N% left". */
|
|
1791
|
+
function usageLeft(usedPercent) {
|
|
1792
|
+
return Math.max(0, Math.round(100 - usedPercent));
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* true when every window explicitly reads 0% left.
|
|
1797
|
+
* false when the account is logged in without that reading.
|
|
1798
|
+
* null when there is no reading to count, including a signed-out account.
|
|
1799
|
+
*/
|
|
1800
|
+
function accountUsageSpent(usage) {
|
|
1801
|
+
if (!usage || usage.signedIn === false) return null;
|
|
1802
|
+
const windows = Array.isArray(usage.windows) ? usage.windows : [];
|
|
1803
|
+
const readable = !usage.error && windows.length > 0 && windows.every((w) => typeof w.usedPercent === 'number' && Number.isFinite(w.usedPercent));
|
|
1804
|
+
if (!readable) return usage.signedIn === true ? false : null;
|
|
1805
|
+
if (typeof usage.credits === 'number' && Number.isFinite(usage.credits) && usage.credits > 0) return false;
|
|
1806
|
+
return windows.every((w) => usageLeft(w.usedPercent) === 0);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/** The portrait is spent only when every logged-in account explicitly reads 0% left. */
|
|
1810
|
+
function cardUsageSpent(provider, usageByAccount) {
|
|
1811
|
+
if (!provider?.available || !provider.usageSource) return false;
|
|
1812
|
+
const accounts = provider.accounts?.length ? provider.accounts : [{ id: 'default' }];
|
|
1813
|
+
let spent = false;
|
|
1814
|
+
for (const account of accounts) {
|
|
1815
|
+
const reading = accountUsageSpent(usageByAccount.get(`${provider.id}/${account.id}`));
|
|
1816
|
+
if (reading === false) return false;
|
|
1817
|
+
if (reading === true) spent = true;
|
|
1818
|
+
}
|
|
1819
|
+
return spent;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1698
1822
|
function renderUsage(card, provider) {
|
|
1699
1823
|
const host = card.querySelector('.usage');
|
|
1700
1824
|
const account = selectedAccount(provider);
|
|
1701
1825
|
const usage = usageFor(provider, account);
|
|
1826
|
+
card.classList.toggle('spent', cardUsageSpent(provider, state.usage));
|
|
1702
1827
|
renderTier(card, provider, provider.usageSource ? usage : null);
|
|
1703
1828
|
const start = card.querySelector('.new');
|
|
1704
1829
|
const unsigned = Boolean(provider.available && usage?.signedIn === false);
|
|
@@ -1716,7 +1841,7 @@ function renderUsage(card, provider) {
|
|
|
1716
1841
|
}
|
|
1717
1842
|
host.replaceChildren(...usage.windows.map((w) => {
|
|
1718
1843
|
const node = $('meter-template').content.firstElementChild.cloneNode(true);
|
|
1719
|
-
const left =
|
|
1844
|
+
const left = usageLeft(w.usedPercent);
|
|
1720
1845
|
node.classList.toggle('low', left <= 25 && left > 10);
|
|
1721
1846
|
node.classList.toggle('empty', left <= 10);
|
|
1722
1847
|
node.querySelector('.meter-label').textContent = w.label;
|
|
@@ -4082,8 +4207,11 @@ function renderGitHubViews() {
|
|
|
4082
4207
|
const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
|
|
4083
4208
|
$('github-clone-into').hidden = !ready || shown !== 'repos';
|
|
4084
4209
|
const running = Boolean(viewData('actions')?.value?.running);
|
|
4210
|
+
const outage = viewData('actions')?.value?.service ?? null;
|
|
4085
4211
|
$('github-running').hidden = !running;
|
|
4086
|
-
$('github-
|
|
4212
|
+
$('github-outage').hidden = !outage;
|
|
4213
|
+
$('github-view-actions').setAttribute('aria-label', ['Actions', outage && `GitHub Actions ${SERVICE_STATES[outage.status]}`, running && 'a workflow is running'].filter(Boolean).join(', '));
|
|
4214
|
+
$('github-view-actions').title = outage ? `GitHub Actions ${SERVICE_STATES[outage.status]}` : '';
|
|
4087
4215
|
renderPickerList();
|
|
4088
4216
|
if (shown === 'issues') renderIssues();
|
|
4089
4217
|
else if (shown === 'actions') renderRuns();
|
|
@@ -4346,6 +4474,22 @@ function runLabel(run) {
|
|
|
4346
4474
|
return String(run.status === 'completed' && run.conclusion ? run.conclusion : run.status).replaceAll('_', ' ');
|
|
4347
4475
|
}
|
|
4348
4476
|
|
|
4477
|
+
const SERVICE_STATES = {
|
|
4478
|
+
degraded_performance: 'is degraded',
|
|
4479
|
+
partial_outage: 'has a partial outage',
|
|
4480
|
+
major_outage: 'has a major outage',
|
|
4481
|
+
under_maintenance: 'is under maintenance',
|
|
4482
|
+
};
|
|
4483
|
+
|
|
4484
|
+
/** GitHub's status page says Actions is not healthy: runs may wait for a runner or be cancelled. */
|
|
4485
|
+
function serviceNote(service) {
|
|
4486
|
+
if (!service) return null;
|
|
4487
|
+
const line = el('p', 'github-service-note', `GitHub Actions ${SERVICE_STATES[service.status]}. Runs may wait for a runner or be cancelled. `);
|
|
4488
|
+
if (service.incident) line.append(keyed(externalLink(service.incident.name, service.incident.url), 'incident'), ' · ');
|
|
4489
|
+
line.append(keyed(externalLink('GitHub Status', service.url), 'status-page'));
|
|
4490
|
+
return line;
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4349
4493
|
function renderRuns() {
|
|
4350
4494
|
const panel = $('github-runs');
|
|
4351
4495
|
if (!githubPick.repo) return redraw(panel, [noRepoNote('Actions runs')]);
|
|
@@ -4358,6 +4502,7 @@ function renderRuns() {
|
|
|
4358
4502
|
});
|
|
4359
4503
|
redraw(panel, [
|
|
4360
4504
|
viewTools('actions'),
|
|
4505
|
+
serviceNote(slot?.value?.service),
|
|
4361
4506
|
banner,
|
|
4362
4507
|
viewStatus('actions', slot?.value && !runs.length ? 'No workflow runs yet.' : null),
|
|
4363
4508
|
rows.length ? el('div', 'history-list', ...rows) : null,
|
|
@@ -4596,6 +4741,7 @@ function upsertSession(session) {
|
|
|
4596
4741
|
|
|
4597
4742
|
function dropSession(id) {
|
|
4598
4743
|
state.sessions.delete(id);
|
|
4744
|
+
state.environmentUI?.sessionRemoved(id);
|
|
4599
4745
|
const view = state.views.get(id);
|
|
4600
4746
|
const pane = state.panes.indexOf(id);
|
|
4601
4747
|
if (pane !== -1) closePane(pane, { focusTerminal: paneNodes[pane].contains(document.activeElement) });
|
|
@@ -6064,6 +6210,7 @@ $('settings-menu').addEventListener('change', (e) => {
|
|
|
6064
6210
|
else if (e.target.name === 'sound') changeSound(e.target);
|
|
6065
6211
|
else if (e.target.name === 'voice') changeVoice(e.target);
|
|
6066
6212
|
else if (e.target.name === 'autostart') changeAutostart(e.target);
|
|
6213
|
+
else if (e.target.name === 'startup') changeStartup(e.target);
|
|
6067
6214
|
});
|
|
6068
6215
|
/**
|
|
6069
6216
|
* Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
|
|
@@ -6203,6 +6350,13 @@ publishTopbarHeight();
|
|
|
6203
6350
|
applyDockLayout();
|
|
6204
6351
|
|
|
6205
6352
|
state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
|
|
6206
|
-
state.environmentUI = createEnvironmentUI({
|
|
6353
|
+
state.environmentUI = createEnvironmentUI({
|
|
6354
|
+
api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError,
|
|
6355
|
+
workingFolder: () => $('cwd').value.trim(),
|
|
6356
|
+
sessions: () => [...state.sessions.values()].map((session) => ({
|
|
6357
|
+
id: session.id, name: session.name, tool: session.provider?.tool || '', cwd: session.cwd || '', multiplexer: Boolean(session.multiplexer),
|
|
6358
|
+
})),
|
|
6359
|
+
});
|
|
6360
|
+
$('cwd').addEventListener('input', () => { if ($('environment').open) state.environmentUI.sync(); });
|
|
6207
6361
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
6208
6362
|
boot();
|
package/web/environment.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
const STATES = { pending: 'Not checked', not_found: 'Not found', unavailable: 'Runtime unavailable', failed: 'Probe failed' };
|
|
2
|
+
const PIN_STATES = { configured: 'Configured', unreadable: 'Unreadable', invalid: 'Invalid' };
|
|
3
|
+
const SCOPES = [
|
|
4
|
+
['manager', 'Manager', 'Manager environment'],
|
|
5
|
+
['project', 'This project', 'Project pins'],
|
|
6
|
+
['session', 'Existing session', 'Session spawn environment'],
|
|
7
|
+
['launch', 'New shell', 'New shell launch PATH'],
|
|
8
|
+
];
|
|
2
9
|
|
|
3
10
|
export function runtimeValue(row) { return row.status === 'ok' ? row.version : STATES[row.status] || 'Probe failed'; }
|
|
4
11
|
|
|
5
|
-
export function createEnvironmentUI({
|
|
12
|
+
export function createEnvironmentUI({
|
|
13
|
+
api, onAuthError, isAuthError = () => false, document = globalThis.document,
|
|
14
|
+
workingFolder = () => '', sessions = () => [],
|
|
15
|
+
} = {}) {
|
|
6
16
|
const $ = (id) => document.getElementById(id);
|
|
7
17
|
const dialog = $('environment');
|
|
8
|
-
let
|
|
18
|
+
let manager = null, current = null, scope = 'manager', request = 0, pending = false, online = false, managerPid = null, error = '', openerId = null;
|
|
19
|
+
let projectFolder = '', visibleProject = '', sessionId = '';
|
|
20
|
+
const slots = new Map();
|
|
9
21
|
const element = (tag, text, className) => {
|
|
10
22
|
const node = document.createElement(tag);
|
|
11
23
|
if (text !== undefined) node.textContent = text;
|
|
@@ -15,13 +27,13 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
15
27
|
|
|
16
28
|
function renderSummary(host) {
|
|
17
29
|
const values = host.querySelector('.environment-values');
|
|
18
|
-
const found = (
|
|
30
|
+
const found = (manager?.runtimes || []).filter((row) => row.status === 'ok');
|
|
19
31
|
values.replaceChildren(...found.flatMap((row) => [element('dt', row.label), element('dd', row.version)]));
|
|
20
32
|
const note = host.querySelector('.environment-note');
|
|
21
|
-
note.textContent = error ? 'Environment check unavailable'
|
|
22
|
-
:
|
|
23
|
-
:
|
|
24
|
-
: !
|
|
33
|
+
note.textContent = error && scope === 'manager' ? 'Environment check unavailable'
|
|
34
|
+
: manager?.refreshing ? manager.checkedAt ? 'Refreshing · showing previous check' : 'Checking environment…'
|
|
35
|
+
: manager?.error ? manager.error
|
|
36
|
+
: !manager ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function rowElement(row) {
|
|
@@ -42,52 +54,225 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
42
54
|
return node;
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
function pinElement(row) {
|
|
58
|
+
const node = element('section', undefined, 'environment-row');
|
|
59
|
+
const head = element('div', undefined, 'environment-row-head');
|
|
60
|
+
head.append(element('strong', row.label), element('span', PIN_STATES[row.status] || 'Invalid'));
|
|
61
|
+
node.append(head);
|
|
62
|
+
node.append(element('p', row.source, 'environment-detail'));
|
|
63
|
+
if (row.version) node.append(element('code', row.version, 'environment-path'));
|
|
64
|
+
if (row.detail) node.append(element('p', row.detail, 'environment-detail'));
|
|
65
|
+
return node;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function showRuntimes(rows) {
|
|
69
|
+
$('environment-runtimes').hidden = false;
|
|
70
|
+
$('environment-runtimes').replaceChildren(...(rows || []).map(rowElement));
|
|
71
|
+
$('environment-pins').hidden = true;
|
|
72
|
+
$('environment-pins').replaceChildren();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function activeSnapshot() {
|
|
76
|
+
if (scope === 'manager') return manager;
|
|
77
|
+
const folder = workingFolder();
|
|
78
|
+
if (scope === 'project') {
|
|
79
|
+
if (!folder || folder !== projectFolder || !visibleProject || current?.cwd !== visibleProject) return null;
|
|
80
|
+
return current?.scope === 'project' ? current : null;
|
|
81
|
+
}
|
|
82
|
+
if (scope === 'session' && current?.sessionId !== sessionId) return null;
|
|
83
|
+
return current?.scope === scope ? current : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
function render() {
|
|
87
|
+
const listed = sessions();
|
|
88
|
+
if (scope === 'session' && !listed.some((session) => session.id === sessionId)) {
|
|
89
|
+
sessionId = listed[0]?.id || '';
|
|
90
|
+
current = slots.get(`session:${sessionId}`) || null;
|
|
91
|
+
}
|
|
46
92
|
for (const host of document.querySelectorAll('.environment-summary:not([hidden])')) renderSummary(host);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
$('environment-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
const data = activeSnapshot();
|
|
94
|
+
const title = SCOPES.find(([id]) => id === scope)?.[2] || 'Manager environment';
|
|
95
|
+
$('environment-title').textContent = title;
|
|
96
|
+
$('environment-host').textContent = data?.host || manager?.host ? `On ${data?.host || manager.host}` : 'On the computer running Agent Guild';
|
|
97
|
+
for (const [id] of SCOPES) {
|
|
98
|
+
const button = $(`environment-scope-${id}`);
|
|
99
|
+
button.ariaPressed = String(id === scope);
|
|
100
|
+
}
|
|
101
|
+
const folder = workingFolder();
|
|
102
|
+
$('environment-intro').textContent = scope === 'project'
|
|
103
|
+
? 'Configured versions in this folder. A pin is not proof the runtime is installed.'
|
|
104
|
+
: scope === 'session'
|
|
105
|
+
? 'The PATH this manager passed to the session. Startup files that ran afterward are not included.'
|
|
106
|
+
: scope === 'launch'
|
|
107
|
+
? 'A new session receives this PATH. The shell may then run its startup files.'
|
|
108
|
+
: 'Versions available to the manager. Shell profiles, projects and existing sessions can use different versions.';
|
|
109
|
+
$('environment-python-note').hidden = scope !== 'manager';
|
|
110
|
+
$('environment-folder').hidden = scope !== 'project';
|
|
111
|
+
$('environment-folder').textContent = folder ? `Folder: ${data?.cwd || folder}` : 'Choose a working folder.';
|
|
112
|
+
$('environment-session-label').hidden = scope !== 'session';
|
|
113
|
+
$('environment-session').hidden = scope !== 'session';
|
|
114
|
+
if (scope === 'session') {
|
|
115
|
+
const select = $('environment-session');
|
|
116
|
+
const same = select.children.length === listed.length && listed.every((session, index) => select.children[index]?.value === session.id);
|
|
117
|
+
if (!same) {
|
|
118
|
+
select.replaceChildren(...listed.map((session) => {
|
|
119
|
+
const option = element('option', `${session.name || session.tool || 'Session'} · ${session.cwd || 'unknown folder'}`);
|
|
120
|
+
option.value = session.id;
|
|
121
|
+
return option;
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
select.value = sessionId;
|
|
125
|
+
}
|
|
126
|
+
const refreshing = Boolean(data?.refreshing);
|
|
127
|
+
const blocked = scope === 'project' ? !folder : scope === 'session' ? !sessionId : false;
|
|
128
|
+
$('environment-refresh').disabled = !online || pending || refreshing || blocked;
|
|
129
|
+
$('environment-refresh').textContent = pending || refreshing ? 'Checking…' : 'Refresh';
|
|
130
|
+
$('environment-status').textContent = error || (blocked && scope === 'project' ? 'Choose a working folder.'
|
|
131
|
+
: blocked ? 'No sessions.'
|
|
132
|
+
: scope === 'project' && folder !== projectFolder ? 'Refresh to read this folder.'
|
|
133
|
+
: data?.refreshing ? data.checkedAt ? 'Refreshing. Previous results remain visible.' : scope === 'project' ? 'Reading pin files…' : 'Checking…'
|
|
134
|
+
: data?.error ? data.error
|
|
135
|
+
: data?.stale ? data.detail || 'These pins may be out of date. Refresh to read the folder again.'
|
|
136
|
+
: data?.availability === 'unavailable' ? data.detail
|
|
137
|
+
: pending && data?.checkedAt ? 'Refreshing. Previous results remain visible.'
|
|
138
|
+
: data?.checkedAt ? `Last checked ${new Date(data.checkedAt).toLocaleString()}`
|
|
139
|
+
: 'Not checked yet.');
|
|
140
|
+
const tools = scope === 'project' || data?.availability === 'unavailable' ? [] : (data?.tools || []);
|
|
141
|
+
const showTools = scope !== 'project' && data?.availability !== 'unavailable';
|
|
142
|
+
$('environment-tools-heading').hidden = !showTools;
|
|
143
|
+
$('environment-tools-note').hidden = !showTools;
|
|
144
|
+
$('environment-tools').hidden = !showTools;
|
|
145
|
+
$('environment-tools-empty').hidden = !showTools;
|
|
146
|
+
if (scope === 'project' && data && folder === projectFolder) {
|
|
147
|
+
$('environment-runtimes').hidden = true;
|
|
148
|
+
$('environment-runtimes').replaceChildren();
|
|
149
|
+
$('environment-pins').hidden = false;
|
|
150
|
+
$('environment-pins').replaceChildren(...(data.pins.length ? data.pins.map(pinElement) : [element('p', data.refreshing ? 'Reading pin files…' : 'No version pins in this folder.', 'environment-detail')]));
|
|
151
|
+
} else if (scope === 'session' && data?.availability === 'unavailable') {
|
|
152
|
+
showRuntimes([]);
|
|
153
|
+
} else if (data && (scope !== 'project' || folder === projectFolder)) {
|
|
154
|
+
showRuntimes(data.runtimes);
|
|
155
|
+
} else {
|
|
156
|
+
showRuntimes([]);
|
|
157
|
+
}
|
|
158
|
+
if (showTools) {
|
|
159
|
+
$('environment-tools').replaceChildren(...tools.map((tool) => {
|
|
160
|
+
const node = element('section', undefined, 'environment-row');
|
|
161
|
+
node.append(element('strong', `${tool.label} · Detected`), element('code', tool.path, 'environment-path'));
|
|
162
|
+
return node;
|
|
163
|
+
}));
|
|
164
|
+
$('environment-tools-empty').hidden = Boolean(tools.length);
|
|
165
|
+
$('environment-tools-empty').textContent = data?.refreshing ? 'Checking…' : data?.error ? 'Tool discovery did not finish.' : 'No supported tools detected.';
|
|
166
|
+
}
|
|
167
|
+
$('environment-manager-node').hidden = scope !== 'manager';
|
|
168
|
+
$('environment-manager-node').textContent = scope === 'manager' && data?.managerNode
|
|
62
169
|
? `Agent Guild is running on Node.js ${data.managerNode.version} (${data.managerNode.path}).` : '';
|
|
170
|
+
if (scope === 'session' && data?.availability === 'ok' && data.spawnCwd) {
|
|
171
|
+
$('environment-manager-node').hidden = false;
|
|
172
|
+
$('environment-manager-node').textContent = `Spawn folder: ${data.spawnCwd}`;
|
|
173
|
+
}
|
|
174
|
+
if (scope === 'launch' && data?.detail) {
|
|
175
|
+
$('environment-manager-node').hidden = false;
|
|
176
|
+
$('environment-manager-node').textContent = data.detail;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function remember(snapshot) {
|
|
181
|
+
if (!snapshot?.scope) return false;
|
|
182
|
+
if (snapshot.scope === 'project') {
|
|
183
|
+
const previous = slots.get(`project:${snapshot.cwd}`);
|
|
184
|
+
if (previous && snapshot.revision < previous.revision) return false;
|
|
185
|
+
slots.set(`project:${snapshot.cwd}`, snapshot);
|
|
186
|
+
if (snapshot.cwd !== visibleProject) return false;
|
|
187
|
+
current = snapshot;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (snapshot.scope === 'session') {
|
|
191
|
+
// Each session has its own revisions. Cache background updates without
|
|
192
|
+
// changing the selected session's results or clearing its request error.
|
|
193
|
+
const key = `session:${snapshot.sessionId}`;
|
|
194
|
+
const previous = slots.get(key);
|
|
195
|
+
if (previous && snapshot.revision < previous.revision) return false;
|
|
196
|
+
slots.set(key, snapshot);
|
|
197
|
+
if (scope !== 'session' || snapshot.sessionId !== sessionId) return false;
|
|
198
|
+
current = snapshot;
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const previous = slots.get(snapshot.scope);
|
|
202
|
+
if (previous && snapshot.revision < previous.revision) return false;
|
|
203
|
+
slots.set(snapshot.scope, snapshot);
|
|
204
|
+
if (snapshot.scope === 'manager') manager = snapshot;
|
|
205
|
+
if (snapshot.scope === scope) current = snapshot;
|
|
206
|
+
return true;
|
|
63
207
|
}
|
|
64
208
|
|
|
65
209
|
function updated(snapshot) {
|
|
66
|
-
if (!online || !snapshot
|
|
67
|
-
if (data && snapshot.revision < data.revision) return;
|
|
68
|
-
data = snapshot;
|
|
210
|
+
if (!online || !remember(snapshot)) return;
|
|
69
211
|
error = '';
|
|
70
212
|
render();
|
|
71
213
|
}
|
|
72
214
|
|
|
215
|
+
function requestFor(refresh) {
|
|
216
|
+
if (scope === 'manager') return refresh ? ['POST', '/environment/refresh', {}] : ['GET', '/environment'];
|
|
217
|
+
if (scope === 'project') {
|
|
218
|
+
const cwd = workingFolder();
|
|
219
|
+
if (cwd !== projectFolder) visibleProject = '';
|
|
220
|
+
projectFolder = cwd;
|
|
221
|
+
if (!cwd) return null;
|
|
222
|
+
return refresh
|
|
223
|
+
? ['POST', '/environment/refresh', { scope: 'project', cwd }]
|
|
224
|
+
: ['GET', `/environment?scope=project&cwd=${encodeURIComponent(cwd)}`];
|
|
225
|
+
}
|
|
226
|
+
if (scope === 'session') {
|
|
227
|
+
if (!sessionId) return null;
|
|
228
|
+
return refresh
|
|
229
|
+
? ['POST', '/environment/refresh', { scope: 'session', id: sessionId }]
|
|
230
|
+
: ['GET', `/environment?scope=session&id=${encodeURIComponent(sessionId)}`];
|
|
231
|
+
}
|
|
232
|
+
return refresh ? ['POST', '/environment/refresh', { scope: 'launch' }] : ['GET', '/environment?scope=launch'];
|
|
233
|
+
}
|
|
234
|
+
|
|
73
235
|
async function load(refresh = false) {
|
|
74
|
-
if (!online
|
|
75
|
-
const
|
|
236
|
+
if (!online) return;
|
|
237
|
+
const spec = requestFor(refresh);
|
|
238
|
+
if (!spec) { error = ''; pending = false; render(); return; }
|
|
239
|
+
const currentRequest = ++request;
|
|
76
240
|
pending = true;
|
|
77
241
|
error = '';
|
|
78
242
|
render();
|
|
79
243
|
try {
|
|
80
|
-
const snapshot = await api(
|
|
81
|
-
if (
|
|
244
|
+
const snapshot = await api(...spec);
|
|
245
|
+
if (currentRequest !== request) return;
|
|
246
|
+
if (snapshot?.scope === 'project') visibleProject = snapshot.cwd;
|
|
247
|
+
updated(snapshot);
|
|
82
248
|
} catch (err) {
|
|
83
|
-
if (
|
|
249
|
+
if (currentRequest !== request) return;
|
|
84
250
|
if (isAuthError(err)) onAuthError?.(err.message);
|
|
85
|
-
else error = 'Could not read
|
|
251
|
+
else error = err.message || 'Could not read this environment. Try Refresh.';
|
|
86
252
|
} finally {
|
|
87
|
-
if (
|
|
253
|
+
if (currentRequest === request) { pending = false; render(); }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function choose(next) {
|
|
258
|
+
if (scope === next && next !== 'session') return;
|
|
259
|
+
scope = next;
|
|
260
|
+
error = '';
|
|
261
|
+
if (next === 'session') {
|
|
262
|
+
const listed = sessions();
|
|
263
|
+
if (!listed.some((session) => session.id === sessionId)) sessionId = listed[0]?.id || '';
|
|
88
264
|
}
|
|
265
|
+
current = slots.get(next === 'session' ? `session:${sessionId}` : next) || null;
|
|
266
|
+
render();
|
|
267
|
+
load(false);
|
|
89
268
|
}
|
|
90
269
|
|
|
270
|
+
for (const [id] of SCOPES) $(`environment-scope-${id}`).addEventListener('click', () => choose(id));
|
|
271
|
+
$('environment-session').addEventListener('change', () => {
|
|
272
|
+
sessionId = $('environment-session').value;
|
|
273
|
+
current = slots.get(`session:${sessionId}`) || null;
|
|
274
|
+
load(false);
|
|
275
|
+
});
|
|
91
276
|
$('environment-close').addEventListener('click', () => dialog.close());
|
|
92
277
|
$('environment-refresh').addEventListener('click', () => load(true));
|
|
93
278
|
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
|
|
@@ -100,8 +285,11 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
100
285
|
return {
|
|
101
286
|
updated,
|
|
102
287
|
connected(pid) {
|
|
103
|
-
if (pid !== managerPid) {
|
|
104
|
-
|
|
288
|
+
if (pid !== managerPid) {
|
|
289
|
+
manager = null; current = null; slots.clear(); managerPid = pid; scope = 'manager';
|
|
290
|
+
visibleProject = ''; projectFolder = ''; sessionId = '';
|
|
291
|
+
}
|
|
292
|
+
request++; pending = false; online = true; error = '';
|
|
105
293
|
load();
|
|
106
294
|
},
|
|
107
295
|
disconnected() {
|
|
@@ -110,6 +298,20 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
110
298
|
render();
|
|
111
299
|
},
|
|
112
300
|
close() { openerId = null; dialog.close(); this.disconnected(); },
|
|
301
|
+
sync() { render(); },
|
|
302
|
+
sessionRemoved(id) {
|
|
303
|
+
// Paint stays free of requests. The session list is already updated, so one
|
|
304
|
+
// check of the next session starts here instead of on every repaint.
|
|
305
|
+
if (!$('environment').open || scope !== 'session') return;
|
|
306
|
+
const selected = sessionId === id;
|
|
307
|
+
if (!selected) { render(); return; }
|
|
308
|
+
const listed = sessions();
|
|
309
|
+
sessionId = listed[0]?.id || '';
|
|
310
|
+
current = slots.get(sessionId ? `session:${sessionId}` : '') || null;
|
|
311
|
+
error = '';
|
|
312
|
+
if (!sessionId || !online) { render(); return; }
|
|
313
|
+
load(false);
|
|
314
|
+
},
|
|
113
315
|
renderCard(card, provider) {
|
|
114
316
|
const host = card.querySelector('.environment-summary');
|
|
115
317
|
host.hidden = !Array.isArray(provider.shells);
|
|
@@ -118,6 +320,9 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
118
320
|
button.dataset.muxFocus = `${provider.id}:environment`;
|
|
119
321
|
button.addEventListener('click', () => {
|
|
120
322
|
openerId = provider.id;
|
|
323
|
+
scope = 'manager';
|
|
324
|
+
current = manager;
|
|
325
|
+
error = '';
|
|
121
326
|
render();
|
|
122
327
|
if (!dialog.open) dialog.showModal();
|
|
123
328
|
});
|