Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: the per-agent page has no message box; its send route and the broker's operator message are removed

The page's composer (its input, slash commands and completion), POST /send in
hive-agent, the OperatorMsg request of the agent socket and its handler in
hive-c0re, and the broker-error helper only the send route used are deleted:
writing to an agent is the swarm UI's terminal. cancel turn, new session and
log out stay in the state badge's menu; POST /api/compact stays, with nothing
on the page calling it. The request-line limit keeps its size, and its comment
says it is a generous bound now. The docs describe the actions in place of the
footer and the prompt.
This commit is contained in:
iris 2026-11-06 01:07:57 +01:00
commit 23f4f1823b
17 changed files with 77 additions and 505 deletions

View file

@ -100,7 +100,7 @@ box it gets:
at `/knowledge`, plus hyperhive's own docs in the container. → at `/knowledge`, plus hyperhive's own docs in the container. →
[knowledge](docs/integrations/knowledge.md) [knowledge](docs/integrations/knowledge.md)
- **web UI** — a per-agent page behind the gateway: live terminal of the - **web UI** — a per-agent page behind the gateway: live terminal of the
turn stream, model / effort switching, cancel, `/compact`. turn stream, model / effort switching, cancel.
- **GUI** (opt-in, `services.hyperhive.agent.gui.enable`) — a Wayland - **GUI** (opt-in, `services.hyperhive.agent.gui.enable`) — a Wayland
desktop with screenshot / keyboard / mouse tools, viewable over VNC in desktop with screenshot / keyboard / mouse tools, viewable over VNC in
the web UI. the web UI.

View file

