diff --git a/CLAUDE.md b/CLAUDE.md index 1744cd06..08c49675 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,13 +182,6 @@ and the MCP tools. Claude drives any further `recv`/`send` itself — harness no longer relays claude's stdout as a reply. Stdout is logged for debugging; the side effects (sends via MCP) are what matter. -**Operator input** moved from the hive-c0re dashboard's T4LK form to each -per-agent page. The per-agent `/send` POST hits the new -`AgentRequest::OperatorMsg` / `ManagerRequest::OperatorMsg` wire verb, -which enqueues `Message { from: "operator", to: , body }` directly -into the broker. No more global recipient dropdown — one input per agent -page, scoped to that agent. - **Live view.** Each agent runs a `hive_ag3nt::events::Bus` (a `tokio::sync::broadcast` wrapper). The harness emits: - `TurnStart { from, body }` when a wake-up message is popped. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 1b4c4705..b2d8f4c2 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -63,18 +63,8 @@ async fn main() -> Result<()> { let ui_state = login_state.clone(); let bus = Bus::new(); let ui_bus = bus.clone(); - let ui_socket = cli.socket.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve( - label, - port, - ui_state, - ui_bus, - ui_socket, - web_ui::Flavor::Agent, - ) - .await - { + if let Err(e) = web_ui::serve(label, port, ui_state, ui_bus).await { tracing::error!(error = ?e, "web ui failed"); } }); diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index abaae900..9928f288 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -74,18 +74,8 @@ async fn main() -> Result<()> { let ui_state = login_state.clone(); let bus = Bus::new(); let ui_bus = bus.clone(); - let ui_socket = cli.socket.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve( - label, - port, - ui_state, - ui_bus, - ui_socket, - web_ui::Flavor::Manager, - ) - .await - { + if let Err(e) = web_ui::serve(label, port, ui_state, ui_bus).await { tracing::error!(error = ?e, "web ui failed"); } }); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 3f16df27..bca27a45 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -5,7 +5,6 @@ use std::convert::Infallible; use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; @@ -21,7 +20,6 @@ use axum::{ use serde::Deserialize; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; -use crate::client; use crate::events::Bus; use crate::login::LoginState; use crate::login_session::{LoginSession, drop_if_finished}; @@ -37,38 +35,18 @@ struct AppState { login: LoginStateCell, session: Arc>>>, bus: Bus, - socket: PathBuf, - flavor: Flavor, } -/// Which wire protocol the per-agent UI's `/send` handler should speak. -/// Sub-agent → `AgentRequest::OperatorMsg`; manager → `ManagerRequest::OperatorMsg`. -#[derive(Debug, Clone, Copy)] -pub enum Flavor { - Agent, - Manager, -} - -pub async fn serve( - label: String, - port: u16, - login: LoginStateCell, - bus: Bus, - socket: PathBuf, - flavor: Flavor, -) -> Result<()> { +pub async fn serve(label: String, port: u16, login: LoginStateCell, bus: Bus) -> Result<()> { let state = AppState { label, login, session: Arc::new(Mutex::new(None)), bus, - socket, - flavor, }; let app = Router::new() .route("/", get(index)) .route("/events/stream", get(events_stream)) - .route("/send", post(post_send)) .route("/login/start", post(post_login_start)) .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) @@ -87,7 +65,7 @@ async fn index(State(state): State) -> Html { let login = *state.login.lock().unwrap(); let session_snapshot = state.session.lock().unwrap().clone(); let body = match (login, session_snapshot) { - (LoginState::Online, _) => render_online(&state.label), + (LoginState::Online, _) => render_online(), (LoginState::NeedsLogin, None) => render_needs_login_idle(), (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), }; @@ -97,15 +75,9 @@ async fn index(State(state): State) -> Html { )) } -fn render_online(label: &str) -> String { +fn render_online() -> String { format!( - "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n\ -
\n \ - \n \ - \n\ -
\n\ -

enqueued with from: operator on this agent's inbox; the next turn picks it up.

\n\ - {LIVE_PANEL}", + "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n{LIVE_PANEL}", ) } @@ -118,17 +90,15 @@ const LIVE_PANEL: &str = r#" "#; @@ -228,56 +191,10 @@ fn render_login_in_progress(session: &Arc) -> String { ) } -#[derive(Deserialize)] -struct SendForm { - body: String, -} - -async fn post_send(State(state): State, Form(form): Form) -> Response { - let body = form.body.trim().to_owned(); - if body.is_empty() { - return error_response("send: `body` required"); - } - let result = match state.flavor { - Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>( - &state.socket, - &hive_sh4re::AgentRequest::OperatorMsg { body }, - ) - .await - { - Ok(hive_sh4re::AgentResponse::Ok) => Ok(()), - Ok(hive_sh4re::AgentResponse::Err { message }) => Err(message), - Ok(other) => Err(format!("unexpected response: {other:?}")), - Err(e) => Err(format!("transport: {e:#}")), - }, - Flavor::Manager => match client::request::<_, hive_sh4re::ManagerResponse>( - &state.socket, - &hive_sh4re::ManagerRequest::OperatorMsg { body }, - ) - .await - { - Ok(hive_sh4re::ManagerResponse::Ok) => Ok(()), - Ok(hive_sh4re::ManagerResponse::Err { message }) => Err(message), - Ok(other) => Err(format!("unexpected response: {other:?}")), - Err(e) => Err(format!("transport: {e:#}")), - }, - }; - match result { - Ok(()) => Redirect::to("/").into_response(), - Err(e) => error_response(&format!("send failed: {e}")), - } -} - async fn events_stream( State(state): State, ) -> Sse>> { - tracing::info!("sse: client subscribed"); let rx = state.bus.subscribe(); - // Drop a "hello" note into the bus so every new subscriber sees at - // least one event immediately and can clear the connecting placeholder. - state - .bus - .emit(crate::events::LiveEvent::Note("live stream attached".into())); let stream = BroadcastStream::new(rx).filter_map(|res| { let ev = res.ok()?; let json = serde_json::to_string(&ev).ok()?; @@ -407,17 +324,6 @@ const STYLE: &str = r#" .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-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; diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index df1f5996..25250468 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -107,15 +107,5 @@ fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResponse { message: format!("{e:#}"), }, }, - AgentRequest::OperatorMsg { body } => match broker.send(&Message { - from: "operator".to_owned(), - to: agent.to_owned(), - body: body.clone(), - }) { - Ok(()) => AgentResponse::Ok, - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), - }, - }, } } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 37e3fbd7..1dc29716 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -20,7 +20,7 @@ use axum::{ }, routing::{get, post}, }; -use hive_sh4re::Approval; +use hive_sh4re::{Approval, MANAGER_AGENT, Message}; use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -44,6 +44,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/destroy/{name}", post(post_destroy)) .route("/rebuild/{name}", post(post_rebuild)) .route("/request-spawn", post(post_request_spawn)) + .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); @@ -82,11 +83,35 @@ 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{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname), + talk = render_talk(&containers), )) } +#[derive(Deserialize)] +struct SendForm { + to: String, + body: String, +} + +async fn post_send(State(state): State, Form(form): Form) -> Response { + let to = form.to.trim().to_owned(); + let body = form.body.trim().to_owned(); + if to.is_empty() || body.is_empty() { + return error_response("send: `to` and `body` required"); + } + let msg = Message { + from: "operator".into(), + to, + body, + }; + match state.coord.broker.send(&msg) { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(&format!("send failed: {e:#}")), + } +} + async fn messages_stream( State(state): State, ) -> Sse>> { @@ -271,6 +296,25 @@ async fn render_approvals(approvals: &[Approval]) -> String { out } +fn render_talk(containers: &[String]) -> String { + let mut options = String::new(); + let _ = writeln!( + options, + "", + ); + for container in containers { + if container == MANAGER_NAME { + continue; + } + if let Some(name) = container.strip_prefix(AGENT_PREFIX) { + let _ = writeln!(options, ""); + } + } + format!( + "

◆ T4LK ◆

\n
══════════════════════════════════════════════════════════════
\n
\n \n \n \n
\n

sends as from: operator. Replies stream into the message panel below.

\n" + ) +} + /// Filter out approvals whose agent state dir was wiped out from under us /// (e.g. by a test script's cleanup). Marks them failed so they fall out of /// `pending` on next render. diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 38c60057..c474eb6c 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -81,16 +81,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, - ManagerRequest::OperatorMsg { body } => match coord.broker.send(&Message { - from: "operator".to_owned(), - to: MANAGER_AGENT.to_owned(), - body: body.clone(), - }) { - Ok(()) => ManagerResponse::Ok, - Err(e) => ManagerResponse::Err { - message: format!("{e:#}"), - }, - }, ManagerRequest::Status => match coord.broker.count_pending(MANAGER_AGENT) { Ok(unread) => ManagerResponse::Status { unread }, Err(e) => ManagerResponse::Err { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 2d13954a..efb65859 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -151,11 +151,6 @@ pub enum AgentRequest { /// Non-mutating: how many pending messages are addressed to me? /// Used by the harness to render a status line after each tool call. Status, - /// Operator-injected message TO this agent (from this agent's own web - /// UI). Recipient is implicit — `from` is `"operator"`. Effectively the - /// per-agent equivalent of the old dashboard T4LK form, but scoped to - /// the agent whose page the operator is on. - OperatorMsg { body: String }, } /// Responses on a per-agent socket. @@ -216,9 +211,6 @@ pub enum ManagerRequest { /// Non-mutating: pending message count, used to render a status line /// after each MCP tool call (mirrors `AgentRequest::Status`). Status, - /// Operator-injected message TO the manager (from the manager's own web - /// UI). Same shape as `AgentRequest::OperatorMsg`. - OperatorMsg { body: String }, /// Submit a spawn request for the user to approve. On approval the host /// creates and starts the container. Brand-new agent names only — if an /// agent of the same name already exists, the approval will fail.