Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
baefb32bd5 Merge forge/main into atlas/5034-s4-remove-hive-model 2026-10-11 03:42:22 +02:00
atlas
a7e640514c Merge forge/main into atlas/5034-s4-remove-hive-model 2026-10-11 02:09:05 +02:00
atlas
4719395b31 docs, comments: drop references to the removed model/effort picker
subagent.md no longer says the web UI's model picker offers
availableModels, and the comments in Dropdown, ComponentsPage and
WantedMenu no longer point at the deleted per-agent Picker.
2026-10-11 01:54:23 +02:00
atlas
ed271e655f agents: remove the hive-level /effort control too
mara on #5034: "effort shares /models fate". The per-agent web UI no
longer sets effort: the `/effort` slash command, the effort picker,
`POST /api/effort`, `Bus::set_effort`, the `hyperhive-effort` writer,
`EFFORT_LEVELS`/`is_valid_effort` and the ACP effort-list payload are
gone, and `lib/modelEffort.ts` with them. The effort chip is
display-only and shows its source (`effort_source` in `/api/state`);
on ACP it stays hidden while the session offers no effort level.

The read side is unchanged: with no swarm value an agent runs an
existing `hyperhive-effort` file, else its agent.nix effort, else
medium. Existing files are not touched.

Refs #5034
2026-10-11 01:28:44 +02:00
atlas
594693f4c7 agents: remove the hive-level /model control; the swarm editor sets the model
The per-agent web UI no longer sets the model: the `/model` slash
command, the model picker, `POST /api/model`, `Bus::set_model` and the
`hyperhive-model` writer are gone. The model chip is display-only and
shows where the model comes from (`model_source` in `/api/state`). The
model is set on the swarm (swarm UI agents page, `swarmctl agent
inference set`).

The read side is unchanged: with no swarm value an agent runs its
agent.nix model, else an existing `hyperhive-model` file, else haiku.
Existing files are not touched.

With the picker gone, its model list goes too: `available_models` in
`/api/state`, the ACP model-list filter, and the harness's global
`HIVE_AVAILABLE_MODELS`. The subagent daemon keeps its own
`HIVE_AVAILABLE_MODELS` from mcp.nix, and the `availableModels`
assertion stays.

`/effort` (command, picker, route, file) is unchanged.

Refs #5034
2026-10-10 23:11:53 +02:00
33 changed files with 315 additions and 812 deletions

1
Cargo.lock generated
View file

@ -1725,6 +1725,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower",
"tower-http 0.7.0", "tower-http 0.7.0",
"tracing", "tracing",
] ]

View file

