({ status: "idle" });
+
+ async function submit(e: Event) {
+ e.preventDefault();
+ setResult({ status: "submitting" });
+ try {
+ const r = await fetch(
+ `/api/agents/${encodeURIComponent(agentName)}/input`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ body }),
+ },
+ );
+ if (!r.ok) {
+ setResult({ status: "error", problem: await readApiError(r) });
+ return;
+ }
+ setResult({ status: "sent" });
+ setBody("");
+ } catch (err) {
+ setResult({ status: "error", problem: { detail: String(err) } });
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
index bcf0e43f..7d0f54e1 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
@@ -1,7 +1,7 @@
// — the MVP mara's own ruling asked for: not a separate
-// page, a small read-only preview embedded below `AgentsPage`'s detail
-// panel fields. No input — sending input back to the agent is explicitly
-// follow-up scope, not this issue's. Consumes swarm-controller's
+// page, a small preview embedded below `AgentsPage`'s detail panel fields, with
+// an `AgentInputForm` under an agent's terminal (a subagent's has none: it
+// takes no input of its own). Consumes swarm-controller's
// `GET /api/agents/{name}/term/stream`, or for a subagent
// `GET /api/agents/{name}/subagents/{subagent}/term/stream`, via
// `useSwarmTermStream`, rendering through the same `@hive/shared` `Row`
@@ -49,6 +49,7 @@ import {
useSwarmAgentStateStream,
type TokenUsage,
} from "./useSwarmAgentStateStream.js";
+import { AgentInputForm } from "./AgentInputForm.js";
import "./AgentTermPreview.css";
const NEAR_BOTTOM_PX = 48;
@@ -256,6 +257,7 @@ export function AgentTermPreview({
)}
+ {subagent === undefined && }
);
}
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
index 149b6a4f..cebaaad6 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
@@ -1,10 +1,8 @@
// — the swarm terminal tab's full page, mounted at
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (below `AgentsPage`'s
-// detail panel) is explicitly capped and read-only; this is that same
+// detail panel) is height-capped; this is that same
// component's `fullHeight` mount — no 12em cap, floating header badges
-// shown (there's no detail-panel `` here to duplicate them). Sending
-// input back to the agent is still explicit follow-up scope, same as the
-// preview's own file comment says — this page doesn't change that.
+// shown (there's no detail-panel `` here to duplicate them).
//
// Registers itself in the dynamic-tabs row on mount (not just when opened
// via the preview's expand trigger) — a bookmark, a shared link, or a
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
index 911cada4..a6c70284 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
@@ -651,9 +651,7 @@ export function AgentsPage() {
}
/>
- {/* MVP scope per mara's own ruling: a small read-only
- preview, no header/no input — the full terminal
- (+ sending input back) is explicit follow-up scope. */}
+ {/* The agent's live terminal and its input box. */}
(socket: &Path, poll_ms: u64) -> Result<()> {
) {
tracing::warn!(error = ?e, "legacy db_migrate failed — continuing with fresh/partial state db");
}
- let (reminder_tx, reminder_rx) = tokio::sync::mpsc::unbounded_channel();
+ // Messages made inside this container rather than by the broker:
+ // reminders and the swarm UI's operator input.
+ let (local_msg_tx, local_msg_rx) = tokio::sync::mpsc::unbounded_channel();
+ swarm_input::spawn(local_msg_tx.clone());
let reminder_store = match reminders::Reminders::open(&paths::state_db()) {
Ok(store) => Some(Arc::new(store)),
Err(e) => {
@@ -603,7 +607,7 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> {
None
}
};
- tokio::spawn(reminder_timer::run(reminder_store.clone(), reminder_tx));
+ tokio::spawn(reminder_timer::run(reminder_store.clone(), local_msg_tx));
let (todo_wake, todos_store) = spawn_todo_socket(reminder_store.clone(), &bus);
if matches!(initial, LoginState::NeedsLogin) {
login::wait_for_login(
@@ -630,7 +634,7 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> {
&files,
todo_wake,
todos_store,
- reminder_rx,
+ local_msg_rx,
interrupted,
)
.await
@@ -665,7 +669,7 @@ async fn serve_loop(
files: &turn::TurnFiles,
todo_wake: Arc,
todos_store: Option>,
- mut reminder_rx: tokio::sync::mpsc::UnboundedReceiver,
+ mut local_msg_rx: tokio::sync::mpsc::UnboundedReceiver,
interrupted: Arc,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "harness serve");
@@ -690,7 +694,7 @@ async fn serve_loop(
// Nothing here touches the broker: not calling `S::recv_next` is
// exactly the "messages queue unacked, resume drains the
// backlog" semantic, with no fencing and nothing to requeue.
- // Reminders (unbounded channel) and todo wakes (a `Notify`
+ // Local messages (unbounded channel) and todo wakes (a `Notify`
// permit) buffer on their own. The web UI and MCP daemons run as
// separate tasks, so the agent stays inspectable while parked.
//
@@ -733,7 +737,7 @@ async fn serve_loop(
biased;
o = S::recv_next(socket) => o,
() = todo_wake.notified() => RecvOutcome::LocalTodo,
- Some(dm) = reminder_rx.recv() => RecvOutcome::Message(dm),
+ Some(dm) = local_msg_rx.recv() => RecvOutcome::Message(dm),
}
} {
RecvOutcome::Message(first) => first,
diff --git a/hive-agent/src/swarm_input.rs b/hive-agent/src/swarm_input.rs
new file mode 100644
index 00000000..c77b1702
--- /dev/null
+++ b/hive-agent/src/swarm_input.rs
@@ -0,0 +1,144 @@
+//! Operator input from the swarm UI, taken straight off the swarm queue.
+//!
+//! swarm-controller sends each message on
+//! [`swarm_queue_client::agent_input_subject`] for this agent. This task
+//! subscribes to it and pushes each message into the serve loop's local
+//! message channel, where it becomes a turn from the operator it names.
+//! hive-c0re is not in the path, so the message has no broker row: the hive
+//! dashboard's history and `recv` never see it, and `send` has no recipient
+//! by the sender's name.
+//!
+//! **`from` is taken as given.** The controller is the only client granted
+//! publish on the subject (`swarm-nats-auth`'s reader grant), and it names
+//! the sender from the swarm gateway's authelia answer.
+//!
+//! **Core NATS, no reply.** A message sent while this task is not subscribed
+//! is gone; the controller sends it as a request so the operator gets a 503
+//! for that instead. No agent grant covers a reply subject, so nothing here
+//! answers the request.
+
+use futures_util::{Stream, StreamExt as _};
+use hive_sh4re::inbox::DeliveredMessage;
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::swarm_queue::Connection;
+
+/// Start the subscriber, if this agent has a queue credential and a label to
+/// name its subject with.
+pub fn spawn(tx: UnboundedSender) {
+ if !crate::swarm_queue::configured() {
+ return;
+ }
+ let agent = crate::identity::label();
+ if agent.is_empty() {
+ tracing::warn!("this agent has no label; not taking input from the swarm queue");
+ return;
+ }
+ tokio::spawn(run(tx, agent));
+}
+
+async fn run(tx: UnboundedSender, agent: String) {
+ let Some(Connection { client, .. }) = crate::swarm_queue::client().await else {
+ return;
+ };
+ let subject = swarm_queue_client::agent_input_subject(&agent);
+ let subscriber = match client.subscribe(subject.clone()).await {
+ Ok(subscriber) => subscriber,
+ Err(e) => {
+ tracing::warn!(subject, error = %e, "swarm input: subscribe failed");
+ return;
+ }
+ };
+ tracing::info!(subject, "taking operator input from the swarm queue");
+ forward(subscriber.map(|msg| msg.payload), &tx).await;
+}
+
+/// Push each decodable payload into `tx` until either end closes.
+async fn forward>(
+ payloads: impl Stream- ,
+ tx: &UnboundedSender,
+) {
+ let mut payloads = std::pin::pin!(payloads);
+ while let Some(payload) = payloads.next().await {
+ let Some(dm) = delivered(payload.as_ref()) else {
+ continue;
+ };
+ if tx.send(dm).is_err() {
+ // The serve loop is gone: the harness is shutting down.
+ return;
+ }
+ }
+}
+
+/// The message a payload becomes, or `None` (logged) for one that is not an
+/// [`swarm_queue_client::AgentInput`].
+fn delivered(payload: &[u8]) -> Option {
+ match serde_json::from_slice::(payload) {
+ Ok(input) => {
+ tracing::info!(from = %input.from, "swarm input: message received");
+ Some(DeliveredMessage {
+ from: input.from,
+ body: input.body,
+ id: 0,
+ redelivered: false,
+ in_reply_to: None,
+ })
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "swarm input: undecodable message dropped");
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::forward;
+ use swarm_queue_client::AgentInput;
+
+ fn payload(from: &str, body: &str) -> Vec {
+ serde_json::to_vec(&AgentInput {
+ from: from.to_owned(),
+ body: body.to_owned(),
+ })
+ .expect("encodes")
+ }
+
+ #[tokio::test]
+ async fn each_message_reaches_the_serve_loop_as_from_its_sender() {
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ let payloads = vec![
+ payload("mara", "first"),
+ b"not json".to_vec(),
+ payload("argus", "second"),
+ ];
+ forward(futures_util::stream::iter(payloads), &tx).await;
+ drop(tx);
+
+ let mut got = Vec::new();
+ while let Some(dm) = rx.recv().await {
+ got.push(dm);
+ }
+ // The undecodable one is dropped, the ones either side of it are not.
+ assert_eq!(got.len(), 2, "{got:?}");
+ assert_eq!(
+ (got[0].from.as_str(), got[0].body.as_str()),
+ ("mara", "first")
+ );
+ assert_eq!(
+ (got[1].from.as_str(), got[1].body.as_str()),
+ ("argus", "second")
+ );
+ // No broker row behind it, so no `[msg #id]` tag in the prompt.
+ assert!(got.iter().all(|dm| dm.id == 0 && dm.in_reply_to.is_none()));
+ }
+
+ #[tokio::test]
+ async fn a_closed_serve_loop_stops_the_forwarder() {
+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
+ drop(rx);
+ // An endless stream: returning at all proves the closed channel ended it.
+ let payloads = futures_util::stream::repeat(payload("mara", "hi"));
+ forward(payloads, &tx).await;
+ }
+}
diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix
index 90443482..14bd2889 100644
--- a/nix/host-modules/swarm-ui.nix
+++ b/nix/host-modules/swarm-ui.nix
@@ -215,7 +215,14 @@ in
# nginx forwards, no prefix-stripping to keep in sync by hand.
"/api/" = {
proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:";
- extraConfig = swarmAuthRequest;
+ # `Remote-User` from authelia's answer, set here so it replaces
+ # any the client sent: swarm-controller takes it as the sender of
+ # the text the operator types to an agent (`agent_input.rs`).
+ extraConfig = ''
+ ${swarmAuthRequest}
+ auth_request_set $authelia_user $upstream_http_remote_user;
+ proxy_set_header Remote-User $authelia_user;
+ '';
};
# ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`,
# and that is deliberate rather than an omission.
@@ -247,10 +254,10 @@ in
'';
};
# Session identity for the header's profile menu: initials
- # avatar + "who is this" line. `auth_request` above
- # only ever answers yes/no — it never forwards *who* — so the
- # frontend has no other way to learn this. Same-origin proxy
- # straight to authelia's own `GET /api/user/info`
+ # avatar + "who is this" line. `auth_request` tells the
+ # browser nothing about *who* (only `/api/` passes it on, to the
+ # controller), so the frontend has no other way to learn this.
+ # Same-origin proxy straight to authelia's own `GET /api/user/info`
# (session-cookie authenticated) rather than new
# swarm-controller code: the cookie is already valid here (the
# session cookie's domain is the swarm's, shared across every
diff --git a/swarm-controller/src/agent_input.rs b/swarm-controller/src/agent_input.rs
new file mode 100644
index 00000000..3e6bfef5
--- /dev/null
+++ b/swarm-controller/src/agent_input.rs
@@ -0,0 +1,300 @@
+//! `POST /api/agents/{name}/input` — text the operator types in the swarm UI,
+//! handed to one agent's harness as an inbox turn. No hive is in the path: the
+//! agent subscribes to [`swarm_queue_client::agent_input_subject`] itself.
+//!
+//! **The sender is the `Remote-User` header**, which the gateway sets on
+//! `/api/` from authelia's auth-request answer and so overwrites whatever the
+//! client sent (`nix/host-modules/swarm-ui.nix`). A request without one is
+//! refused rather than sent anonymously.
+//!
+//! **Sent as a NATS request** so that an agent with no subscriber is a 503
+//! instead of text that silently goes nowhere. The agent does not reply: no
+//! agent grant covers a reply subject. A request that times out therefore
+//! means a subscriber held the message, and that is all "sent" means. Core
+//! NATS, no stream: an agent that restarts before its turn loses the text.
+
+use std::time::Duration;
+
+use axum::Json;
+use axum::extract::{Path, State};
+use axum::http::{HeaderMap, StatusCode};
+use problem_details::ProblemDetails;
+use serde::Deserialize;
+use swarm_queue_client::AgentInput;
+use utoipa::ToSchema;
+
+use crate::{AppState, error_problem};
+
+/// The header the gateway names the session's user in.
+const SENDER_HEADER: &str = "remote-user";
+
+/// How long to wait for the server's no-responders answer. It comes back
+/// within a round trip when nothing subscribes; a subscriber sends nothing,
+/// so a send to a live agent always waits this long.
+const NO_RESPONDERS_WINDOW: Duration = Duration::from_secs(2);
+
+/// Text for one agent.
+#[derive(Deserialize, ToSchema)]
+pub struct PostAgentInputRequest {
+ /// What the agent's turn shows as the message body, verbatim. Not logged.
+ body: String,
+}
+
+/// What became of one message.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum Delivery {
+ /// A subscriber held it.
+ Sent,
+ /// Nothing subscribes to the agent's input subject.
+ NotRunning,
+ /// The queue refused or failed the request.
+ Failed(String),
+}
+
+/// Where a message goes: the queue in production, a fake in the tests.
+pub(crate) trait InputSink {
+ fn deliver(&self, subject: String, payload: Vec) -> impl Future