@oddessentials/agent-guild 0.19.0 → 0.21.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/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();
@@ -500,7 +580,9 @@ function renderShells(card, provider) {
500
580
  const chip = host.children[i];
501
581
  chip.setAttribute('aria-selected', String(shell.id === selected));
502
582
  chip.textContent = shell.label;
503
- chip.title = `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
583
+ chip.title = shell.multiplexer
584
+ ? `Start new sessions inside ${shell.label}. Stopping or closing one detaches it; it keeps running in ${shell.label}.\n${shell.path}`
585
+ : `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
504
586
  });
505
587
  }
506
588
 
@@ -2609,6 +2691,8 @@ function accountLabel(s) {
2609
2691
 
2610
2692
  function statusText(s) {
2611
2693
  if (s.status === 'exited') {
2694
+ // A multiplexer's client exits 1 when detached by Stop: its exit code tells the user nothing.
2695
+ if (s.multiplexer) return 'Closed';
2612
2696
  if (s.signal) return `Exited (${s.signal})`;
2613
2697
  return s.exitCode === 0 || s.exitCode === null ? 'Exited' : `Exited (${s.exitCode})`;
2614
2698
  }
@@ -2652,8 +2736,24 @@ function resumable(s) {
2652
2736
  && !runningOn(s.provider.id, s.account?.id ?? 'default', id));
2653
2737
  }
2654
2738
 
2739
+ /** A stopped tmux or herdr card whose multiplexer still has its session. */
2740
+ function reattachable(s) {
2741
+ return Boolean(s.status === 'exited' && s.multiplexer?.reattachable);
2742
+ }
2743
+
2744
+ async function reattachSession(id) {
2745
+ try {
2746
+ upsertSession((await api('POST', `/sessions/${id}/reattach`)).session);
2747
+ openPanel(id);
2748
+ } catch (err) {
2749
+ if (err instanceof AuthError) return showAuth(err.message);
2750
+ toast(err.message, 8000);
2751
+ }
2752
+ }
2753
+
2655
2754
  function resumeCard(id) {
2656
2755
  const s = state.sessions.get(id);
2756
+ if (s && reattachable(s)) return reattachSession(id);
2657
2757
  if (!s || !resumable(s)) return;
2658
2758
  const provider = state.providers.find((p) => p.id === s.provider.id);
2659
2759
  const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
@@ -2698,14 +2798,20 @@ function updateCard(node, s) {
2698
2798
  renderAgents(node.querySelector('.agents'), s.agents, s.shells || []);
2699
2799
  paintReporting(node.querySelector('.agents-row'), s);
2700
2800
  node.classList.toggle('exited', s.status === 'exited');
2701
- node.querySelector('.stop').hidden = s.status !== 'running';
2801
+ const stop = node.querySelector('.stop');
2802
+ stop.hidden = s.status !== 'running';
2803
+ stop.textContent = s.multiplexer ? 'Detach' : 'Stop';
2804
+ stop.title = s.multiplexer ? `Detach from ${s.multiplexer.label}; it keeps running. Reattach with: ${s.multiplexer.attach}` : '';
2702
2805
  node.querySelector('.remove').hidden = s.status === 'running';
2703
2806
  const useButton = node.querySelector('.use-folder');
2704
2807
  useButton.hidden = !clonedPath(s);
2705
2808
  useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
2706
2809
  const resume = node.querySelector('.resume');
2707
- resume.hidden = !resumable(s);
2708
- resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
2810
+ resume.hidden = !resumable(s) && !reattachable(s);
2811
+ resume.textContent = s.multiplexer ? 'Reattach' : 'Resume';
2812
+ resume.title = s.multiplexer
2813
+ ? `Attach this card to its ${s.multiplexer.label} session again, as ${s.multiplexer.attach} would`
2814
+ : `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
2709
2815
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
2710
2816
  const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
2711
2817
  const shellCount = (s.shells || []).length;
@@ -2757,6 +2863,11 @@ function guardLeaving() {
2757
2863
  }
2758
2864
 
2759
2865
  function upsertSession(session) {
2866
+ // An exit is final for the process it ended. A reply sent before it, such as Stop's, can
2867
+ // arrive after the exit was announced and must not show the session running again; a
2868
+ // reattached tmux or herdr session runs a newer process, started after that exit.
2869
+ const known = state.sessions.get(session.id);
2870
+ if (known?.status === 'exited' && session.status !== 'exited' && !(Date.parse(session.startedAt) > Date.parse(known.exitedAt))) return;
2760
2871
  state.sessions.set(session.id, session);
2761
2872
  renderSessions();
2762
2873
  noticeClone(session);
@@ -2770,17 +2881,25 @@ function dropSession(id) {
2770
2881
  renderSessions();
2771
2882
  }
2772
2883
 
2884
+ /** What happens to a running session when it is stopped, for the confirmation. */
2885
+ function stopNote(s) {
2886
+ const mux = s.multiplexer;
2887
+ return mux ? `${mux.label} keeps running it. Reattach it from its card, or in a terminal with: ${mux.attach}` : `The ${s.provider.tool} process will be ended.`;
2888
+ }
2889
+
2773
2890
  async function stopSession(id) {
2774
2891
  const s = state.sessions.get(id);
2775
2892
  if (!s || s.status !== 'running') return;
2776
- if (!confirm(`Stop "${s.name}"? The ${s.provider.tool} process will be ended.`)) return;
2893
+ if (!confirm(`${s.multiplexer ? 'Detach' : 'Stop'} "${s.name}"? ${stopNote(s)}`)) return;
2777
2894
  try { upsertSession((await api('POST', `/sessions/${id}/stop`)).session); } catch (err) { toast(err.message); }
2778
2895
  }
2779
2896
 
2780
2897
  async function removeSession(id) {
2781
2898
  const s = state.sessions.get(id);
2782
2899
  if (!s) return;
2783
- if (s.status === 'running' && !confirm(`"${s.name}" is still running. End it and remove it?`)) return;
2900
+ // The card goes, so only the terminal can reattach it.
2901
+ const question = s.multiplexer ? `Detach it and remove it? ${s.multiplexer.label} keeps running it. Reattach it in a terminal with: ${s.multiplexer.attach}` : 'End it and remove it?';
2902
+ if (s.status === 'running' && !confirm(`"${s.name}" is still running. ${question}`)) return;
2784
2903
  try { await api('DELETE', `/sessions/${id}`); dropSession(id); } catch (err) { toast(err.message); }
2785
2904
  }
2786
2905
 
@@ -3051,6 +3170,23 @@ function suppressQueryReplies(term) {
3051
3170
  }
3052
3171
  }
3053
3172
 
3173
+ /** The text an OSC 52 sequence ("c;<base64>") asks to copy, or null for a clipboard query or anything unreadable. */
3174
+ function osc52Text(data) {
3175
+ const payload = data.slice(data.indexOf(';') + 1);
3176
+ if (!data.includes(';') || !payload || payload === '?' || payload.length > 4 * 1024 * 1024) return null;
3177
+ try {
3178
+ return new TextDecoder().decode(Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)));
3179
+ } catch {
3180
+ return null;
3181
+ }
3182
+ }
3183
+
3184
+ /** The line a terminal shows when its process ends. A tmux or herdr client's exit code says nothing about the session it showed. */
3185
+ function exitLine(s, { exitCode, signal }) {
3186
+ if (s?.multiplexer) return '[closed]';
3187
+ return `[process exited with ${signal ? `signal ${signal}` : `code ${exitCode ?? 0}`}]`;
3188
+ }
3189
+
3054
3190
  class TerminalView {
3055
3191
  constructor(sessionId) {
3056
3192
  this.id = sessionId;
@@ -3061,6 +3197,8 @@ class TerminalView {
3061
3197
  fontSize: coarsePointer.matches ? 14 : 13,
3062
3198
  scrollback: 5000,
3063
3199
  macOptionIsMeta: true,
3200
+ // Option-drag selects text even while a program reads the mouse, as Shift-drag does elsewhere.
3201
+ macOptionClickForcesSelection: true,
3064
3202
  theme: TERMINAL_THEME,
3065
3203
  });
3066
3204
  this.fit = new window.FitAddon.FitAddon();
@@ -3068,7 +3206,16 @@ class TerminalView {
3068
3206
  this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
3069
3207
  this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
3070
3208
  suppressQueryReplies(this.term);
3071
- this.term.onData((data) => this.send({ type: 'input', data }));
3209
+ // Programs copy with OSC 52: tmux's copy mode, and herdr where it cannot reach the
3210
+ // system clipboard itself. Only the open terminal may write it; nothing reads it.
3211
+ this.term.parser.registerOscHandler(52, (data) => {
3212
+ const text = osc52Text(data);
3213
+ if (text !== null && state.activeId === this.id) navigator.clipboard?.writeText(text).catch(() => {});
3214
+ return true;
3215
+ });
3216
+ this.term.onData((data) => {
3217
+ this.send({ type: 'input', data });
3218
+ });
3072
3219
  this.opened = false;
3073
3220
  this.disposed = false;
3074
3221
  this.retry = 0;
@@ -3116,11 +3263,9 @@ class TerminalView {
3116
3263
  case 'data':
3117
3264
  this.term.write(msg.data);
3118
3265
  break;
3119
- case 'exit': {
3120
- const how = msg.signal ? `signal ${msg.signal}` : `code ${msg.exitCode ?? 0}`;
3121
- this.term.write(`\r\n\x1b[2m[process exited with ${how}]\x1b[0m\r\n`);
3266
+ case 'exit':
3267
+ this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
3122
3268
  break;
3123
- }
3124
3269
  default:
3125
3270
  break;
3126
3271
  }
@@ -3238,7 +3383,7 @@ function updatePanel() {
3238
3383
  $('panel-sub').title = modelTitle(s);
3239
3384
  renderAgents($('panel-agents'), s.agents, s.shells || []);
3240
3385
  const stop = $('panel-stop');
3241
- stop.textContent = s.status === 'running' ? 'Stop' : 'Remove';
3386
+ stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
3242
3387
  }
3243
3388
 
3244
3389
  // ---- stopping the manager -------------------------------------------------
@@ -3324,7 +3469,7 @@ function leaveStopping() {
3324
3469
  function restartGaveUp() {
3325
3470
  if (!state.stopping || !state.restarting) return;
3326
3471
  state.restarting = false;
3327
- setConnection('down', 'Session manager stopped');
3472
+ setConnection('down', state.stopRemaining === null ? 'Manager unavailable' : 'Session manager stopped');
3328
3473
  showStopped('stopped', 'The session manager did not come back',
3329
3474
  `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
3475
  }
@@ -3386,14 +3531,26 @@ function showManagerStopped() {
3386
3531
 
3387
3532
  // ---- events ---------------------------------------------------------------
3388
3533
 
3534
+ function showManagerUnavailable() {
3535
+ setConnection('down', 'Manager unavailable. Trying to reconnect…');
3536
+ if (state.stopping) {
3537
+ clearTimeout(restartTimer);
3538
+ showStopped('stopped', 'Manager unavailable', 'The manager stopped responding before confirming shutdown. Sessions may still be running. This page will reconnect automatically.');
3539
+ }
3540
+ }
3541
+
3389
3542
  function connectEvents() {
3543
+ if (state.pageAway) return;
3390
3544
  const ws = new WebSocket(wsUrl('/events'));
3391
3545
  state.eventsSocket = ws;
3392
3546
  ws.onopen = () => {
3547
+ if (state.pageAway || state.eventsSocket !== ws) return;
3548
+ managerLoss.cancel();
3393
3549
  state.eventsRetry = 0;
3394
3550
  setConnection('ok', 'Connected to session manager');
3395
3551
  };
3396
3552
  ws.onmessage = (event) => {
3553
+ if (state.pageAway || state.eventsSocket !== ws) return;
3397
3554
  const msg = JSON.parse(event.data);
3398
3555
  if (msg.type === 'hello') {
3399
3556
  // The manager is back after a stop or restart; the page picks up where it was.
@@ -3406,9 +3563,10 @@ function connectEvents() {
3406
3563
  setConnection('ok', 'Connected to session manager');
3407
3564
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
3408
3565
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
3566
+ managerConnected(msg);
3409
3567
  renderSessions();
3410
3568
  sessionsShown = true;
3411
- setUpgrade(msg.upgrade);
3569
+ setUpgrade(msg.upgrade, true);
3412
3570
  loadNews();
3413
3571
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
3414
3572
  if ($('changelog').open) loadChangelog();
@@ -3427,6 +3585,7 @@ function connectEvents() {
3427
3585
  enterStopping(0, msg.restart === true);
3428
3586
  state.stopRemaining = Number(msg.remaining) || 0;
3429
3587
  showManagerStopped();
3588
+ managerGone();
3430
3589
  } else if (msg.type === 'session.created' || msg.type === 'session.updated') {
3431
3590
  upsertSession(msg.session);
3432
3591
  } else if (msg.type === 'session.removed') {
@@ -3439,16 +3598,21 @@ function connectEvents() {
3439
3598
  }
3440
3599
  };
3441
3600
  ws.onclose = () => {
3442
- if (state.stopping) {
3601
+ if (state.pageAway || state.eventsSocket !== ws) return;
3602
+ if (state.managerUnavailable) {
3603
+ showManagerUnavailable();
3604
+ } else if (state.stopping && state.stopRemaining !== null) {
3443
3605
  showManagerStopped();
3444
3606
  } else {
3445
- setConnection('down', 'Session manager not reachable. Run "agent-guild open" to start it.');
3607
+ setConnection('down', 'Connection interrupted. Trying to reconnect…');
3446
3608
  }
3609
+ managerLoss.disconnected();
3447
3610
  // Keep trying: after a stop, a relaunched manager brings the page back by itself.
3448
3611
  const delay = Math.min(5000, 500 * 2 ** state.eventsRetry++);
3449
3612
  setTimeout(async () => {
3613
+ if (state.pageAway || state.eventsSocket !== ws) return;
3450
3614
  try { await loadProviders(); } catch (err) { if (err instanceof AuthError) return showAuth(err.message); }
3451
- connectEvents();
3615
+ if (!state.pageAway && state.eventsSocket === ws) connectEvents();
3452
3616
  }, delay);
3453
3617
  };
3454
3618
  }
@@ -3476,6 +3640,7 @@ let statsInterval;
3476
3640
  let newsTimer;
3477
3641
 
3478
3642
  function showAuth(message = '') {
3643
+ managerLoss.cancel();
3479
3644
  closeModels();
3480
3645
  closeNews();
3481
3646
  closeChangelog();
@@ -3611,6 +3776,16 @@ $('news').addEventListener('close', () => {
3611
3776
  document.addEventListener('visibilitychange', () => {
3612
3777
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
3613
3778
  });
3779
+ addEventListener('pagehide', () => {
3780
+ state.pageAway = true;
3781
+ managerLoss.cancel();
3782
+ state.eventsSocket?.close();
3783
+ });
3784
+ addEventListener('pageshow', (event) => {
3785
+ if (!event.persisted) return;
3786
+ state.pageAway = false;
3787
+ connectEvents();
3788
+ });
3614
3789
  $('version').addEventListener('click', openChangelog);
3615
3790
  $('changelog-close').addEventListener('click', closeChangelog);
3616
3791
  $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
@@ -3629,6 +3804,7 @@ $('changelog-restart').addEventListener('click', () => {
3629
3804
  });
3630
3805
  addEventListener('storage', (e) => {
3631
3806
  if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3807
+ else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
3632
3808
  // Another tab reordered the cards.
3633
3809
  else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3634
3810
  });
@@ -3674,6 +3850,7 @@ $('upgrade').addEventListener('click', upgradeManager);
3674
3850
  $('appearance-menu').addEventListener('change', (e) => {
3675
3851
  if (e.target.name === 'skin') changeSkin(e.target);
3676
3852
  else if (e.target.name === 'theme') changeTheme(e.target);
3853
+ else if (e.target.name === 'sound') changeSound(e.target);
3677
3854
  });
3678
3855
  $('appearance-menu').addEventListener('toggle', (e) => {
3679
3856
  if (e.newState !== 'open') return;
@@ -3707,6 +3884,7 @@ $('providers').addEventListener('pointerout', (e) => {
3707
3884
  });
3708
3885
  applyTheme(currentTheme());
3709
3886
  renderSkinChoices();
3887
+ $('sound').checked = soundOn();
3710
3888
  // Follow the system setting until the user picks a theme.
3711
3889
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
3712
3890
  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-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");
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 {