diff --git a/Cargo.lock b/Cargo.lock index 006f8fbe..ca9b6f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,6 @@ dependencies = [ "anyhow", "axum", "clap", - "hive-fr0nt", "hive-sh4re", "rmcp", "rusqlite", @@ -471,7 +470,6 @@ dependencies = [ "anyhow", "axum", "clap", - "hive-fr0nt", "hive-sh4re", "libc", "rusqlite", @@ -483,10 +481,6 @@ 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 4c2bbbb1..2f4c26f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"] [workspace.package] edition = "2024" @@ -18,7 +18,6 @@ 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 257282a0..a2acde83 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`**~~ ✓ 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). +- **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. - ~~**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 5df9a209..f1a253e3 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -10,7 +10,6 @@ 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 ca61c2a1..0cf22279 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -1,9 +1,24 @@ -/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended - to this stylesheet by `serve_css` at runtime. */ +: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 */ +} 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; @@ -262,9 +277,39 @@ 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-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. */ +/* 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; +} .term-input { padding: 0.4em 1em 0.8em; } .term-input .sendform-term { display: flex; @@ -301,4 +346,147 @@ 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); } -/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ +.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); } diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 14d61677..3c9461bd 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -532,15 +532,98 @@ 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 || !window.HiveTerminal) return; - log.innerHTML = ''; + 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; }, + }; + // 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. @@ -567,13 +650,16 @@ 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. - function renderRichToolUse(c, api) { + // 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) { const name = c.name || ''; const input = c.input || {}; if (name === 'Write' || name === 'Edit') { @@ -597,7 +683,7 @@ } const summary = '→ ' + name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; - return api.detailsDiff('tool-use', summary, body); + return detailsDiff('tool-use', summary, body); } if (name === 'mcp__hyperhive__send') { const to = input.to || '?'; @@ -606,11 +692,35 @@ const lines = body.split('\n').length; const summary = '→ send → ' + to + (lines > 1 ? ` · ${lines}L` : '') + (headline ? ' · ' + headline + (body.length > 80 ? '…' : '') : ''); - return api.details('tool-use', summary, body); + return details('tool-use', summary, body); } return null; } - function renderToolResult(c, api) { + 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) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); @@ -622,28 +732,33 @@ 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) { - api.row('tool-result', summary); + row('tool-result', summary); } else { - api.details('tool-result-block', summary, txt); + details('tool-result-block', summary, txt); } } - function renderStream(v, api) { - // Drop session init, claude's result line, rate-limit — noise. - // TurnEnd communicates pass/fail; session init isn't actionable. + 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. 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()) api.row('text', c.text); + if (c.type === 'text' && c.text && c.text.trim()) row('text', c.text); else if (c.type === 'thinking') { const txt = (c.thinking || c.text || '').trim(); - api.row('thinking', txt ? '· ' + txt : '· thinking …'); + row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') { - if (!renderRichToolUse(c, api)) { - api.row('tool-use', '→ ' + fmtToolUse(c)); + // 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)); } } } @@ -651,71 +766,90 @@ } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { - if (c.type === 'tool_result') renderToolResult(c, api); + if (c.type === 'tool_result') renderToolResult(c); } return; } - api.row('sys', '· ' + trim(JSON.stringify(v), 200)); + 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)); } - // 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 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); + } + } - 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'); - }, + 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]'); + }; }); - - // 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 6180184a..5ebae41f 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -33,7 +33,6 @@
- diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index ce982311..84a3d1d2 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -152,11 +152,9 @@ 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`). 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. + /// 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`. pub message: String, /// Fire `delay_seconds` from now (relative). Set this OR /// `at_unix_timestamp`, not both. @@ -290,11 +288,10 @@ 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 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." + 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." )] async fn remind(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -665,45 +662,6 @@ 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 \ @@ -792,7 +750,6 @@ 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 5eb13fce..80a142fb 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -93,7 +93,6 @@ 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)) @@ -163,16 +162,10 @@ async fn serve_index() -> impl IntoResponse { } async fn serve_css() -> impl IntoResponse { - // 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, + ( + [("content-type", "text/css")], include_str!("../assets/agent.css"), - ); - ([("content-type", "text/css")], body) + ) } async fn serve_app_js() -> impl IntoResponse { @@ -182,13 +175,6 @@ 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 a32e5d1b..47ef2a88 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -10,7 +10,6 @@ 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 dd893997..416a4669 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -1,9 +1,27 @@ -/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended - to this stylesheet by `serve_css` at runtime. */ +: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 */ +} 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 39c3ed8d..978eab44 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -98,9 +98,6 @@ 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); @@ -192,9 +189,6 @@ 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) @@ -220,6 +214,21 @@ 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, @@ -227,87 +236,38 @@ fn handle_remind( timing: &hive_sh4re::ReminderTiming, file_path: Option<&str>, ) -> AgentResponse { - match store_remind(coord, agent, message, timing, file_path) { - Ok(()) => AgentResponse::Ok, - Err(message) => AgentResponse::Err { message }, - } -} - -/// 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 (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, ""), }; - 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") + if message.len() > cap { + return AgentResponse::Err { + message: format!( + "reminder body too long ({} bytes, max {cap}){hint}", + message.len() + ), + }; + } + 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:#}"), + }, + } } /// Resolve the `due_at` unix timestamp for a Remind request. Returns @@ -333,30 +293,3 @@ 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 cf0c626e..5e3e5945 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -114,16 +114,10 @@ async fn serve_index() -> impl IntoResponse { } async fn serve_css() -> impl IntoResponse { - // 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, + ( + [("content-type", "text/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 deleted file mode 100644 index eaef1e94..00000000 --- a/hive-c0re/src/limits.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! 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 489dbacf..8918e68b 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -17,7 +17,6 @@ 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 e7b93076..c25cbb0b 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -86,9 +86,6 @@ 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() { @@ -250,9 +247,6 @@ 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() @@ -307,20 +301,6 @@ 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 c7d83f0f..e8d0a780 100644 --- a/hive-c0re/src/reminder_scheduler.rs +++ b/hive-c0re/src/reminder_scheduler.rs @@ -121,9 +121,8 @@ 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). `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> { +/// inline-falls-back). +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()); }; @@ -165,28 +164,13 @@ pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), 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. `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); +/// reason string on rejection. +fn resolve_host_path(agent: &str, req_path: &str) -> Result { + let prefix = format!("/agents/{agent}/state/"); let Some(rel) = req_path.strip_prefix(&prefix) else { return Err(format!( "must be absolute and under `{prefix}` (got `{req_path}`)" @@ -244,21 +228,6 @@ 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 deleted file mode 100644 index 1bc5f00f..00000000 --- a/hive-fr0nt/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[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 deleted file mode 100644 index ee7b64e6..00000000 --- a/hive-fr0nt/assets/base.css +++ /dev/null @@ -1,24 +0,0 @@ -/* 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 deleted file mode 100644 index 62f33d64..00000000 --- a/hive-fr0nt/assets/terminal.css +++ /dev/null @@ -1,182 +0,0 @@ -/* 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 deleted file mode 100644 index 625af28a..00000000 --- a/hive-fr0nt/assets/terminal.js +++ /dev/null @@ -1,217 +0,0 @@ -// 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 deleted file mode 100644 index 8bec6aa8..00000000 --- a/hive-fr0nt/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! 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 6de7228c..314aba60 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -486,17 +486,6 @@ 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)]