Compare commits

...
23 changed files with 882 additions and 494 deletions

6
Cargo.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -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-<ts>.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/<agent>/state/...` to host `/var/lib/hyperhive/agents/<agent>/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.

View file

@ -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

View file

@ -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. */

View file

@ -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 <details>, 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 <details>, 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
// <details> 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.

View file

@ -33,6 +33,7 @@
<div id="term-input" class="term-input"></div>
</div>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -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/<you>/state/reminders/auto-<ts>.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/<you>/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/<you>/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<RemindArgs>) -> 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<RemindArgs>) -> 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<String> {
"request_apply_commit",
"ask_operator",
"get_logs",
"remind",
],
};
let mut out: Vec<String> = names

View file

@ -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,

View file

@ -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

View file

@ -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;

View file

@ -98,6 +98,9 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
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<u64>,
) -> 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<Coordinator>,
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<Coordinator>,
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/<agent>/state/reminders/auto-<ts>.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>), 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<i64> {
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"));
}
}

View file

@ -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 {

61
hive-c0re/src/limits.rs Normal file
View file

@ -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/<you>/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"));
}
}

View file

@ -17,6 +17,7 @@ mod dashboard;
mod events_vacuum;
mod forge;
mod lifecycle;
mod limits;
mod manager_server;
mod meta;
mod migrate;

View file

@ -86,6 +86,9 @@ fn manager_recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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,

View file

@ -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/<name>/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<PathBuf, String> {
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<PathBuf, String> {
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);

7
hive-fr0nt/Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "hive-fr0nt"
edition.workspace = true
version.workspace = true
[lints]
workspace = true

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 <div class="row cls">
// api.details(cls, summary, body) → appends <details class="row cls">
// with a <pre.tool-body>
// 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 };
})();

34
hive-fr0nt/src/lib.rs Normal file
View file

@ -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");

View file

@ -486,6 +486,17 @@ pub enum ManagerRequest {
#[serde(default)]
lines: Option<u32>,
},
/// 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-<ts>.md` (the manager container's own state
/// mount) and the inbox sees a pointer.
Remind {
message: String,
timing: ReminderTiming,
#[serde(default)]
file_path: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]