@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/README.md +40 -9
- 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 +129 -8
- package/web/environment.js +239 -34
- package/web/index.html +23 -7
- package/web/styles.css +28 -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();
|
|
@@ -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-
|
|
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({
|
|
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();
|
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
|
});
|
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
|
-
<
|
|
385
|
-
|
|
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
|
-
<
|
|
390
|
-
<
|
|
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>
|