@ -105,8 +105,8 @@ primary mechanism; hyperhive still owns that). `outputStyle` is fleet-wide
preamble/recap Concise drops carries no information a turn-loop consumer preamble/recap Concise drops carries no information a turn-loop consumer
needs. needs.
**Session reset** is available via `POST /api/new-session` (or **Session reset** is available via `POST /api/new-session` (the state
`/new-session` slash command). It does _not_ touch the session inline — badge's `new session` menu row). It does _not_ touch the session inline —
that would race a mid-write claude process. Instead `Bus::request_session_reset()` that would race a mid-write claude process. Instead `Bus::request_session_reset()`
sets a one-shot flag consumed at the next turn boundary by `drive_turn`, sets a one-shot flag consumed at the next turn boundary by `drive_turn`,
which **archives** the current session: it renames the backing which **archives** the current session: it renames the backing

View file

@ -75,8 +75,8 @@ JS renders — no full-page reloads.
- **[Dashboard layout](dashboard.md)** — every tab and standalone page, - **[Dashboard layout](dashboard.md)** — every tab and standalone page,
in full implementation detail: endpoint shapes, event wiring, exact in full implementation detail: endpoint shapes, event wiring, exact
badge-derivation rules. badge-derivation rules.
- **[Per-agent page](agent.md)** — the per-agent terminal, composer, - **[Per-agent page](agent.md)** — the per-agent terminal,
side panel, slash commands, and per-agent endpoints. side panel, actions, and per-agent endpoints.
- **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE - **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE
multiplexing, terminal pane, listener bind, per-agent relative multiplexing, terminal pane, listener bind, per-agent relative
paths, `data-async` form pattern, atomic repaint. paths, `data-async` form pattern, atomic repaint.
@ -106,8 +106,8 @@ touching the code rather than using the UI:
[`terminal-rendering.md`](terminal-rendering.md) (full row taxonomy [`terminal-rendering.md`](terminal-rendering.md) (full row taxonomy
and dispatch walkthrough); for a high-level summary see and dispatch walkthrough); for a high-level summary see
[`agent.md`](agent.md) (Per-stream rendering). [`agent.md`](agent.md) (Per-stream rendering).
- **"What slash commands does the agent accept?"** → - **"What actions does the agent page offer?"** →
[`agent.md`](agent.md) (Terminal-embedded prompt). [`agent.md`](agent.md) (Actions).
- **"What are the per-agent HTTP endpoints?"** → - **"What are the per-agent HTTP endpoints?"** →
[`agent.md`](agent.md) (Per-agent endpoints). [`agent.md`](agent.md) (Per-agent endpoints).
- **"Which CSS variable to use / where are colours defined?"** → - **"Which CSS variable to use / where are colours defined?"** →

View file

@ -4,9 +4,10 @@
> [Shape (shared)](shape.md) · [Dashboard layout](dashboard.md) > [Shape (shared)](shape.md) · [Dashboard layout](dashboard.md)
Three fixed-position layers frame a full-viewport terminal: a header, Two fixed-position layers frame a full-viewport terminal: a header and
scrollable main content, and a footer composer — plus a slide-in side scrollable main content — plus a slide-in side panel for flyouts and long
panel for flyouts and long content. content. The page has no message box: to write to an agent, use the swarm
UI's terminal.
## Header ## Header
@ -91,13 +92,9 @@ at a given viewport width. Two columns:
(`GET /api/state`'s `links` field) also feeds (`GET /api/state`'s `links` field) also feeds
`DashboardState.links` for the dashboard card's icon strip — `DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for `agent_links()` in hive-agent is the single source of truth for
both. The dashboard link lives in this 🔗 popover, and both. The dashboard link lives in this 🔗 popover. `cancel turn`,
`/new-session` / `/logout` are `new session` and `log out` are rows of the state badge's menu, each
typed slash commands with their own type-twice confirm (see with a click-again confirm (see Actions below).
below).
- `/cancel` (slash command, below) is the only cancel-turn path; the
turn-loop state badge already shows `thinking` as the
discoverability cue.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
Values throughout come from `GET /api/state`'s cold-load snapshot, Values throughout come from `GET /api/state`'s cold-load snapshot,
@ -129,22 +126,14 @@ and scrolls behind the fixed header + footer.
`.terminal-wrap` applies `backdrop-filter: blur` for the frost `.terminal-wrap` applies `backdrop-filter: blur` for the frost
effect, which creates a CSS stacking context — anchoring the effect, which creates a CSS stacking context — anchoring the
pill inside that context would confine its `z-index` below the pill inside that context would confine its `z-index` below the
fixed composer in the root stacking context, and it'd never fixed header in the root stacking context, and it'd never
float. `.agent-main` has no backdrop-filter (no stacking-context float. `.agent-main` has no backdrop-filter (no stacking-context
creators), so the pill's `z-index` reaches the root and properly creators), so the pill's `z-index` reaches the root and properly
composites above the composer. Geometry is unchanged — composites above the header. Geometry is unchanged —
`.agent-main` and `.terminal-wrap` both `inset: 0` fill the same `.agent-main` and `.terminal-wrap` both `inset: 0` fill the same
area. area.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
## Footer / composer
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
glass, symmetric with the header. Contains the operator-input
textarea (`#term-input`) — multi-line, Enter sends, Shift+Enter
newlines, Tab-completes slash commands (see [Terminal-embedded
prompt](#terminal-embedded-prompt) below).
## Side panel ## Side panel
**Side panel** (slide-in from right): singleton shared with the **Side panel** (slide-in from right): singleton shared with the
@ -226,35 +215,27 @@ the full row taxonomy and dispatch logic):
turn-start), triggers a `refreshState()`. The time suffix is turn-start), triggers a `refreshState()`. The time suffix is
guarded on a numeric `ts`, so rows degrade cleanly when absent. guarded on a numeric `ts`, so rows degrade cleanly when absent.
## Terminal-embedded prompt ## Actions
The operator input lives *inside* the terminal-wrap as a The state badge's menu carries the actions on the agent's claude:
prompt-style textarea below the live tail: multi-line (Enter
sends, Shift+Enter newlines), tab-completes slash commands.
Slash commands today: - `cancel turn` — `POST /api/cancel` → SIGINT to the turn's claude and every
- `/help` — list commands locally.
- `/clear` — wipe the local terminal view (server history kept).
- `/cancel` — `POST /api/cancel` → SIGINT to the turn's claude and every
command it started (its process group), emits a Note. On an ACP agent command it started (its process group), emits a Note. On an ACP agent
it sends the agent `session/cancel` instead, and SIGINT if the turn it sends the agent `session/cancel` instead, and SIGINT if the turn
hasn't ended 10s later. Also surfaces as a `■ cancel turn` button in hasn't ended 10s later. Offered while the state is `thinking`.
the state row while state=thinking. - `new session` — `POST /api/new-session` (confirms first).
- `/compact` — `POST /api/compact` → sets the deferred
`Bus::request_compact()` flag and returns immediately. The harness
runs the `/compact` at the next turn boundary (end of the in-flight
turn, or `turn::run_pending_compact` when idle); output streams into
the live panel. Works mid-turn, not only when idle. Either way the live
panel ends with a `/compact done` or `/compact failed: …` note.
- `/new-session` — `POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely. `--continue`, dropping the resume session entirely.
- `/logout` — `POST /api/logout` (confirms first). Wipes OAuth - `log out` — `POST /api/logout` (confirms first). Wipes OAuth
credential files, parks the agent in `needs_login`, and preserves credential files, parks the agent in `needs_login`, and preserves
session history (`~/.claude/projects/`). session history (`~/.claude/projects/`).
Unknown `/foo` shows an error row instead of being silently sent. `POST /api/compact` sets the deferred `Bus::request_compact()` flag and
returns immediately. The harness runs the `/compact` at the next turn
boundary (end of the in-flight turn, or `turn::run_pending_compact` when
idle); output streams into the live panel, which ends with a `/compact done`
or `/compact failed: …` note. It works mid-turn, not only when idle. Nothing
on this page calls it now.
## Per-agent endpoints ## Per-agent endpoints
@ -270,7 +251,6 @@ delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event- polls (session output streams in updates that aren't event-
shaped). shaped).
- `POST /send` — operator-injected message into this agent's inbox.
- `POST /login/{start,code,cancel}` — claude OAuth login flow. - `POST /login/{start,code,cancel}` — claude OAuth login flow.
Start/cancel emit `LiveEvent::StatusChanged` to flip the Start/cancel emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`. badge to/from `needs_login_in_progress`.

View file

@ -20,7 +20,6 @@ import { HeaderPill } from "./components/HeaderPill.js";
import { SidePanel } from "./components/SidePanel.js"; import { SidePanel } from "./components/SidePanel.js";
import { InboxPanel } from "./components/InboxPanel.js"; import { InboxPanel } from "./components/InboxPanel.js";
import { TodosPanel } from "./components/TodosPanel.js"; import { TodosPanel } from "./components/TodosPanel.js";
import { TermInput } from "./components/TermInput.js";
import { LoginFlow } from "./components/LoginFlow.js"; import { LoginFlow } from "./components/LoginFlow.js";
import { useAgentState } from "./hooks/useAgentState.js"; import { useAgentState } from "./hooks/useAgentState.js";
import { useTodos } from "./hooks/useTodos.js"; import { useTodos } from "./hooks/useTodos.js";
@ -79,11 +78,10 @@ function tokenTotal(u: TokenUsage | null): number | null {
); );
} }
// `?hide=header,input` — an embedding companion window (e.g. a native // `?hide=header` — an embedding companion window (e.g. a native desktop
// desktop shell) supplies its own header/composer chrome around this // shell) supplies its own header chrome around this page, so it asks for
// page, so it asks for just the terminal feed. Read once per load (the // just the terminal feed. Read once per load (the value can't change without
// value can't change without a navigation); comma-separated so both // a navigation); comma-separated so more parts can be hidden in one param.
// axes can be hidden in one param.
const hideParam = new URLSearchParams(window.location.search).get("hide"); const hideParam = new URLSearchParams(window.location.search).get("hide");
const HIDDEN = new Set(hideParam ? hideParam.split(",").filter(Boolean) : []); const HIDDEN = new Set(hideParam ? hideParam.split(",").filter(Boolean) : []);
@ -95,16 +93,13 @@ export function Root() {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null); const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const liveStreamRef = useRef<LiveStreamHandle>(null); const liveStreamRef = useRef<LiveStreamHandle>(null);
const hideHeader = HIDDEN.has("header"); const hideHeader = HIDDEN.has("header");
const hideInput = HIDDEN.has("input");
// Body classes, not inline styles: agent.css's `--agent-header-h`/ // Body classes, not inline styles: agent.css's `--agent-header-h`/
// `--agent-header-real-h`/`--agent-composer-h` custom properties already // `--agent-header-real-h` custom properties already drive every
// drive every dependent calc() (overlay offset, terminal scroll padding) // dependent calc() (overlay offset, terminal scroll padding) — zeroing
// — zeroing them here collapses all of that in one place instead of // them here collapses all of that in one place instead of touching each rule.
// touching each rule.
useEffect(() => { useEffect(() => {
document.body.classList.toggle("agent-hide-header", hideHeader); document.body.classList.toggle("agent-hide-header", hideHeader);
document.body.classList.toggle("agent-hide-input", hideInput); }, [hideHeader]);
}, [hideHeader, hideInput]);
// Ticks the status badge's "thinking Xm Ys" age once a second, // Ticks the status badge's "thinking Xm Ys" age once a second,
// independent of the ~4s `/api/state` poll cadence — without its own // independent of the ~4s `/api/state` poll cadence — without its own
@ -134,17 +129,6 @@ export function Root() {
? `${state.label} // ${state.hive_name}` ? `${state.label} // ${state.hive_name}`
: `${tab} // hyperhive`; : `${tab} // hyperhive`;
}, [state?.label, state?.qualified_label, state?.hive_name]); }, [state?.label, state?.qualified_label, state?.hive_name]);
const termInput = hideInput ? null : (
<footer class="agent-composer">
<TermInput
label={state?.label ?? "…"}
online={state?.status === "online"}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()}
/>
</footer>
);
const pills = ( const pills = (
<> <>
<HeaderPill <HeaderPill
@ -237,7 +221,6 @@ export function Root() {
<main className="agent-main"> <main className="agent-main">
<LiveStream ref={liveStreamRef} /> <LiveStream ref={liveStreamRef} />
</main> </main>
{termInput}
{panel} {panel}
</> </>
); );
@ -363,7 +346,6 @@ export function Root() {
) : null} ) : null}
<LiveStream ref={liveStreamRef} /> <LiveStream ref={liveStreamRef} />
</main> </main>
{termInput}
{panel} {panel}
</> </>
); );

