From e2ed58c1a7078b9c717bb5e0b50b4dbd0604df93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:17:48 +0200 Subject: [PATCH 1/4] dashboard: per-line color on approval diffs --- hive-c0re/src/dashboard.rs | 39 +++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 37e3fbd7..eb55d9e0 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -249,12 +249,12 @@ async fn render_approvals(approvals: &[Approval]) -> String { hive_sh4re::ApprovalKind::ApplyCommit => { let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; let diff = approval_diff(&a.agent, &a.commit_ref).await; + let diff_html = render_diff_lines(&diff); let _ = writeln!( out, - "
  • \n
    #{id} {agent} apply {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff}
    \n
  • ", + "
  • \n
    #{id} {agent} apply {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff_html}
    \n
  • ", id = a.id, agent = a.agent, - diff = html_escape(&diff), ); } hive_sh4re::ApprovalKind::Spawn => { @@ -294,6 +294,33 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { .collect() } +/// Render a unified diff with per-line CSS classes so the dashboard can +/// colour adds / dels / hunk headers / context. Each line becomes a +/// `` tagged by its leading character; the wrapping `
    ` 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,
    +            "{}",
    +            html_escape(raw),
    +        );
    +    }
    +    out
    +}
    +
     /// Returns either an empty string (agent is up-to-date / no rev known) or
     /// a clickable "needs update" badge whose form POSTs to /rebuild/.
     fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
    @@ -599,9 +626,15 @@ const STYLE: &str = r#"
         overflow-x: auto;
         font-size: 0.85em;
         line-height: 1.4;
    -    color: var(--fg);
    +    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);
    
    From 8fbee4fbf2a25ee0f79294572041f644c7374abe Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?m=C3=BCde?= 
    Date: Fri, 15 May 2026 16:21:25 +0200
    Subject: [PATCH 2/4] dashboard: async forms with spinner + rebuild button on
     every container
    
    ---
     hive-c0re/src/dashboard.rs | 56 +++++++++++++++++++++++++++++++++-----
     1 file changed, 49 insertions(+), 7 deletions(-)
    
    diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
    index eb55d9e0..77fb0e42 100644
    --- a/hive-c0re/src/dashboard.rs
    +++ b/hive-c0re/src/dashboard.rs
    @@ -82,7 +82,7 @@ async fn index(headers: HeaderMap, State(state): State) -> Html\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n",
    +        "\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n\n\n",
             containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
         ))
     }
    @@ -181,7 +181,7 @@ fn render_containers(
         let mut out = String::from(
             "

    ◆ C0NTAINERS ◆

    \n
    ══════════════════════════════════════════════════════════════
    \n", ); - out.push_str("
    \n \n \n
    \n

    spawn requests queue as approvals. operator approves below to actually create the container.

    \n"); + out.push_str("
    \n \n \n
    \n

    spawn requests queue as approvals. operator approves below to actually create the container.

    \n"); // Render in-flight spawns first so the operator sees feedback immediately. if !transient.is_empty() { out.push_str("
      \n"); @@ -212,7 +212,7 @@ fn render_containers( let update_badge = update_badge_for(MANAGER_NAME, current_rev); let _ = writeln!( out, - "
    • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}
    • ", + "
    • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}\n
      \n
    • ", ); } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let port = lifecycle::agent_web_port(name); @@ -227,7 +227,7 @@ fn render_containers( let update_badge = update_badge_for(name, current_rev); let _ = writeln!( out, - "
    • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
      \n
    • ", + "
    • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
      \n
      \n
    • ", ); } } @@ -252,7 +252,7 @@ async fn render_approvals(approvals: &[Approval]) -> String { let diff_html = render_diff_lines(&diff); let _ = writeln!( out, - "
    • \n
      #{id} {agent} apply {sha_short}\n
      \n
      \n
      \n
      diff vs applied
      {diff_html}
      \n
    • ", + "
    • \n
      #{id} {agent} apply {sha_short}\n
      \n
      \n
      \n
      diff vs applied
      {diff_html}
      \n
    • ", id = a.id, agent = a.agent, ); @@ -260,7 +260,7 @@ async fn render_approvals(approvals: &[Approval]) -> String { hive_sh4re::ApprovalKind::Spawn => { let _ = writeln!( out, - "
    • \n
      #{id} {agent} spawn new sub-agent — container will be created on approve\n
      \n
      \n
      \n
    • ", + "
    • \n
      #{id} {agent} spawn new sub-agent — container will be created on approve\n
      \n
      \n
      \n
    • ", id = a.id, agent = a.agent, ); @@ -331,7 +331,7 @@ fn update_badge_for(name: &str, current_rev: Option<&str>) -> String { return String::new(); } format!( - "
      ", + "
      ", ) } @@ -407,6 +407,47 @@ const BANNER: &str = r#""#; +/// Generic async submit + spinner for any `
      `. 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 = r#""#; + const MSG_FLOW: &str = r#"

      ◆ MESS4GE FL0W ◆

      ══════════════════════════════════════════════════════════════

      live tail — newest at the top. tap on every send / recv through the broker.

      @@ -551,6 +592,7 @@ const STYLE: &str = r#" .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; } From e9b213690eb5854499f2eabc094566aeb724bc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:27:51 +0200 Subject: [PATCH 3/4] dedupe: lift drive_turn/emit_turn_end/wait_for_login into hive_ag3nt::turn --- hive-ag3nt/src/bin/hive-ag3nt.rs | 84 +++++--------------------------- hive-ag3nt/src/bin/hive-m1nd.rs | 72 ++------------------------- hive-ag3nt/src/turn.rs | 65 +++++++++++++++++++++++- 3 files changed, 81 insertions(+), 140 deletions(-) diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index e421780f..81e05c5f 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -91,11 +91,15 @@ async fn main() -> Result<()> { LoginState::NeedsLogin => { // Partial-run mode: keep the harness alive (so the web UI // stays bound) but don't drive the turn loop. Poll the - // claude dir periodically so a successful login (whether - // from the dashboard PTY path in step 4 or via - // `root-login` + `claude auth login` in the meantime) - // transitions us into the turn loop without a restart. - needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms, bus).await + // claude dir; once a session lands we enter `serve`. + turn::wait_for_login(&claude_dir, login_state.clone(), poll_ms).await; + serve( + &cli.socket, + 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>, - 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( socket: &Path, interval: Duration, @@ -158,8 +138,9 @@ async fn serve( body: body.clone(), }); let prompt = format_wake_prompt(&label, &from, &body); - let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await; - emit_turn_end(&bus, &outcome); + let outcome = + turn::drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await; + turn::emit_turn_end(&bus, &outcome); } Ok(AgentResponse::Empty) => {} 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 /// 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 diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index a4c2634a..f32393f0 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -92,11 +92,8 @@ async fn main() -> Result<()> { match initial { LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await, LoginState::NeedsLogin => { - tracing::warn!( - claude_dir = %claude_dir.display(), - "manager has no claude session — staying in partial-run mode" - ); - needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms, bus).await + turn::wait_for_login(&claude_dir, login_state, poll_ms).await; + serve(&cli.socket, Duration::from_millis(poll_ms), bus).await } } } @@ -126,26 +123,6 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> { 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>, - 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<()> { tracing::info!(socket = %socket.display(), "hive-m1nd serve"); 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(), }); let prompt = format_wake_prompt(&label, &from, &body); - let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; - emit_turn_end(&bus, &outcome); + let outcome = + turn::drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; + turn::emit_turn_end(&bus, &outcome); } Ok(ManagerResponse::Empty) => {} 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 /// prompt doesn't have access to, and points the manager at its own /// editable config repo for self-modification. diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 5af016b9..387fe199 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -5,14 +5,16 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::{Result, bail}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use crate::events::{Bus, LiveEvent}; +use crate::login::{self, LoginState}; use crate::mcp; /// Inline `--settings` JSON applied to every claude invocation. We turn off @@ -59,6 +61,67 @@ pub enum TurnOutcome { 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>, 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 /// live event bus. Prompt goes over stdin (variadic /// `--allowedTools`/`--tools` would otherwise eat a trailing positional From edf42b7e93767511265318c31182fed1d5d8f204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:32:35 +0200 Subject: [PATCH 4/4] extract dashboard + agent CSS/JS to assets/ (include_str!) --- hive-ag3nt/assets/agent.css | 140 +++++++++++++++ hive-ag3nt/assets/live.js | 103 +++++++++++ hive-ag3nt/src/web_ui.rs | 265 ++-------------------------- hive-c0re/assets/async_forms.js | 38 ++++ hive-c0re/assets/dashboard.css | 209 ++++++++++++++++++++++ hive-c0re/assets/msg_flow.js | 30 ++++ hive-c0re/src/dashboard.rs | 296 ++------------------------------ 7 files changed, 547 insertions(+), 534 deletions(-) create mode 100644 hive-ag3nt/assets/agent.css create mode 100644 hive-ag3nt/assets/live.js create mode 100644 hive-c0re/assets/async_forms.js create mode 100644 hive-c0re/assets/dashboard.css create mode 100644 hive-c0re/assets/msg_flow.js diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css new file mode 100644 index 00000000..1943405a --- /dev/null +++ b/hive-ag3nt/assets/agent.css @@ -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); } diff --git a/hive-ag3nt/assets/live.js b/hive-ag3nt/assets/live.js new file mode 100644 index 00000000..28200e40 --- /dev/null +++ b/hive-ag3nt/assets/live.js @@ -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]'); + }; +})(); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index d6cd09f9..df88324a 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -132,115 +132,13 @@ fn render_online(label: &str) -> String { /// 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 = r#" -

      live

      -
      connecting…
      - -"#; +const LIVE_PANEL: &str = concat!( + "

      live

      \n", + "
      connecting…
      \n", + "", +); fn render_needs_login_idle() -> String { "

      ▓█▓▒░ NEEDS L0G1N ▓█▓▒░

      \n

      No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

      \n\n \n\n

      Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

      ".into() @@ -394,147 +292,8 @@ fn html_escape(s: &str) -> String { .replace('"', """) } -const STYLE: &str = r#" - -"#; +const STYLE: &str = concat!( + "", +); diff --git a/hive-c0re/assets/async_forms.js b/hive-c0re/assets/async_forms.js new file mode 100644 index 00000000..1fe78344 --- /dev/null +++ b/hive-c0re/assets/async_forms.js @@ -0,0 +1,38 @@ +// Generic async submit + spinner for any `
      `. +// 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 = ''; + } + 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; } + } + }); + }); +})(); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css new file mode 100644 index 00000000..f2f17415 --- /dev/null +++ b/hive-c0re/assets/dashboard.css @@ -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); } diff --git a/hive-c0re/assets/msg_flow.js b/hive-c0re/assets/msg_flow.js new file mode 100644 index 00000000..b7930737 --- /dev/null +++ b/hive-c0re/assets/msg_flow.js @@ -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 = + '' + tsFmt(m.at) + '' + + '' + kind + '' + + '' + esc(m.from) + '' + + '' + + '' + esc(m.to) + '' + + '' + esc(m.body) + ''; + 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); + }; +})(); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 77fb0e42..6492c8ed 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -411,296 +411,30 @@ const BANNER: &str = r#"