Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ed33d94f | ||
|
|
211599c589 | ||
|
|
0cc25d33d8 | ||
|
|
08f2ec5232 | ||
|
|
875a8f5be4 | ||
|
|
48ebfefd1a |
13 changed files with 268 additions and 116 deletions
|
|
@ -337,7 +337,9 @@ loops over every stale container.
|
||||||
Container row buttons (rendered per-state by `assets/app.js`):
|
Container row buttons (rendered per-state by `assets/app.js`):
|
||||||
|
|
||||||
- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents
|
- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents
|
||||||
`DESTR0Y` (container removed, state + creds kept).
|
`DESTR0Y` (container removed, state + creds kept) + `PURG3`
|
||||||
|
(DESTR0Y plus wipes `/var/lib/hyperhive/{agents,applied}/<name>/`;
|
||||||
|
no undo).
|
||||||
- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`.
|
- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`.
|
||||||
- Stopped: `▶ ST4RT`.
|
- Stopped: `▶ ST4RT`.
|
||||||
- Stale marker: clickable `needs update ↻` badge (same target as rebuild
|
- Stale marker: clickable `needs update ↻` badge (same target as rebuild
|
||||||
|
|
|
||||||
38
TODO.md
38
TODO.md
|
|
@ -37,16 +37,23 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
turns, and so `/events/history` survives restart. Cap rows per
|
turns, and so `/events/history` survives restart. Cap rows per
|
||||||
agent or auto-vacuum on age, same trade-off as the bounded broker
|
agent or auto-vacuum on age, same trade-off as the bounded broker
|
||||||
entry below.
|
entry below.
|
||||||
- **Granular agent state badge** above the terminal: `idle 💤 / thinking 🧠 /
|
- **State badge: compacting + napping states.** Idle/thinking already
|
||||||
compacting 📦` with an age timer (`thinking · 12s`). Drives a state
|
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
|
||||||
channel from the harness: idle when waiting on the inbox, thinking
|
`napping 😴` once the `/compact` trigger and `nap` tool exist —
|
||||||
while claude's stream is open, compacting when `/compact` is in
|
both need a harness signal (an explicit `LiveEvent::StateChange`
|
||||||
flight. Replaces the binary "harness alive — turn loop running" line.
|
variant or piggyback on Note).
|
||||||
- **Terminal: slash commands + tab-completion.** Operator-facing
|
- **Terminal: slash commands beyond /help and /clear.** Operator-facing
|
||||||
in-terminal commands: `/help`, `/model`, `/compact`, `/clear`. Tab
|
in-terminal commands still to add: `/model`, `/compact`, `/cancel`.
|
||||||
completes command names + model names (cf. bitburner-agent's pattern).
|
Each needs harness-side support (model override, force compaction,
|
||||||
- **Terminal: multi-line input.** Replace the single-line `<input>` with
|
cancel current claude turn).
|
||||||
an auto-growing textarea; Enter sends, Shift+Enter newlines.
|
- **Terminal: bigger.** The 32em max-height is cramped on a 1080p+
|
||||||
|
screen. Grow it (e.g. `min(70vh, 60em)`) so the live tail is the
|
||||||
|
main visual element of the page rather than a strip.
|
||||||
|
- **Terminal: sticky-bottom auto-scroll.** Today every appended row
|
||||||
|
scrolls to bottom, so the view shifts while the operator is reading
|
||||||
|
scrolled-up. Track whether the user is *already* at the bottom
|
||||||
|
(within a small threshold), and only auto-scroll when that's true.
|
||||||
|
Show a small "↓ N new" indicator when not at bottom; click to jump.
|
||||||
- **Terminal: cancel-current-turn button.** Explicit "kill claude
|
- **Terminal: cancel-current-turn button.** Explicit "kill claude
|
||||||
process for this turn" control. Harness needs to track the
|
process for this turn" control. Harness needs to track the
|
||||||
in-flight claude child PID and offer a `/cancel` endpoint that sends
|
in-flight claude child PID and offer a `/cancel` endpoint that sends
|
||||||
|
|
@ -57,9 +64,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
same session id. Surfaces as a slash command in the terminal + a
|
same session id. Surfaces as a slash command in the terminal + a
|
||||||
toolbar button while the state badge is `idle`. Sets state to
|
toolbar button while the state badge is `idle`. Sets state to
|
||||||
`compacting` during the run.
|
`compacting` during the run.
|
||||||
- **Visuals.** Frosted-glass backdrop blur on the terminal wrap,
|
|
||||||
per-event fade-in slide-up animation on new rows, badge pulse
|
|
||||||
animation on state-badge transitions.
|
|
||||||
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
||||||
the harness. Pairs well with the unprivileged-container work — would let
|
the harness. Pairs well with the unprivileged-container work — would let
|
||||||
the operator drop into the container without `nixos-container root-login`.
|
the operator drop into the container without `nixos-container root-login`.
|
||||||
|
|
@ -115,12 +119,4 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
- **Container crash events.** Watch `container@*.service` via D-Bus, push
|
- **Container crash events.** Watch `container@*.service` via D-Bus, push
|
||||||
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
|
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
|
||||||
react (restart, escalate, etc.).
|
react (restart, escalate, etc.).
|
||||||
- **`destroy --purge`.** Today `destroy` keeps state by design; add an
|
|
||||||
opt-in flag (CLI + dashboard) to also wipe `/var/lib/hyperhive/agents/<name>/`
|
|
||||||
and `/var/lib/hyperhive/applied/<name>/`.
|
|
||||||
|
|
||||||
## Cleanup / docs
|
|
||||||
|
|
||||||
- **Debug-only sub-commands.** `hive-ag3nt send/recv` and the analogous
|
|
||||||
`hive-m1nd send/recv/...` exist only for ops debugging. Move them into a
|
|
||||||
hidden `debug` sub-command to declutter `--help`, or drop entirely.
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
max-width: 70em;
|
max-width: 110em;
|
||||||
margin: 1.5em auto;
|
margin: 1.5em auto;
|
||||||
padding: 0 1.5em;
|
padding: 0 1.5em;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
@ -124,12 +124,49 @@ pre.diff {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
}
|
}
|
||||||
|
#state-row {
|
||||||
|
margin: 0.4em 0 0.2em;
|
||||||
|
}
|
||||||
|
.state-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: color 280ms ease, border-color 280ms ease,
|
||||||
|
box-shadow 280ms ease, background 280ms ease;
|
||||||
|
}
|
||||||
|
.state-badge.state-loading {
|
||||||
|
color: var(--muted); border-color: var(--purple-dim);
|
||||||
|
}
|
||||||
|
.state-badge.state-offline {
|
||||||
|
color: var(--muted); border-color: var(--muted);
|
||||||
|
}
|
||||||
|
.state-badge.state-idle {
|
||||||
|
color: var(--cyan); border-color: var(--cyan);
|
||||||
|
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
|
||||||
|
}
|
||||||
|
.state-badge.state-thinking {
|
||||||
|
color: var(--amber); border-color: var(--amber);
|
||||||
|
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
||||||
|
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.state-badge.state-just-changed {
|
||||||
|
animation: state-flash 600ms ease-out;
|
||||||
|
}
|
||||||
|
@keyframes state-flash {
|
||||||
|
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||||
|
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
|
/* Terminal-ish wrapper holding the live output + prompt input as one
|
||||||
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
|
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
|
||||||
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
|
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
|
||||||
so anything that bleeds through (page banner glow, scroll position)
|
so anything that bleeds through (page banner glow, scroll position)
|
||||||
reads as out-of-focus depth instead of sharp competing detail. */
|
reads as out-of-focus depth instead of sharp competing detail. */
|
||||||
.terminal-wrap {
|
.terminal-wrap {
|
||||||
|
position: relative;
|
||||||
background: rgba(17, 17, 27, 0.78);
|
background: rgba(17, 17, 27, 0.78);
|
||||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||||
backdrop-filter: blur(8px) saturate(120%);
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
|
|
@ -148,7 +185,10 @@ pre.diff {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0.8em 1em 0.4em;
|
padding: 0.8em 1em 0.4em;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 32em;
|
/* 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-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
@ -224,6 +264,36 @@ pre.diff {
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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 {
|
details.row {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,54 @@
|
||||||
if (ta) ta.disabled = !online;
|
if (ta) ta.disabled = !online;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Granular state badge: idle / thinking / offline. Driven from SSE
|
||||||
|
// turn_start/turn_end. Age timer ticks client-side; badge re-renders
|
||||||
|
// each second so the "· 12s" suffix stays current. State changes
|
||||||
|
// trigger a short flash animation via .state-just-changed.
|
||||||
|
const STATE_LABELS = {
|
||||||
|
loading: { glyph: '…', text: 'booting' },
|
||||||
|
offline: { glyph: '○', text: 'offline' },
|
||||||
|
idle: { glyph: '💤', text: 'idle' },
|
||||||
|
thinking: { glyph: '🧠', text: 'thinking' },
|
||||||
|
};
|
||||||
|
let stateName = 'loading';
|
||||||
|
let stateSince = Date.now();
|
||||||
|
let stateTickTimer = null;
|
||||||
|
function fmtAge(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h + 'h ' + (m % 60) + 'm';
|
||||||
|
}
|
||||||
|
function renderStateBadge() {
|
||||||
|
const badge = $('state-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
const def = STATE_LABELS[stateName] || STATE_LABELS.loading;
|
||||||
|
const age = fmtAge(Date.now() - stateSince);
|
||||||
|
badge.textContent = def.glyph + ' ' + def.text + ' · ' + age;
|
||||||
|
badge.className = 'state-badge state-' + stateName;
|
||||||
|
}
|
||||||
|
function setState(next) {
|
||||||
|
if (next === stateName) return;
|
||||||
|
stateName = next;
|
||||||
|
stateSince = Date.now();
|
||||||
|
const badge = $('state-badge');
|
||||||
|
if (badge) {
|
||||||
|
// Re-add the flash class so the animation replays.
|
||||||
|
badge.classList.remove('state-just-changed');
|
||||||
|
void badge.offsetWidth;
|
||||||
|
badge.classList.add('state-just-changed');
|
||||||
|
}
|
||||||
|
renderStateBadge();
|
||||||
|
}
|
||||||
|
function startStateTicker() {
|
||||||
|
if (stateTickTimer) return;
|
||||||
|
stateTickTimer = setInterval(renderStateBadge, 1000);
|
||||||
|
}
|
||||||
|
startStateTicker();
|
||||||
|
|
||||||
// Track banner activity by reference-counting in-flight turns. A turn
|
// Track banner activity by reference-counting in-flight turns. A turn
|
||||||
// can begin while the previous turn_end is still in the pipeline (rare
|
// can begin while the previous turn_end is still in the pipeline (rare
|
||||||
// but happens on tight wake cycles), so we count rather than toggle.
|
// but happens on tight wake cycles), so we count rather than toggle.
|
||||||
|
|
@ -277,6 +325,11 @@
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
||||||
renderTermInput(s.label, s.status === 'online');
|
renderTermInput(s.label, s.status === 'online');
|
||||||
|
// Drive the state badge from the harness status. Live SSE events
|
||||||
|
// override to 'thinking' / 'idle' as turns start/end; this only
|
||||||
|
// kicks in for the not-online (offline) case and the initial seed.
|
||||||
|
if (s.status !== 'online') setState('offline');
|
||||||
|
else if (stateName === 'loading' || stateName === 'offline') setState('idle');
|
||||||
// Skip the re-render if nothing structurally changed. The most
|
// Skip the re-render if nothing structurally changed. The most
|
||||||
// common case is `online` polling itself — without this guard, the
|
// common case is `online` polling itself — without this guard, the
|
||||||
// operator's <input value> gets clobbered every cycle.
|
// operator's <input value> gets clobbered every cycle.
|
||||||
|
|
@ -331,13 +384,58 @@
|
||||||
row: (cls, text) => row(cls, text),
|
row: (cls, text) => row(cls, text),
|
||||||
clear: () => { log.innerHTML = ''; placeholder = null; },
|
clear: () => { log.innerHTML = ''; placeholder = null; },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sticky-bottom auto-scroll. If the user is reading scrolled-up, new
|
||||||
|
// rows do NOT yank the view. A floating "↓ N new" pill appears in
|
||||||
|
// the bottom-right corner; clicking it jumps to bottom and clears
|
||||||
|
// the counter. Scrolling back near the bottom also clears it.
|
||||||
|
const NEAR_BOTTOM_PX = 48;
|
||||||
|
let unseen = 0;
|
||||||
|
let pill = null;
|
||||||
|
function isNearBottom() {
|
||||||
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||||
|
}
|
||||||
|
function ensurePill() {
|
||||||
|
if (pill) return pill;
|
||||||
|
pill = document.createElement('button');
|
||||||
|
pill.type = 'button';
|
||||||
|
pill.className = 'tail-pill';
|
||||||
|
pill.addEventListener('click', () => {
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
});
|
||||||
|
log.parentElement.appendChild(pill);
|
||||||
|
return pill;
|
||||||
|
}
|
||||||
|
function updatePill() {
|
||||||
|
if (unseen <= 0) {
|
||||||
|
if (pill) pill.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensurePill();
|
||||||
|
pill.textContent = '↓ ' + unseen + ' new';
|
||||||
|
pill.classList.add('visible');
|
||||||
|
}
|
||||||
|
log.addEventListener('scroll', () => {
|
||||||
|
if (isNearBottom()) {
|
||||||
|
unseen = 0;
|
||||||
|
updatePill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function afterAppend() {
|
||||||
|
if (currentNoAnim || isNearBottom()) {
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
} else {
|
||||||
|
unseen += 1;
|
||||||
|
updatePill();
|
||||||
|
}
|
||||||
|
}
|
||||||
function row(cls, text) {
|
function row(cls, text) {
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const e = document.createElement('div');
|
const e = document.createElement('div');
|
||||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
e.textContent = text;
|
e.textContent = text;
|
||||||
log.appendChild(e);
|
log.appendChild(e);
|
||||||
log.scrollTop = log.scrollHeight;
|
afterAppend();
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
function details(cls, summary, body) {
|
function details(cls, summary, body) {
|
||||||
|
|
@ -352,7 +450,7 @@
|
||||||
pre.textContent = body;
|
pre.textContent = body;
|
||||||
d.appendChild(pre);
|
d.appendChild(pre);
|
||||||
log.appendChild(d);
|
log.appendChild(d);
|
||||||
log.scrollTop = log.scrollHeight;
|
afterAppend();
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
||||||
|
|
@ -429,7 +527,7 @@
|
||||||
function handle(ev, opts) {
|
function handle(ev, opts) {
|
||||||
const fromHistory = !!(opts && opts.fromHistory);
|
const fromHistory = !!(opts && opts.fromHistory);
|
||||||
if (ev.kind === 'turn_start') {
|
if (ev.kind === 'turn_start') {
|
||||||
if (!fromHistory) setBannerActive(true);
|
if (!fromHistory) { setBannerActive(true); setState('thinking'); }
|
||||||
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
||||||
if (ev.unread > 0) {
|
if (ev.unread > 0) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
|
|
@ -444,7 +542,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.kind === 'turn_end') {
|
if (ev.kind === 'turn_end') {
|
||||||
if (!fromHistory) setBannerActive(false);
|
if (!fromHistory) { setBannerActive(false); setState('idle'); }
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
||||||
// Login may have just landed (or session re-enters Online). Pull
|
// Login may have just landed (or session re-enters Online). Pull
|
||||||
|
|
@ -483,6 +581,7 @@
|
||||||
}
|
}
|
||||||
currentNoAnim = false;
|
currentNoAnim = false;
|
||||||
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
||||||
|
if (openTurns > 0) setState('thinking');
|
||||||
if (events.length) row('note', '─── live (older above) ───');
|
if (events.length) row('note', '─── live (older above) ───');
|
||||||
else setPlaceholder('(connected — waiting for events)');
|
else setPlaceholder('(connected — waiting for events)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="state-row">
|
||||||
|
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="terminal-wrap">
|
<div class="terminal-wrap">
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent};
|
use hive_ag3nt::events::{Bus, LiveEvent};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
|
|
@ -29,10 +29,6 @@ enum Cmd {
|
||||||
#[arg(long, default_value_t = 1000)]
|
#[arg(long, default_value_t = 1000)]
|
||||||
poll_ms: u64,
|
poll_ms: u64,
|
||||||
},
|
},
|
||||||
/// Send a message to another agent.
|
|
||||||
Send { to: String, body: String },
|
|
||||||
/// Pop one message from the inbox.
|
|
||||||
Recv,
|
|
||||||
/// Run the agent's MCP server on stdio. Spawned by `claude` via
|
/// Run the agent's MCP server on stdio. Spawned by `claude` via
|
||||||
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
||||||
/// the hyperhive broker.
|
/// the hyperhive broker.
|
||||||
|
|
@ -103,17 +99,6 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::Send { to, body } => {
|
|
||||||
let resp: AgentResponse =
|
|
||||||
client::request(&cli.socket, &AgentRequest::Send { to, body }).await?;
|
|
||||||
render(&resp)?;
|
|
||||||
check(&resp)
|
|
||||||
}
|
|
||||||
Cmd::Recv => {
|
|
||||||
let resp: AgentResponse = client::request(&cli.socket, &AgentRequest::Recv).await?;
|
|
||||||
render(&resp)?;
|
|
||||||
check(&resp)
|
|
||||||
}
|
|
||||||
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
|
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,14 +177,3 @@ async fn inbox_unread(socket: &Path) -> u64 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(resp: &AgentResponse) -> Result<()> {
|
|
||||||
println!("{}", serde_json::to_string_pretty(resp)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(resp: &AgentResponse) -> Result<()> {
|
|
||||||
if let AgentResponse::Err { message } = resp {
|
|
||||||
bail!("{message}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
//! Manager harness. Talks to the manager socket (bind-mounted from the host
|
//! Manager harness. Talks to the manager socket (bind-mounted from the host
|
||||||
//! at `/run/hive/mcp.sock` inside the `hm1nd` container) using the privileged
|
//! at `/run/hive/mcp.sock` inside the `hm1nd` container). Two surfaces:
|
||||||
//! tool surface. Phase 4 minimum: a CLI to exercise the verbs from a shell,
|
//! `serve` (long-lived turn loop) and `mcp` (stdio MCP server claude spawns).
|
||||||
//! plus a `serve` loop that logs the manager's inbox.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent};
|
use hive_ag3nt::events::{Bus, LiveEvent};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
|
|
@ -32,25 +31,10 @@ enum Cmd {
|
||||||
#[arg(long, default_value_t = 1000)]
|
#[arg(long, default_value_t = 1000)]
|
||||||
poll_ms: u64,
|
poll_ms: u64,
|
||||||
},
|
},
|
||||||
/// Send a message to a sub-agent (or anywhere — the broker doesn't validate).
|
|
||||||
Send { to: String, body: String },
|
|
||||||
/// Pop one message from the manager's inbox.
|
|
||||||
Recv,
|
|
||||||
/// Submit a spawn request for the user to approve (creates a pending
|
|
||||||
/// approval; on approval the host creates + starts the container).
|
|
||||||
RequestSpawn { name: String },
|
|
||||||
/// Kill a sub-agent.
|
|
||||||
Kill { name: String },
|
|
||||||
/// Start a stopped sub-agent.
|
|
||||||
Start { name: String },
|
|
||||||
/// Restart a sub-agent (stop + start).
|
|
||||||
Restart { name: String },
|
|
||||||
/// Submit a config commit on the agent's config repo for user approval.
|
|
||||||
RequestApplyCommit { agent: String, commit_ref: String },
|
|
||||||
/// Run the manager MCP server on stdio. Spawned by claude via
|
/// Run the manager MCP server on stdio. Spawned by claude via
|
||||||
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
|
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
|
||||||
/// manager tool surface (`request_spawn`, `kill`,
|
/// manager tool surface (`request_spawn`, `kill`, `start`, `restart`,
|
||||||
/// `request_apply_commit`).
|
/// `request_apply_commit`, `ask_operator`).
|
||||||
Mcp,
|
Mcp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,34 +85,10 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await,
|
|
||||||
Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await,
|
|
||||||
Cmd::RequestSpawn { name } => {
|
|
||||||
one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await
|
|
||||||
}
|
|
||||||
Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await,
|
|
||||||
Cmd::Start { name } => one_shot(&cli.socket, ManagerRequest::Start { name }).await,
|
|
||||||
Cmd::Restart { name } => one_shot(&cli.socket, ManagerRequest::Restart { name }).await,
|
|
||||||
Cmd::RequestApplyCommit { agent, commit_ref } => {
|
|
||||||
one_shot(
|
|
||||||
&cli.socket,
|
|
||||||
ManagerRequest::RequestApplyCommit { agent, commit_ref },
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await,
|
Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> {
|
|
||||||
let resp: ManagerResponse = client::request(socket, &req).await?;
|
|
||||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
|
||||||
if let ManagerResponse::Err { message } = resp {
|
|
||||||
bail!("{message}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
||||||
let mcp_config = turn::write_mcp_config(socket).await?;
|
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,10 @@
|
||||||
' ',
|
' ',
|
||||||
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
||||||
'destroy ' + c.name + '? container is removed; state + creds kept.'),
|
'destroy ' + c.name + '? container is removed; state + creds kept.'),
|
||||||
|
' ',
|
||||||
|
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
|
||||||
|
'PURGE ' + c.name + '? container, config history, claude creds, '
|
||||||
|
+ 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
|
|
|
||||||
|
|
@ -132,22 +132,40 @@ fn finish_approval(
|
||||||
/// kept, so recreating an agent of the same name reuses prior config + creds
|
/// kept, so recreating an agent of the same name reuses prior config + creds
|
||||||
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
||||||
/// is cleared because its contents (the mcp socket) don't survive restarts
|
/// is cleared because its contents (the mcp socket) don't survive restarts
|
||||||
/// anyway. A future `--purge` path can wipe state when the operator opts in.
|
/// anyway. With `purge=true` the persistent trees are also wiped — config
|
||||||
|
/// history, claude creds, notes — there is no undo.
|
||||||
/// Refuses the manager (declarative; would fight with the host's nixos config).
|
/// Refuses the manager (declarative; would fight with the host's nixos config).
|
||||||
pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> {
|
pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> {
|
||||||
if name == MANAGER_NAME || name == MANAGER_AGENT {
|
if name == MANAGER_NAME || name == MANAGER_AGENT {
|
||||||
bail!("refusing to destroy the manager ({name})");
|
bail!("refusing to destroy the manager ({name})");
|
||||||
}
|
}
|
||||||
tracing::info!(%name, "destroy");
|
tracing::info!(%name, purge, "destroy");
|
||||||
lifecycle::destroy(name).await?;
|
lifecycle::destroy(name).await?;
|
||||||
coord.unregister_agent(name);
|
coord.unregister_agent(name);
|
||||||
let runtime = Coordinator::agent_dir(name);
|
let runtime = Coordinator::agent_dir(name);
|
||||||
if runtime.exists() {
|
if runtime.exists() {
|
||||||
let _ = std::fs::remove_dir_all(&runtime);
|
let _ = std::fs::remove_dir_all(&runtime);
|
||||||
}
|
}
|
||||||
let _ = coord
|
if purge {
|
||||||
.approvals
|
for dir in [
|
||||||
.fail_pending_for_agent(name, "agent destroyed");
|
Coordinator::agent_state_root(name),
|
||||||
|
Coordinator::agent_applied_dir(name),
|
||||||
|
] {
|
||||||
|
if dir.exists()
|
||||||
|
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = coord.approvals.fail_pending_for_agent(
|
||||||
|
name,
|
||||||
|
if purge {
|
||||||
|
"agent purged"
|
||||||
|
} else {
|
||||||
|
"agent destroyed"
|
||||||
|
},
|
||||||
|
);
|
||||||
coord.notify_manager(&HelperEvent::Destroyed {
|
coord.notify_manager(&HelperEvent::Destroyed {
|
||||||
agent: name.to_owned(),
|
agent: name.to_owned(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -424,8 +424,20 @@ fn strip_container_prefix(name: &str) -> String {
|
||||||
.to_owned()
|
.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
#[derive(Deserialize, Default)]
|
||||||
match actions::destroy(&state.coord, &name).await {
|
struct DestroyForm {
|
||||||
|
#[serde(default)]
|
||||||
|
purge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_destroy(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
Form(form): Form<DestroyForm>,
|
||||||
|
) -> Response {
|
||||||
|
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
|
||||||
|
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
|
||||||
|
match actions::destroy(&state.coord, &name, purge).await {
|
||||||
Ok(()) => Redirect::to("/").into_response(),
|
Ok(()) => Redirect::to("/").into_response(),
|
||||||
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,14 @@ enum Cmd {
|
||||||
/// Stop a managed container (graceful).
|
/// Stop a managed container (graceful).
|
||||||
Kill { name: String },
|
Kill { name: String },
|
||||||
/// Tear down a sub-agent container. Container is removed; persistent
|
/// Tear down a sub-agent container. Container is removed; persistent
|
||||||
/// state (config repos + Claude credentials) is kept by default.
|
/// state (config repos + Claude credentials) is kept by default. Pass
|
||||||
Destroy { name: String },
|
/// `--purge` to also wipe the agent's state dirs (config + creds +
|
||||||
|
/// notes). No undo.
|
||||||
|
Destroy {
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
purge: bool,
|
||||||
|
},
|
||||||
/// Apply pending config to a managed container.
|
/// Apply pending config to a managed container.
|
||||||
Rebuild { name: String },
|
Rebuild { name: String },
|
||||||
/// List managed containers.
|
/// List managed containers.
|
||||||
|
|
@ -121,8 +127,8 @@ async fn main() -> Result<()> {
|
||||||
Cmd::Kill { name } => {
|
Cmd::Kill { name } => {
|
||||||
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
|
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
|
||||||
}
|
}
|
||||||
Cmd::Destroy { name } => {
|
Cmd::Destroy { name, purge } => {
|
||||||
render(client::request(&cli.socket, HostRequest::Destroy { name }).await?)
|
render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
|
||||||
}
|
}
|
||||||
Cmd::Rebuild { name } => {
|
Cmd::Rebuild { name } => {
|
||||||
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
|
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
});
|
});
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Destroy { name } => {
|
HostRequest::Destroy { name, purge } => {
|
||||||
actions::destroy(&coord, name).await?;
|
actions::destroy(&coord, name, *purge).await?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Rebuild { name } => {
|
HostRequest::Rebuild { name } => {
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,15 @@ pub enum HostRequest {
|
||||||
/// Tear down a sub-agent container: stop + remove + drop the systemd
|
/// Tear down a sub-agent container: stop + remove + drop the systemd
|
||||||
/// drop-in, purge pending approvals. Persistent state (proposed/applied
|
/// drop-in, purge pending approvals. Persistent state (proposed/applied
|
||||||
/// repos, Claude credentials) is KEPT by default — recreating the agent
|
/// repos, Claude credentials) is KEPT by default — recreating the agent
|
||||||
/// with the same name reuses prior config + login. Manager not destroyable.
|
/// with the same name reuses prior config + login. With `purge=true`
|
||||||
Destroy { name: String },
|
/// the agent's `/var/lib/hyperhive/{agents,applied}/<name>/` trees are
|
||||||
|
/// also wiped (config history + creds + notes gone forever). Manager
|
||||||
|
/// not destroyable.
|
||||||
|
Destroy {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
purge: bool,
|
||||||
|
},
|
||||||
/// Apply pending config to a managed container.
|
/// Apply pending config to a managed container.
|
||||||
Rebuild { name: String },
|
Rebuild { name: String },
|
||||||
/// List managed containers.
|
/// List managed containers.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue