@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/README.md +31 -2
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +9 -6
- package/src/manager/config.mjs +21 -0
- package/src/manager/herdr.mjs +156 -0
- package/src/manager/main.mjs +6 -1
- package/src/manager/providers.mjs +1 -1
- package/src/manager/server.mjs +9 -4
- package/src/manager/session-manager.mjs +229 -16
- package/src/manager/session.mjs +53 -15
- package/src/manager/shells.mjs +63 -7
- package/web/alerts.js +155 -0
- package/web/app.js +196 -18
- 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/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 =
|
|
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')
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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', '
|
|
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-
|
|
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 {
|