Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf42b7e93 | ||
|
|
e9b213690e | ||
|
|
8fbee4fbf2 | ||
|
|
e2ed58c1a7 |
10 changed files with 668 additions and 639 deletions
140
hive-ag3nt/assets/agent.css
Normal file
140
hive-ag3nt/assets/agent.css
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0a0014;
|
||||||
|
--fg: #e0d4ff;
|
||||||
|
--muted: #6c5c8c;
|
||||||
|
--purple: #cc66ff;
|
||||||
|
--purple-dim: #4a1a6a;
|
||||||
|
--amber: #ffb84d;
|
||||||
|
--green: #66ff99;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
|
max-width: 70em;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
padding: 0 1.5em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
color: var(--purple);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
h2, h3 {
|
||||||
|
color: var(--purple);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
color: var(--purple-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.meta { color: var(--muted); font-size: 0.85em; }
|
||||||
|
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
|
||||||
|
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
|
||||||
|
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||||
|
a { color: #66e0ff; }
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--purple);
|
||||||
|
color: var(--purple);
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(204, 102, 255, 0.1); }
|
||||||
|
.btn-login { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
|
||||||
|
.btn-rebuild {
|
||||||
|
color: var(--amber);
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
padding: 0.15em 0.6em;
|
||||||
|
font-size: 0.55em;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-left: 0.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-rebuild:hover { background: rgba(255, 184, 77, 0.1); }
|
||||||
|
.btn-send { color: var(--green); border-color: var(--green); }
|
||||||
|
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||||
|
.sendform input {
|
||||||
|
font-family: inherit; font-size: 1em;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sendform input:focus { outline: 1px solid var(--purple); }
|
||||||
|
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||||
|
.loginform input {
|
||||||
|
font-family: inherit; font-size: 1em;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||||
|
pre.diff {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.6em 0.8em;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 30em;
|
||||||
|
}
|
||||||
|
.live {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 32em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.live .row {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 0.05em 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
margin: 0.1em 0;
|
||||||
|
}
|
||||||
|
.live .row + .row { border-top: 0; }
|
||||||
|
.live .turn-start {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1em;
|
||||||
|
border-left-color: var(--amber);
|
||||||
|
padding-top: 0.3em;
|
||||||
|
}
|
||||||
|
.live .turn-start:first-child { margin-top: 0; }
|
||||||
|
.live .turn-body {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.live .turn-end-ok { color: #66ff99; border-left-color: #66ff99; margin-bottom: 0.4em; }
|
||||||
|
.live .turn-end-fail { color: #ff6b6b; border-left-color: #ff6b6b; margin-bottom: 0.4em; }
|
||||||
|
.live .text { color: var(--fg); padding-left: 1.2em; }
|
||||||
|
.live .thinking { color: var(--muted); font-style: italic; padding-left: 1.2em; }
|
||||||
|
.live .tool-use { color: #66e0ff; padding-left: 1.2em; }
|
||||||
|
.live .tool-result { color: var(--muted); padding-left: 1.2em; }
|
||||||
|
.live .result { color: var(--green); padding-left: 0.5em; }
|
||||||
|
.live .sys, .live .note { color: var(--muted); }
|
||||||
103
hive-ag3nt/assets/live.js
Normal file
103
hive-ag3nt/assets/live.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
(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]');
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -91,11 +91,15 @@ async fn main() -> Result<()> {
|
||||||
LoginState::NeedsLogin => {
|
LoginState::NeedsLogin => {
|
||||||
// Partial-run mode: keep the harness alive (so the web UI
|
// Partial-run mode: keep the harness alive (so the web UI
|
||||||
// stays bound) but don't drive the turn loop. Poll the
|
// stays bound) but don't drive the turn loop. Poll the
|
||||||
// claude dir periodically so a successful login (whether
|
// claude dir; once a session lands we enter `serve`.
|
||||||
// from the dashboard PTY path in step 4 or via
|
turn::wait_for_login(&claude_dir, login_state.clone(), poll_ms).await;
|
||||||
// `root-login` + `claude auth login` in the meantime)
|
serve(
|
||||||
// transitions us into the turn loop without a restart.
|
&cli.socket,
|
||||||
needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms, bus).await
|
Duration::from_millis(poll_ms),
|
||||||
|
login_state,
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,30 +118,6 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-checks `claude_dir` every `poll_ms` ms. As soon as it contains a session
|
|
||||||
/// (login completed), flips `state` to `Online` and enters the turn loop.
|
|
||||||
async fn needs_login_loop(
|
|
||||||
socket: &Path,
|
|
||||||
claude_dir: &Path,
|
|
||||||
state: Arc<Mutex<LoginState>>,
|
|
||||||
poll_ms: u64,
|
|
||||||
bus: Bus,
|
|
||||||
) -> Result<()> {
|
|
||||||
tracing::warn!(
|
|
||||||
claude_dir = %claude_dir.display(),
|
|
||||||
"no claude session — staying in partial-run mode (web UI only)"
|
|
||||||
);
|
|
||||||
let probe = Duration::from_millis(poll_ms.max(2000));
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(probe).await;
|
|
||||||
if login::has_session(claude_dir) {
|
|
||||||
tracing::info!("claude session detected — entering turn loop");
|
|
||||||
*state.lock().unwrap() = LoginState::Online;
|
|
||||||
return serve(socket, Duration::from_millis(poll_ms), state, bus).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(
|
async fn serve(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
|
|
@ -158,8 +138,9 @@ async fn serve(
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&label, &from, &body);
|
let prompt = format_wake_prompt(&label, &from, &body);
|
||||||
let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
|
let outcome =
|
||||||
emit_turn_end(&bus, &outcome);
|
turn::drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
|
||||||
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
}
|
}
|
||||||
Ok(AgentResponse::Empty) => {}
|
Ok(AgentResponse::Empty) => {}
|
||||||
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
|
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
|
||||||
|
|
@ -176,47 +157,6 @@ async fn serve(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive one turn end-to-end. If claude hits `Prompt is too long`, run
|
|
||||||
/// `/compact` against the persistent session and retry once. Returns the
|
|
||||||
/// final `TurnOutcome` to drive the `TurnEnd` live event.
|
|
||||||
async fn drive_turn(
|
|
||||||
prompt: &str,
|
|
||||||
mcp_config: &Path,
|
|
||||||
bus: &Bus,
|
|
||||||
flavor: mcp::Flavor,
|
|
||||||
) -> turn::TurnOutcome {
|
|
||||||
match turn::run_turn(prompt, mcp_config, bus, flavor).await {
|
|
||||||
turn::TurnOutcome::PromptTooLong => {
|
|
||||||
if let Err(e) = turn::compact_session(bus).await {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
|
||||||
return turn::TurnOutcome::Failed(e);
|
|
||||||
}
|
|
||||||
turn::run_turn(prompt, mcp_config, bus, flavor).await
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_turn_end(bus: &Bus, outcome: &turn::TurnOutcome) {
|
|
||||||
match outcome {
|
|
||||||
turn::TurnOutcome::Ok | turn::TurnOutcome::PromptTooLong => {
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: true,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
tracing::info!("claude turn finished");
|
|
||||||
}
|
|
||||||
turn::TurnOutcome::Failed(e) => {
|
|
||||||
let note = format!("{e:#}");
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: false,
|
|
||||||
note: Some(note.clone()),
|
|
||||||
});
|
|
||||||
tracing::warn!(error = %note, "claude turn failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// System prompt handed to claude on each turn. The harness has already
|
/// System prompt handed to claude on each turn. The harness has already
|
||||||
/// popped one message off the inbox (the wake signal); claude is told
|
/// popped one message off the inbox (the wake signal); claude is told
|
||||||
/// about it and the MCP tools, and is expected to drive any further
|
/// about it and the MCP tools, and is expected to drive any further
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,8 @@ async fn main() -> Result<()> {
|
||||||
match initial {
|
match initial {
|
||||||
LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await,
|
LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await,
|
||||||
LoginState::NeedsLogin => {
|
LoginState::NeedsLogin => {
|
||||||
tracing::warn!(
|
turn::wait_for_login(&claude_dir, login_state, poll_ms).await;
|
||||||
claude_dir = %claude_dir.display(),
|
serve(&cli.socket, Duration::from_millis(poll_ms), bus).await
|
||||||
"manager has no claude session — staying in partial-run mode"
|
|
||||||
);
|
|
||||||
needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms, bus).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,26 +123,6 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manager-side mirror of hive-ag3nt's needs-login loop: keep the web UI
|
|
||||||
/// alive, poll the claude dir, enter `serve` once login lands.
|
|
||||||
async fn needs_login_loop(
|
|
||||||
socket: &Path,
|
|
||||||
claude_dir: &Path,
|
|
||||||
state: Arc<Mutex<LoginState>>,
|
|
||||||
poll_ms: u64,
|
|
||||||
bus: Bus,
|
|
||||||
) -> Result<()> {
|
|
||||||
let probe = Duration::from_millis(poll_ms.max(2000));
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(probe).await;
|
|
||||||
if login::has_session(claude_dir) {
|
|
||||||
tracing::info!("manager claude session detected — entering inbox loop");
|
|
||||||
*state.lock().unwrap() = LoginState::Online;
|
|
||||||
return serve(socket, Duration::from_millis(poll_ms), bus).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
||||||
let mcp_config = turn::write_mcp_config(socket).await?;
|
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||||
|
|
@ -172,8 +149,9 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&label, &from, &body);
|
let prompt = format_wake_prompt(&label, &from, &body);
|
||||||
let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
|
let outcome =
|
||||||
emit_turn_end(&bus, &outcome);
|
turn::drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
|
||||||
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Empty) => {}
|
Ok(ManagerResponse::Empty) => {}
|
||||||
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
||||||
|
|
@ -190,46 +168,6 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive one manager turn end-to-end with the same overflow-then-compact
|
|
||||||
/// retry as sub-agents.
|
|
||||||
async fn drive_turn(
|
|
||||||
prompt: &str,
|
|
||||||
mcp_config: &Path,
|
|
||||||
bus: &Bus,
|
|
||||||
flavor: mcp::Flavor,
|
|
||||||
) -> turn::TurnOutcome {
|
|
||||||
match turn::run_turn(prompt, mcp_config, bus, flavor).await {
|
|
||||||
turn::TurnOutcome::PromptTooLong => {
|
|
||||||
if let Err(e) = turn::compact_session(bus).await {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
|
||||||
return turn::TurnOutcome::Failed(e);
|
|
||||||
}
|
|
||||||
turn::run_turn(prompt, mcp_config, bus, flavor).await
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_turn_end(bus: &Bus, outcome: &turn::TurnOutcome) {
|
|
||||||
match outcome {
|
|
||||||
turn::TurnOutcome::Ok | turn::TurnOutcome::PromptTooLong => {
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: true,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
tracing::info!("manager turn finished");
|
|
||||||
}
|
|
||||||
turn::TurnOutcome::Failed(e) => {
|
|
||||||
let note = format!("{e:#}");
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: false,
|
|
||||||
note: Some(note.clone()),
|
|
||||||
});
|
|
||||||
tracing::warn!(error = %note, "manager turn failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manager-flavored wake prompt. Mentions the privileged tools the sub-agent
|
/// Manager-flavored wake prompt. Mentions the privileged tools the sub-agent
|
||||||
/// prompt doesn't have access to, and points the manager at its own
|
/// prompt doesn't have access to, and points the manager at its own
|
||||||
/// editable config repo for self-modification.
|
/// editable config repo for self-modification.
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::events::{Bus, LiveEvent};
|
use crate::events::{Bus, LiveEvent};
|
||||||
|
use crate::login::{self, LoginState};
|
||||||
use crate::mcp;
|
use crate::mcp;
|
||||||
|
|
||||||
/// Inline `--settings` JSON applied to every claude invocation. We turn off
|
/// Inline `--settings` JSON applied to every claude invocation. We turn off
|
||||||
|
|
@ -59,6 +61,67 @@ pub enum TurnOutcome {
|
||||||
Failed(anyhow::Error),
|
Failed(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive one turn end-to-end, transparently compacting + retrying once on
|
||||||
|
/// `Prompt is too long`. Both the sub-agent and manager loops call this.
|
||||||
|
pub async fn drive_turn(
|
||||||
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
) -> TurnOutcome {
|
||||||
|
match run_turn(prompt, mcp_config, bus, flavor).await {
|
||||||
|
TurnOutcome::PromptTooLong => {
|
||||||
|
if let Err(e) = compact_session(bus).await {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
||||||
|
return TurnOutcome::Failed(e);
|
||||||
|
}
|
||||||
|
run_turn(prompt, mcp_config, bus, flavor).await
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the per-turn `TurnEnd` event + log line. Single owner so the
|
||||||
|
/// agent and manager loops agree on outcome semantics.
|
||||||
|
pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||||
|
match outcome {
|
||||||
|
TurnOutcome::Ok | TurnOutcome::PromptTooLong => {
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: true,
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
tracing::info!("turn finished");
|
||||||
|
}
|
||||||
|
TurnOutcome::Failed(e) => {
|
||||||
|
let note = format!("{e:#}");
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: false,
|
||||||
|
note: Some(note.clone()),
|
||||||
|
});
|
||||||
|
tracing::warn!(error = %note, "turn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until the bound `~/.claude/` dir contains a session, polling
|
||||||
|
/// `claude_dir` on a `poll_ms` interval (min 2s). Flips `state` to
|
||||||
|
/// `Online` when login lands; caller resumes its serve loop.
|
||||||
|
pub async fn wait_for_login(claude_dir: &Path, state: Arc<Mutex<LoginState>>, poll_ms: u64) {
|
||||||
|
tracing::warn!(
|
||||||
|
claude_dir = %claude_dir.display(),
|
||||||
|
"no claude session — staying in partial-run mode (web UI only)"
|
||||||
|
);
|
||||||
|
let probe = Duration::from_millis(poll_ms.max(2000));
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(probe).await;
|
||||||
|
if login::has_session(claude_dir) {
|
||||||
|
tracing::info!("claude session detected — entering turn loop");
|
||||||
|
*state.lock().unwrap() = LoginState::Online;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
|
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
|
||||||
/// live event bus. Prompt goes over stdin (variadic
|
/// live event bus. Prompt goes over stdin (variadic
|
||||||
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
|
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
|
||||||
|
|
|
||||||
|
|
@ -132,115 +132,13 @@ fn render_online(label: &str) -> String {
|
||||||
/// Live event tail rendered into every `/` response when the agent is online.
|
/// 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
|
/// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page
|
||||||
/// reload, so the login flow and other forms aren't clobbered.
|
/// reload, so the login flow and other forms aren't clobbered.
|
||||||
const LIVE_PANEL: &str = r#"
|
const LIVE_PANEL: &str = concat!(
|
||||||
<h3>live</h3>
|
"<h3>live</h3>\n",
|
||||||
<div id="live" class="live"><div class="meta">connecting…</div></div>
|
"<div id=\"live\" class=\"live\"><div class=\"meta\">connecting…</div></div>\n",
|
||||||
<script>
|
"<script>\n",
|
||||||
(function() {
|
include_str!("../assets/live.js"),
|
||||||
const log = document.getElementById('live');
|
"</script>",
|
||||||
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]');
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn render_needs_login_idle() -> String {
|
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()
|
"<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()
|
||||||
|
|
@ -394,147 +292,8 @@ fn html_escape(s: &str) -> String {
|
||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
const STYLE: &str = r#"
|
const STYLE: &str = concat!(
|
||||||
<style>
|
"<style>\n",
|
||||||
:root {
|
include_str!("../assets/agent.css"),
|
||||||
--bg: #0a0014;
|
"</style>",
|
||||||
--fg: #e0d4ff;
|
);
|
||||||
--muted: #6c5c8c;
|
|
||||||
--purple: #cc66ff;
|
|
||||||
--purple-dim: #4a1a6a;
|
|
||||||
--amber: #ffb84d;
|
|
||||||
--green: #66ff99;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
||||||
max-width: 70em;
|
|
||||||
margin: 1.5em auto;
|
|
||||||
padding: 0 1.5em;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.banner {
|
|
||||||
color: var(--purple);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
h2, h3 {
|
|
||||||
color: var(--purple);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
|
||||||
}
|
|
||||||
.divider {
|
|
||||||
color: var(--purple-dim);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.meta { color: var(--muted); font-size: 0.85em; }
|
|
||||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
|
|
||||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
|
|
||||||
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
|
|
||||||
a { color: #66e0ff; }
|
|
||||||
.btn {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--purple);
|
|
||||||
color: var(--purple);
|
|
||||||
padding: 0.25em 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
.btn:hover { background: rgba(204, 102, 255, 0.1); }
|
|
||||||
.btn-login { color: var(--amber); border-color: var(--amber); }
|
|
||||||
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
|
|
||||||
.btn-rebuild {
|
|
||||||
color: var(--amber);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
padding: 0.15em 0.6em;
|
|
||||||
font-size: 0.55em;
|
|
||||||
font-family: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
margin-left: 0.6em;
|
|
||||||
vertical-align: middle;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-rebuild:hover { background: rgba(255, 184, 77, 0.1); }
|
|
||||||
.btn-send { color: var(--green); border-color: var(--green); }
|
|
||||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
|
||||||
.sendform input {
|
|
||||||
font-family: inherit; font-size: 1em;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.sendform input:focus { outline: 1px solid var(--purple); }
|
|
||||||
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
|
||||||
.loginform input {
|
|
||||||
font-family: inherit; font-size: 1em;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.loginform input:focus { outline: 1px solid var(--purple); }
|
|
||||||
pre.diff {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.6em 0.8em;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 30em;
|
|
||||||
}
|
|
||||||
.live {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 32em;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.live .row {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
padding: 0.05em 0;
|
|
||||||
line-height: 1.45;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
padding-left: 0.5em;
|
|
||||||
margin: 0.1em 0;
|
|
||||||
}
|
|
||||||
.live .row + .row { border-top: 0; }
|
|
||||||
.live .turn-start {
|
|
||||||
color: var(--amber);
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 1em;
|
|
||||||
border-left-color: var(--amber);
|
|
||||||
padding-top: 0.3em;
|
|
||||||
}
|
|
||||||
.live .turn-start:first-child { margin-top: 0; }
|
|
||||||
.live .turn-body {
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: normal;
|
|
||||||
margin-top: 0.15em;
|
|
||||||
padding-left: 1.2em;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.live .turn-end-ok { color: #66ff99; border-left-color: #66ff99; margin-bottom: 0.4em; }
|
|
||||||
.live .turn-end-fail { color: #ff6b6b; border-left-color: #ff6b6b; margin-bottom: 0.4em; }
|
|
||||||
.live .text { color: var(--fg); padding-left: 1.2em; }
|
|
||||||
.live .thinking { color: var(--muted); font-style: italic; padding-left: 1.2em; }
|
|
||||||
.live .tool-use { color: #66e0ff; padding-left: 1.2em; }
|
|
||||||
.live .tool-result { color: var(--muted); padding-left: 1.2em; }
|
|
||||||
.live .result { color: var(--green); padding-left: 0.5em; }
|
|
||||||
.live .sys, .live .note { color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
"#;
|
|
||||||
|
|
|
||||||
38
hive-c0re/assets/async_forms.js
Normal file
38
hive-c0re/assets/async_forms.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Generic async submit + spinner for any `<form data-async>`.
|
||||||
|
// Replaces the standard form-POST navigation: button shows a spinner during
|
||||||
|
// the request, `data-confirm` runs first (skips the action if cancelled),
|
||||||
|
// page reloads on success so the new state is reflected.
|
||||||
|
(() => {
|
||||||
|
document.querySelectorAll('form[data-async]').forEach(form => {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form.dataset.confirm && !confirm(form.dataset.confirm)) return;
|
||||||
|
const btn = form.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||||
|
const original = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner">◐</span>';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(form.action, {
|
||||||
|
method: form.method || 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert('action failed: ' + err);
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
209
hive-c0re/assets/dashboard.css
Normal file
209
hive-c0re/assets/dashboard.css
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0a0014;
|
||||||
|
--bg-elev: #18002a;
|
||||||
|
--fg: #e0d4ff;
|
||||||
|
--muted: #6c5c8c;
|
||||||
|
--purple: #cc66ff;
|
||||||
|
--purple-dim: #4a1a6a;
|
||||||
|
--cyan: #00ffff;
|
||||||
|
--pink: #ff3399;
|
||||||
|
--amber: #ffaa00;
|
||||||
|
--green: #00ff88;
|
||||||
|
--red: #ff4466;
|
||||||
|
--border: #2a0a4a;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
|
max-width: 70em;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
padding: 0 1.5em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
color: var(--purple);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
color: var(--purple);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
margin-top: 2em;
|
||||||
|
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
color: var(--purple-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
ul { list-style: none; padding-left: 0; }
|
||||||
|
li { padding: 0.5em 0; }
|
||||||
|
.glyph { color: var(--purple); margin-right: 0.5em; }
|
||||||
|
a {
|
||||||
|
color: var(--cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 0 4px rgba(0, 255, 255, 0.5);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.role {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(255, 51, 153, 0.1); }
|
||||||
|
.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(255, 170, 0, 0.1); }
|
||||||
|
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
|
||||||
|
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
|
||||||
|
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
|
||||||
|
.empty { color: var(--muted); font-style: italic; }
|
||||||
|
code {
|
||||||
|
color: var(--amber);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
|
||||||
|
.approvals form.inline { display: inline; margin-left: 0.4em; }
|
||||||
|
ul form.inline { display: inline-block; }
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid;
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 0 4px currentColor;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; }
|
||||||
|
.btn-approve { color: var(--green); border-color: var(--green); }
|
||||||
|
.btn-deny { color: var(--red); border-color: var(--red); }
|
||||||
|
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||||
|
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||||
|
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
|
||||||
|
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
||||||
|
.spawnform input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.spawnform input::placeholder { color: var(--muted); }
|
||||||
|
.spawnform input:focus { outline: 1px solid var(--purple); }
|
||||||
|
.role-pending { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.btn-inline {
|
||||||
|
font-family: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
}
|
||||||
|
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
|
||||||
|
.kind {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
color: var(--purple-dim);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.kind-spawn { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.talkform {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6em;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.talkform select, .talkform input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
.talkform select { color: var(--amber); }
|
||||||
|
.talkform input { flex: 1; }
|
||||||
|
.talkform input::placeholder { color: var(--muted); }
|
||||||
|
.talkform input:focus, .talkform select:focus { outline: 1px solid var(--purple); }
|
||||||
|
details { margin-top: 0.5em; }
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
summary:hover { color: var(--purple); }
|
||||||
|
.diff {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.8em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.diff span { display: block; }
|
||||||
|
.diff .diff-add { color: var(--green); }
|
||||||
|
.diff .diff-del { color: var(--red); }
|
||||||
|
.diff .diff-hunk { color: var(--cyan); }
|
||||||
|
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
||||||
|
.diff .diff-ctx { color: var(--fg); }
|
||||||
|
.msgflow {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.8em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 32em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.msgrow { display: grid; grid-template-columns: auto auto auto auto auto 1fr; gap: 0.6em; align-items: baseline; padding: 0.1em 0; }
|
||||||
|
.msgrow.sent .msg-arrow { color: var(--cyan); }
|
||||||
|
.msgrow.delivered .msg-arrow { color: var(--green); }
|
||||||
|
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
||||||
|
.msg-arrow { font-weight: bold; }
|
||||||
|
.msg-from { color: var(--amber); }
|
||||||
|
.msg-sep { color: var(--muted); }
|
||||||
|
.msg-to { color: var(--pink); }
|
||||||
|
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
footer {
|
||||||
|
margin-top: 4em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
footer a { color: var(--purple); }
|
||||||
30
hive-c0re/assets/msg_flow.js
Normal file
30
hive-c0re/assets/msg_flow.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
(() => {
|
||||||
|
const flow = document.getElementById('msgflow');
|
||||||
|
if (!flow) return;
|
||||||
|
flow.innerHTML = '';
|
||||||
|
const es = new EventSource('/messages/stream');
|
||||||
|
const MAX_ROWS = 200;
|
||||||
|
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||||
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
let m;
|
||||||
|
try { m = JSON.parse(e.data); } catch { return; }
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'msgrow ' + m.kind;
|
||||||
|
const kind = m.kind === 'sent' ? '→' : '✓';
|
||||||
|
row.innerHTML =
|
||||||
|
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
|
||||||
|
'<span class="msg-arrow">' + kind + '</span>' +
|
||||||
|
'<span class="msg-from">' + esc(m.from) + '</span>' +
|
||||||
|
'<span class="msg-sep">→</span>' +
|
||||||
|
'<span class="msg-to">' + esc(m.to) + '</span>' +
|
||||||
|
'<span class="msg-body">' + esc(m.body) + '</span>';
|
||||||
|
flow.insertBefore(row, flow.firstChild);
|
||||||
|
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
flow.insertBefore(Object.assign(document.createElement('div'), {
|
||||||
|
className: 'msgrow meta', textContent: '[connection lost — retrying]'
|
||||||
|
}), flow.firstChild);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -82,7 +82,7 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
|
||||||
};
|
};
|
||||||
|
|
||||||
Html(format!(
|
Html(format!(
|
||||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
|
||||||
containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
|
containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +181,7 @@ fn render_containers(
|
||||||
let mut out = String::from(
|
let mut out = String::from(
|
||||||
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
||||||
);
|
);
|
||||||
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
|
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\" data-async>\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
|
||||||
// Render in-flight spawns first so the operator sees feedback immediately.
|
// Render in-flight spawns first so the operator sees feedback immediately.
|
||||||
if !transient.is_empty() {
|
if !transient.is_empty() {
|
||||||
out.push_str("<ul>\n");
|
out.push_str("<ul>\n");
|
||||||
|
|
@ -212,7 +212,7 @@ fn render_containers(
|
||||||
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
|
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span></li>",
|
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span>\n <form method=\"POST\" action=\"/rebuild/{MANAGER_NAME}\" class=\"inline\" data-async data-confirm=\"rebuild manager? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n</li>",
|
||||||
);
|
);
|
||||||
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
||||||
let port = lifecycle::agent_web_port(name);
|
let port = lifecycle::agent_web_port(name);
|
||||||
|
|
@ -227,7 +227,7 @@ fn render_containers(
|
||||||
let update_badge = update_badge_for(name, current_rev);
|
let update_badge = update_badge_for(name, current_rev);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
|
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" data-async data-confirm=\"destroy {name}? container is removed; state + creds kept.\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -249,18 +249,18 @@ async fn render_approvals(approvals: &[Approval]) -> String {
|
||||||
hive_sh4re::ApprovalKind::ApplyCommit => {
|
hive_sh4re::ApprovalKind::ApplyCommit => {
|
||||||
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
|
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
|
||||||
let diff = approval_diff(&a.agent, &a.commit_ref).await;
|
let diff = approval_diff(&a.agent, &a.commit_ref).await;
|
||||||
|
let diff_html = render_diff_lines(&diff);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
|
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff_html}</pre></details>\n</li>",
|
||||||
id = a.id,
|
id = a.id,
|
||||||
agent = a.agent,
|
agent = a.agent,
|
||||||
diff = html_escape(&diff),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
hive_sh4re::ApprovalKind::Spawn => {
|
hive_sh4re::ApprovalKind::Spawn => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
|
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
|
||||||
id = a.id,
|
id = a.id,
|
||||||
agent = a.agent,
|
agent = a.agent,
|
||||||
);
|
);
|
||||||
|
|
@ -294,6 +294,33 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a unified diff with per-line CSS classes so the dashboard can
|
||||||
|
/// colour adds / dels / hunk headers / context. Each line becomes a
|
||||||
|
/// `<span>` tagged by its leading character; the wrapping `<pre>` keeps
|
||||||
|
/// whitespace intact.
|
||||||
|
fn render_diff_lines(diff: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for raw in diff.lines() {
|
||||||
|
let cls = match raw.as_bytes().first() {
|
||||||
|
// file headers (`--- a/...` / `+++ b/...`) come before any
|
||||||
|
// line starting with a single `+`/`-`. similar-rs emits them
|
||||||
|
// with the doubled prefix.
|
||||||
|
_ if raw.starts_with("--- ") => "diff-file",
|
||||||
|
_ if raw.starts_with("+++ ") => "diff-file",
|
||||||
|
Some(b'@') => "diff-hunk",
|
||||||
|
Some(b'+') => "diff-add",
|
||||||
|
Some(b'-') => "diff-del",
|
||||||
|
_ => "diff-ctx",
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"<span class=\"{cls}\">{}</span>",
|
||||||
|
html_escape(raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns either an empty string (agent is up-to-date / no rev known) or
|
/// Returns either an empty string (agent is up-to-date / no rev known) or
|
||||||
/// a clickable "needs update" badge whose form POSTs to /rebuild/<name>.
|
/// a clickable "needs update" badge whose form POSTs to /rebuild/<name>.
|
||||||
fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
|
fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
|
||||||
|
|
@ -304,7 +331,7 @@ fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" onsubmit=\"return confirm('rebuild {name}? hot-reloads the container.');\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
|
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,252 +407,34 @@ const BANNER: &str = r#"<pre class="banner">
|
||||||
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
||||||
</pre>"#;
|
</pre>"#;
|
||||||
|
|
||||||
|
/// Generic async submit + spinner for any `<form data-async>`. Replaces
|
||||||
|
/// the standard form-POST navigation: button shows a spinner during the
|
||||||
|
/// request, `data-confirm` runs first (skips the action if cancelled),
|
||||||
|
/// page reloads on success so the new state is reflected.
|
||||||
|
const ASYNC_FORMS_JS: &str = concat!(
|
||||||
|
"<script>\n",
|
||||||
|
include_str!("../assets/async_forms.js"),
|
||||||
|
"</script>",
|
||||||
|
);
|
||||||
|
|
||||||
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
|
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
|
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
|
||||||
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>"#;
|
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>"#;
|
||||||
|
|
||||||
const MSG_FLOW_JS: &str = r#"<script>
|
const MSG_FLOW_JS: &str = concat!(
|
||||||
(() => {
|
"<script>\n",
|
||||||
const flow = document.getElementById('msgflow');
|
include_str!("../assets/msg_flow.js"),
|
||||||
if (!flow) return;
|
"</script>",
|
||||||
flow.innerHTML = '';
|
);
|
||||||
const es = new EventSource('/messages/stream');
|
|
||||||
const MAX_ROWS = 200;
|
|
||||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
|
||||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
||||||
es.onmessage = (e) => {
|
|
||||||
let m;
|
|
||||||
try { m = JSON.parse(e.data); } catch { return; }
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'msgrow ' + m.kind;
|
|
||||||
const kind = m.kind === 'sent' ? '→' : '✓';
|
|
||||||
row.innerHTML =
|
|
||||||
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
|
|
||||||
'<span class="msg-arrow">' + kind + '</span>' +
|
|
||||||
'<span class="msg-from">' + esc(m.from) + '</span>' +
|
|
||||||
'<span class="msg-sep">→</span>' +
|
|
||||||
'<span class="msg-to">' + esc(m.to) + '</span>' +
|
|
||||||
'<span class="msg-body">' + esc(m.body) + '</span>';
|
|
||||||
flow.insertBefore(row, flow.firstChild);
|
|
||||||
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
|
|
||||||
};
|
|
||||||
es.onerror = () => {
|
|
||||||
flow.insertBefore(Object.assign(document.createElement('div'), {
|
|
||||||
className: 'msgrow meta', textContent: '[connection lost — retrying]'
|
|
||||||
}), flow.firstChild);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>"#;
|
|
||||||
|
|
||||||
const FOOTER: &str = r#"<footer>
|
const FOOTER: &str = r#"<footer>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||||
</footer>"#;
|
</footer>"#;
|
||||||
|
|
||||||
const STYLE: &str = r#"
|
const STYLE: &str = concat!(
|
||||||
<style>
|
"<style>\n",
|
||||||
:root {
|
include_str!("../assets/dashboard.css"),
|
||||||
--bg: #0a0014;
|
"</style>",
|
||||||
--bg-elev: #18002a;
|
);
|
||||||
--fg: #e0d4ff;
|
|
||||||
--muted: #6c5c8c;
|
|
||||||
--purple: #cc66ff;
|
|
||||||
--purple-dim: #4a1a6a;
|
|
||||||
--cyan: #00ffff;
|
|
||||||
--pink: #ff3399;
|
|
||||||
--amber: #ffaa00;
|
|
||||||
--green: #00ff88;
|
|
||||||
--red: #ff4466;
|
|
||||||
--border: #2a0a4a;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
||||||
max-width: 70em;
|
|
||||||
margin: 1.5em auto;
|
|
||||||
padding: 0 1.5em;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.banner {
|
|
||||||
color: var(--purple);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
h1, h2 {
|
|
||||||
color: var(--purple);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
margin-top: 2em;
|
|
||||||
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
|
||||||
}
|
|
||||||
.divider {
|
|
||||||
color: var(--purple-dim);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
ul { list-style: none; padding-left: 0; }
|
|
||||||
li { padding: 0.5em 0; }
|
|
||||||
.glyph { color: var(--purple); margin-right: 0.5em; }
|
|
||||||
a {
|
|
||||||
color: var(--cyan);
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow: 0 0 4px rgba(0, 255, 255, 0.5);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #fff;
|
|
||||||
text-shadow: 0 0 12px rgba(0, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
.role {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.4em;
|
|
||||||
padding: 0.05em 0.5em;
|
|
||||||
border: 1px solid;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(255, 51, 153, 0.1); }
|
|
||||||
.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(255, 170, 0, 0.1); }
|
|
||||||
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
|
|
||||||
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
|
|
||||||
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
|
|
||||||
.empty { color: var(--muted); font-style: italic; }
|
|
||||||
code {
|
|
||||||
color: var(--amber);
|
|
||||||
background: var(--bg-elev);
|
|
||||||
padding: 0.1em 0.4em;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
|
|
||||||
.approvals form.inline { display: inline; margin-left: 0.4em; }
|
|
||||||
ul form.inline { display: inline-block; }
|
|
||||||
.btn {
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid;
|
|
||||||
padding: 0.25em 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
text-shadow: 0 0 4px currentColor;
|
|
||||||
}
|
|
||||||
.btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; }
|
|
||||||
.btn-approve { color: var(--green); border-color: var(--green); }
|
|
||||||
.btn-deny { color: var(--red); border-color: var(--red); }
|
|
||||||
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
|
||||||
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
|
|
||||||
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
|
||||||
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
|
||||||
.spawnform input {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.spawnform input::placeholder { color: var(--muted); }
|
|
||||||
.spawnform input:focus { outline: 1px solid var(--purple); }
|
|
||||||
.role-pending { color: var(--amber); border-color: var(--amber); }
|
|
||||||
.btn-inline {
|
|
||||||
font-family: inherit;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 0.4em;
|
|
||||||
}
|
|
||||||
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
|
|
||||||
.kind {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.4em;
|
|
||||||
padding: 0.05em 0.5em;
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
color: var(--purple-dim);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.kind-spawn { color: var(--amber); border-color: var(--amber); }
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
||||||
.talkform {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6em;
|
|
||||||
align-items: stretch;
|
|
||||||
margin-top: 0.5em;
|
|
||||||
}
|
|
||||||
.talkform select, .talkform input {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
}
|
|
||||||
.talkform select { color: var(--amber); }
|
|
||||||
.talkform input { flex: 1; }
|
|
||||||
.talkform input::placeholder { color: var(--muted); }
|
|
||||||
.talkform input:focus, .talkform select:focus { outline: 1px solid var(--purple); }
|
|
||||||
details { margin-top: 0.5em; }
|
|
||||||
summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
summary:hover { color: var(--purple); }
|
|
||||||
.diff {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.8em;
|
|
||||||
margin-top: 0.4em;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--fg);
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
.msgflow {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.8em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 32em;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.msgrow { display: grid; grid-template-columns: auto auto auto auto auto 1fr; gap: 0.6em; align-items: baseline; padding: 0.1em 0; }
|
|
||||||
.msgrow.sent .msg-arrow { color: var(--cyan); }
|
|
||||||
.msgrow.delivered .msg-arrow { color: var(--green); }
|
|
||||||
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
|
||||||
.msg-arrow { font-weight: bold; }
|
|
||||||
.msg-from { color: var(--amber); }
|
|
||||||
.msg-sep { color: var(--muted); }
|
|
||||||
.msg-to { color: var(--pink); }
|
|
||||||
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
|
||||||
footer {
|
|
||||||
margin-top: 4em;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
footer a { color: var(--purple); }
|
|
||||||
</style>
|
|
||||||
"#;
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue