@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/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
- /** The manager's sign-in setting; hidden when the manager has none. */
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
- $('autostart').disabled = true;
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
- async function changeAutostart(input) {
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
- const enabled = input.checked;
350
- input.disabled = true;
434
+ disableStartup();
351
435
  try {
352
- renderAutostart((await api('PUT', '/autostart', { enabled })).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 = Math.max(0, Math.round(100 - w.usedPercent));
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-view-actions').setAttribute('aria-label', running ? 'Actions, a workflow is running' : 'Actions');
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({ api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError });
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();
@@ -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({ api, onAuthError, isAuthError = () => false, document = globalThis.document }) {
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 data = null, request = 0, pending = false, online = false, managerPid = null, error = '', openerId = null;
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 = (data?.runtimes || []).filter((row) => row.status === 'ok');
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
- : data?.refreshing ? data.checkedAt ? 'Refreshing · showing previous check' : 'Checking environment…'
23
- : data?.error ? data.error
24
- : !data ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
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
- $('environment-refresh').disabled = !online || pending || Boolean(data?.refreshing);
48
- $('environment-refresh').textContent = pending || data?.refreshing ? 'Checking…' : 'Refresh';
49
- $('environment-status').textContent = error || (data?.refreshing
50
- ? data.checkedAt ? 'Refreshing. Previous results remain visible.' : 'Checking the manager environment…'
51
- : data?.error || (data?.checkedAt ? `Last checked ${new Date(data.checkedAt).toLocaleString()}` : 'Not checked yet.'));
52
- $('environment-runtimes').replaceChildren(...(data?.runtimes || []).map(rowElement));
53
- const tools = data?.tools || [];
54
- $('environment-tools').replaceChildren(...tools.map((tool) => {
55
- const node = element('section', undefined, 'environment-row');
56
- node.append(element('strong', `${tool.label} · Detected`), element('code', tool.path, 'environment-path'));
57
- return node;
58
- }));
59
- $('environment-tools-empty').hidden = Boolean(tools.length);
60
- $('environment-tools-empty').textContent = data?.refreshing ? 'Checking…' : data?.error ? 'Tool discovery did not finish.' : 'No supported tools detected.';
61
- $('environment-manager-node').textContent = data?.managerNode
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 || snapshot.scope !== 'manager') return;
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 || pending) return;
75
- const current = ++request;
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(refresh ? 'POST' : 'GET', refresh ? '/environment/refresh' : '/environment', refresh ? {} : undefined);
81
- if (current === request) updated(snapshot);
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 (current !== request) return;
249
+ if (currentRequest !== request) return;
84
250
  if (isAuthError(err)) onAuthError?.(err.message);
85
- else error = 'Could not read the manager environment. Try Refresh.';
251
+ else error = err.message || 'Could not read this environment. Try Refresh.';
86
252
  } finally {
87
- if (current === request) { pending = false; render(); }
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) { data = null; managerPid = pid; }
104
- request++; pending = false; online = true;
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
  });