hive-agent: the agent has no login flow of its own, credentials come from the swarm
This commit is contained in:
parent
52d26dd067
commit
ac3b3cfcd3
9 changed files with 46 additions and 844 deletions
|
|
@ -1,9 +1,7 @@
|
|||
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
|
||||
// to the real `/api/state` snapshot via `useAgentState`. Covers
|
||||
// everything `app.js` did, including the `needs_login_idle`/
|
||||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
||||
// no credentials has no other web-UI path back online, so this isn't
|
||||
// optional polish.
|
||||
// everything `app.js` did. An agent without credentials shows the
|
||||
// `needs_login_idle` badge; it logs in from the swarm UI, not here.
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { BadgeTone } from "@hive/shared/badge.js";
|
||||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||
|
|
@ -20,7 +18,6 @@ import { HeaderPill } from "./components/HeaderPill.js";
|
|||
import { SidePanel } from "./components/SidePanel.js";
|
||||
import { InboxPanel } from "./components/InboxPanel.js";
|
||||
import { TodosPanel } from "./components/TodosPanel.js";
|
||||
import { LoginFlow } from "./components/LoginFlow.js";
|
||||
import { useAgentState } from "./hooks/useAgentState.js";
|
||||
import { useTodos } from "./hooks/useTodos.js";
|
||||
import { fmtAge, fmtTokens } from "./lib/format.js";
|
||||
|
|
@ -49,7 +46,6 @@ const ALIVE_LABELS: Record<
|
|||
online: { glyph: "●", text: "alive", tone: "positive" },
|
||||
rate_limited: { glyph: "⊘", text: "rate limited", tone: "warning" },
|
||||
needs_login_idle: { glyph: "◌", text: "needs login", tone: "warning" },
|
||||
needs_login_in_progress: { glyph: "◌", text: "logging in", tone: "warning" },
|
||||
};
|
||||
const STATE_LABELS: Record<
|
||||
string,
|
||||
|
|
@ -336,14 +332,6 @@ export function Root() {
|
|||
</Header>
|
||||
)}
|
||||
<main className="agent-main">
|
||||
{state.status === "needs_login_idle" ||
|
||||
state.status === "needs_login_in_progress" ? (
|
||||
<LoginFlow
|
||||
status={state.status}
|
||||
session={state.session}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
) : null}
|
||||
<LiveStream ref={liveStreamRef} />
|
||||
</main>
|
||||
{panel}
|
||||
|
|
|
|||
|
|
@ -233,35 +233,6 @@ h3 {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Login flow overlay: only rendered when status != online. Sits
|
||||
centred over the (likely-empty) terminal area; doesn't take chrome
|
||||
space in the normal online flow. `--agent-header-real-h` (set by
|
||||
the new Preact page's `Header.tsx` via ResizeObserver, falling back
|
||||
to the static `--agent-header-h` guess before the first
|
||||
measurement / on the old app.js page, which never sets it) is the
|
||||
header's ACTUAL rendered height, not an assumed one — see
|
||||
Header.tsx's file comment for why this matters (a static guess is
|
||||
wrong the moment the header wraps an extra line on some viewport). */
|
||||
.agent-status-overlay {
|
||||
position: absolute;
|
||||
top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 44em;
|
||||
width: calc(100% - 3em);
|
||||
z-index: 10;
|
||||
}
|
||||
.agent-status-overlay:empty {
|
||||
display: none;
|
||||
}
|
||||
.agent-status-overlay > * {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
padding: 1em 1.2em;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
|
|
@ -308,16 +279,6 @@ a:hover {
|
|||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-login {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.btn-cancel {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.6em;
|
||||
}
|
||||
/* Orphaned rules — left here as a tombstone so a search for the
|
||||
class name finds them. Live consumers gone:
|
||||
- `.btn-rebuild` was a per-agent header chip, then briefly an
|
||||
|
|
@ -343,57 +304,6 @@ a:hover {
|
|||
.sendform input:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.loginform {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.5em;
|
||||
align-items: stretch;
|
||||
}
|
||||
.loginform input {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
flex: 1;
|
||||
}
|
||||
.loginform input:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
|
||||
/* Show / hide toggle for the masked OAuth-code input. Quiet by
|
||||
default (muted border + transparent bg), lights amber on hover
|
||||
/ when pressed (`aria-pressed="true"`) so the operator sees at
|
||||
a glance whether the code is currently visible. */
|
||||
.loginform-reveal {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.6em;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.loginform-reveal:hover,
|
||||
.loginform-reveal:focus-visible {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
outline: none;
|
||||
}
|
||||
.loginform-reveal[aria-pressed="true"] {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 8px -2px var(--amber);
|
||||
}
|
||||
pre.diff {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--purple-dim);
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
/* .login-card — one elevated-surface card for the whole login-recovery
|
||||
overlay, replacing the old markup's per-`<p>`/`<form>` boxing
|
||||
(`agent.css`'s `.agent-status-overlay > *`, still needed there
|
||||
verbatim since app.js's still-live markup depends on it until the
|
||||
index.html cutover — this file coexists with it rather than editing
|
||||
it). Same visual language as `Dropdown`'s `.ui-dropdown`
|
||||
(`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/
|
||||
shadow values match exactly, so this reads as the same "popup" family
|
||||
as every other floating surface in the app, just laid out as a static
|
||||
block instead of an anchored, position:absolute menu (that part of
|
||||
`Dropdown` is specific to hanging under a trigger and doesn't apply
|
||||
here — `.agent-status-overlay` already centres this card itself). */
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
||||
padding: 1em 1.2em;
|
||||
}
|
||||
.login-card > * {
|
||||
margin: 0;
|
||||
}
|
||||
/* claude's real OAuth URL is one long unbroken token (a query string
|
||||
with no whitespace) — without this it doesn't wrap at the card's
|
||||
bounded width (.agent-status-overlay caps at 44em) and instead
|
||||
overflows straight past the card's right edge (mara reported this
|
||||
with a screenshot). `overflow-wrap: anywhere` lets the
|
||||
browser break mid-token as a last resort, same as any other
|
||||
long-URL-in-a-narrow-column case — `word-break: break-all` is the
|
||||
older alternative but breaks more eagerly than needed here. */
|
||||
.login-url {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
|
||||
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
|
||||
// as real functional UI, not just a status label: an agent whose
|
||||
// credentials got rotated (or hit `/logout`) has NO other way back
|
||||
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
|
||||
// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal`
|
||||
// rules verbatim (same class names as the old markup).
|
||||
//
|
||||
// mara (screenshot review): "reuse the popup component for the login,
|
||||
// the floating separate panels should be one card." The old markup's
|
||||
// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/
|
||||
// `<form>` separately (a fine translation of `el().append()`-per-line,
|
||||
// but not what a hand-authored component should do) — this renders one
|
||||
// `.login-card` instead, styled with the same "elevated surface" look
|
||||
// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/
|
||||
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
|
||||
// render is menu-item-shaped, not a generic content container — not a
|
||||
// fit for a login form + output pane).
|
||||
import { useState } from "preact/hooks";
|
||||
import type { SessionView } from "../types.js";
|
||||
import {
|
||||
postLoginStart,
|
||||
postLoginCode,
|
||||
postLoginCancel,
|
||||
} from "../lib/loginAction.js";
|
||||
import "./LoginFlow.css";
|
||||
|
||||
function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
postLoginStart().then((r) => {
|
||||
setBusy(false);
|
||||
if (r.ok) onStarted();
|
||||
else setError(r.detail ?? "failed");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-card">
|
||||
<p class="status-needs-login">◌ NEEDS L0G1N</p>
|
||||
<p>
|
||||
No Claude session in <code>~/.claude/</code>. The harness is up but the
|
||||
turn loop is paused until you log in.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ ST4RT L0G1N
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<p class="meta">
|
||||
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth
|
||||
URL will appear here when claude emits it; paste the resulting code back
|
||||
into the form below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginInProgress({
|
||||
session,
|
||||
onDone,
|
||||
}: {
|
||||
session: SessionView | null;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const finished = session?.finished ?? false;
|
||||
|
||||
function submitCode(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Success: the next `/api/state` poll flips `status` back to
|
||||
// `online` (or leaves it `needs_login_in_progress` on a bad code,
|
||||
// with the process's own stderr showing up in `output` below) —
|
||||
// nothing local to update here either way.
|
||||
postLoginCode(code).then((r) => {
|
||||
setBusy(false);
|
||||
if (!r.ok) setError(r.detail ?? "failed");
|
||||
});
|
||||
}
|
||||
|
||||
function cancel(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
postLoginCancel().then(() => {
|
||||
setBusy(false);
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-card">
|
||||
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
|
||||
{session?.url ? (
|
||||
<>
|
||||
<p class="login-url">
|
||||
▶{" "}
|
||||
<a href={session.url} target="_blank" rel="noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
<p class="meta">
|
||||
open this URL in a browser, complete the OAuth flow, paste the
|
||||
resulting code below.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p class="meta">
|
||||
waiting for claude to emit an OAuth URL on stdout… (output below)
|
||||
</p>
|
||||
)}
|
||||
{!finished ? (
|
||||
<form class="loginform" onSubmit={submitCode}>
|
||||
<input
|
||||
name="code"
|
||||
type={reveal ? "text" : "password"}
|
||||
placeholder="paste OAuth code here (hidden)"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
spellcheck={false}
|
||||
value={code}
|
||||
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="loginform-reveal"
|
||||
title="show / hide pasted code"
|
||||
aria-label="show / hide pasted OAuth code"
|
||||
aria-pressed={reveal}
|
||||
onClick={() => setReveal((r) => !r)}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ S3ND C0DE
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
|
||||
<button type="submit" class="btn btn-cancel" disabled={busy}>
|
||||
cancel + kill
|
||||
</button>
|
||||
</form>
|
||||
{finished ? (
|
||||
<p class="status-needs-login">
|
||||
claude process exited: {session?.exit_note || "exited"}. Start over if
|
||||
needed.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<h3>output</h3>
|
||||
<pre class="diff">{session?.output || ""}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginFlow({
|
||||
status,
|
||||
session,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: "needs_login_idle" | "needs_login_in_progress";
|
||||
session: SessionView | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="agent-status-overlay">
|
||||
{status === "needs_login_idle" ? (
|
||||
<LoginIdle onStarted={onRefresh} />
|
||||
) : (
|
||||
<LoginInProgress session={session} onDone={onRefresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
|
||||
values exactly (background/border/radius/shadow, item padding/hover),
|
||||
same "reuse the values, not the component" call `.login-card`
|
||||
(LoginFlow.css) already made, for the same reason: this popover's
|
||||
a "reuse the values, not the component" call: this popover's
|
||||
items are real `<a>` tags, not `Dropdown`'s command-dispatch
|
||||
`<button>`s. Right-anchored — MetaNav sits in the header's right-hand
|
||||
pills cluster, so a left anchor would push it off-screen. */
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@
|
|||
// always `<button>`s for command dispatch, which would lose real link
|
||||
// semantics like ctrl/middle-click and "copy link address"), but
|
||||
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
|
||||
// the same "reuse the values, not the component" call `LoginFlow`'s
|
||||
// `.login-card` makes for the same reason.
|
||||
// a "reuse the values, not the component" call.
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
<link rel="stylesheet" href="static/colors.css" />
|
||||
<link rel="stylesheet" href="static/theme.css" />
|
||||
<link rel="stylesheet" href="static/agent.css" />
|
||||
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
|
||||
<!-- main.css (bundled component CSS: Badge/Dropdown/MetaNav/
|
||||
StatusChips/SidePanel/...) MUST load after agent.css —
|
||||
`.login-card`/`.meta-nav-popover`/etc. share class-selector
|
||||
`.meta-nav-popover`/etc. share class-selector
|
||||
specificity with agent.css's legacy rules, so load order is what
|
||||
decides which one wins the cascade (see LoginFlow.css/MetaNav.css
|
||||
file comments). -->
|
||||
decides which one wins the cascade (see the MetaNav.css
|
||||
file comment). -->
|
||||
<link rel="stylesheet" href="static/main.css" />
|
||||
</head>
|
||||
<body class="agent-shell">
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
// POST `/login/start` / `/login/code` / `/login/cancel` — same-origin
|
||||
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
|
||||
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
|
||||
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` /
|
||||
// broad-2xx-4xx-treated-as-ok contract as `termActions.ts` — none of
|
||||
// these three routes actually redirect today, but the same tolerant
|
||||
// check costs nothing and keeps every same-origin POST in this package
|
||||
// behaving identically.
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: undefined,
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postLoginStart = () => post("login/start");
|
||||
export const postLoginCode = (code: string) =>
|
||||
post("login/code", new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post("login/cancel");
|
||||
|
|
@ -12,12 +12,7 @@ export interface AgentState {
|
|||
label: string;
|
||||
qualified_label: string;
|
||||
dashboard_port: number;
|
||||
status:
|
||||
| "online"
|
||||
| "rate_limited"
|
||||
| "needs_login_idle"
|
||||
| "needs_login_in_progress";
|
||||
session: SessionView | null;
|
||||
status: "online" | "rate_limited" | "needs_login_idle";
|
||||
turn_state: string;
|
||||
turn_state_since: number;
|
||||
model: string;
|
||||
|
|
@ -48,16 +43,6 @@ export interface AgentLink {
|
|||
kind: "container" | "forge" | "external";
|
||||
}
|
||||
|
||||
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
|
||||
// while `status === 'needs_login_in_progress'` (the in-flight
|
||||
// `claude auth login` session's streamed output).
|
||||
export interface SessionView {
|
||||
url: string | null;
|
||||
output: string;
|
||||
finished: boolean;
|
||||
exit_note: string | null;
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue