diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index e1f3881d..7d2b424e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1473,245 +1473,6 @@ window.marked = marked; } return d; } - // Generic args-pretty-printer for unknown / extra-MCP tools. The - // built-in switch handles the common claude/hyperhive tools; this - // is the fallback so an `mcp__matrix__send_message` or similar - // doesn't dump raw JSON. Heuristics: single string-valued field → - // `Name field: "value"`; single dict-valued field → `Name field - // {…}`; otherwise compact JSON. Always trimmed to fit a row. - function fmtArgsGeneric(name, input) { - const keys = Object.keys(input || {}); - if (keys.length === 0) return name + '()'; - if (keys.length === 1) { - const k = keys[0]; - const v = input[k]; - if (typeof v === 'string') { - const oneline = v.replace(/\s+/g, ' ').trim(); - return name + ' ' + k + ': ' + JSON.stringify(trim(oneline, 100)); - } - if (typeof v === 'number' || typeof v === 'boolean') { - return name + ' ' + k + ': ' + JSON.stringify(v); - } - } - // Multi-field: render `k: v` pairs with strings/numbers inlined and - // anything else summarised by type so the row stays readable. - const pretty = keys.slice(0, 4).map((k) => { - const v = input[k]; - if (v == null) return k + ': null'; - if (typeof v === 'string') { - const oneline = v.replace(/\s+/g, ' ').trim(); - return k + ': ' + JSON.stringify(trim(oneline, 40)); - } - if (typeof v === 'number' || typeof v === 'boolean') return k + ': ' + v; - if (Array.isArray(v)) return k + `: [${v.length}]`; - return k + ': {…}'; - }); - const tail = keys.length > 4 ? ' …+' + (keys.length - 4) : ''; - return name + ' ' + pretty.join(' · ') + tail; - } - // Per-tool glyph for the tool-use row prefix — gives each hive / MCP / - // built-in tool a distinctive icon instead of a generic wrench, so the - // scrollback is scannable at a glance. Exact tool names first, then - // MCP-server family fallbacks, then a generic wrench default. - const TOOL_ICONS = { - 'mcp__hyperhive__send': '📤', - 'mcp__hyperhive__recv': '📥', - 'mcp__hyperhive__ask': '❓', - 'mcp__hyperhive__answer': '✍️', - 'mcp__hyperhive__remind': '⏰', - 'mcp__hyperhive__set_status': '🏷️', - 'mcp__hyperhive__get_loose_ends': '🪢', - 'mcp__hyperhive__cancel_loose_end': '✂️', - 'mcp__hyperhive__ack_until': '✅', - 'mcp__hyperhive__get_agent_meta': 'ℹ️', - 'mcp__hyperhive__request_next_turn': '⏩', - 'mcp__hyperhive__restart': '↻', - 'mcp__hyperhive__kill': '⏹️', - 'mcp__hyperhive__start': '▶️', - 'mcp__hyperhive__update': '🔄', - 'mcp__hyperhive__list_containers': '📋', - 'mcp__hyperhive__get_logs': '📜', - 'mcp__hyperhive__get_host_journal': '📜', - 'mcp__matrix__read_room': '📖', - 'mcp__matrix__mark_read': '👁️', - 'mcp__matrix__list_rooms': '📋', - 'mcp__matrix__list_room_members': '📋', - 'mcp__matrix__list_invites': '📋', - 'mcp__bash__kill': '🛑', - Read: '📖', Write: '💾', Edit: '✏️', Glob: '🔍', Grep: '🔍', - }; - function toolIcon(name) { - if (TOOL_ICONS[name]) return TOOL_ICONS[name]; - if (typeof name === 'string') { - if (name.startsWith('mcp__matrix__')) return '💬'; - if (name.startsWith('mcp__bash__')) return '🖥️'; - if (name.includes('schedule')) return '⏱️'; - // request_init_config / request_update_meta_inputs - if (name.startsWith('mcp__hyperhive__request_')) return '📦'; - } - return '🔧'; - } - // Shorten a matrix room id or alias for display. Room ids (!xxx:server) - // are trimmed to the first 8 local chars; aliases (#name:server) are - // returned as-is (they're already readable). Falls back to the raw - // value truncated. - function fmtRoom(r) { - if (!r) return '?'; - if (r.startsWith('!')) return r.slice(0, r.indexOf(':') > 0 ? r.indexOf(':') : 9); - if (r.startsWith('#')) return r.split(':')[0] || r; - return trim(r, 20); - } - // Shorten @user:server → @user. - function fmtUser(u) { - if (!u) return '?'; - const colon = u.indexOf(':'); - return colon > 0 ? u.slice(0, colon) : u; - } - // 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.startsWith('mcp__bash__') - ? name.slice('mcp__bash__'.length) + '*' - : name.startsWith('mcp__matrix__') - ? name.slice('mcp__matrix__'.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.run_in_background ? ' [bg]' : '') - + ' $ ' + (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': { - // Surface the long-poll wait + batch size — a bare `recv()` row - // hides whether the agent is parking a turn (wait_seconds) or - // draining a burst (max). - const parts = []; - if (input.wait_seconds != null) parts.push('wait ' + input.wait_seconds + 's'); - if (input.max != null) parts.push('max ' + input.max); - return short + (parts.length ? ' ' + parts.join(' · ') : '()'); - } - case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); - case 'mcp__hyperhive__restart': return short + ' ' + (input.name || ''); - case 'mcp__hyperhive__start': return short + ' ' + (input.name || ''); - case 'mcp__hyperhive__update': return short + ' ' + (input.name || ''); - case 'mcp__hyperhive__ack_until': - return short + ' ≤' + (input.up_to != null ? input.up_to : '?'); - case 'mcp__hyperhive__get_logs': - return short + ' ' + (input.agent || '?') - + (input.lines != null ? ' · ' + input.lines + 'L' : ''); - case 'mcp__hyperhive__get_host_journal': { - const parts = []; - if (input.container) parts.push(input.container); - else if (input.unit) parts.push(input.unit); - if (input.grep) parts.push('/' + input.grep + '/'); - if (input.lines != null) parts.push(input.lines + 'L'); - return short + (parts.length ? ' ' + parts.join(' · ') : '()'); - } - case 'mcp__hyperhive__remind': { - // Surface when the reminder fires + the first line of the message. - // `delay_seconds` → human-readable "+5m"; `at_unix_timestamp` → - // "at HH:MM"; message truncated to fit. - let when = ''; - if (input.delay_seconds != null) { - const s = input.delay_seconds; - when = '+' + (s < 60 ? s + 's' : s < 3600 ? Math.round(s / 60) + 'm' - : (s / 3600).toFixed(1) + 'h'); - } else if (input.at_unix_timestamp != null) { - when = 'at ' + new Date(input.at_unix_timestamp * 1000) - .toISOString().slice(11, 16) + 'Z'; - } - const msg = String(input.message || input.file_path || '').replace(/\s+/g, ' ').trim(); - return short + (when ? ' ' + when : '') + (msg ? ' "' + trim(msg, 60) + '"' : ''); - } - case 'mcp__hyperhive__request_init_config': - return short + ' ' + (input.name || '?'); - case 'mcp__hyperhive__request_update_meta_inputs': { - const ins = Array.isArray(input.inputs) && input.inputs.length - ? '[' + input.inputs.slice(0, 4).join(', ') - + (input.inputs.length > 4 ? ', …' : '') + ']' - : 'all'; - return short + ' ' + ins; - } - case 'mcp__hyperhive__list_schedules': - return short + '()'; - case 'mcp__hyperhive__cancel_schedule': - return short + ' #' + (input.id != null ? input.id : '?') - + (Array.isArray(input.targets) && input.targets.length - ? ' [' + input.targets.join(', ') + ']' : ' all'); - case 'mcp__hyperhive__fire_schedule_now': - return short + ' #' + (input.id != null ? input.id : '?'); - case 'mcp__hyperhive__edit_schedule': { - const parts = ['#' + (input.id != null ? input.id : '?')]; - if (input.body != null) parts.push('body'); - if (input.interval_seconds != null) parts.push('interval'); - if (input.next_fire_at_unix != null) parts.push('next'); - if (input.targets_add && input.targets_add.length) parts.push('+' + input.targets_add.length + ' tgt'); - if (input.targets_remove && input.targets_remove.length) parts.push('-' + input.targets_remove.length + ' tgt'); - return short + ' ' + parts.join(' · '); - } - case 'mcp__hyperhive__request_schedule_prompt': { - const tgts = Array.isArray(input.targets) ? input.targets : []; - const when = input.first_fire_at_unix != null - ? new Date(input.first_fire_at_unix * 1000).toISOString().slice(11, 16) + 'Z' - : '?'; - return short + ' → ' + (tgts.length ? tgts.join(', ') : '?') + ' at ' + when - + (input.interval_seconds != null ? ' +' + input.interval_seconds + 's' : ''); - } - case 'mcp__bash__run': { - // Rich renderer handles the full body; this summary covers any - // fallback path and the details summary line. - const firstLine = String(input.cmd || '').split('\n')[0]; - return short + ' $ ' + trim(firstLine.trim(), 72); - } - case 'mcp__bash__status': - return short + ' id:' + (input.id || '?') - + (input.wait_seconds != null ? ' · wait ' + input.wait_seconds + 's' : ''); - case 'mcp__bash__kill': - return short + ' ' + (input.id || '?') + (input.force ? ' [force]' : ''); - case 'mcp__hyperhive__set_status': - return short + ' "' + trim(String(input.text || ''), 60) + '"'; - case 'mcp__hyperhive__get_loose_ends': - return short + (input.agent ? ' [' + input.agent + ']' : '()'); - case 'mcp__hyperhive__get_agent_meta': - return short + (input.name ? ' ' + input.name : '()'); - case 'mcp__hyperhive__cancel_loose_end': - return short + ' ' + (input.kind || '?') + ' #' + (input.id != null ? input.id : '?'); - case 'mcp__matrix__read_room': - return short + ' ' + fmtRoom(input.room) - + (input.limit != null ? ' [' + input.limit + ']' : ''); - case 'mcp__matrix__mark_read': - return short + ' ' + fmtRoom(input.room); - case 'mcp__matrix__send_message': - return short + ' → ' + fmtRoom(input.room) + ': ' - + JSON.stringify(trim(String(input.body || ''), 50)); - case 'mcp__matrix__send_dm': - return short + ' → ' + fmtUser(input.user_id) + ': ' - + JSON.stringify(trim(String(input.body || ''), 50)); - case 'mcp__matrix__send_reply': - return short + ' → ' + fmtRoom(input.room) + ': ' - + JSON.stringify(trim(String(input.body || ''), 50)); - case 'mcp__matrix__send_reaction': - return short + ' ' + fmtRoom(input.room) + ' ' + (input.key || '?'); - case 'mcp__matrix__join_room': - return short + ' ' + fmtRoom(input.room); - case 'mcp__matrix__open_dm': - return short + ' ' + fmtUser(input.user_id); - case 'mcp__matrix__invite_user': - return short + ' ' + fmtUser(input.user_id) + ' → ' + fmtRoom(input.room); - case 'mcp__matrix__download_file': - return short + ' ' + fmtRoom(input.room); - default: return fmtArgsGeneric(short, input); - } - } // Build a "rich" tool_use row for tools whose input has a body we // want the operator to see in full. Returns null for any other tool // so the caller falls back to the flat-row path. @@ -1721,6 +1482,7 @@ window.marked = marked; function renderRichToolUse(c, api) { const name = c.name || ''; const input = c.input || {}; + const icon = c._icon || '🔧'; if (name === 'Write' || name === 'Edit') { const path = input.file_path || '?'; let body; @@ -1746,7 +1508,7 @@ window.marked = marked; // the CSS disclosure caret leads the text). const summary = name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; - return api.detailsDiff('tool-use', summary, body, toolIcon(name)); + return api.detailsDiff('tool-use', summary, body, icon); } // Message-bearing tools render default-open with a markdown body so // the operator sees the content without an extra click. send / ask @@ -1757,7 +1519,7 @@ window.marked = marked; const lines = body.split('\n').length; return detailsOpenMd(api, 'tool-use', 'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''), - body, toolIcon(name)); + body, icon); } if (name === 'mcp__hyperhive__ask') { const to = input.to || 'operator'; @@ -1765,7 +1527,7 @@ window.marked = marked; const lines = q.split('\n').length; const d = detailsOpenMd(api, 'tool-use', 'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''), - q, toolIcon(name)); + q, icon); // When the ask targets the operator, mount an inline answer // slot in the live terminal — see docs/web-ui.md::Per-agent // page (Ask → operator inline-answer binding) for the slot @@ -1795,7 +1557,7 @@ window.marked = marked; const lines = a.split('\n').length; return detailsOpenMd(api, 'tool-use', 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), - a, toolIcon(name)); + a, icon); } // Bash task runner — show full command in an expandable pre block so // multi-line scripts are readable. Summary uses the first line so the @@ -1804,7 +1566,7 @@ window.marked = marked; const cmd = String(input.cmd || ''); const firstLine = cmd.split('\n')[0]; const summary = 'run* $ ' + trim(firstLine.trim(), 72); - return api.details('tool-use', summary, '$ ' + cmd, toolIcon(name)); + return api.details('tool-use', summary, '$ ' + cmd, icon); } return null; } @@ -1922,101 +1684,28 @@ window.marked = marked; // then "✓ done") for what's really one event. const updatePluginInstall = makeCoalescer('note'); function renderStream(v, api) { - // Drop claude's result line and rate-limit — noise. TurnEnd - // communicates pass/fail; rate-limit events are noisy status chatter. - if (v.type === 'rate_limit_event') return; - if (v.type === 'result') return; - // `system` events: `init` is silent startup noise; `api_retry` - // and `api_error` get human-readable notes; unknown subtypes get a - // muted line rather than a raw-JSON dump in the loud `sys` colour. + // Backend pre-computes `_category` on all known event types. + // "drop" covers: type=result, type=rate_limit_event, and system/init. + if (v._category === 'drop') return; + if (v.type === 'system') { - if (v.subtype === 'init') return; - if (v.subtype === 'api_retry') { - const parts = ['⚠ api retry']; - if (v.attempt != null && v.max_retries != null) - parts.push(v.attempt + '/' + v.max_retries); - if (v.error) parts.push(String(v.error)); - else if (v.error_status) parts.push('HTTP ' + v.error_status); - if (v.retry_delay_ms != null) - parts.push(Math.round(v.retry_delay_ms) + 'ms'); - api.row('note', parts.join(' · ')); + const cat = v._category; + const summary = v._summary; + // thinking_tok: collapse many ticks into one in-place counter row. + if (cat === 'thinking_tok') { + updateThinkingTokens(api, summary || 'thinking…'); return; } - if (v.subtype === 'api_error') { - const msg = v.error || v.message - || (v.error_status ? 'HTTP ' + v.error_status : 'unknown'); - api.row('note stderr', '✗ api error · ' + msg); - return; - } - // Live thinking-token counter — claude streams many of these per - // turn (a running `estimated_tokens` total while it thinks). Collapse - // consecutive ticks into ONE in-place-updating row instead of a note - // per tick (see `makeCoalescer` above). - if (v.subtype === 'thinking_tokens') { - const n = v.estimated_tokens; - const text = 'thinking … ' - + (n != null ? '~' + Number(n).toLocaleString() + ' tokens' : ''); - updateThinkingTokens(api, text); - return; - } - // plugin_install: claude is loading/finishing a plugin (MCP server or - // slash-command provider). Show the status so the operator knows when - // a fresh session is loading its toolset. + // plugin_install: coalesced in-place row while the plugin loads. if (v.subtype === 'plugin_install') { - const status = v.status === 'completed' ? '✓ done' - : v.status === 'started' ? 'loading…' - : (v.status || '?'); - updatePluginInstall(api, '⚙ plugin install · ' + status); + updatePluginInstall(api, summary || '⚙ plugin install'); return; } - // commands_changed: the set of available slash commands changed (usually - // right after plugin_install). Show the count in the summary; expand to - // see the full list. - if (v.subtype === 'commands_changed') { - const cmds = Array.isArray(v.commands) ? v.commands : []; - if (!cmds.length) { - api.row('note', '⚙ commands changed · (empty)'); - return; - } - const summary = '⚙ commands changed · ' + cmds.length + ' available'; - const body = cmds.map((c) => { - const aliases = c.aliases && c.aliases.length - ? ' [/' + c.aliases.join(', /') + ']' : ''; - return '/' + c.name + aliases; - }).join('\n'); - api.details('note', summary, body); - return; - } - // compact_boundary: claude completed a compaction pass. The metadata - // carries pre/post token counts, duration, and the trigger (manual vs - // auto). Show a single summary line so the operator can gauge how much - // context was shed. - if (v.subtype === 'compact_boundary') { - const m = v.compact_metadata || {}; - const parts = ['⚙ compact']; - if (m.trigger) parts.push(m.trigger); - if (m.pre_tokens != null && m.post_tokens != null) { - const fmtTok = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' - : n >= 1_000 ? Math.round(n / 1000) + 'k' - : String(n); - parts.push(fmtTok(m.pre_tokens) + '→' + fmtTok(m.post_tokens) + ' tokens'); - } - if (m.duration_ms != null) { - const ms = m.duration_ms; - parts.push(ms < 1000 ? ms + 'ms' : (ms / 1000).toFixed(1) + 's'); - } - api.row('note', parts.join(' · ')); - return; - } - // Bare `status` ticks (claude's own generic "still working" signal, - // no detail beyond the label) — collapse consecutive ticks into one - // updating row instead of a fresh note each (see `makeCoalescer` - // above). When the harness state is `compacting` (set by the - // `turn_state_changed` SSE event), show elapsed time via `stateSince` - // — the same source the state badge uses — so the terminal reflects - // compaction progress; otherwise fall back to the generic label. + // status: backend provides the base label; when the harness state is + // `compacting` we override with elapsed time from `stateSince` — a + // client-side wall-clock value the backend can't know at emit time. if (v.subtype === 'status') { - let label = '⚙ status'; + let label = summary || '⚙ status'; if (stateName === 'compacting') { const elapsed = Math.round((Date.now() - stateSince) / 1000); label = '⚙ compact · ' + elapsed + 's…'; @@ -2024,10 +1713,13 @@ window.marked = marked; updateStatus(api, label); return; } - // Other system subtypes (context_window_exceeded, etc.) — render a - // muted note with the subtype label; reserve the loud orange `sys` - // catch-all for truly unrecognised top-level types. - api.row('note', '⚙ ' + (v.subtype || 'system')); + // details: expandable row with _summary as header, _body as content. + if (cat === 'details') { + api.details('note', summary || '⚙ ' + (v.subtype || ''), v._body || ''); + return; + } + // note (and any unknown category): single summary line. + api.row('note', summary || '⚙ ' + (v.subtype || 'system')); return; } // Background-task subagent events (claude's `Task` tool spawns @@ -2052,8 +1744,15 @@ window.marked = marked; } else if (c.type === 'tool_use') { if (c.id && c.name) toolNameById.set(c.id, c.name); - if (!renderRichToolUse(c, api)) { - api.row('tool-use', fmtToolUse(c), toolIcon(c.name)); + // `_category: "rich"` is stamped by the backend on tools that + // have full-body renderers (Write/Edit diffs, send/ask/answer + // message bodies). Flat-row tools use backend _icon/_summary. + if (c._category === 'rich') { + if (!renderRichToolUse(c, api)) { + api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧'); + } + } else { + api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧'); } } } diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 241e16dd..1fd37ac3 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -36,6 +36,13 @@ use serde_json::{Value, json}; /// already-enriched values if the DB is ever pre-populated by a future phase). pub fn enrich(v: &mut Value) { match v.get("type").and_then(Value::as_str).unwrap_or("") { + // Top-level result/rate_limit_event are drop-category noise — stamp + // the same category the frontend uses to silently discard them. + "result" | "rate_limit_event" => { + if let Some(obj) = v.as_object_mut() { + obj.entry("_category").or_insert_with(|| json!("drop")); + } + } "system" => enrich_system(v), "assistant" => enrich_assistant(v), _ => {} @@ -246,6 +253,7 @@ fn is_rich_tool(name: &str) -> bool { name, "Write" | "Edit" + | "mcp__bash__run" | "mcp__hyperhive__send" | "mcp__hyperhive__ask" | "mcp__hyperhive__answer" @@ -300,10 +308,9 @@ fn tool_icon_fallback(name: &str) -> &'static str { } // --------------------------------------------------------------------------- -// tool_use summary formatter +// tool_use summary formatter — dispatch to per-family sub-functions // --------------------------------------------------------------------------- -#[allow(clippy::too_many_lines)] fn fmt_tool_use(name: &str, input: &Value) -> String { // Short name: strip the MCP server prefix for display. let short = if let Some(rest) = name.strip_prefix("mcp__hyperhive__") { @@ -316,6 +323,20 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { name.to_owned() }; + if name.starts_with("mcp__hyperhive__") { + fmt_hyperhive_tool(name, &short, input) + } else if name.starts_with("mcp__bash__") { + fmt_bash_tool(name, &short, input) + } else if name.starts_with("mcp__matrix__") { + fmt_matrix_tool(name, &short, input) + } else { + fmt_builtin_tool(name, &short, input) + } +} + +/// Built-in claude tools: Read/Write/Edit/Glob/Grep/Bash/TodoWrite and any +/// unknown tool that doesn't carry a known MCP server prefix. +fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String { match name { "Read" | "Write" | "Edit" => format!("{short} {}", sv(input, "file_path")), "Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")), @@ -338,6 +359,13 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { .map_or(0, Vec::len); format!("{short} ({n} items)") } + _ => fmt_args_generic(short, input), + } +} + +/// `mcp__hyperhive__*` tools. +fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String { + match name { "mcp__hyperhive__send" => { let body = trim_str(&sv(input, "body"), 80); format!("{short} → {}: {}", sv(input, "to"), json_str(&body)) @@ -395,42 +423,111 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { format!("{short} {}", parts.join(" · ")) } } - "mcp__hyperhive__remind" => { - let when = if let Some(s) = input.get("delay_seconds").and_then(Value::as_u64) { - if s < 60 { - format!("+{s}s") - } else if s < 3_600 { - format!("+{}m", s / 60) - } else { - #[allow(clippy::cast_precision_loss)] - let h_f = s as f64 / 3_600.0; - format!("+{h_f:.1}h") - } - } else if let Some(ts) = input.get("at_unix_timestamp").and_then(Value::as_u64) { - let h = (ts % 86_400) / 3_600; - let m = (ts % 3_600) / 60; - format!("at {h:02}:{m:02}Z") - } else { - String::new() - }; - let msg_raw = input - .get("message") - .or_else(|| input.get("file_path")) - .and_then(Value::as_str) - .unwrap_or(""); - let msg = trim_str(&msg_raw.replace(char::is_whitespace, " "), 60); - let when_part = if when.is_empty() { - String::new() - } else { - format!(" {when}") - }; - let msg_part = if msg.is_empty() { - String::new() - } else { - format!(" \"{msg}\"") - }; - format!("{short}{when_part}{msg_part}") + "mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input), + "mcp__hyperhive__request_update_meta_inputs" + | "mcp__hyperhive__list_schedules" + | "mcp__hyperhive__cancel_schedule" + | "mcp__hyperhive__fire_schedule_now" + | "mcp__hyperhive__edit_schedule" + | "mcp__hyperhive__request_schedule_prompt" => { + fmt_hyperhive_schedule_tool(name, short, input) } + "mcp__hyperhive__set_status" => { + format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60)) + } + "mcp__hyperhive__get_loose_ends" => { + let agent = input + .get("agent") + .and_then(Value::as_str) + .map_or_else(|| "()".to_owned(), |a| format!(" [{a}]")); + format!("{short}{agent}") + } + "mcp__hyperhive__get_agent_meta" => { + let name_part = input + .get("name") + .and_then(Value::as_str) + .map_or_else(|| "()".to_owned(), |n| format!(" {n}")); + format!("{short}{name_part}") + } + "mcp__hyperhive__cancel_loose_end" => { + let id = input + .get("id") + .and_then(Value::as_u64) + .map_or_else(|| "?".to_owned(), |n| n.to_string()); + format!("{short} {} #{id}", sv(input, "kind")) + } + _ => fmt_args_generic(short, input), + } +} + +fn fmt_hyperhive_remind(short: &str, input: &Value) -> String { + let when = if let Some(s) = input.get("delay_seconds").and_then(Value::as_u64) { + if s < 60 { + format!("+{s}s") + } else if s < 3_600 { + format!("+{}m", s / 60) + } else { + #[allow(clippy::cast_precision_loss)] + let h_f = s as f64 / 3_600.0; + format!("+{h_f:.1}h") + } + } else if let Some(ts) = input.get("at_unix_timestamp").and_then(Value::as_u64) { + let h = (ts % 86_400) / 3_600; + let m = (ts % 3_600) / 60; + format!("at {h:02}:{m:02}Z") + } else { + String::new() + }; + let msg_raw = input + .get("message") + .or_else(|| input.get("file_path")) + .and_then(Value::as_str) + .unwrap_or(""); + let msg = trim_str(&msg_raw.replace(char::is_whitespace, " "), 60); + let when_part = if when.is_empty() { + String::new() + } else { + format!(" {when}") + }; + let msg_part = if msg.is_empty() { + String::new() + } else { + format!(" \"{msg}\"") + }; + format!("{short}{when_part}{msg_part}") +} + +fn fmt_hyperhive_edit_schedule(short: &str, input: &Value) -> String { + let id = input + .get("id") + .and_then(Value::as_u64) + .map_or_else(|| "?".to_owned(), |n| n.to_string()); + let mut parts = vec![format!("#{id}")]; + if input.get("body").is_some() { + parts.push("body".to_owned()); + } + if input.get("interval_seconds").is_some() { + parts.push("interval".to_owned()); + } + if input.get("next_fire_at_unix").is_some() { + parts.push("next".to_owned()); + } + if let Some(a) = input.get("targets_add").and_then(Value::as_array) + && !a.is_empty() + { + parts.push(format!("+{} tgt", a.len())); + } + if let Some(r) = input.get("targets_remove").and_then(Value::as_array) + && !r.is_empty() + { + parts.push(format!("-{} tgt", r.len())); + } + format!("{short} {}", parts.join(" · ")) +} + +/// Schedule-management hyperhive tools (list/cancel/fire/edit/request). +fn fmt_hyperhive_schedule_tool(name: &str, short: &str, input: &Value) -> String { + match name { "mcp__hyperhive__request_update_meta_inputs" => { let ins = match input.get("inputs").and_then(Value::as_array) { Some(arr) if !arr.is_empty() => { @@ -464,33 +561,7 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { .map_or_else(|| "?".to_owned(), |n| n.to_string()); format!("{short} #{id}") } - "mcp__hyperhive__edit_schedule" => { - let id = input - .get("id") - .and_then(Value::as_u64) - .map_or_else(|| "?".to_owned(), |n| n.to_string()); - let mut parts = vec![format!("#{id}")]; - if input.get("body").is_some() { - parts.push("body".to_owned()); - } - if input.get("interval_seconds").is_some() { - parts.push("interval".to_owned()); - } - if input.get("next_fire_at_unix").is_some() { - parts.push("next".to_owned()); - } - if let Some(a) = input.get("targets_add").and_then(Value::as_array) - && !a.is_empty() - { - parts.push(format!("+{} tgt", a.len())); - } - if let Some(r) = input.get("targets_remove").and_then(Value::as_array) - && !r.is_empty() - { - parts.push(format!("-{} tgt", r.len())); - } - format!("{short} {}", parts.join(" · ")) - } + "mcp__hyperhive__edit_schedule" => fmt_hyperhive_edit_schedule(short, input), "mcp__hyperhive__request_schedule_prompt" => { let tgts = match input.get("targets").and_then(Value::as_array) { Some(arr) => arr @@ -518,6 +589,13 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { .unwrap_or_default(); format!("{short} → {tgts} at {when}{recur}") } + _ => fmt_args_generic(short, input), + } +} + +/// `mcp__bash__*` tools. +fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String { + match name { "mcp__bash__run" => { let cmd = sv(input, "cmd"); let first = cmd.lines().next().unwrap_or("").trim().to_owned(); @@ -539,30 +617,13 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { }; format!("{short} {}{force}", sv(input, "id")) } - "mcp__hyperhive__set_status" => { - format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60)) - } - "mcp__hyperhive__get_loose_ends" => { - let agent = input - .get("agent") - .and_then(Value::as_str) - .map_or_else(|| "()".to_owned(), |a| format!(" [{a}]")); - format!("{short}{agent}") - } - "mcp__hyperhive__get_agent_meta" => { - let name_part = input - .get("name") - .and_then(Value::as_str) - .map_or_else(|| "()".to_owned(), |n| format!(" {n}")); - format!("{short}{name_part}") - } - "mcp__hyperhive__cancel_loose_end" => { - let id = input - .get("id") - .and_then(Value::as_u64) - .map_or_else(|| "?".to_owned(), |n| n.to_string()); - format!("{short} {} #{id}", sv(input, "kind")) - } + _ => fmt_args_generic(short, input), + } +} + +/// `mcp__matrix__*` tools. +fn fmt_matrix_tool(name: &str, short: &str, input: &Value) -> String { + match name { "mcp__matrix__read_room" => { let limit = input .get("limit") @@ -584,20 +645,18 @@ fn fmt_tool_use(name: &str, input: &Value) -> String { fmt_user(&sv(input, "user_id")), json_str(&trim_str(&sv(input, "body"), 50)) ), - "mcp__matrix__send_reaction" => { - format!( - "{short} {} {}", - fmt_room(&sv(input, "room")), - sv(input, "key") - ) - } + "mcp__matrix__send_reaction" => format!( + "{short} {} {}", + fmt_room(&sv(input, "room")), + sv(input, "key") + ), "mcp__matrix__open_dm" => format!("{short} {}", fmt_user(&sv(input, "user_id"))), "mcp__matrix__invite_user" => format!( "{short} {} → {}", fmt_user(&sv(input, "user_id")), fmt_room(&sv(input, "room")) ), - _ => fmt_args_generic(&short, input), + _ => fmt_args_generic(short, input), } } @@ -671,9 +730,10 @@ fn fmt_tok(n: u64) -> String { /// Shorten a matrix room id/alias for display. fn fmt_room(r: &str) -> String { if r.starts_with('!') { - // Room id: keep only the local part before the colon (up to 8 chars). - let end = r.find(':').unwrap_or(r.len()).min(9); - r[..end].to_owned() + // Room id: keep only the local part before the colon (up to 9 chars). + // Use chars().take() so we never slice on a non-ASCII byte boundary. + let colon = r.find(':').unwrap_or(r.len()); + r.chars().take(colon.min(9)).collect() } else if r.starts_with('#') { // Alias: keep `#name` part before the server. r.split(':').next().unwrap_or(r).to_owned() diff --git a/hive-agent/src/web_ui/stream.rs b/hive-agent/src/web_ui/stream.rs index 24f855a9..3c881cd4 100644 --- a/hive-agent/src/web_ui/stream.rs +++ b/hive-agent/src/web_ui/stream.rs @@ -84,7 +84,7 @@ pub(super) async fn events_stream( // (`_icon`, `_summary`, `_category`) so the frontend doesn't need to // duplicate the dispatch logic. The DB stores raw events; enrichment // is applied here so both the live tail and the history endpoint - // deliver the same shape (see `events_history` below). + // deliver the same shape (see `events_history` above). if let crate::events::LiveEvent::Stream(ref mut v) = ev.event { crate::stream_enrich::enrich(v); }