diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 1482419..47882d3 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -113,6 +113,20 @@ pre.diff { word-break: break-all; max-height: 30em; } +/* Terminal-ish look for the live panel. Crust as bg (almost-black), + slightly inset, mauve phosphor glow. */ +.live.terminal { + background: #11111b; + border: 1px solid var(--purple-dim); + box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7); + border-radius: 4px; + padding: 0.8em 1em; + overflow-y: auto; + max-height: 32em; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + font-size: 0.92em; + color: #cdd6f4; +} .live { background: rgba(255, 255, 255, 0.02); border: 1px solid var(--purple-dim); @@ -121,6 +135,43 @@ pre.diff { max-height: 32em; font-family: inherit; } +.live .unread-badge { + color: var(--amber); + font-weight: normal; + margin-left: 0.6em; + font-size: 0.85em; + text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); +} +details.row { + white-space: normal; + padding-left: 0.5em; +} +details.row > summary { + cursor: pointer; + color: var(--muted); + list-style: none; + white-space: pre-wrap; + word-break: break-word; +} +details.row > summary::before { + content: '▸ '; + color: var(--muted); + display: inline-block; + width: 1em; +} +details.row[open] > summary::before { content: '▾ '; } +details.row.tool-result-block > summary { color: var(--muted); } +details.row > pre.tool-body { + margin: 0.3em 0 0.4em 1.2em; + padding: 0.4em 0.6em; + background: rgba(255, 255, 255, 0.03); + border-left: 2px solid var(--purple-dim); + color: var(--fg); + white-space: pre-wrap; + word-break: break-word; + max-height: 22em; + overflow-y: auto; +} .live .row { white-space: pre-wrap; word-break: break-word; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 54b1e83..cc31b13 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -225,46 +225,101 @@ log.scrollTop = log.scrollHeight; return e; } + function details(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || ''); + const s = document.createElement('summary'); + s.textContent = summary; + d.appendChild(s); + const pre = document.createElement('pre'); + pre.className = 'tool-body'; + pre.textContent = body; + d.appendChild(pre); + log.appendChild(d); + log.scrollTop = log.scrollHeight; + return d; + } function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } + // Pretty-print a tool call: per-known-tool format, fallback to JSON + // for unknown tools. + function fmtToolUse(c) { + const name = c.name || ''; + const input = c.input || {}; + const short = name.startsWith('mcp__hyperhive__') + ? name.slice('mcp__hyperhive__'.length) + '*' : name; + switch (name) { + case 'Read': return short + ' ' + (input.file_path || ''); + case 'Write': return short + ' ' + (input.file_path || ''); + case 'Edit': return short + ' ' + (input.file_path || ''); + case 'Glob': return short + ' ' + (input.pattern || ''); + case 'Grep': return short + ' ' + (input.pattern || ''); + case 'Bash': return short + ' $ ' + (input.command || ''); + case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)'; + case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': ' + + JSON.stringify(input.body || '').slice(0, 80); + case 'mcp__hyperhive__recv': return short + '()'; + case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || ''); + case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); + case 'mcp__hyperhive__request_apply_commit': + return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); + default: return name + ' ' + trim(JSON.stringify(input), 200); + } + } + function renderToolResult(c) { + const txt = Array.isArray(c.content) + ? c.content.map(p => p.text || '').join('') + : (c.content || ''); + const summary = '← ' + (() => { + const trimmed = txt.replace(/\s+/g, ' ').trim(); + if (!trimmed) return '(empty)'; + if (trimmed.length <= 120) return trimmed; + const lines = txt.split('\n').filter(l => l.length).length; + const headline = trimmed.slice(0, 90) + '…'; + return `${lines}L · ${headline}`; + })(); + // For empty / short results, render as a flat row (no expand). + if (!txt.trim() || txt.length <= 120) { + row('tool-result', summary); + } else { + details('tool-result-block', summary, txt); + } + } 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; - } + // Drop session init, claude's result line, rate-limit — they're + // noise. TurnEnd communicates pass/fail; session init data isn't + // actionable. + if (v.type === 'system' && v.subtype === 'init') return; + if (v.type === 'rate_limit_event') return; + if (v.type === 'result') 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)); + else if (c.type === 'thinking') { + const txt = (c.thinking || c.text || '').trim(); + row('thinking', txt ? '· ' + txt : '· thinking …'); + } + else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c)); } 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)); - } + if (c.type === 'tool_result') renderToolResult(c); } return; } - if (v.type === 'result') { - row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : '')); - return; - } row('sys', '· ' + trim(JSON.stringify(v), 200)); } function handle(ev) { if (ev.kind === 'turn_start') { const block = row('turn-start', '◆ TURN ← ' + ev.from); + if (ev.unread > 0) { + const badge = document.createElement('span'); + badge.className = 'unread-badge'; + badge.textContent = '· ' + ev.unread + ' unread'; + block.appendChild(badge); + } const body = document.createElement('div'); body.className = 'turn-body'; body.textContent = ev.body; diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index b65ccd4..bfcdd46 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -10,13 +10,13 @@

◆ … ◆

══════════════════════════════════════════════════════════════
+

live

+
connecting…
+

loading…

-

live

-
connecting…
- diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 4d94fab..ecb9bcc 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -135,11 +135,13 @@ async fn serve( match recv { Ok(AgentResponse::Message { from, body }) => { tracing::info!(%from, %body, "inbox"); + let unread = inbox_unread(socket).await; bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), + unread, }); - let prompt = format_wake_prompt(&from, &body); + let prompt = format_wake_prompt(&from, &body, unread); let outcome = turn::drive_turn( &prompt, &mcp_config, @@ -168,9 +170,24 @@ async fn serve( /// Per-turn user prompt. The role/tools/etc. is in the system prompt /// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the -/// wake signal claude reacts to. -fn format_wake_prompt(from: &str, body: &str) -> String { - format!("Incoming message from `{from}`:\n---\n{body}\n---") +/// wake signal claude reacts to. `unread` is the count of *other* +/// messages in the inbox right after this one was popped. +fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String { + let pending = if unread == 0 { + String::new() + } else { + format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)") + }; + format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}") +} + +/// Best-effort: ask our own per-agent socket how many messages are still +/// pending after the wake-up Recv. Returns 0 if anything goes wrong. +async fn inbox_unread(socket: &Path) -> u64 { + match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await { + Ok(AgentResponse::Status { unread }) => unread, + _ => 0, + } } fn render(resp: &AgentResponse) -> Result<()> { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index e139893..295dd1f 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -151,11 +151,13 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { // so the wake prompt can label it as such. } tracing::info!(%from, %body, "manager inbox"); + let unread = inbox_unread(socket).await; bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), + unread, }); - let prompt = format_wake_prompt(&from, &body); + let prompt = format_wake_prompt(&from, &body, unread); let outcome = turn::drive_turn( &prompt, &mcp_config, @@ -185,7 +187,20 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { /// Per-turn user prompt. The role/tools/etc. is in the system prompt /// (`prompts/manager.md` → `claude --system-prompt-file`); this is just -/// the wake signal. -fn format_wake_prompt(from: &str, body: &str) -> String { - format!("Incoming message from `{from}`:\n---\n{body}\n---") +/// the wake signal. `unread` is the inbox depth after this message was +/// popped. +fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String { + let pending = if unread == 0 { + String::new() + } else { + format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)") + }; + format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}") +} + +async fn inbox_unread(socket: &Path) -> u64 { + match client::request::<_, ManagerResponse>(socket, &ManagerRequest::Status).await { + Ok(ManagerResponse::Status { unread }) => unread, + _ => 0, + } } diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 1449af2..54e5888 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -20,7 +20,13 @@ const CHANNEL_CAPACITY: usize = 256; #[serde(tag = "kind", rename_all = "snake_case")] pub enum LiveEvent { /// Harness popped a wake-up message and is about to invoke claude. - TurnStart { from: String, body: String }, + /// `unread` is the count of *other* messages still in the inbox at + /// that moment — surfaced as a badge in the live panel header. + TurnStart { + from: String, + body: String, + unread: u64, + }, /// One line of claude's `--output-format stream-json` stdout, parsed as /// a generic JSON value (so we don't have to track every claude-code /// event variant). The frontend pretty-prints by `type` field. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index bd16814..c2c7485 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -87,34 +87,18 @@ pub fn format_recv(resp: Result) -> String { } } -/// Format helper for the status peek used in the status line. -pub fn format_status(resp: Result) -> String { - match resp { - Ok(SocketReply::Status(unread)) => format!("{unread} unread message(s) in inbox"), - Ok(other) => format!("status: unexpected response {other:?}"), - Err(e) => format!("status: transport error: {e:#}"), - } -} - -/// Common envelope around every MCP tool handler: pre-log → run → append -/// a status line → post-log. Free function so both `AgentServer` and -/// `ManagerServer` use the same shape; the per-server `status_line` -/// closure is what differs (different `Status` wire types). -pub async fn run_tool_envelope(tool: &'static str, args: String, status: S, body: F) -> String +/// Common envelope around every MCP tool handler: pre-log → run → +/// post-log. The inbox-status hint used to be appended to every tool +/// result; that lives in the wake prompt + UI header now, so tool +/// results stay clean. +pub async fn run_tool_envelope(tool: &'static str, args: String, body: F) -> String where F: Future, - S: Future, { tracing::info!(tool, %args, "tool: request"); let result = body.await; - let status_text = status.await; - let full = if status_text.is_empty() { - result - } else { - format!("{result}\n\n[status] {status_text}") - }; - tracing::info!(tool, result = %full, "tool: result"); - full + tracing::info!(tool, result = %result, "tool: result"); + result } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -141,19 +125,6 @@ impl AgentServer { pub fn new(socket: PathBuf) -> Self { Self { socket } } - - /// Non-mutating peek used in the status line. Falls back to a vague - /// note rather than failing the whole tool call when the socket - /// hiccups. - async fn status_line(&self) -> String { - let resp = client::request::<_, hive_sh4re::AgentResponse>( - &self.socket, - &hive_sh4re::AgentRequest::Status, - ) - .await - .map(SocketReply::from); - format_status(resp) - } } #[tool_router] @@ -165,7 +136,7 @@ impl AgentServer { async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); - run_tool_envelope("send", log, self.status_line(), async move { + run_tool_envelope("send", log, async move { let resp = client::request::<_, hive_sh4re::AgentResponse>( &self.socket, &hive_sh4re::AgentRequest::Send { @@ -185,7 +156,7 @@ impl AgentServer { or an empty marker if nothing is waiting." )] async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), self.status_line(), async move { + run_tool_envelope("recv", String::new(), async move { let resp = client::request::<_, hive_sh4re::AgentResponse>( &self.socket, &hive_sh4re::AgentRequest::Recv, @@ -258,16 +229,6 @@ impl ManagerServer { Self { socket } } - async fn status_line(&self) -> String { - let resp = client::request::<_, hive_sh4re::ManagerResponse>( - &self.socket, - &hive_sh4re::ManagerRequest::Status, - ) - .await - .map(SocketReply::from); - format_status(resp) - } - /// Helper: issue any `ManagerRequest`, convert the reply through /// `SocketReply`. Manager tools that just need an `Ok` ack share this. async fn dispatch( @@ -289,7 +250,7 @@ impl ManagerServer { async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); - run_tool_envelope("send", log, self.status_line(), async move { + run_tool_envelope("send", log, async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::Send { to: args.to, @@ -306,7 +267,7 @@ impl ManagerServer { empty." )] async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), self.status_line(), async move { + run_tool_envelope("recv", String::new(), async move { let resp = self.dispatch(hive_sh4re::ManagerRequest::Recv).await; format_recv(resp) }) @@ -320,7 +281,7 @@ impl ManagerServer { async fn request_spawn(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); - run_tool_envelope("request_spawn", log, self.status_line(), async move { + run_tool_envelope("request_spawn", log, async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }) .await; @@ -340,7 +301,7 @@ impl ManagerServer { async fn kill(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); - run_tool_envelope("kill", log, self.status_line(), async move { + run_tool_envelope("kill", log, async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::Kill { name: args.name }) .await; @@ -364,7 +325,6 @@ impl ManagerServer { run_tool_envelope( "request_apply_commit", log, - self.status_line(), async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {