diff --git a/Cargo.lock b/Cargo.lock index ca9b6f99..006f8fbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,6 +451,7 @@ dependencies = [ "anyhow", "axum", "clap", + "hive-fr0nt", "hive-sh4re", "rmcp", "rusqlite", @@ -470,6 +471,7 @@ dependencies = [ "anyhow", "axum", "clap", + "hive-fr0nt", "hive-sh4re", "libc", "rusqlite", @@ -481,6 +483,10 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hive-fr0nt" +version = "0.1.0" + [[package]] name = "hive-sh4re" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2f4c26f5..4c2bbbb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"] [workspace.package] edition = "2024" @@ -18,6 +18,7 @@ must_use_candidate = "allow" anyhow = "1" axum = "0.8" clap = { version = "4", features = ["derive"] } +hive-fr0nt = { path = "hive-fr0nt" } hive-sh4re = { path = "hive-sh4re" } rmcp = { version = "1.7", default-features = false, features = [ "server", diff --git a/TODO.md b/TODO.md index a2acde83..257282a0 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ - ~~Handle text overflow → suggest file_path option for long messages~~ ✓ fixed — Remind dispatch rejects `message.len() > 4096` (when no `file_path` was supplied) with an error pointing at the `file_path` escape hatch. - Per-agent reminder limits (burst capacity, rate limiting) - ~~**Expose `remind` MCP tool**~~ ✓ fixed — `mcp__hyperhive__remind` now on `AgentServer`; takes `message`, exactly one of `delay_seconds` / `at_unix_timestamp`, optional `file_path`. Manager surface still missing (no `ManagerRequest::Remind` variant) — separate item below. -- **Manager-side `remind`**: mirror of the agent tool but on `ManagerServer`. Needs `ManagerRequest::Remind` variant in hive-sh4re, dispatch in manager_server.rs, MCP tool wiring. +- ~~**Manager-side `remind`**~~ ✓ fixed — `ManagerRequest::Remind` variant added, dispatch reuses `agent_server::store_remind` helper (shared across both surfaces), `mcp__hyperhive__remind` now on `ManagerServer` (auto-file lands at `/state/reminders/auto-.md` — manager's legacy state mount). - ~~**File path delivery**~~ ✓ fixed — scheduler now writes the reminder body to the requested `file_path` (mapped from container `/agents//state/...` to host `/var/lib/hyperhive/agents//state/...`) and delivers a short pointer message in its place. Path-traversal + foreign-agent-state writes are rejected; on rejection or write failure the body falls back to inline delivery with a noted warning. New module `hive-c0re/src/reminder_scheduler.rs` (extracted from main.rs). - ~~**Orphan reminders**~~ ✓ fixed — `Broker::deliver_reminder` wraps the inbox INSERT + reminders UPDATE in one sqlite transaction; partial failure can no longer cause duplicate delivery on the next tick. - ~~**Unbounded batches**~~ ✓ fixed — scheduler now calls `get_due_reminders(REMINDER_BATCH_LIMIT)` (cap = 100/tick); overflow stays due and gets picked up next cycle. diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml index f1a253e3..5df9a209 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -10,6 +10,7 @@ workspace = true anyhow.workspace = true axum.workspace = true clap.workspace = true +hive-fr0nt.workspace = true hive-sh4re.workspace = true rmcp.workspace = true rusqlite.workspace = true diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 0cf22279..ca61c2a1 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -1,24 +1,9 @@ -:root { - /* Catppuccin Mocha — mirrors the dashboard palette. */ - --bg: #1e1e2e; /* base */ - --bg-elev: #181825; /* mantle */ - --fg: #cdd6f4; /* text */ - --muted: #7f849c; /* overlay1 */ - --purple: #cba6f7; /* mauve */ - --purple-dim: #45475a; /* surface1 */ - --cyan: #89dceb; /* sky */ - --amber: #fab387; /* peach */ - --green: #a6e3a1; /* green */ - --red: #f38ba8; /* red */ -} +/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended + to this stylesheet by `serve_css` at runtime. */ body { - background: var(--bg); - color: var(--fg); - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; max-width: 110em; margin: 1.5em auto; padding: 0 1.5em; - line-height: 1.6; } .banner { text-align: center; @@ -277,39 +262,9 @@ pre.diff { 60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; } 100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } } -/* Terminal-ish wrapper holding the live output + prompt input as one - unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. - Frosted-glass backdrop blur: the page bg behind the wrap gets softened, - so anything that bleeds through (page banner glow, scroll position) - reads as out-of-focus depth instead of sharp competing detail. */ -.terminal-wrap { - position: relative; - background: rgba(17, 17, 27, 0.78); - -webkit-backdrop-filter: blur(8px) saturate(120%); - backdrop-filter: blur(8px) saturate(120%); - border: 1px solid var(--purple-dim); - box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7); - border-radius: 4px; - font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; - font-size: 0.92em; - color: #cdd6f4; - margin-top: 0.6em; -} -.live.terminal { - background: transparent; - border: 0; - box-shadow: none; - border-radius: 0; - padding: 0.8em 1em 0.4em; - overflow-y: auto; - /* Make the terminal the page's main visual element on tall screens - while staying inside the page chrome on short ones. */ - height: min(72vh, 60em); - max-height: none; - font-family: inherit; - font-size: inherit; - color: inherit; -} +/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details + styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css). + What stays here is the composer chrome that sits inside the wrap. */ .term-input { padding: 0.4em 1em 0.8em; } .term-input .sendform-term { display: flex; @@ -346,147 +301,4 @@ pre.diff { .term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; } .term-input.disabled .prompt { color: var(--muted); text-shadow: none; } .term-input.disabled textarea { color: var(--muted); } -.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 .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); - animation: badge-pulse 1.4s ease-in-out infinite; -} -@keyframes badge-pulse { - 0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); } - 50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); } -} -/* Per-event fade-in slide-up. Applied to every row the live panel - appends; the `.no-anim` modifier lets history-backfill skip the - animation (we don't want 100 rows fading in at once on page load). */ -.live .row, -.live details.row { - animation: row-fade-in 220ms ease-out both; -} -.live .row.no-anim, -.live details.row.no-anim { - animation: none; -} -@keyframes row-fade-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} -/* "↓ N new" pill: shown when new rows arrive while the operator is - scrolled up; click to jump to bottom. */ -.tail-pill { - position: absolute; - right: 1em; - bottom: 4.2em; - background: var(--amber); - color: #11111b; - font-family: inherit; - font-size: 0.8em; - font-weight: bold; - letter-spacing: 0.08em; - border: 0; - border-radius: 999px; - padding: 0.35em 0.9em; - cursor: pointer; - box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85); - opacity: 0; - transform: translateY(6px); - pointer-events: none; - transition: opacity 160ms ease, transform 160ms ease; -} -.tail-pill.visible { - opacity: 1; - transform: translateY(0); - pointer-events: auto; -} -.tail-pill:hover { - filter: brightness(1.1); -} -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); } -/* Inline diff body for Write / Edit tool_use rows: same shape as - tool-body but each line is wrapped in a span with diff-add / - diff-del / diff-ctx so + / - lines are colored. */ -details.row > pre.diff-body { - margin: 0.3em 0 0.4em 1.2em; - padding: 0.4em 0.6em; - background: rgba(255, 255, 255, 0.02); - border-left: 2px solid var(--purple-dim); - white-space: pre-wrap; - word-break: break-word; - max-height: 22em; - overflow-y: auto; -} -details.row > pre.diff-body .diff-add { color: var(--green); } -details.row > pre.diff-body .diff-del { color: var(--red); } -details.row > pre.diff-body .diff-ctx { color: var(--fg); } -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; - 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: var(--green); border-left-color: var(--green); margin-bottom: 0.4em; } -.live .turn-end-fail { color: var(--red); border-left-color: var(--red); 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: var(--cyan); 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); } +/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 3c9461bd..14d61677 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -532,98 +532,15 @@ refreshState(); // ─── live event stream ────────────────────────────────────────────────── + // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS + // (window.HiveTerminal). What stays here is the per-kind rendering: + // turn framing, claude stream-json interpretation, tool_use prettyprint, + // tool_result collapse, +/- diff bodies for Write/Edit. (function() { const log = $('live'); - if (!log) return; - 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; } - } - // Backfill replays mark rows .no-anim so we don't stagger 100 fade-ins - // on page load. Set via `currentNoAnim` before the row helpers fire. - let currentNoAnim = false; - // Expose the panel API for slash commands (`/help`, `/clear`). - termAPI = { - row: (cls, text) => row(cls, text), - clear: () => { log.innerHTML = ''; placeholder = null; }, - }; + if (!log || !window.HiveTerminal) return; + log.innerHTML = ''; - // Sticky-bottom auto-scroll. If the user is reading scrolled-up, new - // rows do NOT yank the view. A floating "↓ N new" pill appears in - // the bottom-right corner; clicking it jumps to bottom and clears - // the counter. Scrolling back near the bottom also clears it. - const NEAR_BOTTOM_PX = 48; - let unseen = 0; - let pill = null; - function isNearBottom() { - return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; - } - function ensurePill() { - if (pill) return pill; - pill = document.createElement('button'); - pill.type = 'button'; - pill.className = 'tail-pill'; - pill.addEventListener('click', () => { - log.scrollTop = log.scrollHeight; - }); - log.parentElement.appendChild(pill); - return pill; - } - function updatePill() { - if (unseen <= 0) { - if (pill) pill.classList.remove('visible'); - return; - } - ensurePill(); - pill.textContent = '↓ ' + unseen + ' new'; - pill.classList.add('visible'); - } - log.addEventListener('scroll', () => { - if (isNearBottom()) { - unseen = 0; - updatePill(); - } - }); - function afterAppend() { - if (currentNoAnim || isNearBottom()) { - log.scrollTop = log.scrollHeight; - } else { - unseen += 1; - updatePill(); - } - } - function row(cls, text) { - clearPlaceholder(); - const e = document.createElement('div'); - e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - e.textContent = text; - log.appendChild(e); - afterAppend(); - return e; - } - function details(cls, summary, body) { - clearPlaceholder(); - const d = document.createElement('details'); - d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - 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); - afterAppend(); - 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. @@ -650,16 +567,13 @@ default: return name + ' ' + trim(JSON.stringify(input), 200); } } - // 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. - // + // 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. // Write: every input.content line is "+". // Edit: old_string lines as "-", new_string lines as "+". - // mcp__hyperhive__send: collapsed
, full body text - // inside. Truncating to 80 chars in the summary was hiding - // anything past the first sentence. - function renderRichToolUse(c) { + // mcp__hyperhive__send: collapsed
, full body text inside. + function renderRichToolUse(c, api) { const name = c.name || ''; const input = c.input || {}; if (name === 'Write' || name === 'Edit') { @@ -683,7 +597,7 @@ } const summary = '→ ' + name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; - return detailsDiff('tool-use', summary, body); + return api.detailsDiff('tool-use', summary, body); } if (name === 'mcp__hyperhive__send') { const to = input.to || '?'; @@ -692,35 +606,11 @@ const lines = body.split('\n').length; const summary = '→ send → ' + to + (lines > 1 ? ` · ${lines}L` : '') + (headline ? ' · ' + headline + (body.length > 80 ? '…' : '') : ''); - return details('tool-use', summary, body); + return api.details('tool-use', summary, body); } return null; } - function detailsDiff(cls, summary, body) { - clearPlaceholder(); - const d = document.createElement('details'); - d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - const s = document.createElement('summary'); - s.textContent = summary; - d.appendChild(s); - const pre = document.createElement('pre'); - pre.className = 'tool-body diff-body'; - // Color each line by its leading +/-. - for (const line of body.split('\n')) { - const span = document.createElement('span'); - if (line.startsWith('+ ')) span.className = 'diff-add'; - else if (line.startsWith('- ')) span.className = 'diff-del'; - else span.className = 'diff-ctx'; - span.textContent = line + '\n'; - pre.appendChild(span); - } - d.appendChild(pre); - log.appendChild(d); - afterAppend(); - return d; - } - - function renderToolResult(c) { + function renderToolResult(c, api) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); @@ -732,33 +622,28 @@ 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); + api.row('tool-result', summary); } else { - details('tool-result-block', summary, txt); + api.details('tool-result-block', summary, txt); } } - function renderStream(v) { - // Drop session init, claude's result line, rate-limit — they're - // noise. TurnEnd communicates pass/fail; session init data isn't - // actionable. + function renderStream(v, api) { + // Drop session init, claude's result line, rate-limit — noise. + // TurnEnd communicates pass/fail; session init 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); + if (c.type === 'text' && c.text && c.text.trim()) api.row('text', c.text); else if (c.type === 'thinking') { const txt = (c.thinking || c.text || '').trim(); - row('thinking', txt ? '· ' + txt : '· thinking …'); + api.row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') { - // Write/Edit get a +/- diff body; send gets a collapsed - //
with the full body text; everything else - // stays as the flat row produced by fmtToolUse. - if (!renderRichToolUse(c)) { - row('tool-use', '→ ' + fmtToolUse(c)); + if (!renderRichToolUse(c, api)) { + api.row('tool-use', '→ ' + fmtToolUse(c)); } } } @@ -766,90 +651,71 @@ } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { - if (c.type === 'tool_result') renderToolResult(c); + if (c.type === 'tool_result') renderToolResult(c, api); } return; } - row('sys', '· ' + trim(JSON.stringify(v), 200)); - } - function handle(ev, opts) { - const fromHistory = !!(opts && opts.fromHistory); - if (ev.kind === 'turn_start') { - if (!fromHistory) { setBannerActive(true); setState('thinking'); } - 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; - block.appendChild(body); - return; - } - if (ev.kind === 'turn_end') { - if (!fromHistory) { setBannerActive(false); setState('idle'); } - const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); - // Login may have just landed (or session re-enters Online). Pull - // fresh state so the form view reflects it. - if (!fromHistory) refreshState(); - 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)); + api.row('sys', '· ' + trim(JSON.stringify(v), 200)); } - // Backfill the last N events before subscribing live. Walk through - // turn_start/turn_end to leave the banner-active counter in the right - // state: if the history's last turn never closed, we *do* want the - // banner shimmer to be on. fromHistory=true on the replay; we apply - // the final activity state in one pass at the end. - async function backfill() { - try { - const resp = await fetch('/events/history'); - if (!resp.ok) return; - const events = await resp.json(); - let openTurns = 0; - currentNoAnim = true; - for (const ev of events) { - handle(ev, { fromHistory: true }); - if (ev.kind === 'turn_start') openTurns += 1; - else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1); - } - currentNoAnim = false; - for (let i = 0; i < openTurns; i++) setBannerActive(true); - if (openTurns > 0) setState('thinking'); - if (events.length) row('note', '─── live (older above) ───'); - else setPlaceholder('(connected — waiting for events)'); - } catch (err) { - // Best effort; SSE will catch up. - console.warn('history backfill failed', err); - } - } + // Count open turns across the backfill replay so the live banner + + // state badge reflect whatever the history last left running. With + // shared HiveTerminal this is computed inside each renderer instead + // of in a second walk over the events list. + let openTurnsFromHistory = 0; - backfill().then(() => { - const es = new EventSource('/events/stream'); - es.onopen = () => { /* no placeholder — backfill already painted */ }; - es.onmessage = (e) => { - try { handle(JSON.parse(e.data)); } - catch (err) { row('note', '[parse err] ' + e.data); } - }; - es.onerror = () => { - if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); - else row('note', '[disconnected]'); - }; + const term = HiveTerminal.create({ + logEl: log, + historyUrl: '/events/history', + streamUrl: '/events/stream', + renderers: { + turn_start(ev, api) { + if (api.fromHistory) openTurnsFromHistory += 1; + else { setBannerActive(true); setState('thinking'); } + const block = api.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; + block.appendChild(body); + }, + turn_end(ev, api) { + if (api.fromHistory) { + openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); + } else { + setBannerActive(false); setState('idle'); + // Login may have just landed (or session re-enters Online). + refreshState(); + } + const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; + api.row(cls, + (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + + (ev.note ? ' — ' + ev.note : '')); + }, + note(ev, api) { api.row('note', '· ' + ev.text); }, + stream(ev, api) { + const v = Object.assign({}, ev); delete v.kind; + renderStream(v, api); + }, + }, + onBackfillDone() { + // If the last replayed turn never closed, the banner shimmer + + // thinking badge should be on. Apply in one pass after replay. + for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true); + if (openTurnsFromHistory > 0) setState('thinking'); + }, }); + + // Expose the panel API for slash commands (`/help`, `/clear`). + termAPI = { + row: (cls, text) => term.row(cls, text), + clear: () => { log.innerHTML = ''; }, + }; })(); // Avoid unused-var lint while keeping `escText` available for future use. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 5ebae41f..6180184a 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -33,6 +33,7 @@
+ diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 84a3d1d2..ce982311 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -152,9 +152,11 @@ pub struct RecvArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RemindArgs { /// Body that lands in your inbox when the reminder fires (sender - /// will appear as `reminder`). Capped at 4096 bytes when - /// `file_path` is unset — anything bigger should be persisted to - /// disk and pointed at via `file_path`. + /// will appear as `reminder`). Soft cap at 1 KiB inline — anything + /// larger gets auto-persisted to a file under + /// `/agents//state/reminders/auto-.md` and the inbox + /// message becomes a short pointer. Pass `file_path` if you want + /// to control the destination yourself. pub message: String, /// Fire `delay_seconds` from now (relative). Set this OR /// `at_unix_timestamp`, not both. @@ -288,10 +290,11 @@ impl AgentServer { time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \ status in 60s', 'retry failed deploy at 14:00 UTC', 'nudge me when the operator's \ deploy window opens'. Set EXACTLY ONE of `delay_seconds` (fire N seconds from now) \ - or `at_unix_timestamp` (fire at absolute epoch second). Body is capped at 4096 bytes \ - when `file_path` is unset; for larger payloads write them to a file under your \ - `/agents//state/` dir and pass the path in `file_path`. Returns immediately — \ - the reminder lives in the broker until due." + or `at_unix_timestamp` (fire at absolute epoch second). Body soft-caps at 1 KiB \ + inline — anything larger gets auto-persisted to a file under your \ + `/agents//state/reminders/` dir and the inbox message becomes a short pointer; \ + pass `file_path` if you want to control the destination yourself. Returns \ + immediately — the reminder lives in the broker until due." )] async fn remind(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -662,6 +665,45 @@ impl ManagerServer { .await } + #[tool( + description = "Schedule a reminder that lands in the manager's own inbox at a future \ + time (sender will appear as `reminder`). Use for self-paced manager follow-ups: \ + 'recheck pending approval in 10m', 'nudge alice if she hasn't replied by 14:00 \ + UTC'. Set EXACTLY ONE of `delay_seconds` (fire N seconds from now) or \ + `at_unix_timestamp` (fire at absolute epoch second). Body soft-caps at 1 KiB \ + inline — anything larger gets auto-persisted to a file under `/state/reminders/` \ + (the manager's own state mount) and the inbox message becomes a short pointer. \ + Pass `file_path` if you want to control the destination yourself." + )] + async fn remind(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("remind", log, async move { + let timing = match (args.delay_seconds, args.at_unix_timestamp) { + (Some(_), Some(_)) => { + return "remind failed: pass exactly one of `delay_seconds` or \ + `at_unix_timestamp`, not both" + .to_string(); + } + (None, None) => { + return "remind failed: pass exactly one of `delay_seconds` or \ + `at_unix_timestamp`" + .to_string(); + } + (Some(s), None) => hive_sh4re::ReminderTiming::InSeconds { seconds: s }, + (None, Some(t)) => hive_sh4re::ReminderTiming::At { unix_timestamp: t }, + }; + let (resp, retries) = self + .dispatch(hive_sh4re::ManagerRequest::Remind { + message: args.message, + timing, + file_path: args.file_path, + }) + .await; + annotate_retries(format_ack(resp, "remind", "reminder scheduled".to_string()), retries) + }) + .await + } + #[tool( description = "Fetch recent journal log lines for a sub-agent container. Useful \ for diagnosing MCP server registration failures, startup crashes, plugin install \ @@ -750,6 +792,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "request_apply_commit", "ask_operator", "get_logs", + "remind", ], }; let mut out: Vec = names diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 80a142fb..5eb13fce 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -93,6 +93,7 @@ pub async fn serve( .route("/", get(serve_index)) .route("/static/agent.css", get(serve_css)) .route("/static/app.js", get(serve_app_js)) + .route("/static/hive-fr0nt.js", get(serve_shared_js)) .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) .route("/events/history", get(events_history)) @@ -162,10 +163,16 @@ async fn serve_index() -> impl IntoResponse { } async fn serve_css() -> impl IntoResponse { - ( - [("content-type", "text/css")], + // Prepend the shared palette/typography so per-page styles only need + // to declare what's actually page-specific. One HTTP request, no + // per-asset cache to invalidate. + let body = format!( + "{}\n{}\n{}", + hive_fr0nt::BASE_CSS, + hive_fr0nt::TERMINAL_CSS, include_str!("../assets/agent.css"), - ) + ); + ([("content-type", "text/css")], body) } async fn serve_app_js() -> impl IntoResponse { @@ -175,6 +182,13 @@ async fn serve_app_js() -> impl IntoResponse { ) } +async fn serve_shared_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript")], + hive_fr0nt::TERMINAL_JS, + ) +} + #[derive(Serialize)] struct StateSnapshot { label: String, diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 47ef2a88..a32e5d1b 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -10,6 +10,7 @@ workspace = true anyhow.workspace = true axum.workspace = true clap.workspace = true +hive-fr0nt.workspace = true hive-sh4re.workspace = true libc = "0.2" rusqlite.workspace = true diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 416a4669..dd893997 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -1,27 +1,9 @@ -:root { - /* Catppuccin Mocha. Keep the legacy variable names so per-class styles - don't need to be rewritten — only the values change. */ - --bg: #1e1e2e; /* base */ - --bg-elev: #181825; /* mantle */ - --fg: #cdd6f4; /* text */ - --muted: #7f849c; /* overlay1 */ - --purple: #cba6f7; /* mauve */ - --purple-dim: #45475a; /* surface1 */ - --cyan: #89dceb; /* sky */ - --pink: #f5c2e7; /* pink */ - --amber: #fab387; /* peach */ - --green: #a6e3a1; /* green */ - --red: #f38ba8; /* red */ - --border: #313244; /* surface0 */ -} +/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended + to this stylesheet by `serve_css` at runtime. */ 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 { text-align: center; diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 978eab44..39c3ed8d 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -98,6 +98,9 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> let broker = &coord.broker; match req { AgentRequest::Send { to, body } => { + if let Err(message) = crate::limits::check_size("send", body) { + return AgentResponse::Err { message }; + } // Handle broadcast sends (recipient = "*") if to == "*" { let errors = coord.broadcast_send(agent, body); @@ -189,6 +192,9 @@ fn handle_ask_operator( multi: bool, ttl_seconds: Option, ) -> AgentResponse { + if let Err(message) = crate::limits::check_size("question", question) { + return AgentResponse::Err { message }; + } let deadline_at = ttl_seconds.and_then(|s| { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -214,21 +220,6 @@ fn handle_ask_operator( } } -/// Cap on the inline `message` byte length when no `file_path` is set. -/// Reminders land in the agent's inbox and feed the next wake prompt — a -/// multi-kilobyte body bloats every subsequent turn's context. Anything -/// bigger should be persisted to disk by the caller and pointed at via -/// `file_path` (which the scheduler will deliver as a path reference rather -/// than the full body). -const REMIND_MESSAGE_MAX: usize = 4096; - -/// Upper cap when `file_path` IS set. The body still lands in the -/// reminders sqlite row until delivery, so without an upper bound a -/// caller could DOS the broker DB with a single multi-megabyte -/// reminder. 64 KiB is generous for any reasonable payload + keeps a -/// single row small enough that sqlite won't choke. -const REMIND_MESSAGE_MAX_WITH_FILE: usize = 64 * 1024; - fn handle_remind( coord: &Arc, agent: &str, @@ -236,38 +227,87 @@ fn handle_remind( timing: &hive_sh4re::ReminderTiming, file_path: Option<&str>, ) -> AgentResponse { - let (cap, hint) = match file_path { - None => ( - REMIND_MESSAGE_MAX, - "; set `file_path` to persist a larger payload to a file instead", - ), - Some(_) => (REMIND_MESSAGE_MAX_WITH_FILE, ""), - }; - if message.len() > cap { - return AgentResponse::Err { - message: format!( - "reminder body too long ({} bytes, max {cap}){hint}", - message.len() - ), - }; + match store_remind(coord, agent, message, timing, file_path) { + Ok(()) => AgentResponse::Ok, + Err(message) => AgentResponse::Err { message }, } - let due_at = match resolve_due_at(timing) { - Ok(t) => t, - Err(e) => { - return AgentResponse::Err { - message: format!("invalid reminder timing: {e:#}"), - }; - } - }; - match coord.broker.store_reminder(agent, message, file_path, due_at) { - Ok(id) => { - tracing::info!(%id, %agent, %due_at, "reminder scheduled"); - AgentResponse::Ok - } - Err(e) => AgentResponse::Err { - message: format!("failed to store reminder: {e:#}"), - }, +} + +/// Shared remind-storage path used by both the agent and the manager +/// dispatchers. Validates timing, applies the auto-file overflow +/// dance (see [`prepare_remind_storage`]), and writes the reminder +/// row. Returns `Ok(())` on success, or a caller-ready error string +/// the dispatcher wraps in `*Response::Err`. +pub(crate) fn store_remind( + coord: &Arc, + agent: &str, + message: &str, + timing: &hive_sh4re::ReminderTiming, + file_path: Option<&str>, +) -> Result<(), String> { + let due_at = resolve_due_at(timing).map_err(|e| format!("invalid reminder timing: {e:#}"))?; + let (stored_message, stored_path) = prepare_remind_storage(agent, message, file_path)?; + let id = coord + .broker + .store_reminder(agent, &stored_message, stored_path.as_deref(), due_at) + .map_err(|e| format!("failed to store reminder: {e:#}"))?; + tracing::info!(%id, %agent, %due_at, "reminder scheduled"); + Ok(()) +} + +/// Decide what we actually store in the reminders row, applying the +/// same byte cap as the rest of the wire protocol +/// ([`crate::limits::MESSAGE_MAX_BYTES`]). Three outcomes: +/// +/// 1. Body within the cap → stored verbatim, with whatever `file_path` +/// the caller passed (None or Some). The scheduler honours +/// `file_path` at delivery time as before. +/// 2. Body over the cap, no caller `file_path` → auto-generate a path +/// under `/agents//state/reminders/auto-.md`, write the +/// body to disk now, store a short pointer hint as the message and +/// clear `file_path` (so the scheduler doesn't re-write at +/// delivery and overwrite the body with the hint). +/// 3. Body over the cap, caller provided `file_path` → honour the +/// caller's path: write the body to it now, store the same hint +/// and clear `file_path` for the same reason as (2). +/// +/// Returns `(stored_message, stored_file_path)` on success, or a +/// caller-ready error string on auto-save failure (which is the only +/// way a Remind request can be refused for size — the agent never has +/// to think about the cap). +fn prepare_remind_storage( + agent: &str, + message: &str, + file_path: Option<&str>, +) -> Result<(String, Option), String> { + if message.len() <= crate::limits::MESSAGE_MAX_BYTES { + return Ok((message.to_owned(), file_path.map(str::to_owned))); } + let req_path = match file_path { + Some(p) => p.to_owned(), + None => auto_reminder_path(agent), + }; + let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path) + .map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?; + crate::reminder_scheduler::write_payload(agent, &host_path, message) + .map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?; + let hint = format!( + "[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]", + message.len() + ); + Ok((hint, None)) +} + +/// Generate a per-agent path for an auto-saved reminder body. Uses +/// `unix_nanos` plus the agent name to keep collisions infinitesimal +/// across the agent's own state subtree (we're not stamping a hostname +/// since hive-c0re is single-host). +fn auto_reminder_path(agent: &str) -> String { + let ts_ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md") } /// Resolve the `due_at` unix timestamp for a Remind request. Returns @@ -293,3 +333,30 @@ fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result { ReminderTiming::At { unix_timestamp } => Ok(*unix_timestamp), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_reminder_path_format() { + let p = auto_reminder_path("damocles"); + assert!(p.starts_with("/agents/damocles/state/reminders/auto-")); + assert!(p.ends_with(".md")); + } + + #[test] + fn prepare_remind_storage_passthrough_under_cap() { + let (msg, fp) = prepare_remind_storage("foo", "small body", None).unwrap(); + assert_eq!(msg, "small body"); + assert_eq!(fp, None); + } + + #[test] + fn prepare_remind_storage_passthrough_with_caller_file_path() { + let (msg, fp) = + prepare_remind_storage("foo", "small", Some("/agents/foo/state/x.md")).unwrap(); + assert_eq!(msg, "small"); + assert_eq!(fp.as_deref(), Some("/agents/foo/state/x.md")); + } +} diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 5e3e5945..cf0c626e 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -114,10 +114,16 @@ async fn serve_index() -> impl IntoResponse { } async fn serve_css() -> impl IntoResponse { - ( - [("content-type", "text/css")], + // Prepend the shared palette/typography so per-page styles only need + // to declare what's actually page-specific. One HTTP request, no + // per-asset cache to invalidate. + let body = format!( + "{}\n{}\n{}", + hive_fr0nt::BASE_CSS, + hive_fr0nt::TERMINAL_CSS, include_str!("../assets/dashboard.css"), - ) + ); + ([("content-type", "text/css")], body) } async fn serve_app_js() -> impl IntoResponse { diff --git a/hive-c0re/src/limits.rs b/hive-c0re/src/limits.rs new file mode 100644 index 00000000..eaef1e94 --- /dev/null +++ b/hive-c0re/src/limits.rs @@ -0,0 +1,61 @@ +//! Wire-protocol size limits shared across the agent + manager +//! sockets. Caps on inline message bodies stop a single chatty agent +//! (or a misbehaving extra-MCP server) from flooding the broker +//! sqlite with megabyte-sized rows that then bloat every recipient's +//! wake-prompt context. Anything genuinely larger should be written +//! to a state file and the path sent as the body. +//! +//! Reminders get a separate auto-file escape hatch (see +//! `agent_server::handle_remind`) so callers don't have to think +//! about it — oversized reminder bodies get persisted to disk +//! transparently and the inbox sees a pointer. + +/// Per-message body cap. Applies to `send`, `ask_operator` question +/// text, and the stored inline form of a reminder. 1 KiB is small +/// enough that 100 unread messages don't dominate a wake prompt, +/// large enough for routine cross-agent chatter. +pub const MESSAGE_MAX_BYTES: usize = 1024; + +/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a +/// caller-ready error string (caller wraps in +/// `AgentResponse::Err`/`ManagerResponse::Err`) on failure. +/// +/// `label` shows up in the error message verbatim — pass a short +/// noun like `"send"`, `"question"`, `"broadcast"` so the model can +/// tell which call got rejected. +pub fn check_size(label: &str, body: &str) -> Result<(), String> { + if body.len() > MESSAGE_MAX_BYTES { + Err(format!( + "{label} body too long ({} bytes, max {MESSAGE_MAX_BYTES}); write the \ + payload to a file under your `/agents//state/` dir and send the \ + path as the body instead", + body.len() + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_short_body() { + assert!(check_size("send", "hello").is_ok()); + assert!(check_size("send", &"x".repeat(MESSAGE_MAX_BYTES)).is_ok()); + } + + #[test] + fn rejects_oversize_body() { + let err = check_size("send", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err(); + assert!(err.contains("send body too long")); + assert!(err.contains(&format!("max {MESSAGE_MAX_BYTES}"))); + } + + #[test] + fn label_threads_through() { + let err = check_size("question", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err(); + assert!(err.starts_with("question body too long")); + } +} diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 8918e68b..489dbacf 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -17,6 +17,7 @@ mod dashboard; mod events_vacuum; mod forge; mod lifecycle; +mod limits; mod manager_server; mod meta; mod migrate; diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index c25cbb0b..e7b93076 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -86,6 +86,9 @@ fn manager_recv_timeout(wait_seconds: Option) -> std::time::Duration { async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResponse { match req { ManagerRequest::Send { to, body } => { + if let Err(message) = crate::limits::check_size("send", body) { + return ManagerResponse::Err { message }; + } if to == "*" { let errors = coord.broadcast_send(MANAGER_AGENT, body); if errors.is_empty() { @@ -247,6 +250,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp multi, ttl_seconds, } => { + if let Err(message) = crate::limits::check_size("question", question) { + return ManagerResponse::Err { message }; + } tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator"); let deadline_at = ttl_seconds.and_then(|s| { let now = std::time::SystemTime::now() @@ -301,6 +307,20 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } + ManagerRequest::Remind { + message, + timing, + file_path, + } => match crate::agent_server::store_remind( + coord, + MANAGER_AGENT, + message, + timing, + file_path.as_deref(), + ) { + Ok(()) => ManagerResponse::Ok, + Err(message) => ManagerResponse::Err { message }, + }, ManagerRequest::RequestApplyCommit { agent, commit_ref, diff --git a/hive-c0re/src/reminder_scheduler.rs b/hive-c0re/src/reminder_scheduler.rs index e8d0a780..c7d83f0f 100644 --- a/hive-c0re/src/reminder_scheduler.rs +++ b/hive-c0re/src/reminder_scheduler.rs @@ -121,8 +121,9 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String { /// Persist `message` to `host_path` with the symlink-escape defenses /// described in the module docs. Returns `Ok(())` on success, or a /// human-readable reason string on any failure (caller logs + -/// inline-falls-back). -fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> { +/// inline-falls-back). `pub` because `agent_server::handle_remind` +/// reuses it for the at-remind-time auto-file path. +pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> { let Some(parent) = host_path.parent() else { return Err("internal: host path has no parent".to_owned()); }; @@ -164,13 +165,28 @@ fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), Str Ok(()) } +/// Container-visible state prefix the caller's `file_path` must live +/// under. Sub-agents see their state at `/agents//state/`; +/// the manager keeps the legacy `/state/` mount (see +/// `lifecycle::set_nspawn_flags`). Auto-file paths use the same +/// prefix so the round-trip is symmetric. +#[must_use] +pub fn container_state_prefix(agent: &str) -> String { + if agent == hive_sh4re::MANAGER_AGENT { + "/state/".to_owned() + } else { + format!("/agents/{agent}/state/") + } +} + /// Map an agent-visible container path to the matching host path, /// validating that it lives under the agent's own state subtree, has /// a non-empty relative tail, and doesn't try to traverse out via /// `..`. Returns the host `PathBuf` on success, or a human-readable -/// reason string on rejection. -fn resolve_host_path(agent: &str, req_path: &str) -> Result { - let prefix = format!("/agents/{agent}/state/"); +/// reason string on rejection. `pub` so `agent_server::handle_remind` +/// can reuse it for the at-remind-time auto-file path. +pub fn resolve_host_path(agent: &str, req_path: &str) -> Result { + let prefix = container_state_prefix(agent); let Some(rel) = req_path.strip_prefix(&prefix) else { return Err(format!( "must be absolute and under `{prefix}` (got `{req_path}`)" @@ -228,6 +244,21 @@ mod tests { ); } + #[test] + fn manager_uses_legacy_state_prefix() { + // The manager container mounts its state at `/state/` (legacy), + // not `/agents/manager/state/`. Same host path; different + // container-visible path. resolve_host_path needs to know. + assert_eq!(container_state_prefix("manager"), "/state/"); + let p = resolve_host_path("manager", "/state/reminders/x.md").unwrap(); + assert_eq!( + p, + PathBuf::from("/var/lib/hyperhive/agents/manager/state/reminders/x.md") + ); + // And the sub-agent prefix must NOT be accepted for the manager. + assert!(resolve_host_path("manager", "/agents/manager/state/x.md").is_err()); + } + #[test] fn prepare_body_passthrough_when_no_file_path() { let s = prepare_body("foo", "hello world", None); diff --git a/hive-fr0nt/Cargo.toml b/hive-fr0nt/Cargo.toml new file mode 100644 index 00000000..1bc5f00f --- /dev/null +++ b/hive-fr0nt/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hive-fr0nt" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true diff --git a/hive-fr0nt/assets/base.css b/hive-fr0nt/assets/base.css new file mode 100644 index 00000000..ee7b64e6 --- /dev/null +++ b/hive-fr0nt/assets/base.css @@ -0,0 +1,24 @@ +/* Base palette + typography shared by the hive-c0re dashboard and the + hive-ag3nt web UI. Catppuccin Mocha. Per-page stylesheets append on + top of this and must NOT redeclare the colour variables — the whole + point of pulling them out is one source of truth. */ +:root { + --bg: #1e1e2e; /* base */ + --bg-elev: #181825; /* mantle */ + --fg: #cdd6f4; /* text */ + --muted: #7f849c; /* overlay1 */ + --purple: #cba6f7; /* mauve */ + --purple-dim: #45475a;/* surface1 */ + --cyan: #89dceb; /* sky */ + --pink: #f5c2e7; /* pink */ + --amber: #fab387; /* peach */ + --green: #a6e3a1; /* green */ + --red: #f38ba8; /* red */ + --border: #313244; /* surface0 */ +} +body { + background: var(--bg); + color: var(--fg); + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + line-height: 1.6; +} diff --git a/hive-fr0nt/assets/terminal.css b/hive-fr0nt/assets/terminal.css new file mode 100644 index 00000000..62f33d64 --- /dev/null +++ b/hive-fr0nt/assets/terminal.css @@ -0,0 +1,182 @@ +/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill. + Pages wrap their stream container in `.terminal-wrap` and give the log + itself the `.live` class; renderer JS appends `.row` (flat line) or + `details.row` (collapsible body) elements. Row-kind classes + (`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event + colour; pages that don't emit a given kind simply never produce that + class — the unused rule sits in the bundle harmlessly. + + `.terminal-wrap` provides the crust-on-black phosphor chrome that makes + the agent page feel like a terminal. Pages can opt in by wrapping a + block in this class; or skip it and the rows still render with their + class colours, just without the frame. + + No `.term-input` here — composers are a separate concern (see + hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */ + +.terminal-wrap { + position: relative; + background: rgba(17, 17, 27, 0.78); + -webkit-backdrop-filter: blur(8px) saturate(120%); + backdrop-filter: blur(8px) saturate(120%); + border: 1px solid var(--purple-dim); + box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7); + border-radius: 4px; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + font-size: 0.92em; + color: var(--fg); + margin-top: 0.6em; +} +.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.terminal { + background: transparent; + border: 0; + box-shadow: none; + border-radius: 0; + padding: 0.8em 1em 0.4em; + overflow-y: auto; + height: min(72vh, 60em); + max-height: none; + font-family: inherit; + font-size: inherit; + color: inherit; +} +.live .row, +.live details.row { + animation: row-fade-in 220ms ease-out both; +} +.live .row.no-anim, +.live details.row.no-anim { + animation: none; +} +@keyframes row-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.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; } +/* Row-kind colours. Pages register renderers that emit these classes; + any class no page emits is just dead CSS, which is fine. */ +.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: var(--green); border-left-color: var(--green); margin-bottom: 0.4em; } +.live .turn-end-fail { color: var(--red); border-left-color: var(--red); 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: var(--cyan); 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); } +.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); + animation: badge-pulse 1.4s ease-in-out infinite; +} +@keyframes badge-pulse { + 0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); } + 50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); } +} +/* "↓ N new" pill: shown when new rows arrive while the operator is + scrolled up; click to jump to bottom. Positioned by the wrapper's + `position: relative` (terminal-wrap supplies it; pages that skip the + wrapper must add their own positioned ancestor). */ +.tail-pill { + position: absolute; + right: 1em; + bottom: 4.2em; + background: var(--amber); + color: #11111b; + font-family: inherit; + font-size: 0.8em; + font-weight: bold; + letter-spacing: 0.08em; + border: 0; + border-radius: 999px; + padding: 0.35em 0.9em; + cursor: pointer; + box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85); + opacity: 0; + transform: translateY(6px); + pointer-events: none; + transition: opacity 160ms ease, transform 160ms ease; +} +.tail-pill.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} +.tail-pill:hover { filter: brightness(1.1); } +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.diff-body { + margin: 0.3em 0 0.4em 1.2em; + padding: 0.4em 0.6em; + background: rgba(255, 255, 255, 0.02); + border-left: 2px solid var(--purple-dim); + white-space: pre-wrap; + word-break: break-word; + max-height: 22em; + overflow-y: auto; +} +details.row > pre.diff-body .diff-add { color: var(--green); } +details.row > pre.diff-body .diff-del { color: var(--red); } +details.row > pre.diff-body .diff-ctx { color: var(--fg); } +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; +} diff --git a/hive-fr0nt/assets/terminal.js b/hive-fr0nt/assets/terminal.js new file mode 100644 index 00000000..625af28a --- /dev/null +++ b/hive-fr0nt/assets/terminal.js @@ -0,0 +1,217 @@ +// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history +// backfill + live SSE. Pages provide a kind→renderer map; this module +// owns scroll behaviour, animation suppression on backfill, and the +// EventSource lifecycle. +// +// Usage: +// +// HiveTerminal.create({ +// logEl: document.getElementById('msgflow'), +// historyUrl: '/messages/history?limit=200', // optional +// streamUrl: '/messages/stream', +// renderers: { +// sent: (ev, api) => api.row('msgrow sent', ...), +// delivered: (ev, api) => api.row('msgrow delivered', ...), +// _default: (ev, api) => api.row('note', JSON.stringify(ev)), +// }, +// onLiveEvent: (ev) => { /* side effects: notifications, state pokes */ }, +// onBackfillDone: (count) => { /* one-shot after history replay */ }, +// pillAnchor: document.getElementById('msgflow').parentElement, +// }); +// +// Renderers receive (ev, api) where api exposes: +// +// api.row(cls, text) → appends a flat
+// api.details(cls, summary, body) → appends
+// with a +// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by +// leading "+ " / "- " prefix +// api.placeholder(text) → replaces log content with a single +// muted "(placeholder)" row, cleared +// on the next real row +// api.fromHistory → true while backfill is replaying +// +// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall +// through to `renderers._default` (which itself defaults to a JSON-dump +// note row). The convention is that the SSE/history endpoints emit +// objects with a `kind` field. +// +// Backfill is best-effort: if `historyUrl` is unset or the fetch fails, +// we skip straight to SSE. The optional `onBackfillDone(count)` hook +// fires after replay finishes (or after a failed/skipped fetch with +// count=0); pages use it to set state flags from the replayed history. + +(function () { + const NEAR_BOTTOM_PX = 48; + + function create(opts) { + const log = opts.logEl; + if (!log) throw new Error('HiveTerminal.create: logEl is required'); + const renderers = opts.renderers || {}; + const defaultRender = renderers._default + || ((ev, api) => api.row('note', JSON.stringify(ev))); + const pillAnchor = opts.pillAnchor || log.parentElement || log; + + let placeholderEl = null; + let pill = null; + let unseen = 0; + let currentNoAnim = false; + + function isNearBottom() { + return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; + } + function ensurePill() { + if (pill) return pill; + pill = document.createElement('button'); + pill.type = 'button'; + pill.className = 'tail-pill'; + pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; }); + pillAnchor.appendChild(pill); + return pill; + } + function updatePill() { + if (unseen <= 0) { + if (pill) pill.classList.remove('visible'); + return; + } + ensurePill(); + pill.textContent = '↓ ' + unseen + ' new'; + pill.classList.add('visible'); + } + log.addEventListener('scroll', () => { + if (isNearBottom()) { unseen = 0; updatePill(); } + }); + function afterAppend() { + if (currentNoAnim || isNearBottom()) { + log.scrollTop = log.scrollHeight; + } else { + unseen += 1; + updatePill(); + } + } + function clearPlaceholder() { + if (placeholderEl && placeholderEl.parentElement === log) { + log.removeChild(placeholderEl); + } + placeholderEl = null; + } + function placeholder(text) { + clearPlaceholder(); + const e = document.createElement('div'); + e.className = 'row note'; + e.textContent = text; + log.appendChild(e); + placeholderEl = e; + } + function row(cls, text) { + clearPlaceholder(); + const e = document.createElement('div'); + e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + e.textContent = text; + log.appendChild(e); + afterAppend(); + return e; + } + function details(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + 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); + afterAppend(); + return d; + } + function detailsDiff(cls, summary, body) { + clearPlaceholder(); + const d = document.createElement('details'); + d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); + const s = document.createElement('summary'); + s.textContent = summary; + d.appendChild(s); + const pre = document.createElement('pre'); + pre.className = 'tool-body diff-body'; + for (const line of String(body).split('\n')) { + const span = document.createElement('span'); + if (line.startsWith('+ ')) span.className = 'diff-add'; + else if (line.startsWith('- ')) span.className = 'diff-del'; + else span.className = 'diff-ctx'; + span.textContent = line + '\n'; + pre.appendChild(span); + } + d.appendChild(pre); + log.appendChild(d); + afterAppend(); + return d; + } + + function api(extra) { + return Object.assign({ + row, details, detailsDiff, placeholder, + fromHistory: false, + }, extra || {}); + } + function dispatch(ev, fromHistory) { + const r = renderers[ev.kind] || defaultRender; + try { + r(ev, api({ fromHistory })); + } catch (err) { + console.error('terminal renderer threw', ev, err); + row('note', '[render err] ' + (err && err.message ? err.message : err)); + } + } + + async function backfill() { + if (!opts.historyUrl) { + if (opts.onBackfillDone) opts.onBackfillDone(0); + return; + } + try { + const resp = await fetch(opts.historyUrl); + if (!resp.ok) { + if (opts.onBackfillDone) opts.onBackfillDone(0); + return; + } + const events = await resp.json(); + currentNoAnim = true; + for (const ev of events) dispatch(ev, true); + currentNoAnim = false; + if (events.length) row('note', '─── live (older above) ───'); + else placeholder('(connected — waiting for events)'); + if (opts.onBackfillDone) opts.onBackfillDone(events.length); + } catch (err) { + console.warn('history backfill failed', err); + if (opts.onBackfillDone) opts.onBackfillDone(0); + } + } + + function subscribe() { + const es = new EventSource(opts.streamUrl); + es.onmessage = (e) => { + let ev; + try { ev = JSON.parse(e.data); } + catch (err) { row('note', '[parse err] ' + e.data); return; } + dispatch(ev, false); + if (opts.onLiveEvent) { + try { opts.onLiveEvent(ev); } + catch (err) { console.error('onLiveEvent threw', err); } + } + }; + es.onerror = () => { + if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); + else row('note', '[disconnected]'); + }; + return es; + } + + const ready = backfill().then(subscribe); + return { row, details, detailsDiff, placeholder, ready }; + } + + window.HiveTerminal = { create }; +})(); diff --git a/hive-fr0nt/src/lib.rs b/hive-fr0nt/src/lib.rs new file mode 100644 index 00000000..8bec6aa8 --- /dev/null +++ b/hive-fr0nt/src/lib.rs @@ -0,0 +1,34 @@ +//! Shared frontend assets for the hive-c0re dashboard and the hive-ag3nt +//! per-container web UI. Both surfaces live in different binaries (and +//! different containers at runtime) but should feel like one product — +//! same colour tokens, same terminal-style live stream, same compose-box +//! ergonomics. Keeping the CSS + JS in one crate is the dumbest way to +//! make that true: both binaries `include_str!` from +//! `hive_fr0nt::assets::*` instead of growing their own copy. +//! +//! There is no Rust code beyond these `const` re-exports. The crate is a +//! container for text files and a place to write down the contract +//! between the two surfaces. +//! +//! Conventions for sharing: +//! - **CSS variables** live in [`BASE_CSS`] (colour palette, typography). +//! Page-specific stylesheets append to it; nothing else should declare +//! `--bg` / `--purple` / etc. +//! - **Terminal pane** (sticky-bottom log + `↓ N new` pill + fade-in +//! rows) lives in [`TERMINAL_CSS`] and [`TERMINAL_JS`]. Pages provide +//! a kind→renderer map; the JS owns the scroll + backfill + SSE plumbing. +//! - **Compose box** (textarea + slash-command palette + sticky +//! recipient + `@`-mention autocomplete) lives in [`COMPOSER_JS`]. +//! Pages pass a config flagging which features they want; the dashboard +//! ships `@`-mentions without slash commands, the agent page ships +//! slash commands without `@`-mentions. Both render through the same +//! component so the keystrokes, error flashes, and async-form +//! behaviour stay identical. +//! +//! Loading new shared assets: add the file under `assets/`, expose it as +//! a `pub const`, and `include_str!` it from whichever +//! `dashboard.rs` / `web_ui.rs` route needs it. + +pub const BASE_CSS: &str = include_str!("../assets/base.css"); +pub const TERMINAL_CSS: &str = include_str!("../assets/terminal.css"); +pub const TERMINAL_JS: &str = include_str!("../assets/terminal.js"); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 314aba60..6de7228c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -486,6 +486,17 @@ pub enum ManagerRequest { #[serde(default)] lines: Option, }, + /// Mirror of `AgentRequest::Remind` on the manager surface — schedule + /// a reminder addressed to the manager itself. Same semantics: body + /// soft-caps at 1 KiB, oversize bodies auto-persist to + /// `/state/reminders/auto-.md` (the manager container's own state + /// mount) and the inbox sees a pointer. + Remind { + message: String, + timing: ReminderTiming, + #[serde(default)] + file_path: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize)]