@oddessentials/agent-guild 0.19.0 → 0.20.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 +17 -1
- package/package.json +1 -1
- package/src/manager/server.mjs +2 -1
- package/web/alerts.js +155 -0
- package/web/app.js +122 -7
- package/web/index.html +4 -0
- package/web/sounds/License.txt +22 -0
- package/web/sounds/manager-stopped.wav +0 -0
- package/web/sounds/update-available.wav +0 -0
- package/web/styles.css +3 -1
package/README.md
CHANGED
|
@@ -120,7 +120,23 @@ puts a crew of little robots in deep space; **Grove** is a calm moss garden
|
|
|
120
120
|
of gentle nature spirits; **Gnomeland** is a lantern-lit village of gnome
|
|
121
121
|
builders, engineers with a little magic. The page
|
|
122
122
|
follows your system's light or dark setting until you pick one. See
|
|
123
|
-
[docs/SKINS.md](docs/SKINS.md) to make another.
|
|
123
|
+
[docs/SKINS.md](docs/SKINS.md) to make another. **Alert sounds**, off until
|
|
124
|
+
you turn them on in the same menu, chime when the session manager confirms
|
|
125
|
+
a stop or restart, remains unavailable after a brief recovery check, or a
|
|
126
|
+
new version is discovered after the initial version check. Session activity
|
|
127
|
+
and assistant responses have no sounds:
|
|
128
|
+
the providers do not yet offer consistently reliable completion signals.
|
|
129
|
+
|
|
130
|
+
Sounds play in one eligible tab per browser and origin after browser playback
|
|
131
|
+
permission (usually a click or keypress). They require Web Locks and writable
|
|
132
|
+
local storage. Opening or reconnecting a page does not replay old alerts,
|
|
133
|
+
even when a different manager has taken over. Closing a browser does not
|
|
134
|
+
stop the manager. After an unexpected disconnect, an already connected page
|
|
135
|
+
checks the local health endpoint twice, two seconds apart, with a one-second
|
|
136
|
+
timeout per request. If both fail, it shows “Manager unavailable” and alerts
|
|
137
|
+
once; it cannot confirm whether the process exited or sessions ended.
|
|
138
|
+
A successful health check, reconnection, or closing the page cancels the alert.
|
|
139
|
+
There is no added polling while connected and no additional monitoring process.
|
|
124
140
|
|
|
125
141
|
## Commands
|
|
126
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/server.mjs
CHANGED
|
@@ -25,6 +25,7 @@ const MIME = {
|
|
|
25
25
|
'.webp': 'image/webp',
|
|
26
26
|
'.woff2': 'font/woff2',
|
|
27
27
|
'.ico': 'image/x-icon',
|
|
28
|
+
'.wav': 'audio/wav',
|
|
28
29
|
'.json': 'application/json; charset=utf-8',
|
|
29
30
|
};
|
|
30
31
|
|
|
@@ -450,7 +451,7 @@ export function createManagerServer({
|
|
|
450
451
|
|
|
451
452
|
function handleEvents(ws) {
|
|
452
453
|
eventClients.add(ws);
|
|
453
|
-
safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
454
|
+
safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
454
455
|
ws.on('close', () => eventClients.delete(ws));
|
|
455
456
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
456
457
|
}
|
package/web/alerts.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Sounds follow manager availability and new releases, never session activity.
|
|
2
|
+
export const SOUNDS = {
|
|
3
|
+
stopped: 'sounds/manager-stopped.wav',
|
|
4
|
+
update: 'sounds/update-available.wav',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const MAX_ALERT_AGE_MS = 10000;
|
|
8
|
+
export const RECOVERY_WAIT_MS = 2000;
|
|
9
|
+
export const HEALTH_TIMEOUT_MS = 1000;
|
|
10
|
+
const PLAYED_KEY = 'agentGuild.playedSounds';
|
|
11
|
+
|
|
12
|
+
/** Queue eligible pages, allowing another page to try if playback fails.
|
|
13
|
+
* Record only successful playback, under the same cross-tab lock. A bounded
|
|
14
|
+
* ledger prevents delayed tabs from replaying the event after lock release.
|
|
15
|
+
* Without shared coordination, stay silent rather than multiply alerts.
|
|
16
|
+
*/
|
|
17
|
+
export async function playOnce(key, play, { locks = globalThis.navigator?.locks, storage, fresh = () => true, related = [] } = {}) {
|
|
18
|
+
if (!locks) return false;
|
|
19
|
+
try {
|
|
20
|
+
storage ??= globalThis.localStorage;
|
|
21
|
+
if (!storage) return false;
|
|
22
|
+
return await locks.request('agentGuild.sound', async () => {
|
|
23
|
+
if (!fresh()) return false;
|
|
24
|
+
let played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
|
|
25
|
+
if (!Array.isArray(played)) played = [];
|
|
26
|
+
if (played.includes(key) || related.some((other) => played.includes(other))) return false;
|
|
27
|
+
// Check storage is writable before making a sound.
|
|
28
|
+
storage.setItem(PLAYED_KEY, JSON.stringify(played.slice(-127)));
|
|
29
|
+
try {
|
|
30
|
+
if (await play() === false) return false;
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
storage.setItem(PLAYED_KEY, JSON.stringify([...played.slice(-127), key]));
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
} catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A live connection rearms an availability alert for a later outage. */
|
|
39
|
+
export async function rearmSound(key, { locks = globalThis.navigator?.locks, storage } = {}) {
|
|
40
|
+
if (!locks) return;
|
|
41
|
+
try {
|
|
42
|
+
storage ??= globalThis.localStorage;
|
|
43
|
+
if (!storage) return;
|
|
44
|
+
await locks.request('agentGuild.sound', () => {
|
|
45
|
+
const played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
|
|
46
|
+
if (Array.isArray(played) && played.includes(key)) {
|
|
47
|
+
storage.setItem(PLAYED_KEY, JSON.stringify(played.filter((item) => item !== key)));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
} catch { /* Optional sound coordination may be unavailable. */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Two bounded health checks, only after losing a previously live connection.
|
|
54
|
+
* Reconnect, shutdown and page departure cancel both requests and timers.
|
|
55
|
+
* The callback's freshness check also cancels playback waiting on another tab.
|
|
56
|
+
*/
|
|
57
|
+
export function managerLossWatcher({ reachable, unavailable, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
58
|
+
let live = false;
|
|
59
|
+
let pending = null;
|
|
60
|
+
const cancel = () => {
|
|
61
|
+
pending?.abort();
|
|
62
|
+
pending = null;
|
|
63
|
+
live = false;
|
|
64
|
+
};
|
|
65
|
+
const check = async (signal) => {
|
|
66
|
+
const request = new AbortController();
|
|
67
|
+
const abort = () => request.abort();
|
|
68
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
69
|
+
const timer = setTimer(abort, HEALTH_TIMEOUT_MS);
|
|
70
|
+
try { return await reachable(request.signal); }
|
|
71
|
+
catch { return false; }
|
|
72
|
+
finally {
|
|
73
|
+
clearTimer(timer);
|
|
74
|
+
signal.removeEventListener('abort', abort);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const wait = (signal) => new Promise((resolve) => {
|
|
78
|
+
const done = () => {
|
|
79
|
+
clearTimer(timer);
|
|
80
|
+
signal.removeEventListener('abort', done);
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimer(done, RECOVERY_WAIT_MS);
|
|
84
|
+
signal.addEventListener('abort', done, { once: true });
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
connected() { cancel(); live = true; },
|
|
88
|
+
cancel,
|
|
89
|
+
async disconnected() {
|
|
90
|
+
if (!live) return;
|
|
91
|
+
live = false;
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
pending = controller;
|
|
94
|
+
const at = now();
|
|
95
|
+
const fresh = () => pending === controller && !controller.signal.aborted && now() - at <= MAX_ALERT_AGE_MS;
|
|
96
|
+
if (await check(controller.signal) || !fresh()) return;
|
|
97
|
+
await wait(controller.signal);
|
|
98
|
+
if (!fresh() || await check(controller.signal) || !fresh()) return;
|
|
99
|
+
unavailable(at, fresh);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Reconnecting establishes a baseline; only a live stop event alerts. */
|
|
105
|
+
export function stopWatcher() {
|
|
106
|
+
let instance = null;
|
|
107
|
+
let stopped = false;
|
|
108
|
+
return {
|
|
109
|
+
connected(next) {
|
|
110
|
+
if (next !== instance) stopped = false;
|
|
111
|
+
instance = next;
|
|
112
|
+
},
|
|
113
|
+
stopped() {
|
|
114
|
+
if (!instance || stopped) return null;
|
|
115
|
+
stopped = true;
|
|
116
|
+
return instance;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Published releases use semantic versions, including numerically ordered
|
|
122
|
+
// prerelease identifiers. Build metadata does not affect precedence.
|
|
123
|
+
function versionParts(value) {
|
|
124
|
+
return typeof value === 'string' ? value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/) : null;
|
|
125
|
+
}
|
|
126
|
+
function newer(a, b) {
|
|
127
|
+
const av = versionParts(a), bv = versionParts(b);
|
|
128
|
+
for (let i = 1; i <= 3; i++) if (+av[i] !== +bv[i]) return +av[i] > +bv[i];
|
|
129
|
+
if (!av[4] || !bv[4]) return Boolean(bv[4]) && !av[4];
|
|
130
|
+
const ap = av[4].split('.'), bp = bv[4].split('.');
|
|
131
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
132
|
+
if (ap[i] === bp[i]) continue;
|
|
133
|
+
if (ap[i] === undefined || bp[i] === undefined) return bp[i] === undefined;
|
|
134
|
+
const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
|
|
135
|
+
if (an && bn) return +ap[i] > +bp[i];
|
|
136
|
+
if (an !== bn) return bn;
|
|
137
|
+
return ap[i] > bp[i];
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The first successful version report is a baseline, even after nulls.
|
|
143
|
+
* Reconnect snapshots also advance it silently. Never alert on a rollback.
|
|
144
|
+
*/
|
|
145
|
+
export function updateWatcher() {
|
|
146
|
+
let seen = null;
|
|
147
|
+
return (upgrade, baseline = false) => {
|
|
148
|
+
const version = upgrade?.latestVersion;
|
|
149
|
+
if (!versionParts(version)) return false;
|
|
150
|
+
if (!seen) { seen = version; return false; }
|
|
151
|
+
if (!newer(version, seen)) return false;
|
|
152
|
+
seen = version;
|
|
153
|
+
return !baseline && upgrade.available === true;
|
|
154
|
+
};
|
|
155
|
+
}
|
package/web/app.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Agent Guild web page. A thin client of the session manager's local API:
|
|
2
2
|
// it never owns sessions, so closing the page leaves them running.
|
|
3
3
|
|
|
4
|
+
import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
|
|
5
|
+
|
|
4
6
|
const TOKEN_KEY = 'agentGuild.token';
|
|
5
7
|
const CWD_KEY = 'agentGuild.cwd';
|
|
6
8
|
const ACCOUNTS_KEY = 'agentGuild.accounts';
|
|
@@ -13,6 +15,7 @@ const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
|
|
|
13
15
|
const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
14
16
|
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
15
17
|
const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
|
|
18
|
+
const SOUND_KEY = 'agentGuild.sound';
|
|
16
19
|
const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
|
|
17
20
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
18
21
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
@@ -36,6 +39,8 @@ const state = {
|
|
|
36
39
|
activeId: null,
|
|
37
40
|
eventsSocket: null,
|
|
38
41
|
eventsRetry: 0,
|
|
42
|
+
pageAway: false,
|
|
43
|
+
managerUnavailable: false,
|
|
39
44
|
/** True while the events socket is open. */
|
|
40
45
|
connected: false,
|
|
41
46
|
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
@@ -206,6 +211,80 @@ function changeSkin(input) {
|
|
|
206
211
|
revealChange(input.closest('label'), () => { document.documentElement.dataset.skin = skin; });
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
// ---- sound ----------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const audio = {};
|
|
217
|
+
const stopAlert = stopWatcher();
|
|
218
|
+
const updateAlert = updateWatcher();
|
|
219
|
+
const managerLoss = managerLossWatcher({
|
|
220
|
+
async reachable(signal) {
|
|
221
|
+
const res = await fetch('/api/v1/health', { cache: 'no-store', signal });
|
|
222
|
+
const health = await res.json();
|
|
223
|
+
return res.ok && health.ok === true && health.name === 'agent-guild';
|
|
224
|
+
},
|
|
225
|
+
unavailable(at, fresh) {
|
|
226
|
+
state.managerUnavailable = true;
|
|
227
|
+
showManagerUnavailable();
|
|
228
|
+
alertSound('stopped', `unavailable.${state.managerInstance}`, at, fresh, [`stopped.${state.managerInstance}`]);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
function soundOn() {
|
|
233
|
+
return load(SOUND_KEY) === 'on';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Keep the tiny clips ready while the manager can still serve them. */
|
|
237
|
+
function prepareSounds() {
|
|
238
|
+
if (!soundOn()) return;
|
|
239
|
+
for (const [name, file] of Object.entries(SOUNDS)) {
|
|
240
|
+
audio[name] ??= new Audio(file);
|
|
241
|
+
audio[name].preload = 'auto';
|
|
242
|
+
if (audio[name].error) audio[name].load();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Plays a sound when sounds are on. Browsers allow it once the user has clicked or typed on the page. */
|
|
247
|
+
function playSound(name) {
|
|
248
|
+
if (!soundOn()) return Promise.resolve(false);
|
|
249
|
+
audio[name] ??= new Audio(SOUNDS[name]);
|
|
250
|
+
audio[name].currentTime = 0;
|
|
251
|
+
return audio[name].play().then(() => true);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Plays an alert in one open page only; `key` names the event. */
|
|
255
|
+
function alertSound(name, key, at = Date.now(), fresh = () => true, related = []) {
|
|
256
|
+
if (soundOn()) playOnce(key, () => playSound(name), {
|
|
257
|
+
fresh: () => !state.pageAway && soundOn() && fresh() && Date.now() - at <= MAX_ALERT_AGE_MS,
|
|
258
|
+
related,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function changeSound(input) {
|
|
263
|
+
save(SOUND_KEY, input.checked ? 'on' : null);
|
|
264
|
+
prepareSounds();
|
|
265
|
+
playSound('update').catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A hello identifies this manager lifetime, independent of connections. */
|
|
269
|
+
function managerConnected(msg) {
|
|
270
|
+
prepareSounds();
|
|
271
|
+
state.managerInstance = msg.startedAt && msg.pid ? `${msg.pid}.${msg.startedAt}` : null;
|
|
272
|
+
stopAlert.connected(state.managerInstance);
|
|
273
|
+
state.managerUnavailable = false;
|
|
274
|
+
if (state.managerInstance) managerLoss.connected();
|
|
275
|
+
else managerLoss.cancel();
|
|
276
|
+
if (state.managerInstance) rearmSound(`unavailable.${state.managerInstance}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A confirmed manager stop; a browser socket closing never calls this. */
|
|
280
|
+
function managerGone() {
|
|
281
|
+
managerLoss.cancel();
|
|
282
|
+
state.managerUnavailable = false;
|
|
283
|
+
const instance = stopAlert.stopped();
|
|
284
|
+
if (instance) alertSound('stopped', `stopped.${instance}`, Date.now(),
|
|
285
|
+
() => state.managerInstance === instance && !state.connected, [`unavailable.${instance}`]);
|
|
286
|
+
}
|
|
287
|
+
|
|
209
288
|
/** Places the open menu under its button, right-aligned with it and kept on screen. */
|
|
210
289
|
function placeAppearanceMenu() {
|
|
211
290
|
const menu = $('appearance-menu');
|
|
@@ -269,9 +348,10 @@ function renderUpgrade() {
|
|
|
269
348
|
note.title = title;
|
|
270
349
|
}
|
|
271
350
|
|
|
272
|
-
function setUpgrade(upgrade) {
|
|
351
|
+
function setUpgrade(upgrade, baseline = false) {
|
|
273
352
|
const before = state.upgrade;
|
|
274
353
|
state.upgrade = upgrade || null;
|
|
354
|
+
if (updateAlert(state.upgrade, baseline)) alertSound('update', `update.${state.upgrade.latestVersion}`);
|
|
275
355
|
renderUpgrade();
|
|
276
356
|
renderVersion();
|
|
277
357
|
if ($('changelog').open) renderChangelog();
|
|
@@ -3068,7 +3148,9 @@ class TerminalView {
|
|
|
3068
3148
|
this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
|
|
3069
3149
|
this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
|
|
3070
3150
|
suppressQueryReplies(this.term);
|
|
3071
|
-
this.term.onData((data) =>
|
|
3151
|
+
this.term.onData((data) => {
|
|
3152
|
+
this.send({ type: 'input', data });
|
|
3153
|
+
});
|
|
3072
3154
|
this.opened = false;
|
|
3073
3155
|
this.disposed = false;
|
|
3074
3156
|
this.retry = 0;
|
|
@@ -3324,7 +3406,7 @@ function leaveStopping() {
|
|
|
3324
3406
|
function restartGaveUp() {
|
|
3325
3407
|
if (!state.stopping || !state.restarting) return;
|
|
3326
3408
|
state.restarting = false;
|
|
3327
|
-
setConnection('down', 'Session manager stopped');
|
|
3409
|
+
setConnection('down', state.stopRemaining === null ? 'Manager unavailable' : 'Session manager stopped');
|
|
3328
3410
|
showStopped('stopped', 'The session manager did not come back',
|
|
3329
3411
|
`Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
|
|
3330
3412
|
}
|
|
@@ -3386,14 +3468,26 @@ function showManagerStopped() {
|
|
|
3386
3468
|
|
|
3387
3469
|
// ---- events ---------------------------------------------------------------
|
|
3388
3470
|
|
|
3471
|
+
function showManagerUnavailable() {
|
|
3472
|
+
setConnection('down', 'Manager unavailable. Trying to reconnect…');
|
|
3473
|
+
if (state.stopping) {
|
|
3474
|
+
clearTimeout(restartTimer);
|
|
3475
|
+
showStopped('stopped', 'Manager unavailable', 'The manager stopped responding before confirming shutdown. Sessions may still be running. This page will reconnect automatically.');
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3389
3479
|
function connectEvents() {
|
|
3480
|
+
if (state.pageAway) return;
|
|
3390
3481
|
const ws = new WebSocket(wsUrl('/events'));
|
|
3391
3482
|
state.eventsSocket = ws;
|
|
3392
3483
|
ws.onopen = () => {
|
|
3484
|
+
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
3485
|
+
managerLoss.cancel();
|
|
3393
3486
|
state.eventsRetry = 0;
|
|
3394
3487
|
setConnection('ok', 'Connected to session manager');
|
|
3395
3488
|
};
|
|
3396
3489
|
ws.onmessage = (event) => {
|
|
3490
|
+
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
3397
3491
|
const msg = JSON.parse(event.data);
|
|
3398
3492
|
if (msg.type === 'hello') {
|
|
3399
3493
|
// The manager is back after a stop or restart; the page picks up where it was.
|
|
@@ -3406,9 +3500,10 @@ function connectEvents() {
|
|
|
3406
3500
|
setConnection('ok', 'Connected to session manager');
|
|
3407
3501
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
3408
3502
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
3503
|
+
managerConnected(msg);
|
|
3409
3504
|
renderSessions();
|
|
3410
3505
|
sessionsShown = true;
|
|
3411
|
-
setUpgrade(msg.upgrade);
|
|
3506
|
+
setUpgrade(msg.upgrade, true);
|
|
3412
3507
|
loadNews();
|
|
3413
3508
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
3414
3509
|
if ($('changelog').open) loadChangelog();
|
|
@@ -3427,6 +3522,7 @@ function connectEvents() {
|
|
|
3427
3522
|
enterStopping(0, msg.restart === true);
|
|
3428
3523
|
state.stopRemaining = Number(msg.remaining) || 0;
|
|
3429
3524
|
showManagerStopped();
|
|
3525
|
+
managerGone();
|
|
3430
3526
|
} else if (msg.type === 'session.created' || msg.type === 'session.updated') {
|
|
3431
3527
|
upsertSession(msg.session);
|
|
3432
3528
|
} else if (msg.type === 'session.removed') {
|
|
@@ -3439,16 +3535,21 @@ function connectEvents() {
|
|
|
3439
3535
|
}
|
|
3440
3536
|
};
|
|
3441
3537
|
ws.onclose = () => {
|
|
3442
|
-
if (state.
|
|
3538
|
+
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
3539
|
+
if (state.managerUnavailable) {
|
|
3540
|
+
showManagerUnavailable();
|
|
3541
|
+
} else if (state.stopping && state.stopRemaining !== null) {
|
|
3443
3542
|
showManagerStopped();
|
|
3444
3543
|
} else {
|
|
3445
|
-
setConnection('down', '
|
|
3544
|
+
setConnection('down', 'Connection interrupted. Trying to reconnect…');
|
|
3446
3545
|
}
|
|
3546
|
+
managerLoss.disconnected();
|
|
3447
3547
|
// Keep trying: after a stop, a relaunched manager brings the page back by itself.
|
|
3448
3548
|
const delay = Math.min(5000, 500 * 2 ** state.eventsRetry++);
|
|
3449
3549
|
setTimeout(async () => {
|
|
3550
|
+
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
3450
3551
|
try { await loadProviders(); } catch (err) { if (err instanceof AuthError) return showAuth(err.message); }
|
|
3451
|
-
connectEvents();
|
|
3552
|
+
if (!state.pageAway && state.eventsSocket === ws) connectEvents();
|
|
3452
3553
|
}, delay);
|
|
3453
3554
|
};
|
|
3454
3555
|
}
|
|
@@ -3476,6 +3577,7 @@ let statsInterval;
|
|
|
3476
3577
|
let newsTimer;
|
|
3477
3578
|
|
|
3478
3579
|
function showAuth(message = '') {
|
|
3580
|
+
managerLoss.cancel();
|
|
3479
3581
|
closeModels();
|
|
3480
3582
|
closeNews();
|
|
3481
3583
|
closeChangelog();
|
|
@@ -3611,6 +3713,16 @@ $('news').addEventListener('close', () => {
|
|
|
3611
3713
|
document.addEventListener('visibilitychange', () => {
|
|
3612
3714
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
3613
3715
|
});
|
|
3716
|
+
addEventListener('pagehide', () => {
|
|
3717
|
+
state.pageAway = true;
|
|
3718
|
+
managerLoss.cancel();
|
|
3719
|
+
state.eventsSocket?.close();
|
|
3720
|
+
});
|
|
3721
|
+
addEventListener('pageshow', (event) => {
|
|
3722
|
+
if (!event.persisted) return;
|
|
3723
|
+
state.pageAway = false;
|
|
3724
|
+
connectEvents();
|
|
3725
|
+
});
|
|
3614
3726
|
$('version').addEventListener('click', openChangelog);
|
|
3615
3727
|
$('changelog-close').addEventListener('click', closeChangelog);
|
|
3616
3728
|
$('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
|
|
@@ -3629,6 +3741,7 @@ $('changelog-restart').addEventListener('click', () => {
|
|
|
3629
3741
|
});
|
|
3630
3742
|
addEventListener('storage', (e) => {
|
|
3631
3743
|
if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
|
|
3744
|
+
else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
|
|
3632
3745
|
// Another tab reordered the cards.
|
|
3633
3746
|
else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
|
|
3634
3747
|
});
|
|
@@ -3674,6 +3787,7 @@ $('upgrade').addEventListener('click', upgradeManager);
|
|
|
3674
3787
|
$('appearance-menu').addEventListener('change', (e) => {
|
|
3675
3788
|
if (e.target.name === 'skin') changeSkin(e.target);
|
|
3676
3789
|
else if (e.target.name === 'theme') changeTheme(e.target);
|
|
3790
|
+
else if (e.target.name === 'sound') changeSound(e.target);
|
|
3677
3791
|
});
|
|
3678
3792
|
$('appearance-menu').addEventListener('toggle', (e) => {
|
|
3679
3793
|
if (e.newState !== 'open') return;
|
|
@@ -3707,6 +3821,7 @@ $('providers').addEventListener('pointerout', (e) => {
|
|
|
3707
3821
|
});
|
|
3708
3822
|
applyTheme(currentTheme());
|
|
3709
3823
|
renderSkinChoices();
|
|
3824
|
+
$('sound').checked = soundOn();
|
|
3710
3825
|
// Follow the system setting until the user picks a theme.
|
|
3711
3826
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
3712
3827
|
if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
|
package/web/index.html
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
<label class="choice"><input type="radio" name="theme" value="light"><span class="mode-light">Light</span></label>
|
|
37
37
|
<label class="choice"><input type="radio" name="theme" value="dark"><span class="mode-dark">Dark</span></label>
|
|
38
38
|
</fieldset>
|
|
39
|
+
<fieldset>
|
|
40
|
+
<legend>Sound</legend>
|
|
41
|
+
<label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
|
|
42
|
+
</fieldset>
|
|
39
43
|
</div>
|
|
40
44
|
<button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
|
|
41
45
|
<button id="stop-manager" class="btn danger stop-manager" type="button" hidden title="Stop the session manager. This ends every session.">Stop manager</button>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
Interface Sounds (1.0)
|
|
4
|
+
|
|
5
|
+
Created/distributed by Kenney (www.kenney.nl)
|
|
6
|
+
Creation date: 11-02-2020
|
|
7
|
+
|
|
8
|
+
------------------------------
|
|
9
|
+
|
|
10
|
+
License: (Creative Commons Zero, CC0)
|
|
11
|
+
http://creativecommons.org/publicdomain/zero/1.0/
|
|
12
|
+
|
|
13
|
+
This content is free to use in personal, educational and commercial projects.
|
|
14
|
+
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
|
15
|
+
|
|
16
|
+
------------------------------
|
|
17
|
+
|
|
18
|
+
Donate: http://support.kenney.nl
|
|
19
|
+
Patreon: http://patreon.com/kenney/
|
|
20
|
+
|
|
21
|
+
Follow on Twitter for updates:
|
|
22
|
+
http://twitter.com/KenneyNL
|
|
Binary file
|
|
Binary file
|
package/web/styles.css
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
--font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
|
|
23
23
|
--icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
|
|
24
24
|
--icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
|
|
25
|
-
--icon-
|
|
25
|
+
--icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
|
|
26
|
+
--icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
|
|
26
27
|
--icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
|
|
27
28
|
--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
|
|
28
29
|
--icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
|
|
@@ -341,6 +342,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
|
|
|
341
342
|
background: currentColor; mask: var(--icon-sun) center / contain no-repeat;
|
|
342
343
|
}
|
|
343
344
|
:root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
|
|
345
|
+
.appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
|
|
344
346
|
.appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
|
|
345
347
|
.appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
|
|
346
348
|
.appearance-menu {
|