View file

@ -16,7 +16,7 @@
/* ─── full-screen layout overrides ───────────────────────────────── /* ─── full-screen layout overrides ─────────────────────────────────
The agent page mounts a full-viewport terminal under a fixed The agent page mounts a full-viewport terminal under a fixed
frosted-glass header + composer pair. See docs/web-ui/agent.md::Per-agent frosted-glass header. See docs/web-ui/agent.md::Per-agent
page for the layer / pill / side-panel structure; rules below page for the layer / pill / side-panel structure; rules below
override the in-page defaults from @hive/shared. */ override the in-page defaults from @hive/shared. */
@ -25,13 +25,12 @@
padding) alongside the two-row main column (title + nav-links padding) alongside the two-row main column (title + nav-links
on top, state strip below). */ on top, state strip below). */
--agent-header-h: 6em; --agent-header-h: 6em;
--agent-composer-h: 3.6em;
--agent-frost-bg: color-mix(in srgb, var(--bg) 72%, transparent); --agent-frost-bg: color-mix(in srgb, var(--bg) 72%, transparent);
--agent-frost-blur: blur(12px) saturate(140%); --agent-frost-blur: blur(12px) saturate(140%);
} }
/* `?hide=header,input` (per-agent-page embed mode): `Root.tsx` toggles /* `?hide=header` (per-agent-page embed mode): `Root.tsx` toggles
these body classes when it omits `<Header>`/the composer footer. this body class when it omits `<Header>`.
Zeroing the custom properties here — rather than overriding each Zeroing the custom properties here — rather than overriding each
dependent rule — collapses every calc() that reads them (overlay dependent rule — collapses every calc() that reads them (overlay
offset, terminal scroll padding) in one place. */ offset, terminal scroll padding) in one place. */
@ -39,9 +38,6 @@ body.agent-hide-header {
--agent-header-h: 0px; --agent-header-h: 0px;
--agent-header-real-h: 0px; --agent-header-real-h: 0px;
} }
body.agent-hide-input {
--agent-composer-h: 0px;
}
html, html,
body { body {
@ -266,30 +262,6 @@ h3 {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
} }
.agent-composer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--agent-composer-h);
background: var(--agent-frost-bg);
-webkit-backdrop-filter: var(--agent-frost-blur);
backdrop-filter: var(--agent-frost-blur);
border-top: 1px solid var(--purple-dim);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
}
.agent-composer .term-input {
/* The composer is its own chrome now — drop the in-terminal-wrap
padding the legacy layout assumed. */
padding: 0.45em 1em;
}
.agent-composer .term-input .sendform-term {
/* No dashed top-border in the floating composer — the box-shadow
and frosted border already separate it from the terminal. */
border-top: 0;
padding-top: 0;
}
.meta { .meta {
color: var(--muted); color: var(--muted);
font-size: 0.85em; font-size: 0.85em;
@ -618,9 +590,8 @@ pre.diff {
(StatusChips.tsx's `StatusMenu`, `@hive/shared`'s (StatusChips.tsx's `StatusMenu`, `@hive/shared`'s
`.ui-dropdown-item-danger`). */ `.ui-dropdown-item-danger`). */
/* Orphaned tombstone — `.btn-new-session` round-pill moved into the /* Orphaned tombstone — `.btn-new-session` round-pill moved into the
overflow menu, then out of the header entirely: it's the overflow menu, then out of the header entirely: it's a row of the
`/new-session` slash command now (TermInput.tsx), no button/chip state badge's menu now, no button/chip left to style. */
left to style. */
/* Markup carries `hive-pill` alongside `state-badge` (shared pill.css /* Markup carries `hive-pill` alongside `state-badge` (shared pill.css
supplies the shape); this rule is only the transition + state- supplies the shape); this rule is only the transition + state-
specific colour below. */ specific colour below. */
@ -681,8 +652,8 @@ pre.diff {
black frame for the in-page case; the agent page now owns the black frame for the in-page case; the agent page now owns the
whole viewport so the frame chrome would be redundant noise. whole viewport so the frame chrome would be redundant noise.
`.live.terminal` similarly drops the in-page max-height cap so `.live.terminal` similarly drops the in-page max-height cap so
it can fill the main area top-to-bottom; the floating header + it can fill the main area top-to-bottom; the floating header
composer overlay it via fixed positioning. */ overlays it via fixed positioning. */
.agent-main .terminal-wrap { .agent-main .terminal-wrap {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -698,94 +669,28 @@ pre.diff {
inset: 0; inset: 0;
height: auto; height: auto;
max-height: none; max-height: none;
/* Scroll behind the floating header/composer, but keep the first /* Scroll behind the floating header, but keep the first
and last rows reachable with extra padding inside the scroll and last rows reachable with extra padding inside the scroll
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill, area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
focus restore) clear of the floats too. */ focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
padding-bottom: calc(var(--agent-composer-h) + 0.8em); padding-bottom: 0.8em;
scroll-padding-top: calc( scroll-padding-top: calc(
var(--agent-header-real-h, var(--agent-header-h)) + 0.8em var(--agent-header-real-h, var(--agent-header-h)) + 0.8em
); );
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); scroll-padding-bottom: 0.8em;
overflow: auto; overflow: auto;
} }
/* Tail pill (↓ N new): nudged up so it floats clear of the composer /* Tail pill (↓ N new): z-index bumped above the floating chrome so the
rather than colliding with the frosted bar. z-index bumped above pill sits on the top layer instead of being clipped by it. (Pill is anchored in
the composer (z-30) so the pill sits on the top layer instead of
being clipped by the floating chrome. (Pill is anchored in
`.agent-main` rather than `.terminal-wrap` — see `.agent-main` rather than `.terminal-wrap` — see
docs/web-ui/agent.md::Per-agent page Terminal-wrap paragraph for the docs/web-ui/agent.md::Per-agent page Terminal-wrap paragraph for the
backdrop-filter stacking-context rationale.) */ backdrop-filter stacking-context rationale.) */
.agent-main .tail-pill { .agent-main .tail-pill {
bottom: calc(var(--agent-composer-h) + 0.6em); bottom: 0.6em;
z-index: 35; z-index: 35;
} }
/* Composer chrome lives inside the fixed `.agent-composer` defined
further up. The base rules below stay scoped to whichever ancestor
owns it. */
.term-input .sendform-term {
display: flex;
/* mara, live: "bottom input row text not aligned / inconsistent
sizes." Real cause: `.prompt` (1em, no line-height override) and
`.submit-hint` (0.8em, no line-height override) each get a
browser-default line-box at a different em-relative size, so
`align-items: flex-start` (their old value) pinned each one's
line-box top edge to the row's top — same nominal `padding-top`
but a DIFFERENT absolute offset per element, since 0.25em means
something different at 1em vs 0.8em font-size. `center` sidesteps
the whole "line-box for text this size" mismatch by aligning each
item's own box middle instead of a top edge that was never
actually equal. Still correct once the textarea grows multi-line
(a common composer-bar convention — Slack/Discord centre their
send affordance against a grown box too, not pin it to the first
line). */
align-items: center;
gap: 0.5em;
/* The dashed in-frame separator is dropped — see the
.agent-composer .term-input override above for the floating-bar
variant. */
border-top: 1px dashed var(--purple-dim);
padding-top: 0.5em;
}
.term-input .prompt {
color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent);
user-select: none;
flex: 0 0 auto;
}
.term-input textarea {
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);
resize: none;
overflow-y: auto;
line-height: 1.4;
min-height: 1.4em;
}
.term-input textarea::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 textarea {
color: var(--muted);
}
/* ─── side panel (singleton drawer) ──────────────────────────────── /* ─── side panel (singleton drawer) ────────────────────────────────
Inbox + loose-ends details open here instead of expanding inline. The Inbox + loose-ends details open here instead of expanding inline. The
drawer chrome itself (backdrop, drawer, resize handle, header, title, drawer chrome itself (backdrop, drawer, resize handle, header, title,

View file

@ -33,24 +33,21 @@ import { Row } from "@hive/shared/term-row.js";
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80; const LOAD_MORE_SCROLL_PX = 80;
/** Imperative escape hatch for TermInput's local-only slash commands /** Imperative escape hatch for the page's local-only notes (the result of
* (`/help`, `/clear`) — same shape as app.js's old `termAPI` object, * a state-badge action) — kept as a ref handle rather than lifting the whole
* kept as a ref handle rather than lifting the whole row array up to * row array up to Root so LiveStream/useLiveStream still own their state
* Root so LiveStream/useLiveStream still own their state privately. */ * privately. */
export interface LiveStreamHandle { export interface LiveStreamHandle {
pushNote: (text: string) => void; pushNote: (text: string) => void;
clear: () => void;
} }
export const LiveStream = forwardRef<LiveStreamHandle, object>( export const LiveStream = forwardRef<LiveStreamHandle, object>(
function LiveStream(_props, ref) { function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = const { rows, hasMore, loadingMore, loadMore, pushLocalNote } =
useLiveStream(); useLiveStream();
useImperativeHandle( useImperativeHandle(ref, () => ({ pushNote: pushLocalNote }), [
ref, pushLocalNote,
() => ({ pushNote: pushLocalNote, clear: clearLocal }), ]);
[pushLocalNote, clearLocal],
);
const logRef = useRef<HTMLDivElement>(null); const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0); const [unseen, setUnseen] = useState(0);

View file

@ -1,187 +0,0 @@
// <TermInput> — the fixed composer at the bottom of the page. Sends a
// plain message to this agent's own turn loop (`POST send`), or
// intercepts a `/slash` command locally (never sent to the agent).
// Ported from app.js's `renderTermInput`/`handleSlashCommand`/
// `completeSlash`, with one deliberate UX change: `/new-session` and
// `/logout` used to pop the old shadow-DOM `themedConfirm` modal
// (@hive/shared/modal.js) before firing — this rewrite's destructive
// actions all avoid that widget family (see SidePanel.tsx's/
// useConfirmClick.ts's file comments for why), and a modal doesn't fit
// a text-input flow anyway. Instead: typing the command once arms it
// (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from "preact/hooks";
import {
postCancelTurn,
postCompact,
postNewSession,
postLogout,
postSend,
} from "../lib/termActions.js";
export interface TermInputProps {
label: string;
online: boolean;
onLocalNote: (text: string) => void;
onClear: () => void;
}
const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: "/help", desc: "list slash commands" },
{ name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" },
{
name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)",
},
{
name: "/logout",
desc: "rotate OAuth creds + park in needs-login (type twice to confirm)",
},
];
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
function completeSlash(prefix: string): string | null {
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
if (!matches.length) return null;
const idx = matches.findIndex((c) => c.name === prefix);
return matches[(idx + 1) % matches.length].name;
}
export function TermInput({
label,
online,
onLocalNote,
onClear,
}: TermInputProps) {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
// Which destructive command is currently "armed" (typed once, waiting
// for the confirming repeat) — any other command clears it.
const armedRef = useRef<string | null>(null);
function grow() {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + "px";
}
function reportFailure(label_: string, detail?: string) {
onLocalNote(`✗ ${label_} failed${detail ? ": " + detail : ""}`);
}
function handleSlash(line: string): boolean {
const trimmed = line.trim();
const [cmd] = trimmed.split(/\s+/);
const wasArmed = armedRef.current === cmd;
armedRef.current = null;
switch (cmd) {
case "/help":
onLocalNote("/help");
for (const c of SLASH_COMMANDS)
onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`);
return true;
case "/clear":
onClear();
onLocalNote(
"· terminal cleared (local view only — server history kept)",
);
return true;
case "/cancel":
postCancelTurn().then(
(r) => !r.ok && reportFailure("/cancel", r.detail),
);
return true;
case "/compact":
postCompact().then((r) => !r.ok && reportFailure("/compact", r.detail));
return true;
case "/new-session":
if (wasArmed) {
postNewSession().then(
(r) => !r.ok && reportFailure("/new-session", r.detail),
);
} else {
armedRef.current = "/new-session";
onLocalNote(
"⚠ drops all prior --continue context. type /new-session again to confirm.",
);
}
return true;
case "/logout":
if (wasArmed) {
postLogout().then((r) => !r.ok && reportFailure("/logout", r.detail));
} else {
armedRef.current = "/logout";
onLocalNote(
"⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.",
);
}
return true;
default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
return true;
}
}
function submit() {
const line = value;
if (!line.trim()) return;
setValue("");
requestAnimationFrame(grow);
if (line.trim().startsWith("/")) {
handleSlash(line);
return;
}
armedRef.current = null;
postSend(line).then((r) => !r.ok && reportFailure("send", r.detail));
}
function onInput(e: Event) {
setValue((e.currentTarget as HTMLTextAreaElement).value);
grow();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Tab" && value.startsWith("/") && !value.includes(" ")) {
const next = completeSlash(value);
if (next) {
e.preventDefault();
setValue(next);
}
return;
}
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
return (
<div class={`term-input${online ? "" : " disabled"}`}>
<form
class="sendform-term"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<span class="prompt">operator@{label} ▸</span>
<textarea
ref={taRef}
value={value}
placeholder={`message ${label}…`}
rows={1}
disabled={!online}
autocomplete="off"
onInput={onInput}
onKeyDown={onKeyDown}
/>
<span class="submit-hint">↵ send · ⇧↵ newline · /help</span>
</form>
</div>
);
}

View file

@ -28,13 +28,9 @@ export interface UseLiveStreamResult {
hasMore: boolean; hasMore: boolean;
loadingMore: boolean; loadingMore: boolean;
loadMore: () => void; loadMore: () => void;
/** Append a local-only note row (TermInput's `/help` output, error /** Append a local-only note row (error feedback on a failed state-badge
* feedback on a failed slash-command POST) — never sent anywhere, * action) — never sent anywhere, purely a client-side echo. */
* purely a client-side echo. */
pushLocalNote: (text: string) => void; pushLocalNote: (text: string) => void;
/** Wipe the local view only (server-side history is untouched) —
* TermInput's `/clear`. */
clearLocal: () => void;
} }
function appendRow(rows: TermRow[], row: TermRow): TermRow[] { function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
@ -234,9 +230,5 @@ export function useLiveStream(
}), }),
); );
} }
function clearLocal() { return { rows, hasMore, loadingMore, loadMore, pushLocalNote };
setRows([]);
}
return { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal };
} }

View file

@ -18,7 +18,7 @@
</head> </head>
<body class="agent-shell"> <body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal, <!-- The Preact rewrite owns everything below — header, terminal,
composer, side panel. See main.tsx/Root.tsx for the component side panel. See main.tsx/Root.tsx for the component
tree; docs/web-ui/agent.md for the design. --> tree; docs/web-ui/agent.md for the design. -->
<div id="preact-root"></div> <div id="preact-root"></div>

View file

@ -1,16 +1,9 @@
// Same-origin fetch POST helpers for the term-input slash commands // Same-origin fetch POST helpers for the state badge's actions
// ({ ok, detail } rather than throwing), reused by TermInput.tsx. // ({ ok, detail } rather than throwing).
async function post( async function post(path: string): Promise<{ ok: boolean; detail?: string }> {
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try { try {
const resp = await fetch(path, { const resp = await fetch(path, {
method: "POST", method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body,
redirect: "manual", redirect: "manual",
}); });
const ok = const ok =
@ -32,8 +25,5 @@ async function post(
} }
export const postCancelTurn = () => post("api/cancel"); export const postCancelTurn = () => post("api/cancel");
export const postCompact = () => post("api/compact");
export const postNewSession = () => post("api/new-session"); export const postNewSession = () => post("api/new-session");
export const postLogout = () => post("api/logout"); export const postLogout = () => post("api/logout");
export const postSend = (body: string) =>
post("send", new URLSearchParams({ body }));

View file

@ -1,5 +1,4 @@
//! Operator action POST handlers (send, cancel, compact, reset, todos //! Operator action POST handlers (cancel, compact, reset, todos mark-done).
//! mark-done).
use axum::{ use axum::{
Form, Form,
@ -12,46 +11,6 @@ use std::future::Future;
use super::{AppState, error_response}; use super::{AppState, error_response};
#[derive(Deserialize)]
pub(super) struct SendForm {
body: String,
}
pub(super) async fn post_send(
State(state): State<AppState>,
Form(form): Form<SendForm>,
) -> Response {
let body = form.body.trim().to_owned();
if body.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
}
match super::broker_request(
&state.socket,
&hive_core_agent_sock::Request::OperatorMsg { body },
)
.await
{
// 200 instead of 303 → the client doesn't refetch /api/state.
// The operator message becomes a broker `Sent` (already shown
// server-side in the dashboard); on the agent side, the
// resulting `TurnStart` SSE event drives the terminal + the
// inbox row gets consumed by the time `TurnEnd` fires the
// existing turn-end refresh.
Ok(hive_core_agent_sock::Response::Ok) => {
(axum::http::StatusCode::OK, "ok").into_response()
}
Ok(hive_core_agent_sock::Response::Err { message }) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("send failed: {message}"),
),
Ok(other) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("send failed: unexpected response: {other:?}"),
),
Err(e) => super::broker_error_response(&e, "send"),
}
}
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response { pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
super::cancel_turn(&state.bus, &state.interrupted, "operator: /cancel"); super::cancel_turn(&state.bus, &state.interrupted, "operator: /cancel");
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()

View file

@ -1,7 +1,7 @@
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; //! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page //! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
//! state as JSON; the JS app renders. Live events stream on //! state as JSON; the JS app renders. Live events stream on
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a //! `/events/stream`. Action POSTs (`/login/*`) return either a
//! 303 Redirect (for browsers that submit the form normally) or just //! 303 Redirect (for browsers that submit the form normally) or just
//! 200 OK — the JS app re-fetches `/api/state` afterwards. //! 200 OK — the JS app re-fetches `/api/state` afterwards.
//! //!
@ -76,7 +76,6 @@ fn routes() -> Router<AppState> {
.route("/api/dashboard-state", get(state::api_dashboard_state)) .route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream)) .route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history)) .route("/events/history", get(stream::events_history))
.route("/send", post(actions::post_send))
.route("/login/start", post(auth::post_login_start)) .route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code)) .route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel)) .route("/login/cancel", post(auth::post_login_cancel))
@ -418,14 +417,14 @@ fn error_response(status: StatusCode, message: &str) -> Response {
} }
/// Why a deadline-bounded broker request via the per-agent socket didn't /// Why a deadline-bounded broker request via the per-agent socket didn't
/// yield a response. Kept distinct so action handlers pick the right status /// yield a response. Callers `.ok()` both today; the variants stay distinct so a
/// code (see [`broker_error_response`]) while decorative fetches `.ok()` both. /// handler that must tell a busy hive-c0re from a broken socket can.
enum BrokerError { enum BrokerError {
/// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled. A /// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled, not a
/// retryable state conflict (→ 409), not a server fault. /// broken socket.
Timeout, Timeout,
/// The socket transport itself failed (connect / encode / decode). /// The socket transport itself failed (connect / encode / decode).
Transport(anyhow::Error), Transport,
} }
/// Issue a broker request over the per-agent socket, bounded by /// Issue a broker request over the per-agent socket, bounded by
@ -449,27 +448,11 @@ async fn broker_request(
.await .await
{ {
Ok(Ok(resp)) => Ok(resp), Ok(Ok(resp)) => Ok(resp),
Ok(Err(e)) => Err(BrokerError::Transport(e)), Ok(Err(_)) => Err(BrokerError::Transport),
Err(_) => Err(BrokerError::Timeout), Err(_) => Err(BrokerError::Timeout),
} }
} }
/// Map a [`BrokerError`] to an operator-facing error response: a timeout is a
/// retryable "busy" conflict (409), a transport failure is a 500. `action`
/// prefixes the message (e.g. `"send"`, `"get_loose_ends"`).
fn broker_error_response(err: &BrokerError, action: &str) -> Response {
match err {
BrokerError::Timeout => error_response(
StatusCode::CONFLICT,
&format!("{action}: timed out — hive-c0re busy, retry"),
),
BrokerError::Transport(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{action}: transport: {e:#}"),
),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::os::unix::process::CommandExt as _; use std::os::unix::process::CommandExt as _;

View file

@ -67,7 +67,7 @@ const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1;
/// - Static assets (`/static/`) → `alias` from the nix store with cache /// - Static assets (`/static/`) → `alias` from the nix store with cache
/// headers. Served by nginx directly, no agent socket round-trip. /// headers. Served by nginx directly, no agent socket round-trip.
/// - Everything else (all `/api/*`, `/events/*`, `/screen/ws`, `/icon`, /// - Everything else (all `/api/*`, `/events/*`, `/screen/ws`, `/icon`,
/// `/send`, `/login/*`) → proxied to the agent daemon as before. /// `/login/*`) → proxied to the agent daemon as before.
/// ///
/// When `frontend_dir` is `None` (legacy) each agent gets a single /// When `frontend_dir` is `None` (legacy) each agent gets a single
/// `location /agent/<name>/` proxy block. /// `location /agent/<name>/` proxy block.

View file

@ -219,9 +219,6 @@ async fn dispatch(
handle_recv(coord, agent, *wait_seconds, *max).await handle_recv(coord, agent, *wait_seconds, *max).await
} }
hive_core_agent_sock::Request::Status => handle_status(coord, agent), hive_core_agent_sock::Request::Status => handle_status(coord, agent),
hive_core_agent_sock::Request::OperatorMsg { body } => {
handle_operator_msg(coord, agent, body)
}
hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body), hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body),
hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit), hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit),
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text), hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
@ -405,26 +402,6 @@ fn handle_status(coord: &Arc<Coordinator>, agent: &str) -> hive_core_agent_sock:
} }
} }
/// `OperatorMsg` — deliver an operator-authored message into `agent`'s
/// inbox (sender `operator`).
fn handle_operator_msg(
coord: &Arc<Coordinator>,
agent: &str,
body: &str,
) -> hive_core_agent_sock::Response {
match coord.broker.send(&Message {
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::OPERATOR_RECIPIENT),
to: agent.to_owned(),
body: body.to_owned(),
in_reply_to: None,
}) {
Ok(()) => hive_core_agent_sock::Response::Ok,
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `Recent` — the last `limit` inbox rows for `agent` (read-only, /// `Recent` — the last `limit` inbox rows for `agent` (read-only,
/// doesn't consume). /// doesn't consume).
fn handle_recent( fn handle_recent(

View file

@ -44,11 +44,6 @@ pub enum Request {
/// Non-mutating: how many pending messages are addressed to me? /// Non-mutating: how many pending messages are addressed to me?
/// Used by the harness to render a status line after each tool call. /// Used by the harness to render a status line after each tool call.
Status, Status,
/// Operator-injected message TO this agent (from this agent's own web
/// UI). Recipient is implicit — `from` is `"operator"`. Effectively the
/// per-agent equivalent of the old dashboard T4LK form, but scoped to
/// the agent whose page the operator is on.
OperatorMsg { body: String },
/// Wake-up event injected from inside the container. Recipient is /// Wake-up event injected from inside the container. Recipient is
/// implicit (this agent); `from` is caller-chosen. See /// implicit (this agent); `from` is caller-chosen. See
/// `docs/process/conventions.md::Wake injection` for the trust model and /// `docs/process/conventions.md::Wake injection` for the trust model and

View file

@ -9,11 +9,10 @@ use std::time::Duration;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt};
/// Longest request line accepted, newline included. The largest request a /// Longest request line accepted, newline included. A generous bound, not
/// client sends is an `OperatorMsg` from the agent web UI's `/send` /// tuned to one request: it was sized for an operator message from the agent web
/// (`hive-agent/src/web_ui/actions.rs:21`), whose body axum's default limit /// UI (a body of up to 2 MiB, up to 4 MiB once JSON-escaped, with 4× headroom),
/// caps at 2 MiB; JSON escaping can roughly double that to 4 MiB, and this is /// and no client sends anything near it now.
/// 4× that.
pub const REQUEST_LINE_MAX_BYTES: usize = 16 * 1024 * 1024; pub const REQUEST_LINE_MAX_BYTES: usize = 16 * 1024 * 1024;
/// Default for [`LineDeadlines::line`] and [`LineDeadlines::idle`] on the /// Default for [`LineDeadlines::line`] and [`LineDeadlines::idle`] on the