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:
parent
e149ff3315
commit
23f4f1823b
17 changed files with 77 additions and 505 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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?"** →
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 _;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue