@oddessentials/agent-guild 0.33.2 → 0.34.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();
@@ -4082,8 +4174,11 @@ function renderGitHubViews() {
4082
4174
  const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
4083
4175
  $('github-clone-into').hidden = !ready || shown !== 'repos';
4084
4176
  const running = Boolean(viewData('actions')?.value?.running);
4177
+ const outage = viewData('actions')?.value?.service ?? null;
4085
4178
  $('github-running').hidden = !running;
4086
- $('github-view-actions').setAttribute('aria-label', running ? 'Actions, a workflow is running' : 'Actions');
4179
+ $('github-outage').hidden = !outage;
4180
+ $('github-view-actions').setAttribute('aria-label', ['Actions', outage && `GitHub Actions ${SERVICE_STATES[outage.status]}`, running && 'a workflow is running'].filter(Boolean).join(', '));
4181
+ $('github-view-actions').title = outage ? `GitHub Actions ${SERVICE_STATES[outage.status]}` : '';
4087
4182
  renderPickerList();
4088
4183
  if (shown === 'issues') renderIssues();
4089
4184
  else if (shown === 'actions') renderRuns();
@@ -4346,6 +4441,22 @@ function runLabel(run) {
4346
4441
  return String(run.status === 'completed' && run.conclusion ? run.conclusion : run.status).replaceAll('_', ' ');
4347
4442
  }
4348
4443
 
4444
+ const SERVICE_STATES = {
4445
+ degraded_performance: 'is degraded',
4446
+ partial_outage: 'has a partial outage',
4447
+ major_outage: 'has a major outage',
4448
+ under_maintenance: 'is under maintenance',
4449
+ };
4450
+
4451
+ /** GitHub's status page says Actions is not healthy: runs may wait for a runner or be cancelled. */
4452
+ function serviceNote(service) {
4453
+ if (!service) return null;
4454
+ const line = el('p', 'github-service-note', `GitHub Actions ${SERVICE_STATES[service.status]}. Runs may wait for a runner or be cancelled. `);
4455
+ if (service.incident) line.append(keyed(externalLink(service.incident.name, service.incident.url), 'incident'), ' · ');
4456
+ line.append(keyed(externalLink('GitHub Status', service.url), 'status-page'));
4457
+ return line;
4458
+ }
4459
+
4349
4460
  function renderRuns() {
4350
4461
  const panel = $('github-runs');
4351
4462
  if (!githubPick.repo) return redraw(panel, [noRepoNote('Actions runs')]);
@@ -4358,6 +4469,7 @@ function renderRuns() {
4358
4469
  });
4359
4470
  redraw(panel, [
4360
4471
  viewTools('actions'),
4472
+ serviceNote(slot?.value?.service),
4361
4473
  banner,
4362
4474
  viewStatus('actions', slot?.value && !runs.length ? 'No workflow runs yet.' : null),
4363
4475
  rows.length ? el('div', 'history-list', ...rows) : null,
@@ -4596,6 +4708,7 @@ function upsertSession(session) {
4596
4708
 
4597
4709
  function dropSession(id) {
4598
4710
  state.sessions.delete(id);
4711
+ state.environmentUI?.sessionRemoved(id);
4599
4712
  const view = state.views.get(id);
4600
4713
  const pane = state.panes.indexOf(id);
4601
4714
  if (pane !== -1) closePane(pane, { focusTerminal: paneNodes[pane].contains(document.activeElement) });
@@ -6064,6 +6177,7 @@ $('settings-menu').addEventListener('change', (e) => {
6064
6177
  else if (e.target.name === 'sound') changeSound(e.target);
6065
6178
  else if (e.target.name === 'voice') changeVoice(e.target);
6066
6179
  else if (e.target.name === 'autostart') changeAutostart(e.target);
6180
+ else if (e.target.name === 'startup') changeStartup(e.target);
6067
6181
  });
6068
6182
  /**
6069
6183
  * Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
@@ -6203,6 +6317,13 @@ publishTopbarHeight();
6203
6317
  applyDockLayout();
6204
6318
 
6205
6319
  state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
6206
- state.environmentUI = createEnvironmentUI({ api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError });
6320
+ state.environmentUI = createEnvironmentUI({
6321
+ api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError,
6322
+ workingFolder: () => $('cwd').value.trim(),
6323
+ sessions: () => [...state.sessions.values()].map((session) => ({
6324
+ id: session.id, name: session.name, tool: session.provider?.tool || '', cwd: session.cwd || '', multiplexer: Boolean(session.multiplexer),
6325
+ })),
6326
+ });
6327
+ $('cwd').addEventListener('input', () => { if ($('environment').open) state.environmentUI.sync(); });
6207
6328
  state.token = readTokenFromHash() || load(TOKEN_KEY);
6208
6329
  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
  });
package/web/index.html CHANGED
@@ -48,9 +48,15 @@
48
48
  </fieldset>
49
49
  <fieldset id="autostart-choice" hidden>
50
50
  <legend>Startup</legend>
51
- <label class="choice"><input id="autostart" type="checkbox" name="autostart"><span class="autostart">Launch at sign-in</span></label>
51
+ <label id="autostart-single" class="choice"><input id="autostart" type="checkbox" name="autostart"><span class="autostart">Launch at sign-in</span></label>
52
+ <div id="startup-modes" hidden>
53
+ <label class="choice"><input id="startup-off" type="radio" name="startup" value="off"><span class="startup-off">Only when I start it</span></label>
54
+ <label class="choice"><input id="startup-sign-in" type="radio" name="startup" value="sign-in"><span class="autostart">When I sign in</span></label>
55
+ <label class="choice"><input id="startup-boot" type="radio" name="startup" value="boot"><span class="startup-boot">When the computer starts</span></label>
56
+ </div>
52
57
  <p id="autostart-note" class="voice-note"></p>
53
58
  <p id="autostart-run" class="voice-note autostart-run" role="status" hidden></p>
59
+ <div id="startup-commands" class="startup-commands" hidden></div>
54
60
  </fieldset>
55
61
  <fieldset id="voice-choice" hidden>
56
62
  <legend>Voice</legend>
@@ -212,7 +218,7 @@
212
218
  <div id="github-views" class="github-views" role="tablist" aria-label="GitHub views" hidden>
213
219
  <button id="github-view-repos" class="github-view" type="button" role="tab" aria-controls="github-repos" aria-selected="true" data-view="repos" aria-label="Repositories"><span class="long">Repositories</span><span class="short" aria-hidden="true">Repos</span></button>
214
220
  <button id="github-view-issues" class="github-view" type="button" role="tab" aria-controls="github-issues" aria-selected="false" data-view="issues" tabindex="-1">Issues</button>
215
- <button id="github-view-actions" class="github-view" type="button" role="tab" aria-controls="github-runs" aria-selected="false" data-view="actions" tabindex="-1" aria-label="Actions">Actions<span id="github-running" class="github-running" hidden></span></button>
221
+ <button id="github-view-actions" class="github-view" type="button" role="tab" aria-controls="github-runs" aria-selected="false" data-view="actions" tabindex="-1" aria-label="Actions">Actions<span id="github-outage" class="github-outage" aria-hidden="true" hidden>!</span><span id="github-running" class="github-running" hidden></span></button>
216
222
  <button id="github-view-pulls" class="github-view" type="button" role="tab" aria-controls="github-pulls" aria-selected="false" data-view="pulls" tabindex="-1" aria-label="Pull requests"><span class="long">Pull requests</span><span class="short" aria-hidden="true">PRs</span></button>
217
223
  <button id="github-view-branches" class="github-view" type="button" role="tab" aria-controls="github-branches" aria-selected="false" data-view="branches" tabindex="-1">Branches</button>
218
224
  </div>
@@ -376,18 +382,28 @@
376
382
  <div class="models-bar">
377
383
  <div class="models-heading">
378
384
  <h2 id="environment-title">Manager environment</h2>
379
- <p class="sub">On the computer running Agent Guild</p>
385
+ <p id="environment-host" class="sub">On the computer running Agent Guild</p>
380
386
  </div>
381
387
  <button id="environment-close" class="btn" type="button">Close</button>
382
388
  </div>
383
389
  <div class="models-body">
384
- <p class="environment-detail">Versions available to the manager. Shell profiles, projects and existing sessions can use different versions.</p>
385
- <p class="environment-detail">Python uses <code>python</code> when found, otherwise <code>python3</code>. A failed <code>python</code> check stays primary.</p>
390
+ <div id="environment-scopes" class="environment-scopes" role="group" aria-label="Environment to check">
391
+ <button id="environment-scope-manager" class="environment-scope" type="button" aria-pressed="true">Manager</button>
392
+ <button id="environment-scope-project" class="environment-scope" type="button" aria-pressed="false">This project</button>
393
+ <button id="environment-scope-session" class="environment-scope" type="button" aria-pressed="false">Existing session</button>
394
+ <button id="environment-scope-launch" class="environment-scope" type="button" aria-pressed="false">New shell</button>
395
+ </div>
396
+ <p id="environment-intro" class="environment-detail">Versions available to the manager. Shell profiles, projects and existing sessions can use different versions.</p>
397
+ <p id="environment-python-note" class="environment-detail">Python uses <code>python</code> when found, otherwise <code>python3</code>. A failed <code>python</code> check stays primary.</p>
398
+ <p id="environment-folder" class="environment-detail" hidden>Choose a working folder.</p>
399
+ <label id="environment-session-label" for="environment-session" hidden>Session</label>
400
+ <select id="environment-session" hidden></select>
386
401
  <p id="environment-status" class="environment-detail" role="status"></p>
387
402
  <button id="environment-refresh" class="btn" type="button">Refresh</button>
388
403
  <div id="environment-runtimes"></div>
389
- <h3>Detected tools</h3>
390
- <p class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
404
+ <div id="environment-pins" hidden></div>
405
+ <h3 id="environment-tools-heading">Detected tools</h3>
406
+ <p id="environment-tools-note" class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
391
407
  <div id="environment-tools"></div>
392
408
  <p id="environment-tools-empty" class="environment-detail"></p>
393
409
  <p id="environment-manager-node" class="environment-detail"></p>