ai-agentmanager 0.0.0-stage → 1.0.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +767 -2
- package/SECURITY.md +44 -0
- package/bin/agentmgr-hub.js +16 -0
- package/bin/agentmgr.js +4 -0
- package/docs/architecture.md +67 -0
- package/docs/distributed.md +147 -0
- package/docs/engines.md +177 -0
- package/docs/images/agents.jpg +0 -0
- package/docs/images/conflict-radar.jpg +0 -0
- package/docs/plugins.md +81 -0
- package/package.json +56 -4
- package/public/app.js +3083 -0
- package/public/hub/hub.css +230 -0
- package/public/hub/hub.js +766 -0
- package/public/hub/index.html +146 -0
- package/public/hub/term.js +147 -0
- package/public/index.html +342 -0
- package/public/share.html +34 -0
- package/public/share.js +41 -0
- package/public/style.css +541 -0
- package/public/touchscroll.js +73 -0
- package/public/transport.js +78 -0
- package/scripts/build-release.sh +58 -0
- package/scripts/fix-pty.js +15 -0
- package/src/backlog.js +413 -0
- package/src/brief.js +512 -0
- package/src/cli.js +1447 -0
- package/src/client.js +49 -0
- package/src/config.js +109 -0
- package/src/conflicts.js +105 -0
- package/src/engines/antigravity.js +296 -0
- package/src/engines/claude.js +98 -0
- package/src/engines/codex.js +331 -0
- package/src/engines/index.js +37 -0
- package/src/fleet.js +176 -0
- package/src/git.js +340 -0
- package/src/hub.js +857 -0
- package/src/hubactions.js +107 -0
- package/src/hubbacklog.js +373 -0
- package/src/hublink.js +437 -0
- package/src/inbox.js +94 -0
- package/src/judge.js +278 -0
- package/src/livebrowser.js +637 -0
- package/src/manager.js +2554 -0
- package/src/notify.js +284 -0
- package/src/plugins.js +119 -0
- package/src/preview.js +334 -0
- package/src/races.js +192 -0
- package/src/remote.js +284 -0
- package/src/server.js +759 -0
- package/src/service.js +150 -0
- package/src/sessions.js +160 -0
- package/src/store.js +104 -0
- package/src/stuck.js +160 -0
- package/src/tracker.js +311 -0
- package/src/trust.js +44 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// Hub UI: every machine's agents and what they're waiting on, and (where a machine allows
|
|
2
|
+
// it) answering, messaging, starting and stopping them, new agents, and live terminals.
|
|
3
|
+
// Everything shown comes from runners, so it is only ever set as text, never as HTML.
|
|
4
|
+
import { closeTerminal, openTerminal, syncTerminal } from './term.js';
|
|
5
|
+
const $ = (sel) => document.querySelector(sel);
|
|
6
|
+
const el = (tag, attrs = {}, ...children) => {
|
|
7
|
+
const n = document.createElement(tag);
|
|
8
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
9
|
+
if (v == null || v === false) continue;
|
|
10
|
+
if (k === 'class') n.className = v;
|
|
11
|
+
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
|
12
|
+
else n.setAttribute(k, v === true ? '' : v);
|
|
13
|
+
}
|
|
14
|
+
for (const c of children.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c));
|
|
15
|
+
return n;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const state = { fleet: null, tab: 'inbox', machine: null, query: '', confirmRemove: null, open: new Set() };
|
|
19
|
+
const drafts = new Map(); // typed text survives live re-renders
|
|
20
|
+
const busy = new Set(); // actions in flight, by key
|
|
21
|
+
try {
|
|
22
|
+
state.tab = localStorage.getItem('hub.tab') || 'inbox';
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
async function call(method, route, body) {
|
|
26
|
+
const res = await fetch(`/api${route}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers: { 'content-type': 'application/json', 'x-agentmgr': '1' },
|
|
29
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
const data = await res.json().catch(() => ({}));
|
|
32
|
+
if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toast(msg, bad = false) {
|
|
37
|
+
const t = $('#toast');
|
|
38
|
+
t.textContent = msg;
|
|
39
|
+
t.className = `toast${bad ? ' bad' : ''}`;
|
|
40
|
+
t.hidden = false;
|
|
41
|
+
clearTimeout(toast.timer);
|
|
42
|
+
toast.timer = setTimeout(() => (t.hidden = true), bad ? 6000 : 2500);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ENGINE_LABEL = { claude: 'Claude Code', codex: 'Codex', antigravity: 'Antigravity' };
|
|
46
|
+
/** Engines a machine reports installed (its engine:* labels). */
|
|
47
|
+
const enginesOf = (runner) => (runner?.labels ?? []).filter((l) => l.startsWith('engine:')).map((l) => l.slice(7));
|
|
48
|
+
/** Fill an engine picker; hidden while Claude Code is the only choice. */
|
|
49
|
+
function fillEngines(field, select, engines) {
|
|
50
|
+
const prev = select.value;
|
|
51
|
+
const list = [...new Set(['claude', ...engines])];
|
|
52
|
+
select.replaceChildren(...list.map((e) => el('option', { value: e === 'claude' ? '' : e }, ENGINE_LABEL[e] ?? e)));
|
|
53
|
+
if ([...select.options].some((o) => o.value === prev)) select.value = prev;
|
|
54
|
+
$(field).hidden = list.length < 2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allows = (runner, perm) => Boolean(runner?.online && runner.policy?.allow?.includes(perm));
|
|
58
|
+
|
|
59
|
+
/** Run an action on a machine; the button shows progress, and the result comes as a toast. */
|
|
60
|
+
async function act(runnerId, action, args, { key = `${runnerId}:${action}:${args.agent ?? ''}`, done } = {}) {
|
|
61
|
+
if (busy.has(key)) return;
|
|
62
|
+
busy.add(key);
|
|
63
|
+
render(true);
|
|
64
|
+
try {
|
|
65
|
+
await call('POST', `/runners/${encodeURIComponent(runnerId)}/actions`, { action, args });
|
|
66
|
+
toast(done ?? 'Done');
|
|
67
|
+
return true;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
toast(err.message, true);
|
|
70
|
+
return false;
|
|
71
|
+
} finally {
|
|
72
|
+
busy.delete(key);
|
|
73
|
+
render(true);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const btn = (label, onclick, { primary = false, key, title, cls = '' } = {}) =>
|
|
78
|
+
el('button', { class: `${primary ? 'primary ' : ''}${cls}`.trim() || null, disabled: key && busy.has(key), title, onclick: (e) => (e.stopPropagation(), onclick(e)) }, label);
|
|
79
|
+
|
|
80
|
+
/** A text box whose value survives re-renders (drafts are keyed). */
|
|
81
|
+
function draftInput(key, { placeholder, area = false, initial = '' } = {}) {
|
|
82
|
+
const box = el(area ? 'textarea' : 'input', { placeholder, rows: area ? 3 : null, 'data-draft': key });
|
|
83
|
+
box.value = drafts.get(key) ?? initial;
|
|
84
|
+
box.addEventListener('input', () => drafts.set(key, box.value));
|
|
85
|
+
return box;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------------ time
|
|
89
|
+
|
|
90
|
+
function ago(iso) {
|
|
91
|
+
if (!iso) return '';
|
|
92
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
93
|
+
if (s < 60) return 'just now';
|
|
94
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
95
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
96
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------------ sign in
|
|
100
|
+
|
|
101
|
+
async function boot() {
|
|
102
|
+
const login = location.hash.match(/^#login=(.+)$/);
|
|
103
|
+
if (login) {
|
|
104
|
+
history.replaceState(null, '', location.pathname); // keep the token out of history
|
|
105
|
+
await call('POST', '/login', { token: decodeURIComponent(login[1]) }).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
const failed = location.hash.match(/^#error=(.+)$/);
|
|
108
|
+
if (failed) history.replaceState(null, '', location.pathname);
|
|
109
|
+
const s = await call('GET', '/session').catch(() => ({ authenticated: false }));
|
|
110
|
+
if (s.name) $('#hub-name').textContent = s.name;
|
|
111
|
+
// With Google set up, the token is the fallback, folded away.
|
|
112
|
+
$('#google-btn').hidden = !s.google;
|
|
113
|
+
$('#token-box').open = !s.google;
|
|
114
|
+
$('#token-summary').textContent = s.google ? 'Or use the access token' : 'Use the access token';
|
|
115
|
+
if (!s.authenticated) return showLogin(failed ? decodeURIComponent(failed[1]) : undefined);
|
|
116
|
+
showApp();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function showLogin(message) {
|
|
120
|
+
$('#app').hidden = true;
|
|
121
|
+
$('#top-actions').hidden = true;
|
|
122
|
+
$('#login').hidden = false;
|
|
123
|
+
$('#login-error').hidden = !message;
|
|
124
|
+
$('#login-error').textContent = message ?? '';
|
|
125
|
+
if ($('#token-box').open) $('#token').focus();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
$('#login-form').addEventListener('submit', async (e) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
try {
|
|
131
|
+
await call('POST', '/login', { token: $('#token').value.trim() });
|
|
132
|
+
$('#token').value = '';
|
|
133
|
+
showApp();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
showLogin(err.message);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
$('#logout-btn').addEventListener('click', async () => {
|
|
140
|
+
await call('POST', '/logout').catch(() => {});
|
|
141
|
+
stream?.close();
|
|
142
|
+
closeTerminal();
|
|
143
|
+
showLogin();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------------ live data
|
|
147
|
+
|
|
148
|
+
let stream = null;
|
|
149
|
+
function showApp() {
|
|
150
|
+
$('#login').hidden = true;
|
|
151
|
+
$('#app').hidden = false;
|
|
152
|
+
$('#top-actions').hidden = false;
|
|
153
|
+
connect();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function connect() {
|
|
157
|
+
stream?.close();
|
|
158
|
+
stream = new EventSource('/api/events');
|
|
159
|
+
stream.onopen = () => setConn(true);
|
|
160
|
+
stream.onmessage = (e) => {
|
|
161
|
+
const msg = JSON.parse(e.data);
|
|
162
|
+
if (msg.t === 'fleet') {
|
|
163
|
+
state.fleet = msg.fleet;
|
|
164
|
+
render();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
stream.onerror = async () => {
|
|
168
|
+
setConn(false);
|
|
169
|
+
// EventSource retries by itself; a signed-out session would retry forever, so check.
|
|
170
|
+
const s = await call('GET', '/session').catch(() => null);
|
|
171
|
+
if (s && !s.authenticated) {
|
|
172
|
+
stream.close();
|
|
173
|
+
showLogin('Signed out; sign in again');
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setConn(ok) {
|
|
179
|
+
$('#conn').className = `conn ${ok ? 'ok' : 'bad'}`;
|
|
180
|
+
$('#conn').textContent = ok ? 'live' : 'reconnecting…';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------------ render
|
|
184
|
+
|
|
185
|
+
let deferred = false;
|
|
186
|
+
function render(force = false) {
|
|
187
|
+
const f = state.fleet;
|
|
188
|
+
if (!f) return;
|
|
189
|
+
// Don't rebuild the list under someone typing; catch up when they leave the box.
|
|
190
|
+
const typing = document.activeElement?.dataset?.draft;
|
|
191
|
+
if (typing && !force) {
|
|
192
|
+
deferred = true;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
deferred = false;
|
|
196
|
+
const focus = typing ? { key: typing, at: document.activeElement.selectionStart } : null;
|
|
197
|
+
syncTerminal(f);
|
|
198
|
+
const canCreate = f.runners.some((r) => allows(r, 'create') && r.projects?.length);
|
|
199
|
+
$('#new-btn').hidden = !canCreate;
|
|
200
|
+
$('#hub-name').textContent = f.hub.name;
|
|
201
|
+
if (state.machine && !f.runners.some((r) => r.id === state.machine)) state.machine = null;
|
|
202
|
+
renderMachines(f);
|
|
203
|
+
const inbox = filtered(f.inbox, (i) => [i.agentName, i.title, i.project, i.runnerName]);
|
|
204
|
+
const agents = filtered(f.agents, (a) => [a.name, a.intent, a.project, a.branch, a.runnerName, a.id]);
|
|
205
|
+
$('#inbox-count').textContent = f.inbox.length;
|
|
206
|
+
$('#inbox-count').classList.toggle('hot', f.inbox.some((i) => i.online));
|
|
207
|
+
$('#agents-count').textContent = f.agents.length;
|
|
208
|
+
const open = f.backlog.items.filter((x) => OPEN_ITEM.has(x.status));
|
|
209
|
+
$('#backlog-count').textContent = open.length;
|
|
210
|
+
$('#backlog-count').classList.toggle('hot', open.some((x) => x.status === 'review' || x.stalled || x.status === 'blocked'));
|
|
211
|
+
for (const b of document.querySelectorAll('[role="tab"]')) b.classList.toggle('active', b.dataset.tab === state.tab);
|
|
212
|
+
$('#tab-inbox').hidden = state.tab !== 'inbox';
|
|
213
|
+
$('#tab-agents').hidden = state.tab !== 'agents';
|
|
214
|
+
$('#tab-backlog').hidden = state.tab !== 'backlog';
|
|
215
|
+
const chip = $('#filter-chip');
|
|
216
|
+
const m = f.runners.find((r) => r.id === state.machine);
|
|
217
|
+
chip.hidden = !m;
|
|
218
|
+
chip.className = 'chip clear';
|
|
219
|
+
chip.textContent = m ? `on ${m.name}` : '';
|
|
220
|
+
if (state.tab === 'inbox') renderInbox(inbox, f);
|
|
221
|
+
else if (state.tab === 'backlog') renderBacklog(f);
|
|
222
|
+
else renderAgents(agents, f);
|
|
223
|
+
if (focus) {
|
|
224
|
+
const box = document.querySelector(`[data-draft="${CSS.escape(focus.key)}"]`);
|
|
225
|
+
box?.focus();
|
|
226
|
+
box?.setSelectionRange?.(focus.at, focus.at);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
document.addEventListener('focusout', () => setTimeout(() => deferred && render(), 0));
|
|
230
|
+
|
|
231
|
+
function filtered(items, fields) {
|
|
232
|
+
const q = state.query.toLowerCase();
|
|
233
|
+
return items.filter(
|
|
234
|
+
(x) => (!state.machine || x.runnerId === state.machine) && (!q || fields(x).some((v) => String(v ?? '').toLowerCase().includes(q))),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderMachines(f) {
|
|
239
|
+
const box = $('#machines');
|
|
240
|
+
box.replaceChildren();
|
|
241
|
+
if (!f.runners.length) {
|
|
242
|
+
box.append(
|
|
243
|
+
el(
|
|
244
|
+
'div',
|
|
245
|
+
{ class: 'card empty-machines' },
|
|
246
|
+
el('p', {}, el('b', {}, 'No machines yet.')),
|
|
247
|
+
el('p', { class: 'muted' }, 'Add your first Mac: click ', el('b', {}, 'Add machine'), ' and run the command it shows on that Mac.'),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
for (const r of f.runners) {
|
|
253
|
+
const confirming = state.confirmRemove === r.id;
|
|
254
|
+
const card = el(
|
|
255
|
+
'div',
|
|
256
|
+
{
|
|
257
|
+
class: `machine${r.online ? '' : ' offline'}${state.machine === r.id ? ' selected' : ''}`,
|
|
258
|
+
role: 'button',
|
|
259
|
+
tabindex: '0',
|
|
260
|
+
title: state.machine === r.id ? 'Show all machines' : `Show only ${r.name}`,
|
|
261
|
+
onclick: () => {
|
|
262
|
+
state.machine = state.machine === r.id ? null : r.id;
|
|
263
|
+
render();
|
|
264
|
+
},
|
|
265
|
+
onkeydown: (e) => e.key === 'Enter' && e.currentTarget.click(),
|
|
266
|
+
},
|
|
267
|
+
el('div', { class: 'm-head' }, el('span', { class: `dot${r.online ? ' on' : ''}` }), el('span', { class: 'm-name' }, r.name), el('span', { class: 'muted small' }, r.online ? 'online' : `offline${r.lastSeen ? ` · ${ago(r.lastSeen)}` : ''}`)),
|
|
268
|
+
el(
|
|
269
|
+
'div',
|
|
270
|
+
{ class: 'm-stats' },
|
|
271
|
+
el('span', {}, el('b', {}, r.agents), ' agents'),
|
|
272
|
+
el('span', {}, el('b', {}, r.working), ' working'),
|
|
273
|
+
el('span', { class: r.needs ? 'needs' : '' }, el('b', {}, r.needs), ' need you'),
|
|
274
|
+
),
|
|
275
|
+
el(
|
|
276
|
+
'div',
|
|
277
|
+
{ class: 'm-foot' },
|
|
278
|
+
el('span', { class: 'chips' }, ...(r.policy?.allow ?? []).map((p) => el('span', { class: 'chip', title: 'What this machine lets the hub do' }, p)), r.version ? el('span', { class: 'chip' }, `v${r.version}`) : null),
|
|
279
|
+
el(
|
|
280
|
+
'span',
|
|
281
|
+
{ class: 'chips' },
|
|
282
|
+
openLink(r, null, 'Open'),
|
|
283
|
+
confirming
|
|
284
|
+
? el('button', { class: 'link', onclick: (e) => (e.stopPropagation(), removeMachine(r)) }, 'Remove for sure?')
|
|
285
|
+
: el('button', { class: 'link', title: 'Remove from the hub', onclick: (e) => (e.stopPropagation(), (state.confirmRemove = r.id), render(), setTimeout(() => { if (state.confirmRemove === r.id) { state.confirmRemove = null; render(); } }, 4000)) }, 'Remove'),
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
box.append(card);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function removeMachine(r) {
|
|
294
|
+
state.confirmRemove = null;
|
|
295
|
+
await call('DELETE', `/runners/${encodeURIComponent(r.id)}`).catch((err) => alertLine(err.message));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function alertLine(msg) {
|
|
299
|
+
const p = el('p', { class: 'error' }, msg);
|
|
300
|
+
$('#app').prepend(p);
|
|
301
|
+
setTimeout(() => p.remove(), 5000);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** "Open" goes to the machine's own UI (remote access), at the agent's terminal. */
|
|
305
|
+
function openLink(runner, agentId, label = `Open on ${runner?.name ?? 'machine'}`) {
|
|
306
|
+
const u = runner?.urls?.[0]?.url;
|
|
307
|
+
if (!u) {
|
|
308
|
+
return el('span', { class: 'open disabled', title: `Turn on remote access on ${runner?.name ?? 'that machine'} (agentmgr remote on) to open it from here` }, label);
|
|
309
|
+
}
|
|
310
|
+
const base = `${u.replace(/\/$/, '')}/`;
|
|
311
|
+
const go = agentId ? `${encodeURIComponent(agentId)}/term` : '';
|
|
312
|
+
const href = `${base}${go ? `#${go}` : ''}`;
|
|
313
|
+
const onclick = (e) => {
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
if (!allows(runner, 'signin') || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
316
|
+
// Signed in on the way: the machine hands out a one-time ticket for its own page.
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
const w = window.open('about:blank', '_blank');
|
|
319
|
+
if (w) w.opener = null;
|
|
320
|
+
call('POST', `/runners/${encodeURIComponent(runner.id)}/actions`, { action: 'signin', args: {} })
|
|
321
|
+
.then((r) => `${base}#ticket=${encodeURIComponent(r.ticket)}${go ? `&go=${encodeURIComponent(decodeURIComponent(go))}` : ''}`)
|
|
322
|
+
.catch((err) => (toast(`Opening without sign-in: ${err.message}`, true), href))
|
|
323
|
+
.then((url) => (w ? (w.location.href = url) : (location.href = url)));
|
|
324
|
+
};
|
|
325
|
+
return el('a', { class: 'open', href, target: '_blank', rel: 'noopener noreferrer', onclick, title: allows(runner, 'signin') ? 'Opens signed in (one-time link)' : `Signs in with ${runner.name}'s own access token. Allow "signin" on it to skip that.` }, `${label} ↗`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const runnerOf = (f, id) => f.runners.find((r) => r.id === id);
|
|
329
|
+
const KIND_LABEL = { prompt: 'Needs input', question: 'Question', stuck: 'Stuck?', conflict: 'Overlap' };
|
|
330
|
+
|
|
331
|
+
function renderInbox(items, f) {
|
|
332
|
+
const box = $('#tab-inbox');
|
|
333
|
+
box.replaceChildren();
|
|
334
|
+
if (!items.length) {
|
|
335
|
+
box.append(el('div', { class: 'empty' }, f.inbox.length ? 'Nothing matches the filter.' : f.runners.length ? 'Nothing needs you right now. 🎉' : 'Add a machine to see what its agents need.'));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
for (const it of items) {
|
|
339
|
+
const runner = runnerOf(f, it.runnerId);
|
|
340
|
+
const sub =
|
|
341
|
+
it.kind === 'stuck'
|
|
342
|
+
? it.detail
|
|
343
|
+
: it.kind === 'conflict'
|
|
344
|
+
? `Same files as ${it.with}: ${(it.files ?? []).join(', ')}${it.mergeFirst ? ` · suggested: ${it.mergeFirst === it.agentId ? it.agentName : it.with} lands first` : ''}`
|
|
345
|
+
: it.kind === 'question'
|
|
346
|
+
? [it.question?.context, it.question?.default && `Proceeding with ${it.question.default} until you answer.`, !allows(runner, 'inbox') && it.question?.options?.length && `Options: ${it.question.options.join(' · ')}`].filter(Boolean).join(' ') || null
|
|
347
|
+
: null;
|
|
348
|
+
box.append(
|
|
349
|
+
el(
|
|
350
|
+
'div',
|
|
351
|
+
{ class: `row${it.online ? '' : ' offline'}` },
|
|
352
|
+
el('div', { class: 'r-title' }, el('span', { class: `kind ${it.kind}` }, KIND_LABEL[it.kind]), el('span', { class: 'tag machine' }, it.runnerName), el('span', { class: 'name' }, it.agentName), it.priority <= 1 ? el('span', { class: `tag p${it.priority}` }, `P${it.priority}`) : null, it.project ? el('span', { class: 'tag' }, it.project) : null),
|
|
353
|
+
el('div', { class: 'r-sub' }, it.title),
|
|
354
|
+
sub ? el('div', { class: 'r-meta' }, sub) : null,
|
|
355
|
+
allows(runner, 'inbox') ? inboxActions(it, runner) : it.kind === 'prompt' && it.choices?.length ? el('div', { class: 'choices' }, ...it.choices.map((c) => el('span', {}, `${c.key}. ${c.label}`))) : null,
|
|
356
|
+
el('div', { class: 'r-actions' }, it.at ? el('span', { class: 'muted small' }, ago(it.at)) : null, termButton(runner, it.agentId, it.agentName), openLink(runner, it.agentId)),
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function statusOf(a) {
|
|
363
|
+
if (!a.online) return ['offline', 'machine offline'];
|
|
364
|
+
if (a.attention) return ['attention', 'needs input'];
|
|
365
|
+
if (a.planning) return ['planning', 'planning'];
|
|
366
|
+
if (a.status === 'running') return a.working ? ['working', 'working'] : ['waiting', 'waiting'];
|
|
367
|
+
return [a.status, a.status];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderAgents(agents, f) {
|
|
371
|
+
const box = $('#tab-agents');
|
|
372
|
+
box.replaceChildren();
|
|
373
|
+
if (!agents.length) {
|
|
374
|
+
box.append(el('div', { class: 'empty' }, f.agents.length ? 'Nothing matches the filter.' : 'No agents on any machine yet.'));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const rank = (a) => (a.attention ? 0 : a.working ? 1 : a.status === 'running' ? 2 : 3);
|
|
378
|
+
const byMachine = new Map();
|
|
379
|
+
for (const a of agents) {
|
|
380
|
+
if (!byMachine.has(a.runnerId)) byMachine.set(a.runnerId, []);
|
|
381
|
+
byMachine.get(a.runnerId).push(a);
|
|
382
|
+
}
|
|
383
|
+
for (const [rid, list] of byMachine) {
|
|
384
|
+
const runner = runnerOf(f, rid);
|
|
385
|
+
box.append(el('div', { class: 'group-title' }, el('span', { class: `dot${runner?.online ? ' on' : ''}` }), runner?.name ?? rid, el('span', { class: 'count' }, list.length)));
|
|
386
|
+
list.sort((a, b) => b.online - a.online || rank(a) - rank(b) || a.priority - b.priority || String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
387
|
+
for (const a of list) {
|
|
388
|
+
const [cls, label] = statusOf(a);
|
|
389
|
+
const needs = [a.questions && `${a.questions} question${a.questions > 1 ? 's' : ''}`, a.stuck && 'stuck?', a.conflicts && 'overlap'].filter(Boolean);
|
|
390
|
+
box.append(
|
|
391
|
+
el(
|
|
392
|
+
'div',
|
|
393
|
+
{ class: `row${a.online ? '' : ' offline'}` },
|
|
394
|
+
el('div', { class: 'r-title' }, el('span', { class: `pill ${cls}` }, label), el('span', { class: 'name' }, a.name), a.priority <= 1 ? el('span', { class: `tag p${a.priority}` }, `P${a.priority}`) : null, a.project ? el('span', { class: 'tag' }, a.project) : null, a.engine && a.engine !== 'claude' ? el('span', { class: 'tag label' }, ENGINE_LABEL[a.engine] ?? a.engine) : null, ...needs.map((n) => el('span', { class: 'kind stuck' }, n))),
|
|
395
|
+
a.intent ? el('div', { class: 'r-sub' }, a.intent.length > 220 ? `${a.intent.slice(0, 219)}…` : a.intent) : null,
|
|
396
|
+
el(
|
|
397
|
+
'div',
|
|
398
|
+
{ class: 'r-meta' },
|
|
399
|
+
el('span', { class: 'mono' }, `${a.runnerName}/${a.id}`),
|
|
400
|
+
a.branch ? el('span', { class: 'branch', title: a.branch }, a.branch) : null,
|
|
401
|
+
...a.prs.map((p) => el('a', { href: p.url, target: '_blank', rel: 'noopener noreferrer', class: `tag ${String(p.state ?? '').toLowerCase()}`, title: p.title ?? '' }, `#${p.number ?? '?'}${p.state ? ` ${p.state.toLowerCase()}` : ''}`)),
|
|
402
|
+
a.outcome && a.outcome !== 'in progress' ? el('span', {}, a.outcome) : null,
|
|
403
|
+
a.costUsd != null ? el('span', {}, `$${a.costUsd.toFixed(2)}`) : null,
|
|
404
|
+
a.lastOutputAt || a.updatedAt ? el('span', {}, `active ${ago(a.lastOutputAt ?? a.updatedAt)}`) : null,
|
|
405
|
+
a.error ? el('span', { class: 'error' }, a.error) : null,
|
|
406
|
+
),
|
|
407
|
+
el('div', { class: 'r-actions' }, termButton(runner, a.id, a.name), openLink(runner, a.id)),
|
|
408
|
+
allows(runner, 'control') && a.online ? agentActions(a, runner) : null,
|
|
409
|
+
state.open.has(`tell:${a.runnerId}:${a.id}`) ? tellForm(a) : null,
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ------------------------------------------------------------------ backlog
|
|
417
|
+
|
|
418
|
+
const OPEN_ITEM = new Set(['queued', 'active', 'review', 'blocked']);
|
|
419
|
+
const ITEM_RANK = { review: 0, blocked: 1, active: 2, queued: 3, done: 4, cancelled: 5 };
|
|
420
|
+
|
|
421
|
+
/** A backlog operation; the button shows progress and the result comes as a toast. */
|
|
422
|
+
async function itemOp(it, op, body, done) {
|
|
423
|
+
const key = `item:${it.id}:${op}`;
|
|
424
|
+
if (busy.has(key)) return false;
|
|
425
|
+
busy.add(key);
|
|
426
|
+
render(true);
|
|
427
|
+
try {
|
|
428
|
+
if (op === 'remove') await call('DELETE', `/backlog/${encodeURIComponent(it.id)}`);
|
|
429
|
+
else await call('POST', `/backlog/${encodeURIComponent(it.id)}/${op}`, body ?? {});
|
|
430
|
+
toast(done);
|
|
431
|
+
return true;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
toast(err.message, true);
|
|
434
|
+
return false;
|
|
435
|
+
} finally {
|
|
436
|
+
busy.delete(key);
|
|
437
|
+
render(true);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function itemPill(it) {
|
|
442
|
+
if (it.stalled) return ['attention', `stalled on ${it.runnerName ?? 'a machine'}`];
|
|
443
|
+
if (it.status === 'active') return it.agent?.attention ? ['attention', 'needs input'] : it.agent?.working ? ['working', 'working'] : ['waiting', 'active'];
|
|
444
|
+
if (it.status === 'review') return ['attention', 'review'];
|
|
445
|
+
if (it.status === 'blocked') return ['error', 'blocked'];
|
|
446
|
+
return [it.status, it.status];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderBacklog(f) {
|
|
450
|
+
const box = $('#tab-backlog');
|
|
451
|
+
box.replaceChildren();
|
|
452
|
+
const bl = f.backlog;
|
|
453
|
+
const q = state.query.toLowerCase();
|
|
454
|
+
const items = bl.items
|
|
455
|
+
.filter((x) => (!state.machine || x.runnerId === state.machine) && (!q || [x.title, x.projectName, x.runnerName, x.id, ...x.labels].some((v) => String(v ?? '').toLowerCase().includes(q))))
|
|
456
|
+
.sort((a, b) => (a.stalled ? 1 : ITEM_RANK[a.status]) - (b.stalled ? 1 : ITEM_RANK[b.status]) || a.priority - b.priority || a.order - b.order);
|
|
457
|
+
box.append(
|
|
458
|
+
el(
|
|
459
|
+
'div',
|
|
460
|
+
{ class: 'backlog-head' },
|
|
461
|
+
bl.projects.length ? btn('Add work', openQueue, { primary: true }) : el('span', { class: 'muted small' }, 'To queue work, a machine must allow "create" in its hub settings.'),
|
|
462
|
+
el(
|
|
463
|
+
'label',
|
|
464
|
+
{ class: 'toggle', title: 'Start queued items automatically on machines with room' },
|
|
465
|
+
el('input', {
|
|
466
|
+
type: 'checkbox',
|
|
467
|
+
checked: bl.autoRun,
|
|
468
|
+
onchange: (e) => call('POST', '/backlog/settings', { autoRun: e.target.checked }).catch((err) => toast(err.message, true)),
|
|
469
|
+
}),
|
|
470
|
+
'Auto-start',
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
if (!items.length) {
|
|
475
|
+
box.append(el('div', { class: 'empty' }, bl.items.length ? 'Nothing matches the filter.' : 'Nothing queued. Work added here starts on whichever machine has room.'));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const finished = items.filter((x) => !OPEN_ITEM.has(x.status));
|
|
479
|
+
const shown = [...items.filter((x) => OPEN_ITEM.has(x.status)), ...finished.slice(0, state.open.has('backlog:done') ? finished.length : 5)];
|
|
480
|
+
for (const it of shown) {
|
|
481
|
+
const runner = it.runnerId ? runnerOf(f, it.runnerId) : null;
|
|
482
|
+
const [cls, label] = itemPill(it);
|
|
483
|
+
box.append(
|
|
484
|
+
el(
|
|
485
|
+
'div',
|
|
486
|
+
{ class: `row${OPEN_ITEM.has(it.status) ? '' : ' offline'}` },
|
|
487
|
+
el('div', { class: 'r-title' }, el('span', { class: `pill ${cls}` }, label), el('span', { class: 'name' }, it.title.length > 160 ? `${it.title.slice(0, 159)}…` : it.title), it.priority <= 1 ? el('span', { class: `tag p${it.priority}` }, `P${it.priority}`) : null, el('span', { class: 'tag' }, it.projectName), ...it.labels.map((l) => el('span', { class: 'tag label' }, l))),
|
|
488
|
+
el(
|
|
489
|
+
'div',
|
|
490
|
+
{ class: 'r-meta' },
|
|
491
|
+
el('span', { class: 'mono' }, it.agentId ? `${it.runnerName}/${it.agentId}` : it.id),
|
|
492
|
+
it.waiting ? el('span', {}, `waiting: ${it.waiting}`) : null,
|
|
493
|
+
it.stalled ? el('span', {}, 'Its machine is offline. Wait for it, or requeue to start over on another machine.') : null,
|
|
494
|
+
it.error ? el('span', { class: 'error' }, it.error) : null,
|
|
495
|
+
el('span', {}, ago(it.doneAt ?? it.reviewAt ?? it.startedAt ?? it.createdAt)),
|
|
496
|
+
),
|
|
497
|
+
itemActions(it, runner),
|
|
498
|
+
state.open.has(`reopen:${it.id}`) ? reopenForm(it) : null,
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (finished.length > 5) {
|
|
503
|
+
box.append(btn(state.open.has('backlog:done') ? 'Show fewer' : `Show all ${finished.length} finished`, () => (state.open.has('backlog:done') ? state.open.delete('backlog:done') : state.open.add('backlog:done'), render(true)), { cls: 'ghost' }));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function itemActions(it, runner) {
|
|
508
|
+
const k = (op) => `item:${it.id}:${op}`;
|
|
509
|
+
const b = [];
|
|
510
|
+
if (it.status === 'queued') {
|
|
511
|
+
b.push(btn('Start now', () => itemOp(it, 'start', {}, 'Starting'), { key: k('start'), title: 'Start it now, even past the machines\' limits' }));
|
|
512
|
+
b.push(btn('Remove', () => itemOp(it, 'remove', null, 'Removed'), { key: k('remove'), cls: 'ghost' }));
|
|
513
|
+
}
|
|
514
|
+
if (it.status === 'review') {
|
|
515
|
+
b.push(btn('Accept', () => itemOp(it, 'accept', {}, 'Done'), { primary: true, key: k('accept') }));
|
|
516
|
+
if (allows(runner, 'control')) b.push(btn('Reopen…', () => (state.open.has(`reopen:${it.id}`) ? state.open.delete(`reopen:${it.id}`) : state.open.add(`reopen:${it.id}`), render(true)), { title: 'Send it back to the same agent with a note' }));
|
|
517
|
+
}
|
|
518
|
+
if (it.stalled || it.status === 'blocked' || it.status === 'cancelled') b.push(btn('Requeue', () => itemOp(it, 'requeue', {}, 'Back in the queue'), { key: k('requeue'), title: it.runnerName ? `Start over on another machine than ${it.runnerName}` : 'Back in the queue' }));
|
|
519
|
+
if (it.status === 'active' || it.status === 'blocked') b.push(btn('Cancel', () => itemOp(it, 'cancel', {}, 'Cancelled; its agent is left as it is'), { key: k('cancel'), cls: 'ghost' }));
|
|
520
|
+
if (it.status === 'done' || it.status === 'cancelled') b.push(btn('Remove', () => itemOp(it, 'remove', null, 'Removed'), { key: k('remove'), cls: 'ghost' }));
|
|
521
|
+
if (it.agentId && runner?.online) b.push(termButton(runner, it.agentId, it.agent?.name ?? it.agentId), openLink(runner, it.agentId));
|
|
522
|
+
return b.length ? el('div', { class: 'actions' }, ...b) : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function reopenForm(it) {
|
|
526
|
+
const dk = `reopen:${it.id}:text`;
|
|
527
|
+
const box = draftInput(dk, { area: true, placeholder: 'What still needs doing?' });
|
|
528
|
+
const send = async () => {
|
|
529
|
+
if (await itemOp(it, 'reopen', { note: box.value.trim() }, 'Sent back to its agent')) {
|
|
530
|
+
drafts.delete(dk);
|
|
531
|
+
state.open.delete(`reopen:${it.id}`);
|
|
532
|
+
render(true);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
return el('form', { class: 'reply col', onsubmit: (e) => (e.preventDefault(), send()) }, box, el('div', { class: 'actions' }, el('button', { class: 'primary', disabled: busy.has(`item:${it.id}:reopen`) }, 'Reopen')));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function openQueue() {
|
|
539
|
+
const sel = $('#queue-project');
|
|
540
|
+
const prev = sel.value;
|
|
541
|
+
sel.replaceChildren(
|
|
542
|
+
...state.fleet.backlog.projects.map((p) => el('option', { value: p.ref }, `${p.name} · ${p.machines.map((m) => `${m.name}${m.warm ? '' : ' (not cloned)'}`).join(', ')}`)),
|
|
543
|
+
);
|
|
544
|
+
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev;
|
|
545
|
+
fillEngines('#queue-engine-field', $('#queue-engine'), state.fleet.runners.filter((r) => allows(r, 'create')).flatMap(enginesOf));
|
|
546
|
+
$('#queue-error').hidden = true;
|
|
547
|
+
$('#queue-dialog').showModal();
|
|
548
|
+
$('#queue-titles').focus();
|
|
549
|
+
}
|
|
550
|
+
$('#queue-cancel').addEventListener('click', () => $('#queue-dialog').close());
|
|
551
|
+
$('#queue-form').addEventListener('submit', async (e) => {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
const items = $('#queue-titles').value.split('\n').map((x) => x.trim()).filter(Boolean);
|
|
554
|
+
const body = { items, project: $('#queue-project').value, labels: $('#queue-labels').value, priority: Number($('#queue-priority').value) };
|
|
555
|
+
if ($('#queue-mode').value) body.permissionMode = $('#queue-mode').value;
|
|
556
|
+
if ($('#queue-engine').value) body.engine = $('#queue-engine').value;
|
|
557
|
+
const submit = e.submitter ?? $('#queue-form button.primary');
|
|
558
|
+
submit.disabled = true;
|
|
559
|
+
try {
|
|
560
|
+
const added = await call('POST', '/backlog', body);
|
|
561
|
+
$('#queue-titles').value = '';
|
|
562
|
+
$('#queue-dialog').close();
|
|
563
|
+
toast(`Queued ${added.length} item${added.length > 1 ? 's' : ''}`);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
$('#queue-error').textContent = err.message;
|
|
566
|
+
$('#queue-error').hidden = false;
|
|
567
|
+
} finally {
|
|
568
|
+
submit.disabled = false;
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
function inboxActions(it, runner) {
|
|
573
|
+
const r = runner.id;
|
|
574
|
+
const agent = it.agentId;
|
|
575
|
+
const key = (k) => `${r}:${it.key}:${k}`;
|
|
576
|
+
if (it.kind === 'prompt') {
|
|
577
|
+
return el(
|
|
578
|
+
'div',
|
|
579
|
+
{ class: 'actions' },
|
|
580
|
+
...(it.choices ?? []).map((c) => btn(`${c.key}. ${c.label}`, () => act(r, 'prompt-key', { agent, key: c.key }, { key: key(c.key), done: `Sent ${c.key} to ${it.agentName}` }), { primary: c.key === '1', key: key(c.key) })),
|
|
581
|
+
btn('Esc', () => act(r, 'prompt-key', { agent, key: 'esc' }, { key: key('esc'), done: 'Sent Esc' }), { key: key('esc'), title: 'Escape' }),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (it.kind === 'question') {
|
|
585
|
+
const dk = `${r}:${it.key}:text`;
|
|
586
|
+
const send = (text) =>
|
|
587
|
+
act(r, 'answer', { agent, question: it.question.id, text }, { key: key('answer'), done: `Answer queued for ${it.agentName}` }).then((ok) => ok && drafts.delete(dk));
|
|
588
|
+
const box = draftInput(dk, { placeholder: 'Or type an answer…' });
|
|
589
|
+
return el(
|
|
590
|
+
'div',
|
|
591
|
+
{ class: 'reply col' },
|
|
592
|
+
it.question?.options?.length ? el('div', { class: 'actions' }, ...it.question.options.map((o) => btn(o, () => send(o), { key: key('answer') }))) : null,
|
|
593
|
+
el('form', { class: 'reply', onsubmit: (e) => (e.preventDefault(), box.value.trim() && send(box.value.trim())) }, box, el('button', { class: 'primary', disabled: busy.has(key('answer')) }, 'Send')),
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (it.kind === 'stuck') {
|
|
597
|
+
const dk = `${r}:${it.key}:nudge`;
|
|
598
|
+
const box = draftInput(dk, { area: true, initial: it.nudge ?? '' });
|
|
599
|
+
return el(
|
|
600
|
+
'div',
|
|
601
|
+
{ class: 'reply col' },
|
|
602
|
+
box,
|
|
603
|
+
el(
|
|
604
|
+
'div',
|
|
605
|
+
{ class: 'actions' },
|
|
606
|
+
btn('Send nudge', () => act(r, 'nudge', { agent, key: it.stuckKey, text: box.value }, { key: key('nudge'), done: `Nudge queued for ${it.agentName}` }).then((ok) => ok && drafts.delete(dk)), { primary: true, key: key('nudge') }),
|
|
607
|
+
btn('Dismiss', () => act(r, 'dismiss-stuck', { agent, key: it.stuckKey }, { key: key('dismiss'), done: 'Dismissed' }), { key: key('dismiss'), cls: 'ghost' }),
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (it.kind === 'conflict' && it.withId) {
|
|
612
|
+
return el(
|
|
613
|
+
'div',
|
|
614
|
+
{ class: 'actions' },
|
|
615
|
+
btn(`Tell ${it.agentName}`, () => act(r, 'warn-conflict', { agent, with: it.withId }, { key: key('warn'), done: `Told ${it.agentName}` }), { key: key('warn') }),
|
|
616
|
+
btn('Tell both', async () => {
|
|
617
|
+
await act(r, 'warn-conflict', { agent, with: it.withId }, { key: key('warn'), done: 'Told both' });
|
|
618
|
+
await act(r, 'warn-conflict', { agent: it.withId, with: agent }, { key: key('warn2'), done: 'Told both' });
|
|
619
|
+
}, { key: key('warn') }),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function agentActions(a, runner) {
|
|
626
|
+
const r = runner.id;
|
|
627
|
+
const key = (k) => `${r}:${a.id}:${k}`;
|
|
628
|
+
const running = a.status === 'running';
|
|
629
|
+
const tellKey = `tell:${a.runnerId}:${a.id}`;
|
|
630
|
+
return el(
|
|
631
|
+
'div',
|
|
632
|
+
{ class: 'actions' },
|
|
633
|
+
btn('Message', () => (state.open.has(tellKey) ? state.open.delete(tellKey) : state.open.add(tellKey), render(true), document.querySelector(`[data-draft="${CSS.escape(`${tellKey}:text`)}"]`)?.focus()), { title: 'Type a message into its session (sent when it is idle; resumes it if closed)' }),
|
|
634
|
+
running
|
|
635
|
+
? btn('Stop', () => act(r, 'stop', { agent: a.id }, { key: key('stop'), done: `Stopping ${a.name}; its session is kept` }), { key: key('stop'), cls: 'ghost' })
|
|
636
|
+
: a.status !== 'preparing'
|
|
637
|
+
? btn('Start', () => act(r, 'start', { agent: a.id }, { key: key('start'), done: `Starting ${a.name}` }), { key: key('start') })
|
|
638
|
+
: null,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function tellForm(a) {
|
|
643
|
+
const tellKey = `tell:${a.runnerId}:${a.id}`;
|
|
644
|
+
const dk = `${tellKey}:text`;
|
|
645
|
+
const box = draftInput(dk, { area: true, placeholder: `Message for ${a.name}…` });
|
|
646
|
+
const send = async () => {
|
|
647
|
+
if (!box.value.trim()) return;
|
|
648
|
+
const ok = await act(a.runnerId, 'tell', { agent: a.id, text: box.value.trim() }, { key: `${a.runnerId}:${a.id}:tell`, done: `Queued for ${a.name}: typed in when it's idle` });
|
|
649
|
+
if (ok) {
|
|
650
|
+
drafts.delete(dk);
|
|
651
|
+
state.open.delete(tellKey);
|
|
652
|
+
render(true);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
box.addEventListener('keydown', (e) => e.key === 'Enter' && (e.metaKey || e.ctrlKey) && (e.preventDefault(), send()));
|
|
656
|
+
return el('form', { class: 'reply col', onsubmit: (e) => (e.preventDefault(), send()) }, box, el('div', { class: 'actions' }, el('button', { class: 'primary', disabled: busy.has(`${a.runnerId}:${a.id}:tell`) }, 'Send'), el('span', { class: 'muted small' }, '⌘⏎ to send')));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function termButton(runner, agentId, agentName) {
|
|
660
|
+
if (!allows(runner, 'terminal')) return null;
|
|
661
|
+
return btn('Terminal', () => openTerminal({ runnerId: runner.id, runnerName: runner.name, agentId, agentName, kind: 'claude' }), { title: 'Live terminal, through the hub' });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ------------------------------------------------------------------ controls
|
|
665
|
+
|
|
666
|
+
for (const b of document.querySelectorAll('[role="tab"]')) {
|
|
667
|
+
b.addEventListener('click', () => {
|
|
668
|
+
state.tab = b.dataset.tab;
|
|
669
|
+
try {
|
|
670
|
+
localStorage.setItem('hub.tab', state.tab);
|
|
671
|
+
} catch {}
|
|
672
|
+
render();
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
$('#search').addEventListener('input', (e) => {
|
|
676
|
+
state.query = e.target.value.trim();
|
|
677
|
+
render();
|
|
678
|
+
});
|
|
679
|
+
$('#filter-chip').addEventListener('click', () => {
|
|
680
|
+
state.machine = null;
|
|
681
|
+
render();
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
let expiryTimer = null;
|
|
685
|
+
$('#invite-btn').addEventListener('click', async () => {
|
|
686
|
+
try {
|
|
687
|
+
const inv = await call('POST', '/invites');
|
|
688
|
+
$('#invite-cmd').textContent = inv.command;
|
|
689
|
+
$('#invite-warning').textContent = inv.warning || '';
|
|
690
|
+
$('#invite-warning').hidden = !inv.warning;
|
|
691
|
+
const tick = () => {
|
|
692
|
+
const left = Math.max(0, Date.parse(inv.expiresAt) - Date.now());
|
|
693
|
+
$('#invite-expiry').textContent = left ? `Works once, for ${Math.floor(left / 60000)}:${String(Math.floor((left % 60000) / 1000)).padStart(2, '0')} more.` : 'Expired: close and click Add machine again.';
|
|
694
|
+
};
|
|
695
|
+
tick();
|
|
696
|
+
clearInterval(expiryTimer);
|
|
697
|
+
expiryTimer = setInterval(tick, 1000);
|
|
698
|
+
$('#invite-dialog').showModal();
|
|
699
|
+
} catch (err) {
|
|
700
|
+
alertLine(err.message);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
$('#invite-dialog').addEventListener('close', () => clearInterval(expiryTimer));
|
|
704
|
+
$('#copy-btn').addEventListener('click', async () => {
|
|
705
|
+
try {
|
|
706
|
+
await navigator.clipboard.writeText($('#invite-cmd').textContent);
|
|
707
|
+
$('#copy-btn').textContent = 'Copied';
|
|
708
|
+
} catch {
|
|
709
|
+
getSelection().selectAllChildren($('#invite-cmd'));
|
|
710
|
+
$('#copy-btn').textContent = 'Press ⌘C';
|
|
711
|
+
}
|
|
712
|
+
setTimeout(() => ($('#copy-btn').textContent = 'Copy'), 1500);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ------------------------------------------------------------------ new agent
|
|
716
|
+
|
|
717
|
+
function creatable() {
|
|
718
|
+
return (state.fleet?.runners ?? []).filter((r) => allows(r, 'create') && r.projects?.length);
|
|
719
|
+
}
|
|
720
|
+
function fillProjects() {
|
|
721
|
+
const r = creatable().find((x) => x.id === $('#new-machine').value);
|
|
722
|
+
const sel = $('#new-project');
|
|
723
|
+
const prev = sel.value;
|
|
724
|
+
sel.replaceChildren(...(r?.projects ?? []).map((p) => el('option', { value: p.shortcut }, p.name && p.name !== p.shortcut ? `${p.name} (@${p.shortcut})` : `@${p.shortcut}`)));
|
|
725
|
+
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev;
|
|
726
|
+
fillEngines('#new-engine-field', $('#new-engine'), enginesOf(r));
|
|
727
|
+
}
|
|
728
|
+
$('#new-btn').addEventListener('click', () => {
|
|
729
|
+
const list = creatable();
|
|
730
|
+
const sel = $('#new-machine');
|
|
731
|
+
sel.replaceChildren(...list.map((r) => el('option', { value: r.id }, r.name)));
|
|
732
|
+
if (state.machine && list.some((r) => r.id === state.machine)) sel.value = state.machine;
|
|
733
|
+
fillProjects();
|
|
734
|
+
$('#new-error').hidden = true;
|
|
735
|
+
$('#new-dialog').showModal();
|
|
736
|
+
$('#new-intent').focus();
|
|
737
|
+
});
|
|
738
|
+
$('#new-machine').addEventListener('change', fillProjects);
|
|
739
|
+
$('#new-cancel').addEventListener('click', () => $('#new-dialog').close());
|
|
740
|
+
$('#new-form').addEventListener('submit', async (e) => {
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
const runnerId = $('#new-machine').value;
|
|
743
|
+
const args = { project: $('#new-project').value, intent: $('#new-intent').value.trim(), priority: Number($('#new-priority').value) };
|
|
744
|
+
if ($('#new-mode').value) args.permissionMode = $('#new-mode').value;
|
|
745
|
+
if ($('#new-engine').value) args.engine = $('#new-engine').value;
|
|
746
|
+
const submit = e.submitter ?? $('#new-form button.primary');
|
|
747
|
+
submit.disabled = true;
|
|
748
|
+
try {
|
|
749
|
+
await call('POST', `/runners/${encodeURIComponent(runnerId)}/actions`, { action: 'create', args });
|
|
750
|
+
$('#new-intent').value = '';
|
|
751
|
+
$('#new-dialog').close();
|
|
752
|
+
toast('Agent starting; it appears in a moment');
|
|
753
|
+
state.tab = 'agents';
|
|
754
|
+
render(true);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
$('#new-error').textContent = err.message;
|
|
757
|
+
$('#new-error').hidden = false;
|
|
758
|
+
} finally {
|
|
759
|
+
submit.disabled = false;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Relative times ("5m ago") stay current.
|
|
764
|
+
setInterval(() => state.fleet && !document.hidden && render(), 30_000);
|
|
765
|
+
|
|
766
|
+
boot();
|