@ -216,12 +216,12 @@ the retention sweep never deletes open rows.
### `/harness/hyperhive-model` (per agent) ### `/harness/hyperhive-model` (per agent)
Single-line text file holding the claude model name currently Single-line text file holding a claude model name. The harness only
selected for this agent (default `haiku` when absent). Written by reads it, at boot in `Bus::new` and again when the swarm clears the
`Bus::set_model` whenever the operator flips it via `/model model it set. It holds the model the agent runs when neither the swarm nor
<name>` in the web terminal. Read once at harness boot in `HIVE_DEFAULT_MODEL` (agent.nix) names one. Nothing in the harness
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`. writes it. Path overridable via `HYPERHIVE_MODEL_FILE`. Survives
Survives destroy/recreate, gone on `--purge`. destroy/recreate, gone on `--purge`.
### `/harness/paused` (per agent) ### `/harness/paused` (per agent)
@ -318,7 +318,8 @@ Under `/var/lib/hyperhive/agents/<name>/`:
tables above. tables above.
- `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-events.sqlite` — turn-loop event log.
- `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-turn-stats.sqlite` — per-turn timing stats.
- `hyperhive-model` — single-line model name override file. - `hyperhive-model` / `hyperhive-effort` — single-line model name and
effort level files, read-only fallbacks.
- `work/` — the agent's working space for git worktrees, build dirs - `work/` — the agent's working space for git worktrees, build dirs
and other large working data. Bind-mounted RW to and other large working data. Bind-mounted RW to
`/agents/<name>/work` inside the container and owned by the agent `/agents/<name>/work` inside the container and owned by the agent

View file

@ -20,14 +20,16 @@ swarmctl agent inference clear iris
agent's `availableModels` as the models its subagent `start` and agent's `availableModels` as the models its subagent `start` and
`continue` calls may name, read on each call `continue` calls may name, read on each call
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)). ([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
The web UI's model picker doesn't use it.
- `get` shows each field, naming the unset ones (or says the swarm sets - `get` shows each field, naming the unset ones (or says the swarm sets
nothing for the agent), and then the model and effort the agent reports nothing for the agent), and then the model and effort the agent reports
running on, each with where it comes from. running on, each with where it comes from.
- `clear` removes every field; the agent goes back to its own values. - `clear` removes every field; the agent goes back to its own values.
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The
swarm UI's agents page edits the same settings; see [UI](ui.md). swarm UI's agents page edits the same settings; see [UI](ui.md). The
swarm is the only place to change an agent's model or effort at runtime:
its web UI shows both but has no picker and no `/model` or `/effort`
command.
## What an agent runs on ## What an agent runs on
@ -47,11 +49,15 @@ Each running value names where it comes from:
| ------------ | -------------------------------------------------------------------------- | | ------------ | -------------------------------------------------------------------------- |
| `swarm` | the value set here | | `swarm` | the value set here |
| `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix | | `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix |
| `local_file` | a `/model` or `/effort` pick in the agent's web UI | | `local_file` | a `hyperhive-model` or `hyperhive-effort` file in the agent's harness dir |
| `default` | the harness's built-in default (`haiku`, `medium`) | | `default` | the harness's built-in default (`haiku`, `medium`) |
When the agent starts without a swarm value, its agent.nix model wins over When the agent starts without a swarm value, its agent.nix model wins over
its `/model` pick, and its `/effort` pick wins over its agent.nix effort. a `hyperhive-model` file, and a `hyperhive-effort` file wins over its
agent.nix effort. These files come from runtime picks the agent's web UI no
longer offers; the agent still reads them but never writes them, so a
`local_file` value stays until the swarm sets that field or someone deletes
the file.
The agent reports these whenever they change, and again once a minute. The agent reports these whenever they change, and again once a minute.
swarm-controller keeps only what it has received since it started, so for up swarm-controller keeps only what it has received since it started, so for up
@ -63,13 +69,11 @@ Each agent reads its settings every 10 seconds and switches on the next
read: its next turn runs on the new model or effort, and its web UI badge read: its next turn runs on the new model or effort, and its web UI badge
shows it. A turn already running keeps what it started with. shows it. A turn already running keeps what it started with.
- **Its own value is the one it runs without the swarm**, from a `/model` - **Its own value is the one it runs without the swarm**: its agent.nix or a
or `/effort` pick in its web UI or from its agent.nix. Setting a swarm `hyperhive-model` / `hyperhive-effort` file. Setting a swarm value doesn't
value doesn't overwrite it. overwrite it.
- **Clearing a field** (or the whole key) puts the agent back on its own - **Clearing a field** (or the whole key) puts the agent back on its own
value on the next read. value on the next read.
- **A `/model` or `/effort` pick while the swarm sets that field** changes
the agent's own value, but the swarm value comes back on the next read.
- **After a restart**, the agent runs its own values until its first read - **After a restart**, the agent runs its own values until its first read
of its settings succeeds. of its settings succeeds.

View file

@ -337,8 +337,6 @@ happens until the daemon's first connection to the swarm queue succeeds.
The match ignores case. On an ACP agent the names are the agent's own, such The match ignores case. On an ACP agent the names are the agent's own, such
as `<provider>/<model>` on `opencode`. as `<provider>/<model>` on `opencode`.
The web UI's model picker still offers `availableModels`.
## Runtime ## Runtime
A subagent runs on its parent agent's runtime A subagent runs on its parent agent's runtime

View file

@ -146,7 +146,7 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
Show or change the model, effort and subagent models the swarm sets for an agent. Show or change the model, effort and subagent models the swarm sets for an agent.
A field the swarm sets wins over the agent's own value (from its `/model` or `/effort` pick or its agent.nix) from its next turn on; the agent reads its settings every ten seconds. A field the swarm doesn't set, or clears, leaves the agent on its own value. A field the swarm sets wins over the agent's own value (its agent.nix or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on; the agent reads its settings every ten seconds. A field the swarm doesn't set, or clears, leaves the agent on its own value.
**Usage:** `swarmctl agent inference <COMMAND>` **Usage:** `swarmctl agent inference <COMMAND>`

View file

@ -44,19 +44,18 @@ Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix` read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
from the `prompts/claude-settings.json` asset. `effortLevel` is from the `prompts/claude-settings.json` asset. `effortLevel` is
deliberately not in that file — the `--effort` flag deliberately not in that file — the `--effort` flag
(`HIVE_DEFAULT_EFFORT` / the per-agent UI slider) controls effort (`HIVE_DEFAULT_EFFORT` / the swarm's effort setting) controls effort
live, which managed scope would otherwise lock. live, which managed scope would otherwise lock.
The harness reads `<name>` from `Bus::model()` on each turn. The harness reads `<name>` from `Bus::model()` on each turn.
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial `services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to `"haiku"` if unset). The operator can flip it at runtime back to an existing `/harness/hyperhive-model` file, then `"haiku"`, if
with `/model <name>` in the web terminal — the next turn picks it unset). The swarm changes the model at runtime, not the web
up. The harness persists the choice to `/harness/hyperhive-model` so it terminal: a model or effort the swarm sets for the agent replaces the
survives restart; override path: `HYPERHIVE_MODEL_FILE` env var `Bus` value from the next turn, and the harness doesn't persist it;
for tests. A model or effort the swarm sets for the agent replaces the clearing it goes back to the local value →
`Bus` value, and the harness doesn't persist it; clearing it goes back to [`swarm/inference.md`](../swarm/inference.md).
the persisted or nix value → [`swarm/inference.md`](../swarm/inference.md).
`harness_state::context_window_tokens(model)` looks up `harness_state::context_window_tokens(model)` looks up
context-window size per-model. Resolution order (first context-window size per-model. Resolution order (first

View file

@ -47,18 +47,20 @@ at a given viewport width. Two columns:
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
- **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` / - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix. `○ offline` / `… booting` + age suffix.
- **Model badge** (`model · <name> ▾`): a real picker — selecting it opens - **Model and effort badges** (`model · <name>`, `effort · <level>`):
a `Dropdown` of `state.available_models`, selecting one POSTs display only. Each tooltip says where the value comes from
`/api/model` immediately (same endpoint the `/model <name>` slash (`state.model_source` / `state.effort_source`: set on the swarm,
command uses). A header badge: the control and its display sit agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or
together. the built-in default); the model's also says what the last turn
- **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`, resolved it to. Both are set on the swarm: the swarm UI's agents page,
shown when `state.available_efforts` is non-empty. or `swarmctl agent inference set`
- On an ACP agent both pickers list what its session offers (its ([`inference.md`](../swarm/inference.md)).
`model` and `thought_level` config options), and are empty until the - On an ACP agent each badge shows the session's own value when it
first turn has attached a session. The effort picker is hidden when the doesn't offer the requested one. The effort badge is hidden
model offers no effort levels, and while a newly picked model waits for (`state.effort` is `null`) until the first turn has attached a
the next turn, since the levels come with the model. session, when the model offers no effort levels, and while a newly
set model waits for the next turn, since the levels come with the
model.
- **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size, - **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
tooltip shows % of context window) / `cost · 1.3M` (cumulative tooltip shows % of context window) / `cost · 1.3M` (cumulative
tokens billed across every inference in the last turn). tokens billed across every inference in the last turn).
@ -82,8 +84,8 @@ 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; the model/effort both. The dashboard link lives in this 🔗 popover, and
pickers are real badges above; and `/new-session` / `/logout` are `/new-session` / `/logout` are
typed slash commands with their own type-twice confirm (see typed slash commands with their own type-twice confirm (see
below). below).
- `/cancel` (slash command, below) is the only cancel-turn path; the - `/cancel` (slash command, below) is the only cancel-turn path; the
@ -238,14 +240,6 @@ Slash commands today:
turn, or `turn::run_pending_compact` when idle); output streams into 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 the live panel. Works mid-turn, not only when idle. Either way the live
panel ends with a `/compact done` or `/compact failed: …` note. panel ends with a `/compact done` or `/compact failed: …` note.
- `/model <name>` — `POST /api/model` flipping `Bus::set_model`.
Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness
restart / rebuild.
- `/effort <level>` — `POST /api/effort` setting the claude effort
level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next
turn. `StatusChips`' effort badge calls the same endpoint; both stay
in sync via `StateSnapshot.effort`.
- `/new-session` — `POST /api/new-session` (confirms first). - `/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.
@ -280,17 +274,6 @@ shaped).
turn — only the stdin payload differs). Flips state to turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits `Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`. `TurnStateChanged`.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/effort` (`effort=<level>`) — switch the claude
effort level for future sessions. Validated server-side against
`EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are
rejected rather than forwarded to `claude --effort`. Persists via
`Bus::set_effort`, which emits `EffortChanged`. Applies on the next
session start (no mid-session swap).
- On an ACP agent, `/api/model` and `/api/effort` accept only values the
session offers, and the runtime sets them with `session/set_config_option`
before the next prompt.
- `POST /api/new-session` — arm a one-shot for the next turn to - `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`. drop `--continue`. Emits a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the - `POST /api/logout` — three-step teardown that re-uses the
@ -324,7 +307,7 @@ shaped).
the frontend (`useAgentState.ts`) on page load and, on a poll, while the frontend (`useAgentState.ts`) on page load and, on a poll, while
`status === 'needs_login_in_progress'`. Includes `turn_state`, `status === 'needs_login_in_progress'`. Includes `turn_state`,
`context_window_tokens`, `qualified_label`, `context_window_tokens`, `qualified_label`,
`available_models`, `effort`, `available_efforts`, `links`, and `model`, `model_source`, `effort`, `effort_source`, `links`, and
other fields described inline throughout this document. All other fields described inline throughout this document. All
subsequent state updates arrive via SSE. subsequent state updates arrive via SSE.
- `GET /api/dashboard-state` — lean snapshot of agent-owned fields - `GET /api/dashboard-state` — lean snapshot of agent-owned fields
@ -397,10 +380,10 @@ Bus events (new vocabulary on `/events/stream`):
`needs_login_idle` / `needs_login_in_progress`. Drives the `needs_login_idle` / `needs_login_in_progress`. Drives the
alive-badge. The harness sets `rate_limited` when it detects a alive-badge. The harness sets `rate_limited` when it detects a
429 response and clears it when the retry sleep expires. 429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip. - `model_changed { model }` — drives the model chip. The harness emits it
- `effort_changed { effort }` — drives the effort picker chip. when it applies or clears a swarm value.
Emitted from `Bus::set_effort` on `POST /api/effort`; applies on - `effort_changed { effort }` — drives the effort chip. The harness
the next session start. emits it when it applies or clears a swarm value.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }` - `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from — drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last `Bus::record_turn_usage` at turn-end; `ctx` is the last

