agent ui: SPA shell — static index.html + app.js, /api/state JSON
This commit is contained in:
parent
6fc9862c3c
commit
124fd97288
4 changed files with 395 additions and 211 deletions
290
hive-ag3nt/assets/app.js
Normal file
290
hive-ag3nt/assets/app.js
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
// Per-agent web UI. Renders title + login/online view from `/api/state`,
|
||||||
|
// tails `/events/stream` for live claude events, drives async-form
|
||||||
|
// actions (send / login/* / dashboard rebuild).
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||||
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||||
|
);
|
||||||
|
const el = (tag, attrs = {}, ...children) => {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'class') e.className = v;
|
||||||
|
else if (k === 'html') e.innerHTML = v;
|
||||||
|
else e.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
e.append(c.nodeType ? c : document.createTextNode(c));
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
||||||
|
document.addEventListener('submit', async (e) => {
|
||||||
|
const f = e.target;
|
||||||
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||||||
|
const btn = f.querySelector('button[type="submit"], button:not([type])');
|
||||||
|
const original = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||||||
|
try {
|
||||||
|
const resp = await fetch(f.action, {
|
||||||
|
method: f.method || 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(new FormData(f)),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||||
|
|| (resp.status >= 200 && resp.status < 400);
|
||||||
|
if (!ok) {
|
||||||
|
const text = await resp.text().catch(() => '');
|
||||||
|
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clear text inputs the operator typed into (the form value was sent).
|
||||||
|
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
|
||||||
|
refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
alert('action failed: ' + err);
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── state rendering ────────────────────────────────────────────────────
|
||||||
|
function setHeader(label, dashboardPort) {
|
||||||
|
$('banner').textContent =
|
||||||
|
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
|
||||||
|
const title = $('title');
|
||||||
|
title.textContent = `◆ ${label} ◆ `;
|
||||||
|
const btn = el('a', {
|
||||||
|
href: '#', class: 'btn-rebuild', id: 'rebuild-btn',
|
||||||
|
}, '↻ R3BU1LD');
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
|
||||||
|
const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`;
|
||||||
|
const f = document.createElement('form');
|
||||||
|
f.method = 'POST';
|
||||||
|
f.action = url;
|
||||||
|
document.body.appendChild(f);
|
||||||
|
f.submit();
|
||||||
|
});
|
||||||
|
title.append(btn);
|
||||||
|
document.title = `${label} // hyperhive`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOnline(label, root) {
|
||||||
|
root.append(
|
||||||
|
el('p', { class: 'status-online' }, '▓█▓▒░ harness alive — turn loop running ▓█▓▒░'),
|
||||||
|
);
|
||||||
|
const form = el('form', {
|
||||||
|
action: '/send', method: 'POST', class: 'sendform', 'data-async': '',
|
||||||
|
});
|
||||||
|
form.append(
|
||||||
|
el('input', {
|
||||||
|
name: 'body', placeholder: `message ${label} as operator…`,
|
||||||
|
required: '', autocomplete: 'off',
|
||||||
|
}),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-send' }, '◆ S3ND'),
|
||||||
|
);
|
||||||
|
root.append(form);
|
||||||
|
root.append(el('p', { class: 'meta', html:
|
||||||
|
'enqueued with <code>from: operator</code> on this agent\'s inbox; the next turn picks it up.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNeedsLoginIdle(root) {
|
||||||
|
root.append(
|
||||||
|
el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'),
|
||||||
|
el('p', { html:
|
||||||
|
'No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const start = el('form', {
|
||||||
|
action: '/login/start', method: 'POST', 'data-async': '',
|
||||||
|
});
|
||||||
|
start.append(
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
|
||||||
|
);
|
||||||
|
root.append(start);
|
||||||
|
root.append(el('p', { class: 'meta', html:
|
||||||
|
'Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginInProgress(s, root) {
|
||||||
|
root.append(el('p', { class: 'status-needs-login' }, '▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░'));
|
||||||
|
if (s.url) {
|
||||||
|
const link = el('a', {
|
||||||
|
href: s.url, target: '_blank', rel: 'noreferrer',
|
||||||
|
}, s.url);
|
||||||
|
root.append(el('p', {}, '▶ ', link));
|
||||||
|
root.append(el('p', { class: 'meta' },
|
||||||
|
'open this URL in a browser, complete the OAuth flow, paste the resulting code below.',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
root.append(el('p', { class: 'meta' },
|
||||||
|
'waiting for claude to emit an OAuth URL on stdout… (output below)',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (!s.finished) {
|
||||||
|
const code = el('form', {
|
||||||
|
action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '',
|
||||||
|
});
|
||||||
|
code.append(
|
||||||
|
el('input', {
|
||||||
|
name: 'code', placeholder: 'paste OAuth code here',
|
||||||
|
required: '', autocomplete: 'off',
|
||||||
|
}),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'),
|
||||||
|
);
|
||||||
|
root.append(code);
|
||||||
|
}
|
||||||
|
const cancel = el('form', {
|
||||||
|
action: '/login/cancel', method: 'POST', 'data-async': '',
|
||||||
|
style: 'margin-top: 0.4em;',
|
||||||
|
});
|
||||||
|
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
|
||||||
|
root.append(cancel);
|
||||||
|
if (s.finished) {
|
||||||
|
root.append(el('p', { class: 'status-needs-login' },
|
||||||
|
`claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
root.append(el('h3', {}, 'output'));
|
||||||
|
root.append(el('pre', { class: 'diff' }, s.output || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerSet = false;
|
||||||
|
async function refreshState() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/state');
|
||||||
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
|
const s = await resp.json();
|
||||||
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
||||||
|
const root = $('status');
|
||||||
|
root.innerHTML = '';
|
||||||
|
if (s.status === 'online') renderOnline(s.label, root);
|
||||||
|
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
||||||
|
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('refreshState failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshState();
|
||||||
|
// Mid-login refresh on a short interval so the output buffer updates.
|
||||||
|
setInterval(() => {
|
||||||
|
// Cheap; api/state is small. Could subscribe to SSE state events later.
|
||||||
|
refreshState();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// ─── live event stream ──────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const log = $('live');
|
||||||
|
if (!log) return;
|
||||||
|
let placeholder = log.firstChild;
|
||||||
|
function setPlaceholder(text) {
|
||||||
|
log.innerHTML = '';
|
||||||
|
const span = document.createElement('div');
|
||||||
|
span.className = 'meta';
|
||||||
|
span.textContent = text;
|
||||||
|
log.appendChild(span);
|
||||||
|
placeholder = span;
|
||||||
|
}
|
||||||
|
function clearPlaceholder() {
|
||||||
|
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
||||||
|
}
|
||||||
|
function row(cls, text) {
|
||||||
|
clearPlaceholder();
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.className = 'row ' + (cls || '');
|
||||||
|
e.textContent = text;
|
||||||
|
log.appendChild(e);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
||||||
|
function renderStream(v) {
|
||||||
|
if (v.type === 'system' && v.subtype === 'init') {
|
||||||
|
row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'rate_limit_event') {
|
||||||
|
const u = Math.round((v.rate_limit_info?.utilization || 0) * 100);
|
||||||
|
const s = v.rate_limit_info?.status || '';
|
||||||
|
row('sys', '· rate-limit util=' + u + '% (' + s + ')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
||||||
|
for (const c of v.message.content) {
|
||||||
|
if (c.type === 'text' && c.text && c.text.trim()) row('text', c.text);
|
||||||
|
else if (c.type === 'thinking') row('thinking', '· thinking …');
|
||||||
|
else if (c.type === 'tool_use') row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'user' && v.message && v.message.content) {
|
||||||
|
for (const c of v.message.content) {
|
||||||
|
if (c.type === 'tool_result') {
|
||||||
|
const txt = Array.isArray(c.content)
|
||||||
|
? c.content.map(p => p.text || '').join(' ')
|
||||||
|
: (c.content || '');
|
||||||
|
row('tool-result', '← ' + trim(txt, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'result') {
|
||||||
|
row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
||||||
|
}
|
||||||
|
function handle(ev) {
|
||||||
|
if (ev.kind === 'turn_start') {
|
||||||
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'turn-body';
|
||||||
|
body.textContent = ev.body;
|
||||||
|
block.appendChild(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'turn_end') {
|
||||||
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
|
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
||||||
|
// Login may have just landed (or session re-enters Online). Pull
|
||||||
|
// fresh state so the form view reflects it.
|
||||||
|
refreshState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'note') {
|
||||||
|
row('note', '· ' + ev.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'stream') {
|
||||||
|
const v = Object.assign({}, ev); delete v.kind;
|
||||||
|
renderStream(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row('note', JSON.stringify(ev));
|
||||||
|
}
|
||||||
|
const es = new EventSource('/events/stream');
|
||||||
|
es.onopen = () => setPlaceholder('(connected — waiting for events)');
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try { handle(JSON.parse(e.data)); }
|
||||||
|
catch (err) { row('note', '[parse err] ' + e.data); }
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
|
||||||
|
else row('note', '[disconnected]');
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Avoid unused-var lint while keeping `escText` available for future use.
|
||||||
|
void escText;
|
||||||
|
})();
|
||||||
22
hive-ag3nt/assets/index.html
Normal file
22
hive-ag3nt/assets/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>hyperhive agent</title>
|
||||||
|
<link rel="stylesheet" href="/static/agent.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
|
||||||
|
<h2 id="title">◆ … ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
|
||||||
|
<div id="status">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>live</h3>
|
||||||
|
<div id="live" class="live"><div class="meta">connecting…</div></div>
|
||||||
|
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
(function() {
|
|
||||||
const log = document.getElementById('live');
|
|
||||||
let placeholder = log.firstChild;
|
|
||||||
function setPlaceholder(text) {
|
|
||||||
log.innerHTML = '';
|
|
||||||
const span = document.createElement('div');
|
|
||||||
span.className = 'meta';
|
|
||||||
span.textContent = text;
|
|
||||||
log.appendChild(span);
|
|
||||||
placeholder = span;
|
|
||||||
}
|
|
||||||
function clearPlaceholder() {
|
|
||||||
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
|
||||||
}
|
|
||||||
function row(cls, text) {
|
|
||||||
clearPlaceholder();
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'row ' + (cls || '');
|
|
||||||
el.textContent = text;
|
|
||||||
log.appendChild(el);
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
function trim(s, n) {
|
|
||||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
||||||
}
|
|
||||||
function renderStream(v) {
|
|
||||||
if (v.type === 'system' && v.subtype === 'init') {
|
|
||||||
row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v.type === 'rate_limit_event') {
|
|
||||||
const u = Math.round((v.rate_limit_info?.utilization || 0) * 100);
|
|
||||||
const s = v.rate_limit_info?.status || '';
|
|
||||||
row('sys', '· rate-limit util=' + u + '% (' + s + ')');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
||||||
for (const c of v.message.content) {
|
|
||||||
if (c.type === 'text' && c.text && c.text.trim())
|
|
||||||
row('text', c.text);
|
|
||||||
else if (c.type === 'thinking')
|
|
||||||
row('thinking', '· thinking …');
|
|
||||||
else if (c.type === 'tool_use')
|
|
||||||
row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v.type === 'user' && v.message && v.message.content) {
|
|
||||||
for (const c of v.message.content) {
|
|
||||||
if (c.type === 'tool_result') {
|
|
||||||
const txt = Array.isArray(c.content)
|
|
||||||
? c.content.map(p => p.text || '').join(' ')
|
|
||||||
: (c.content || '');
|
|
||||||
row('tool-result', '← ' + trim(txt, 300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (v.type === 'result') {
|
|
||||||
row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : ''));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fallback: small one-liner for unknown events; don't spam.
|
|
||||||
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
|
||||||
}
|
|
||||||
function handle(ev) {
|
|
||||||
if (ev.kind === 'turn_start') {
|
|
||||||
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'turn-body';
|
|
||||||
body.textContent = ev.body;
|
|
||||||
block.appendChild(body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.kind === 'turn_end') {
|
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
|
||||||
const sym = ev.ok ? '✓' : '✗';
|
|
||||||
row(cls, sym + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.kind === 'note') {
|
|
||||||
row('note', '· ' + ev.text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.kind === 'stream') {
|
|
||||||
const v = Object.assign({}, ev); delete v.kind;
|
|
||||||
renderStream(v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
row('note', JSON.stringify(ev));
|
|
||||||
}
|
|
||||||
const es = new EventSource('/events/stream');
|
|
||||||
es.onopen = function() { setPlaceholder('(connected — waiting for events)'); };
|
|
||||||
es.onmessage = function(e) {
|
|
||||||
try { handle(JSON.parse(e.data)); }
|
|
||||||
catch (err) { row('note', '[parse err] ' + e.data); }
|
|
||||||
};
|
|
||||||
es.onerror = function() {
|
|
||||||
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
|
|
||||||
else row('note', '[disconnected]');
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port.
|
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
|
||||||
//! Containers share the host's network namespace (privateNetwork = false), so
|
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
|
||||||
//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by
|
//! state as JSON; the JS app renders. Live events stream on
|
||||||
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name).
|
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a
|
||||||
|
//! 303 Redirect (for browsers that submit the form normally) or just
|
||||||
|
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
@ -12,13 +14,14 @@ use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, Router,
|
Form, Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
response::{
|
response::{
|
||||||
Html, IntoResponse, Redirect, Response,
|
IntoResponse, Redirect, Response,
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
},
|
},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||||
|
|
||||||
use crate::client;
|
use crate::client;
|
||||||
|
|
@ -66,7 +69,10 @@ pub async fn serve(
|
||||||
flavor,
|
flavor,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(serve_index))
|
||||||
|
.route("/static/agent.css", get(serve_css))
|
||||||
|
.route("/static/app.js", get(serve_app_js))
|
||||||
|
.route("/api/state", get(api_state))
|
||||||
.route("/events/stream", get(events_stream))
|
.route("/events/stream", get(events_stream))
|
||||||
.route("/send", post(post_send))
|
.route("/send", post(post_send))
|
||||||
.route("/login/start", post(post_login_start))
|
.route("/login/start", post(post_login_start))
|
||||||
|
|
@ -82,100 +88,83 @@ pub async fn serve(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index(State(state): State<AppState>) -> Html<String> {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static assets + state snapshot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn serve_index() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "text/html; charset=utf-8")],
|
||||||
|
include_str!("../assets/index.html"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "text/css")],
|
||||||
|
include_str!("../assets/agent.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_app_js() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "application/javascript")],
|
||||||
|
include_str!("../assets/app.js"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StateSnapshot {
|
||||||
|
label: String,
|
||||||
|
dashboard_port: u16,
|
||||||
|
/// `"online"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
||||||
|
status: &'static str,
|
||||||
|
/// Present when `status == "needs_login_in_progress"`.
|
||||||
|
session: Option<SessionView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionView {
|
||||||
|
/// First `https://…` claude emitted on stdout, if any.
|
||||||
|
url: Option<String>,
|
||||||
|
/// Accumulated stdout + stderr.
|
||||||
|
output: String,
|
||||||
|
finished: bool,
|
||||||
|
exit_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
drop_if_finished(&state.session);
|
drop_if_finished(&state.session);
|
||||||
let login = *state.login.lock().unwrap();
|
let login = *state.login.lock().unwrap();
|
||||||
let session_snapshot = state.session.lock().unwrap().clone();
|
let session_snapshot = state.session.lock().unwrap().clone();
|
||||||
let body = match (login, session_snapshot) {
|
let (status, session_view) = match (login, session_snapshot) {
|
||||||
(LoginState::Online, _) => render_online(&state.label),
|
(LoginState::Online, _) => ("online", None),
|
||||||
(LoginState::NeedsLogin, None) => render_needs_login_idle(),
|
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
||||||
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session),
|
(LoginState::NeedsLogin, Some(s)) => (
|
||||||
|
"needs_login_in_progress",
|
||||||
|
Some(SessionView {
|
||||||
|
url: s.url(),
|
||||||
|
output: s.output(),
|
||||||
|
finished: s.finished(),
|
||||||
|
exit_note: s.exit_note(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<u16>().ok())
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
.unwrap_or(7000);
|
.unwrap_or(7000);
|
||||||
Html(format!(
|
axum::Json(StateSnapshot {
|
||||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆ <a href=\"#\" id=\"rebuild-btn\" class=\"btn-rebuild\" data-port=\"{dashboard_port}\" data-label=\"{label}\">↻ R3BU1LD</a></h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n<script>\n(function() {{\n const b = document.getElementById('rebuild-btn');\n b.addEventListener('click', function(e) {{\n e.preventDefault();\n if (!confirm('rebuild ' + b.dataset.label + '? container will hot-reload.')) return;\n const url = window.location.protocol + '//' + window.location.hostname + ':' + b.dataset.port + '/rebuild/' + b.dataset.label;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = url;\n document.body.appendChild(form);\n form.submit();\n }});\n}})();\n</script>\n</body>\n</html>\n",
|
label: state.label.clone(),
|
||||||
label = state.label,
|
dashboard_port,
|
||||||
))
|
status,
|
||||||
|
session: session_view,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_online(label: &str) -> String {
|
// ---------------------------------------------------------------------------
|
||||||
format!(
|
// Action handlers
|
||||||
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n\
|
// ---------------------------------------------------------------------------
|
||||||
<form id=\"sendform\" class=\"sendform\">\n \
|
|
||||||
<input id=\"sendbody\" name=\"body\" placeholder=\"message {label} as operator…\" required autocomplete=\"off\">\n \
|
|
||||||
<button type=\"submit\" class=\"btn btn-send\">◆ S3ND</button>\n\
|
|
||||||
</form>\n\
|
|
||||||
<p class=\"meta\">enqueued with <code>from: operator</code> on this agent's inbox; the next turn picks it up.</p>\n\
|
|
||||||
<script>\n\
|
|
||||||
document.getElementById('sendform').addEventListener('submit', async function(e) {{\n \
|
|
||||||
e.preventDefault();\n \
|
|
||||||
const input = document.getElementById('sendbody');\n \
|
|
||||||
const body = input.value.trim();\n \
|
|
||||||
if (!body) return;\n \
|
|
||||||
const resp = await fetch('/send', {{\n \
|
|
||||||
method: 'POST',\n \
|
|
||||||
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},\n \
|
|
||||||
body: new URLSearchParams({{ body }}),\n \
|
|
||||||
redirect: 'manual',\n \
|
|
||||||
}});\n \
|
|
||||||
if (resp.type === 'opaqueredirect' || (resp.ok && resp.status < 400)) {{\n \
|
|
||||||
input.value = '';\n \
|
|
||||||
}} else {{\n \
|
|
||||||
alert('send failed: ' + resp.status);\n \
|
|
||||||
}}\n\
|
|
||||||
}});\n\
|
|
||||||
</script>\n\
|
|
||||||
{LIVE_PANEL}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live event tail rendered into every `/` response when the agent is online.
|
|
||||||
/// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page
|
|
||||||
/// reload, so the login flow and other forms aren't clobbered.
|
|
||||||
const LIVE_PANEL: &str = concat!(
|
|
||||||
"<h3>live</h3>\n",
|
|
||||||
"<div id=\"live\" class=\"live\"><div class=\"meta\">connecting…</div></div>\n",
|
|
||||||
"<script>\n",
|
|
||||||
include_str!("../assets/live.js"),
|
|
||||||
"</script>",
|
|
||||||
);
|
|
||||||
|
|
||||||
fn render_needs_login_idle() -> String {
|
|
||||||
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
|
|
||||||
let url_block = match session.url() {
|
|
||||||
Some(url) => format!(
|
|
||||||
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
|
|
||||||
url = html_escape(&url),
|
|
||||||
),
|
|
||||||
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
|
|
||||||
};
|
|
||||||
let exit_badge = if session.finished() {
|
|
||||||
let note = session.exit_note().unwrap_or_else(|| "exited".into());
|
|
||||||
format!(
|
|
||||||
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
|
|
||||||
note = html_escape(¬e),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let output = session.output();
|
|
||||||
let code_form = if session.finished() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
|
|
||||||
};
|
|
||||||
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
|
|
||||||
format!(
|
|
||||||
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
|
|
||||||
output = html_escape(&output),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SendForm {
|
struct SendForm {
|
||||||
|
|
@ -278,21 +267,7 @@ async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_response(message: &str) -> Response {
|
fn error_response(message: &str) -> Response {
|
||||||
(
|
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
// be noise.
|
||||||
Html(format!(
|
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
|
||||||
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
|
|
||||||
msg = html_escape(message),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_escape(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE: &str = concat!("<style>\n", include_str!("../assets/agent.css"), "</style>",);
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue