Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27108aecf | ||
|
|
0b9e7cbcf6 | ||
|
|
e283e39949 | ||
|
|
7fc3e81062 | ||
|
|
1770b51845 | ||
|
|
0e6bac8388 |
23 changed files with 882 additions and 494 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -16,7 +16,7 @@
|
|||
- ~~Handle text overflow → suggest file_path option for long messages~~ ✓ fixed — Remind dispatch rejects `message.len() > 4096` (when no `file_path` was supplied) with an error pointing at the `file_path` escape hatch.
|
||||
- Per-agent reminder limits (burst capacity, rate limiting)
|
||||
- ~~**Expose `remind` MCP tool**~~ ✓ fixed — `mcp__hyperhive__remind` now on `AgentServer`; takes `message`, exactly one of `delay_seconds` / `at_unix_timestamp`, optional `file_path`. Manager surface still missing (no `ManagerRequest::Remind` variant) — separate item below.
|
||||
- **Manager-side `remind`**: mirror of the agent tool but on `ManagerServer`. Needs `ManagerRequest::Remind` variant in hive-sh4re, dispatch in manager_server.rs, MCP tool wiring.
|
||||
- ~~**Manager-side `remind`**~~ ✓ fixed — `ManagerRequest::Remind` variant added, dispatch reuses `agent_server::store_remind` helper (shared across both surfaces), `mcp__hyperhive__remind` now on `ManagerServer` (auto-file lands at `/state/reminders/auto-<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
61
hive-c0re/src/limits.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ mod dashboard;
|
|||
mod events_vacuum;
|
||||
mod forge;
|
||||
mod lifecycle;
|
||||
mod limits;
|
||||
mod manager_server;
|
||||
mod meta;
|
||||
mod migrate;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
7
hive-fr0nt/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "hive-fr0nt"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
24
hive-fr0nt/assets/base.css
Normal file
24
hive-fr0nt/assets/base.css
Normal 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;
|
||||
}
|
||||
182
hive-fr0nt/assets/terminal.css
Normal file
182
hive-fr0nt/assets/terminal.css
Normal 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;
|
||||
}
|
||||
217
hive-fr0nt/assets/terminal.js
Normal file
217
hive-fr0nt/assets/terminal.js
Normal 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
34
hive-fr0nt/src/lib.rs
Normal 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");
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue