diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css
index 47882d3..9cc0eb0 100644
--- a/hive-ag3nt/assets/agent.css
+++ b/hive-ag3nt/assets/agent.css
@@ -21,12 +21,29 @@ body {
line-height: 1.6;
}
.banner {
- color: var(--purple);
text-align: center;
margin: 0 0 1em 0;
font-size: 0.95em;
- text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25);
overflow-x: auto;
+ background: linear-gradient(
+ 90deg,
+ var(--purple-dim) 0%,
+ var(--purple) 50%,
+ var(--purple-dim) 100%
+ );
+ background-size: 200% 100%;
+ background-position: 50% 0;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
+}
+.banner.active {
+ animation: banner-shimmer 1.8s linear infinite;
+}
+@keyframes banner-shimmer {
+ from { background-position: 200% 0; }
+ to { background-position: -100% 0; }
}
h2, h3 {
color: var(--purple);
@@ -34,12 +51,6 @@ h2, h3 {
letter-spacing: 0.15em;
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
}
-.divider {
- color: var(--purple-dim);
- overflow: hidden;
- white-space: nowrap;
- margin-bottom: 0.5em;
-}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
@@ -113,20 +124,59 @@ pre.diff {
word-break: break-all;
max-height: 30em;
}
-/* Terminal-ish look for the live panel. Crust as bg (almost-black),
- slightly inset, mauve phosphor glow. */
-.live.terminal {
+/* Terminal-ish wrapper holding the live output + prompt input as one
+ unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. */
+.terminal-wrap {
background: #11111b;
border: 1px solid var(--purple-dim);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
border-radius: 4px;
- padding: 0.8em 1em;
- overflow-y: auto;
- max-height: 32em;
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;
+ max-height: 32em;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+}
+.term-input { padding: 0.4em 1em 0.8em; }
+.term-input .sendform-term {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ border-top: 1px dashed var(--purple-dim);
+ padding-top: 0.5em;
+}
+.term-input .prompt {
+ color: var(--green);
+ text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
+ user-select: none;
+ flex: 0 0 auto;
+}
+.term-input input {
+ flex: 1;
+ background: transparent;
+ border: 0;
+ outline: 0;
+ color: var(--fg);
+ font-family: inherit;
+ font-size: 1em;
+ padding: 0.2em 0;
+ caret-color: var(--green);
+}
+.term-input input::placeholder { color: var(--muted); }
+.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 input { color: var(--muted); }
.live {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--purple-dim);
diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js
index 3ae251c..cb89cbd 100644
--- a/hive-ag3nt/assets/app.js
+++ b/hive-ag3nt/assets/app.js
@@ -82,29 +82,15 @@
document.title = `${label} // hyperhive`;
}
- function renderOnline(label, root) {
+ function renderOnline(_label, root) {
root.append(
- el('p', { class: 'status-online' }, '▓█▓▒░ harness alive — turn loop running ▓█▓▒░'),
+ el('p', { class: 'status-online' }, '● harness alive — turn loop running'),
);
- const form = el('form', {
- action: '/send', method: 'POST', class: 'sendform', 'data-async': '',
- });
- form.append(
- el('input', {
- name: 'body', placeholder: `message ${label} as operator…`,
- required: '', autocomplete: 'off',
- }),
- el('button', { type: 'submit', class: 'btn btn-send' }, '◆ S3ND'),
- );
- root.append(form);
- root.append(el('p', { class: 'meta', html:
- 'enqueued with from: operator on this agent\'s inbox; the next turn picks it up.',
- }));
}
function renderNeedsLoginIdle(root) {
root.append(
- el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'),
+ el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'),
el('p', { html:
'No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.',
}),
@@ -122,7 +108,7 @@
}
function renderLoginInProgress(s, root) {
- root.append(el('p', { class: 'status-needs-login' }, '▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░'));
+ root.append(el('p', { class: 'status-needs-login' }, '◌ L0G1N 1N PR0GRESS'));
if (s.url) {
const link = el('a', {
href: s.url, target: '_blank', rel: 'noreferrer',
@@ -168,12 +154,56 @@
let lastStatus = null;
let lastOutputLen = -1;
let pollTimer = null;
+ let termInputRendered = false;
+
+ function renderTermInput(label, online) {
+ const slot = $('term-input');
+ if (!slot) return;
+ if (!termInputRendered) {
+ slot.innerHTML = '';
+ const form = el('form', {
+ action: '/send', method: 'POST',
+ class: 'sendform-term', 'data-async': '',
+ });
+ form.append(
+ el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
+ el('input', {
+ name: 'body', placeholder: 'message ' + label + '…',
+ required: '', autocomplete: 'off',
+ }),
+ el('span', { class: 'submit-hint' }, 'enter ↵'),
+ );
+ slot.append(form);
+ termInputRendered = true;
+ }
+ slot.classList.toggle('disabled', !online);
+ const input = slot.querySelector('input');
+ if (input) input.disabled = !online;
+ }
+
+ // Track banner activity by reference-counting in-flight turns. A turn
+ // 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.
+ let activeTurns = 0;
+ function setBannerActive(on) {
+ const banner = $('banner');
+ if (!banner) return;
+ if (on) {
+ activeTurns += 1;
+ banner.classList.add('active');
+ } else {
+ activeTurns = Math.max(0, activeTurns - 1);
+ if (activeTurns === 0) banner.classList.remove('active');
+ }
+ }
+
async function refreshState() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
+ renderTermInput(s.label, s.status === 'online');
// Skip the re-render if nothing structurally changed. The most
// common case is `online` polling itself — without this guard, the
// operator's gets clobbered every cycle.
@@ -315,8 +345,10 @@
}
row('sys', '· ' + trim(JSON.stringify(v), 200));
}
- function handle(ev) {
+ function handle(ev, opts) {
+ const fromHistory = !!(opts && opts.fromHistory);
if (ev.kind === 'turn_start') {
+ if (!fromHistory) setBannerActive(true);
const block = row('turn-start', '◆ TURN ← ' + ev.from);
if (ev.unread > 0) {
const badge = document.createElement('span');
@@ -331,11 +363,12 @@
return;
}
if (ev.kind === 'turn_end') {
+ if (!fromHistory) setBannerActive(false);
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.
- refreshState();
+ if (!fromHistory) refreshState();
return;
}
if (ev.kind === 'note') {
@@ -349,16 +382,44 @@
}
row('note', JSON.stringify(ev));
}
- const es = new EventSource('/events/stream');
- es.onopen = () => setPlaceholder('(connected — waiting for events)');
- es.onmessage = (e) => {
- try { handle(JSON.parse(e.data)); }
- catch (err) { row('note', '[parse err] ' + e.data); }
- };
- es.onerror = () => {
- if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
- else row('note', '[disconnected]');
- };
+
+ // 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;
+ 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);
+ }
+ for (let i = 0; i < openTurns; i++) setBannerActive(true);
+ 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);
+ }
+ }
+
+ 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]');
+ };
+ });
})();
// Avoid unused-var lint while keeping `escText` available for future use.
diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html
index bfcdd46..7e3613e 100644
--- a/hive-ag3nt/assets/index.html
+++ b/hive-ag3nt/assets/index.html
@@ -8,15 +8,16 @@
░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░