View file

@ -26,7 +26,6 @@ import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from "./lib/format.js"; import { fmtAge, fmtTokens } from "./lib/format.js";
import { resolveDashboardBase } from "./lib/dashboardBase.js"; import { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from "./lib/pauseAction.js"; import { submitPauseResume } from "./lib/pauseAction.js";
import { postModel, postEffort } from "./lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postLogout, postLogout,
@ -220,11 +219,6 @@ export function Root() {
statusLabel="… connecting" statusLabel="… connecting"
statusTone="neutral" statusTone="neutral"
model="" model=""
availableModels={[]}
onSelectModel={() => {}}
effort=""
availableEfforts={[]}
onSelectEffort={() => {}}
paused={false} paused={false}
onTogglePause={() => {}} onTogglePause={() => {}}
thinking={false} thinking={false}
@ -308,15 +302,9 @@ export function Root() {
statusTooltip={primaryStatus.tooltip} statusTooltip={primaryStatus.tooltip}
model={state.model} model={state.model}
resolvedModel={state.resolved_model ?? undefined} resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models} modelSource={state.model_source}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort} effort={state.effort}
availableEfforts={state.available_efforts} effortSource={state.effort_source}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined} ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined} costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused} paused={state.paused}

View file

@ -13,8 +13,8 @@
/* mara, live, on a screenshot of this row: "badges and interactive /* mara, live, on a screenshot of this row: "badges and interactive
elements on same row have inconsistent size." Real cause: Badge.css's elements on same row have inconsistent size." Real cause: Badge.css's
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor `.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
(model/effort pickers, pause) while a plain display `<span>` badge (pause) while a plain display `<span>` badge (model, effort,
(alive, state, ctx, cost — no `onClick`) stays compact, so the row alive, state, ctx, cost — no `onClick`) stays compact, so the row
reads as two different pill sizes even though every badge here is reads as two different pill sizes even though every badge here is
the same `<Badge>` component. Scoped to this row rather than the same `<Badge>` component. Scoped to this row rather than
Badge.css itself — display-only badges elsewhere (e.g. the dashboard) Badge.css itself — display-only badges elsewhere (e.g. the dashboard)

View file

@ -1,6 +1,7 @@
// <StatusChips> — the agent page's status row: the consolidated status // <StatusChips> — the agent page's status row: the consolidated status
// badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in // badge (alive/thinking/paused/etc., with pause/resume + cancel-turn in
// its dropdown), the model + effort pickers, and context/cost usage. // its dropdown), the model and effort (display-only: the swarm sets
// them), and context/cost usage.
// This is the concrete fix for the design guide's own named anti-example // This is the concrete fix for the design guide's own named anti-example
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu // (the old page's model/effort *pickers* lived in the `⋯` overflow menu
// while the current model/effort only showed as a disconnected chip) — // while the current model/effort only showed as a disconnected chip) —
@ -13,6 +14,7 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { InferenceSource } from "../types.js";
import "./StatusChips.css"; import "./StatusChips.css";
export interface StatusChipsProps { export interface StatusChipsProps {
@ -25,11 +27,11 @@ export interface StatusChipsProps {
* operator can tell what an alias resolved to. `undefined` before any * operator can tell what an alias resolved to. `undefined` before any
* turn has completed. */ * turn has completed. */
resolvedModel?: string; resolvedModel?: string;
availableModels: string[]; /** Where `model` comes from (`/api/state` `model_source`). */
onSelectModel: (name: string) => void; modelSource?: InferenceSource;
effort: string; /** Hidden when empty: an ACP session that offers no effort now. */
availableEfforts: string[]; effort?: string | null;
onSelectEffort: (level: string) => void; effortSource?: InferenceSource;
ctxLabel?: string; ctxLabel?: string;
costLabel?: string; costLabel?: string;
paused: boolean; paused: boolean;
@ -50,66 +52,12 @@ export interface StatusChipsProps {
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
} }
const MODEL_DESCRIPTIONS: Record<string, string> = { const SOURCE_LABEL: Record<InferenceSource, string> = {
haiku: "fast", swarm: "set on the swarm",
sonnet: "balanced", agent_nix: "from agent.nix",
opus: "powerful", local_file: "from its own earlier pick",
default: "the built-in default",
}; };
const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: "",
medium: "default",
high: "",
xhigh: "",
max: "",
};
function Picker({
label,
value,
options,
descriptions,
onSelect,
title,
}: {
label: string;
value: string;
options: string[];
descriptions: Record<string, string>;
onSelect: (v: string) => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const dropdownOptions: DropdownOption[] = options.map((name) => ({
value: name,
label: name,
description: descriptions[name] || undefined,
}));
return (
<div class="status-chip-anchor" ref={anchorRef}>
<Badge
label={label}
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
title={title}
/>
<Dropdown
open={open}
options={dropdownOptions}
activeValue={value}
label={`select ${label}`}
onSelect={(v) => {
onSelect(v);
setOpen(false);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}
// The consolidated alive/thinking/paused badge (mara: "why do we // The consolidated alive/thinking/paused badge (mara: "why do we
// have separate alive badge? ... it could be one badge that shows // have separate alive badge? ... it could be one badge that shows
// thinking / idle / paused / ... with a dropdown that opens when // thinking / idle / paused / ... with a dropdown that opens when
@ -235,11 +183,9 @@ export function StatusChips({
statusTooltip, statusTooltip,
model, model,
resolvedModel, resolvedModel,
availableModels, modelSource,
onSelectModel,
effort, effort,
availableEfforts, effortSource,
onSelectEffort,
ctxLabel, ctxLabel,
costLabel, costLabel,
paused, paused,
@ -263,21 +209,23 @@ export function StatusChips({
onNewSession={onNewSession} onNewSession={onNewSession}
onLogout={onLogout} onLogout={onLogout}
/> />
<Picker <Badge
label="model" label="model"
value={model} value={model}
options={availableModels} title={
descriptions={MODEL_DESCRIPTIONS} [
onSelect={onSelectModel} modelSource ? SOURCE_LABEL[modelSource] : null,
title={resolvedModel ? `resolved to ${resolvedModel}` : undefined} resolvedModel ? `resolved to ${resolvedModel}` : null,
]
.filter(Boolean)
.join(" · ") || undefined
}
/> />
{availableEfforts.length ? ( {effort ? (
<Picker <Badge
label="effort" label="effort"
value={effort} value={effort}
options={availableEfforts} title={effortSource ? SOURCE_LABEL[effortSource] : undefined}
descriptions={EFFORT_DESCRIPTIONS}
onSelect={onSelectEffort}
/> />
) : null} ) : null}
{ctxLabel ? ( {ctxLabel ? (

View file

@ -11,7 +11,6 @@
// (a local note explains what confirming does), typing it again within // (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm. // the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from "../lib/modelEffort.js";
import { import {
postCancelTurn, postCancelTurn,
postCompact, postCompact,
@ -32,14 +31,6 @@ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: "/clear", desc: "wipe the terminal panel (local-only)" }, { name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" }, { name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" }, { name: "/compact", desc: "compact the persistent claude session" },
{
name: "/model",
desc: "/model <name> — switch claude model for future turns",
},
{
name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
},
{ {
name: "/new-session", name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)", desc: "fresh claude session next turn (type twice to confirm)",
@ -84,8 +75,7 @@ export function TermInput({
function handleSlash(line: string): boolean { function handleSlash(line: string): boolean {
const trimmed = line.trim(); const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/); const [cmd] = trimmed.split(/\s+/);
const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd; const wasArmed = armedRef.current === cmd;
armedRef.current = null; armedRef.current = null;
@ -131,26 +121,6 @@ export function TermInput({
); );
} }
return true; return true;
case "/model":
if (!arg)
onLocalNote(
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
);
else
postModel(arg).then(
(r) => !r.ok && reportFailure("/model", r.detail),
);
return true;
case "/effort":
if (!arg)
onLocalNote(
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
);
else
postEffort(arg).then(
(r) => !r.ok && reportFailure("/effort", r.detail),
);
return true;
default: default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`); onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
return true; return true;

View file

@ -14,7 +14,7 @@ const RETRY_MS = 5000;
export interface UseAgentStateResult { export interface UseAgentStateResult {
state: AgentState | null; state: AgentState | null;
error: string | null; error: string | null;
/** Force an immediate refresh (e.g. right after a model/effort POST). */ /** Force an immediate refresh (e.g. right after a pause/resume POST). */
refresh: () => void; refresh: () => void;
} }

View file

@ -2,7 +2,7 @@
// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a // (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a
// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin // plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin
// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` / // actions). Same `{ ok, detail }` shape and `redirect: 'manual'` /
// broad-2xx-4xx-treated-as-ok contract as `modelEffort.ts` — none of // broad-2xx-4xx-treated-as-ok contract as `termActions.ts` — none of
// these three routes actually redirect today, but the same tolerant // these three routes actually redirect today, but the same tolerant
// check costs nothing and keeps every same-origin POST in this package // check costs nothing and keeps every same-origin POST in this package
// behaving identically. // behaving identically.

View file

@ -1,39 +0,0 @@
// POST `/api/model` / `/api/effort` — same-origin (this agent's own
// backend), so a plain `fetch` is safe unlike pause/resume (see
// pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is
// ported from app.js unchanged — the endpoints don't actually redirect
// today, but treating an opaque redirect as success costs nothing and
// matches the existing contract exactly.
async function post(
path: string,
field: string,
value: string,
): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ [field]: value }),
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 postModel = (name: string) => post("api/model", "model", name);
export const postEffort = (level: string) =>
post("api/effort", "effort", level);

View file

@ -5,8 +5,7 @@
// origin, but accessed directly they're different ports) and this way // origin, but accessed directly they're different ports) and this way
// hive-c0re doesn't need to grow CORS headers for it — the tradeoff is // hive-c0re doesn't need to grow CORS headers for it — the tradeoff is
// an opaque, unreadable response, which is fine here since the caller // an opaque, unreadable response, which is fine here since the caller
// doesn't need to read it, only trigger a state refresh afterward (same // doesn't need to read it, only trigger a state refresh afterward. `credentials:
// shape as `postModel`/`postEffort` in modelEffort.ts). `credentials:
// 'include'` matches the cookie-sending behavior of the plain <form> // 'include'` matches the cookie-sending behavior of the plain <form>
// submit this replaces. // submit this replaces.
// //

View file

@ -1,6 +1,5 @@
// Same-origin fetch POST helpers for the term-input slash commands — // Same-origin fetch POST helpers for the term-input slash commands
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than // ({ ok, detail } rather than throwing), reused by TermInput.tsx.
// throwing), reused by TermInput.tsx.
async function post( async function post(
path: string, path: string,
body?: URLSearchParams, body?: URLSearchParams,

View file

@ -4,6 +4,9 @@
// today, and the frontend only ever reads a handful of these fields. // today, and the frontend only ever reads a handful of these fields.
// Extend as more of the page gets rebuilt against real data. // Extend as more of the page gets rebuilt against real data.
// Mirrors `swarm_queue_client::agent_inference::Source`.
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
export interface AgentState { export interface AgentState {
seq: number; seq: number;
label: string; label: string;
@ -24,9 +27,9 @@ export interface AgentState {
cost_usage: TokenUsage | null; cost_usage: TokenUsage | null;
hive_name: string | null; hive_name: string | null;
swarm_name: string | null; swarm_name: string | null;
available_models: string[]; model_source: InferenceSource;
effort: string; effort: string | null;
available_efforts: string[]; effort_source: InferenceSource;
paused: boolean; paused: boolean;
inbox: InboxRow[]; inbox: InboxRow[];
links: AgentLink[]; links: AgentLink[];

View file

@ -2,8 +2,7 @@
// the `Badge` that opened it (../badge/Badge.tsx) so the control is // the `Badge` that opened it (../badge/Badge.tsx) so the control is
// visually attached to the thing it affects, instead of living three // visually attached to the thing it affects, instead of living three
// clicks away in a catch-all menu (the junk-drawer pattern // clicks away in a catch-all menu (the junk-drawer pattern
// `docs/web-ui/design-guide.md` calls out by name against the old // `docs/web-ui/design-guide.md` calls out by name). Not a native `<dialog>` — a
// per-agent terminal's model picker). Not a native `<dialog>` — a
// `showModal()` dialog's backdrop would visually detach it from its // `showModal()` dialog's backdrop would visually detach it from its
// anchor badge, and a non-modal `show()` gets no click-outside-to-close // anchor badge, and a non-modal `show()` gets no click-outside-to-close
// for free, so this hand-rolls the same "outside click or Escape // for free, so this hand-rolls the same "outside click or Escape

View file

@ -167,8 +167,7 @@ function RefreshIntervalPickerSample() {
} }
// The badge-triggers-a-dropdown-right-underneath-itself pattern this is // The badge-triggers-a-dropdown-right-underneath-itself pattern this is
// meant to land — the model/effort picker shape from the per-agent // meant to land, demoed here with sample data. The wrapper
// terminal, demoed here with sample data. The wrapper
// needs `position: relative` (`.components-badge-anchor`) so `Dropdown` // needs `position: relative` (`.components-badge-anchor`) so `Dropdown`
// anchors under this badge specifically, not the page. // anchors under this badge specifically, not the page.
function BadgePickerSample() { function BadgePickerSample() {
@ -528,7 +527,7 @@ export function ComponentsPage() {
<Sample label="with a label prefix"> <Sample label="with a label prefix">
<Badge label="ctx" value="534k" /> <Badge label="ctx" value="534k" />
</Sample> </Sample>
<Sample label="interactive, opens a Dropdown (model/effort picker shape)"> <Sample label="interactive, opens a Dropdown">
<BadgePickerSample /> <BadgePickerSample />
</Sample> </Sample>
<Sample label="interactive, toggles itself (pause/resume shape)"> <Sample label="interactive, toggles itself (pause/resume shape)">

View file

@ -8,7 +8,7 @@
// explicit — a dropdown just lets "up" be picked directly regardless // explicit — a dropdown just lets "up" be picked directly regardless
// of what's inferred, no flip-to-the-opposite-of-a-guess involved // of what's inferred, no flip-to-the-opposite-of-a-guess involved
// (mara: "when no state is declared, i want to set it to online"). // (mara: "when no state is declared, i want to set it to online").
// Presentational only, same split as `StatusChips`'s `Picker`/ // Presentational only, same split as `StatusChips`'s
// `StatusMenu`: the caller owns what each selection actually does. // `StatusMenu`: the caller owns what each selection actually does.
// //
// "paused" has no separate resume option here — selecting "up" from a // "paused" has no separate resume option here — selecting "up" from a

Some files were not shown because too many files have changed in this diff Show more