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",
"tokio",
"tokio-stream",
"tower",
"tower-http 0.7.0",
"tracing",
]

View file

@ -216,12 +216,12 @@ the retention sweep never deletes open rows.
### `/harness/hyperhive-model` (per agent)
Single-line text file holding the claude model name currently
selected for this agent (default `haiku` when absent). Written by
`Bus::set_model` whenever the operator flips it via `/model
<name>` in the web terminal. Read once at harness boot in
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
Survives destroy/recreate, gone on `--purge`.
Single-line text file holding a claude model name. The harness only
reads it, at boot in `Bus::new` and again when the swarm clears the
model it set. It holds the model the agent runs when neither the swarm nor
`HIVE_DEFAULT_MODEL` (agent.nix) names one. Nothing in the harness
writes it. Path overridable via `HYPERHIVE_MODEL_FILE`. Survives
destroy/recreate, gone on `--purge`.
### `/harness/paused` (per agent)
@ -318,7 +318,8 @@ Under `/var/lib/hyperhive/agents/<name>/`:
tables above.
- `hyperhive-events.sqlite` — turn-loop event log.
- `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
and other large working data. Bind-mounted RW to
`/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
`continue` calls may name, read on each call
([`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
nothing for the agent), and then the model and effort the agent reports
running on, each with where it comes from.
- `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
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
@ -47,11 +49,15 @@ Each running value names where it comes from:
| ------------ | -------------------------------------------------------------------------- |
| `swarm` | the value set here |
| `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`) |
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.
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
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`
or `/effort` pick in its web UI or from its agent.nix. Setting a swarm
value doesn't overwrite it.
- **Its own value is the one it runs without the swarm**: its agent.nix or a
`hyperhive-model` / `hyperhive-effort` file. Setting a swarm value doesn't
overwrite it.
- **Clearing a field** (or the whole key) puts the agent back on its own
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
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
as `<provider>/<model>` on `opencode`.
The web UI's model picker still offers `availableModels`.
## 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.
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>`

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`
from the `prompts/claude-settings.json` asset. `effortLevel` is
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.
The harness reads `<name>` from `Bus::model()` on each turn.
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to `"haiku"` if unset). The operator can flip it at runtime
with `/model <name>` in the web terminal — the next turn picks it
up. The harness persists the choice to `/harness/hyperhive-model` so it
survives restart; override path: `HYPERHIVE_MODEL_FILE` env var
for tests. A model or effort the swarm sets for the agent replaces the
`Bus` value, and the harness doesn't persist it; clearing it goes back to
the persisted or nix value → [`swarm/inference.md`](../swarm/inference.md).
back to an existing `/harness/hyperhive-model` file, then `"haiku"`, if
unset). The swarm changes the model at runtime, not the web
terminal: a model or effort the swarm sets for the agent replaces the
`Bus` value from the next turn, and the harness doesn't persist it;
clearing it goes back to the local value →
[`swarm/inference.md`](../swarm/inference.md).
`harness_state::context_window_tokens(model)` looks up
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`.
- **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix.
- **Model badge** (`model · <name> ▾`): a real picker — selecting it opens
a `Dropdown` of `state.available_models`, selecting one POSTs
`/api/model` immediately (same endpoint the `/model <name>` slash
command uses). A header badge: the control and its display sit
together.
- **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`,
shown when `state.available_efforts` is non-empty.
- On an ACP agent both pickers list what its session offers (its
`model` and `thought_level` config options), and are empty until the
first turn has attached a session. The effort picker is hidden when the
model offers no effort levels, and while a newly picked model waits for
the next turn, since the levels come with the model.
- **Model and effort badges** (`model · <name>`, `effort · <level>`):
display only. Each tooltip says where the value comes from
(`state.model_source` / `state.effort_source`: set on the swarm,
agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or
the built-in default); the model's also says what the last turn
resolved it to. Both are set on the swarm: the swarm UI's agents page,
or `swarmctl agent inference set`
([`inference.md`](../swarm/inference.md)).
- On an ACP agent each badge shows the session's own value when it
doesn't offer the requested one. The effort badge is hidden
(`state.effort` is `null`) until the first turn has attached a
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,
tooltip shows % of context window) / `cost · 1.3M` (cumulative
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
`DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for
both. The dashboard link lives in this 🔗 popover; the model/effort
pickers are real badges above; and `/new-session` / `/logout` are
both. The dashboard link lives in this 🔗 popover, and
`/new-session` / `/logout` are
typed slash commands with their own type-twice confirm (see
below).
- `/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
the live panel. Works mid-turn, not only when idle. Either way the live
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).
Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely.
@ -280,17 +274,6 @@ shaped).
turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits
`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
drop `--continue`. Emits a `LiveEvent::Note`.
- `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
`status === 'needs_login_in_progress'`. Includes `turn_state`,
`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
subsequent state updates arrive via SSE.
- `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
alive-badge. The harness sets `rate_limited` when it detects a
429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip.
- `effort_changed { effort }` — drives the effort picker chip.
Emitted from `Bus::set_effort` on `POST /api/effort`; applies on
the next session start.
- `model_changed { model }` — drives the model chip. The harness emits it
when it applies or clears a swarm value.
- `effort_changed { effort }` — drives the effort chip. The harness
emits it when it applies or clears a swarm value.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from
`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 { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from "./lib/pauseAction.js";
import { postModel, postEffort } from "./lib/modelEffort.js";
import {
postCancelTurn,
postLogout,
@ -220,11 +219,6 @@ export function Root() {
statusLabel="… connecting"
statusTone="neutral"
model=""
availableModels={[]}
onSelectModel={() => {}}
effort=""
availableEfforts={[]}
onSelectEffort={() => {}}
paused={false}
onTogglePause={() => {}}
thinking={false}
@ -308,15 +302,9 @@ export function Root() {
statusTooltip={primaryStatus.tooltip}
model={state.model}
resolvedModel={state.resolved_model ?? undefined}
availableModels={state.available_models}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
modelSource={state.model_source}
effort={state.effort}
availableEfforts={state.available_efforts}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
effortSource={state.effort_source}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}

View file

@ -13,8 +13,8 @@
/* mara, live, on a screenshot of this row: "badges and interactive
elements on same row have inconsistent size." Real cause: Badge.css's
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
(model/effort pickers, pause) while a plain display `<span>` badge
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
(pause) while a plain display `<span>` badge (model, effort,
alive, state, ctx, cost — no `onClick`) stays compact, so the row
reads as two different pill sizes even though every badge here is
the same `<Badge>` component. Scoped to this row rather than
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
// 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
// (the old page's model/effort *pickers* lived in the `⋯` overflow menu
// while the current model/effort only showed as a disconnected chip) —
@ -13,6 +14,7 @@
import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { InferenceSource } from "../types.js";
import "./StatusChips.css";
export interface StatusChipsProps {
@ -25,11 +27,11 @@ export interface StatusChipsProps {
* operator can tell what an alias resolved to. `undefined` before any
* turn has completed. */
resolvedModel?: string;
availableModels: string[];
onSelectModel: (name: string) => void;
effort: string;
availableEfforts: string[];
onSelectEffort: (level: string) => void;
/** Where `model` comes from (`/api/state` `model_source`). */
modelSource?: InferenceSource;
/** Hidden when empty: an ACP session that offers no effort now. */
effort?: string | null;
effortSource?: InferenceSource;
ctxLabel?: string;
costLabel?: string;
paused: boolean;
@ -50,66 +52,12 @@ export interface StatusChipsProps {
onLogout: () => Promise<void>;
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: "fast",
sonnet: "balanced",
opus: "powerful",
const SOURCE_LABEL: Record<InferenceSource, string> = {
swarm: "set on the swarm",
agent_nix: "from agent.nix",
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
// have separate alive badge? ... it could be one badge that shows
// thinking / idle / paused / ... with a dropdown that opens when
@ -235,11 +183,9 @@ export function StatusChips({
statusTooltip,
model,
resolvedModel,
availableModels,
onSelectModel,
modelSource,
effort,
availableEfforts,
onSelectEffort,
effortSource,
ctxLabel,
costLabel,
paused,
@ -263,21 +209,23 @@ export function StatusChips({
onNewSession={onNewSession}
onLogout={onLogout}
/>
<Picker
<Badge
label="model"
value={model}
options={availableModels}
descriptions={MODEL_DESCRIPTIONS}
onSelect={onSelectModel}
title={resolvedModel ? `resolved to ${resolvedModel}` : undefined}
title={
[
modelSource ? SOURCE_LABEL[modelSource] : null,
resolvedModel ? `resolved to ${resolvedModel}` : null,
]
.filter(Boolean)
.join(" · ") || undefined
}
/>
{availableEfforts.length ? (
<Picker
{effort ? (
<Badge
label="effort"
value={effort}
options={availableEfforts}
descriptions={EFFORT_DESCRIPTIONS}
onSelect={onSelectEffort}
title={effortSource ? SOURCE_LABEL[effortSource] : undefined}
/>
) : null}
{ctxLabel ? (

View file

@ -11,7 +11,6 @@
// (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 { postModel, postEffort } from "../lib/modelEffort.js";
import {
postCancelTurn,
postCompact,
@ -32,14 +31,6 @@ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ 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: "/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",
desc: "fresh claude session next turn (type twice to confirm)",
@ -84,8 +75,7 @@ export function TermInput({
function handleSlash(line: string): boolean {
const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(" ");
const [cmd] = trimmed.split(/\s+/);
const wasArmed = armedRef.current === cmd;
armedRef.current = null;
@ -131,26 +121,6 @@ export function TermInput({
);
}
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:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
return true;

View file

@ -14,7 +14,7 @@ const RETRY_MS = 5000;
export interface UseAgentStateResult {
state: AgentState | 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;
}

View file

@ -2,7 +2,7 @@
// (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 `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
// check costs nothing and keeps every same-origin POST in this package
// 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
// 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
// doesn't need to read it, only trigger a state refresh afterward (same
// shape as `postModel`/`postEffort` in modelEffort.ts). `credentials:
// doesn't need to read it, only trigger a state refresh afterward. `credentials:
// 'include'` matches the cookie-sending behavior of the plain <form>
// submit this replaces.
//

View file

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

View file

@ -4,6 +4,9 @@
// today, and the frontend only ever reads a handful of these fields.
// 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 {
seq: number;
label: string;
@ -24,9 +27,9 @@ export interface AgentState {
cost_usage: TokenUsage | null;
hive_name: string | null;
swarm_name: string | null;
available_models: string[];
effort: string;
available_efforts: string[];
model_source: InferenceSource;
effort: string | null;
effort_source: InferenceSource;
paused: boolean;
inbox: InboxRow[];
links: AgentLink[];

View file

@ -2,8 +2,7 @@
// the `Badge` that opened it (../badge/Badge.tsx) so the control is
// visually attached to the thing it affects, instead of living three
// clicks away in a catch-all menu (the junk-drawer pattern
// `docs/web-ui/design-guide.md` calls out by name against the old
// per-agent terminal's model picker). Not a native `<dialog>` — a
// `docs/web-ui/design-guide.md` calls out by name). Not a native `<dialog>` — a
// `showModal()` dialog's backdrop would visually detach it from its
// 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

View file

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

View file

@ -8,7 +8,7 @@
// explicit — a dropdown just lets "up" be picked directly regardless
// 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").
// Presentational only, same split as `StatusChips`'s `Picker`/
// Presentational only, same split as `StatusChips`'s
// `StatusMenu`: the caller owns what each selection actually does.
//
// "paused" has no separate resume option here — selecting "up" from a

View file

@ -57,6 +57,7 @@ tracing.workspace = true
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }
tower = { version = "0.5", features = ["util"] }
# Single harness serve-loop binary: `hive-agent` (from `src/main.rs`).
# The sibling MCP server is its own bin crate now (`hive-agent-mcp`).

View file

@ -21,7 +21,7 @@ use tokio::sync::broadcast;
use crate::harness_state::{
configured_effort, configured_model, context_window_tokens, local_effort, local_model,
persist_effort, persist_model, read_harness_state, write_harness_state,
read_harness_state, write_harness_state,
};
const CHANNEL_CAPACITY: usize = 256;
@ -115,13 +115,12 @@ pub enum LiveEvent {
/// during the short-lived in-progress window — the client
/// re-fetches only while that flow is active.
StatusChanged { status: String },
/// `/api/model` switched the active claude model. The web UI
/// updates the chip + the per-turn stats sink will key off this
/// The active claude model changed (a swarm value applied or cleared).
/// The web UI updates the chip + the per-turn stats sink will key off this
/// to mark the boundary in its log.
ModelChanged { model: String },
/// `/api/effort` switched the active claude effort level. Applies on
/// the next session start; the web UI updates the picker chip to
/// reflect it.
/// The active claude effort level changed (a swarm value applied or
/// cleared). The web UI updates the chip.
EffortChanged { effort: String },
/// Token usage for the turn just ended. Carries two snapshots:
/// - `ctx` is the LAST inference's usage block (the actual context
@ -282,8 +281,8 @@ pub struct Bus {
store: Option<Arc<EventStore>>,
/// Current turn-loop state + since-when (unix seconds).
state: Arc<Mutex<(TurnState, i64)>>,
/// Model name passed to `claude --model`. Default `haiku`; the
/// operator can override at runtime via `POST /api/model`.
/// Model name passed to `claude --model`. Default `haiku`; a value the
/// swarm sets replaces it at runtime (`crate::swarm_inference`).
model: Arc<Mutex<String>>,
effort: Arc<Mutex<String>>,
/// Where `model` and `effort` come from, as the swarm is told in this
@ -341,8 +340,7 @@ pub struct Bus {
turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>,
/// The model and effort the session offers, for a runtime that reads them
/// from its session (ACP). Set once by the serve loop when it builds the
/// session; unset on claude, whose pickers list the configured models and
/// `EFFORT_LEVELS`.
/// session; unset on claude.
session_choices: Arc<OnceLock<hive_runtime::Choices>>,
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
/// bin loop after minting a session row on a fresh start; stamped onto
@ -407,7 +405,7 @@ impl Bus {
// Write the resolved active model to hyperhive-harness.json on
// startup so hive-c0re can surface the model badge without reading
// hyperhive-model directly. Written once here; updated on every
// set_model() call (runtime override) and emit_status() call.
// apply_model() call and emit_status() call.
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
Self {
tx: Arc::new(tx),
@ -562,7 +560,7 @@ impl Bus {
}
/// Currently-selected claude model name. Read on every turn so a
/// `/model <name>` flip takes effect on the next turn.
/// swarm change takes effect on the next turn.
///
/// # Panics
///
@ -572,27 +570,10 @@ impl Bus {
self.model.lock().unwrap().clone()
}
/// Switch the model for future turns. The current turn (if any)
/// keeps the model it was already running. Persisted to the agent's
/// state dir (`hyperhive-model`) so the override survives harness
/// restart and container rebuild (gone on `--purge`, matching
/// every other piece of agent state).
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_model(&self, name: impl Into<String>) {
let value: String = name.into();
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
self.apply_model(value, Source::LocalFile);
}
/// Switch the model for future turns without persisting it, so
/// `hyperhive-model` keeps the agent's local value. For a value the
/// swarm sets (`crate::swarm_inference`). Emits `ModelChanged` even when
/// only `source` changed.
/// Switch the model for future turns. Never writes `hyperhive-model`, so
/// clearing the swarm value finds the agent's local value again. For a
/// value the swarm sets (`crate::swarm_inference`). Emits `ModelChanged`
/// even when only `source` changed.
///
/// # Panics
///
@ -619,26 +600,9 @@ impl Bus {
self.effort.lock().unwrap().clone()
}
/// Switch the effort level for future sessions. Applies on the next
/// claude launch (no mid-session swap). Persisted to the agent's
/// state dir (`hyperhive-effort`) so the override survives harness
/// restart and container rebuild (gone on `--purge`). Callers must
/// pre-validate with [`crate::harness_state::is_valid_effort`].
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_effort(&self, level: impl Into<String>) {
let value: String = level.into();
if let Err(e) = persist_effort(&value) {
tracing::warn!(error = ?e, "effort: persist failed");
}
self.apply_effort(value, Source::LocalFile);
}
/// Switch the effort level without persisting it, so `hyperhive-effort`
/// keeps the agent's local value. For a value the swarm sets
/// (`crate::swarm_inference`). Emits `EffortChanged` even when only
/// Switch the effort level for future turns. Never writes
/// `hyperhive-effort`, so clearing the swarm value finds the agent's local
/// value again. For a value the swarm sets (`crate::swarm_inference`). Emits `EffortChanged` even when only
/// `source` changed.
///
/// # Panics

View file

@ -8,8 +8,9 @@ use std::path::PathBuf;
use swarm_queue_client::agent_inference::Source;
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
/// for dev / tests; otherwise derived from the agent's harness dir.
/// Path to the `hyperhive-model` file. The harness only reads it, as the
/// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for
/// dev / tests; otherwise derived from the agent's harness dir.
fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-model"),
@ -27,17 +28,9 @@ pub(crate) fn load_model() -> Option<String> {
}
}
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
/// derived from the agent's harness dir.
/// Path to the `hyperhive-effort` file. The harness only reads it, as the
/// fallback in [`local_effort`]. Overridable via `HYPERHIVE_EFFORT_FILE` for
/// dev / tests; otherwise derived from the agent's harness dir.
fn effort_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-effort"),
@ -57,8 +50,8 @@ pub(crate) fn load_effort() -> Option<String> {
/// The model this agent runs when the swarm sets none, and where it comes
/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime
/// override > [`DEFAULT_MODEL`].
/// `services.hyperhive.agent.model` in agent.nix) > an existing
/// `hyperhive-model` file > [`DEFAULT_MODEL`].
pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
model_from(configured, load_model())
}
@ -72,10 +65,9 @@ fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source
}
/// The effort this agent runs at when the swarm sets none, and where it
/// comes from. The inverse of [`local_model`]: a persisted operator pick
/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice
/// survives a harness restart (it only resets on `--purge`); nix is the
/// default for a never-touched agent.
/// comes from. The inverse of [`local_model`]: an existing
/// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`);
/// nix is the default for an agent without one.
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
effort_from(configured, load_effort())
}
@ -88,14 +80,6 @@ fn effort_from(configured: Option<&str>, file: Option<String>) -> (String, Sourc
}
}
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
let path = effort_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{level}\n"))
}
// ---------------------------------------------------------------------------
// Consolidated harness state file
// ---------------------------------------------------------------------------
@ -220,13 +204,13 @@ pub(crate) fn write_api_key_mode(enabled: bool) {
}
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
/// persisted runtime override is present.
/// `hyperhive-model` file is present.
pub const DEFAULT_MODEL: &str = "haiku";
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
/// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent /
/// empty. When `Some`, this takes precedence over any persisted runtime
/// override so that nix config changes always take effect on rebuild.
/// empty. When `Some`, this takes precedence over a `hyperhive-model` file
/// so that nix config changes always take effect on rebuild.
#[must_use]
pub fn configured_model() -> Option<&'static str> {
// Leak once at startup — acceptable for a single config value.
@ -240,22 +224,11 @@ pub fn configured_model() -> Option<&'static str> {
/// into `prompts/claude-settings.json`.
pub const DEFAULT_EFFORT: &str = "medium";
/// Valid claude `--effort` levels, ascending. The operator picker is
/// constrained to these; [`is_valid_effort`] guards the persist path.
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// True iff `level` is one of [`EFFORT_LEVELS`].
#[must_use]
pub fn is_valid_effort(level: &str) -> bool {
EFFORT_LEVELS.contains(&level)
}
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
/// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty.
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
/// runtime override takes precedence over this baseline (see `Bus::new`):
/// the operator's effort pick sticks across harness restart, with the nix
/// value only the default when no override was ever set.
/// Mirrors [`configured_model`]'s env shape. Unlike model, a
/// `hyperhive-effort` file takes precedence over this baseline (see
/// [`local_effort`]).
#[must_use]
pub fn configured_effort() -> Option<&'static str> {
std::env::var("HIVE_DEFAULT_EFFORT")
@ -305,28 +278,11 @@ pub fn context_window_tokens(model: &str) -> u64 {
#[cfg(test)]
mod tests {
use super::{
DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort,
model_from,
};
use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from};
#[test]
fn effort_validation_accepts_only_known_levels() {
for level in EFFORT_LEVELS {
assert!(is_valid_effort(level), "{level} should be valid");
}
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
// picker POST never reaches `claude --effort`.
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
}
}
#[test]
fn compiled_default_effort_is_selectable() {
// The fallback must itself be a valid level, and match the value
// baked into prompts/claude-settings.json.
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
fn compiled_default_effort_matches_claude_settings() {
// The value baked into prompts/claude-settings.json.
assert_eq!(DEFAULT_EFFORT, "medium");
}

View file

@ -323,8 +323,9 @@ mod tests {
let (swarm, swarm_sources, files, cleared, cleared_sources) =
crate::paths::with_harness_dir(|| {
let bus = Bus::new();
bus.set_model("sonnet");
bus.set_effort("low");
let dir = crate::paths::harness_dir();
std::fs::write(dir.join("hyperhive-model"), "sonnet\n").unwrap();
std::fs::write(dir.join("hyperhive-effort"), "low\n").unwrap();
let target = Harness {
bus: bus.clone(),
nix_model: None,

View file

@ -1,5 +1,5 @@
//! Operator action POST handlers (send, cancel, compact, model, effort,
//! reset, todos mark-done).
//! Operator action POST handlers (send, cancel, compact, reset, todos
//! mark-done).
use axum::{
Form,
@ -127,103 +127,6 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct ModelForm {
model: String,
}
/// Switch the model for future turns. The current turn (if any)
/// keeps its model; `/model <name>` applies starting with the next
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
/// claude-side validation — we just hand the string through to
/// `claude --model <name>`; an unknown model surfaces as a turn
/// failure in the live panel and the operator can revert. On ACP the name
/// must be one the session offers, since the runtime sets no other.
pub(super) async fn post_set_model(
State(state): State<AppState>,
Form(form): Form<ModelForm>,
) -> Response {
let name = form.model.trim();
if name.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "model: name required");
}
let text = if state.bus.session_choices().is_some() {
let offered = super::state::pickers(&state.bus).available_models;
if let Some(response) = accept_or_refuse("model", name, &offered) {
return response;
}
format!("operator: /model — model set to '{name}' from the next turn")
} else {
format!("operator: /model — claude model set to '{name}' for future turns")
};
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note { text });
tracing::info!(%name, "operator set model");
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct EffortForm {
effort: String,
}
/// Switch the claude effort level for future sessions. Operator-only
/// (the dashboard picker POSTs here through the gateway). Validated
/// server-side against [`crate::harness_state::EFFORT_LEVELS`] — an out-of-set
/// value is rejected rather than handed to `claude --effort`, since an
/// unknown level would fail every subsequent launch. Applies on the next
/// session start (no mid-session swap). On ACP the level must instead be one
/// the session offers for its model, and applies from the next turn.
pub(super) async fn post_set_effort(
State(state): State<AppState>,
Form(form): Form<EffortForm>,
) -> Response {
let level = form.effort.trim();
let text = if state.bus.session_choices().is_some() {
let offered = super::state::pickers(&state.bus).available_efforts;
if let Some(response) = accept_or_refuse("effort", level, &offered) {
return response;
}
format!("operator: /effort — effort set to '{level}' from the next turn")
} else if crate::harness_state::is_valid_effort(level) {
format!("operator: /effort — claude effort set to '{level}' for future sessions")
} else {
return error_response(
StatusCode::BAD_REQUEST,
&format!(
"effort: level must be one of {}",
crate::harness_state::EFFORT_LEVELS.join(", ")
),
);
};
state.bus.set_effort(level);
state.bus.emit(crate::events::LiveEvent::Note { text });
tracing::info!(%level, "operator set effort");
(axum::http::StatusCode::OK, "ok").into_response()
}
/// The ACP branch of `post_set_model`/`post_set_effort`: `None` when `name`
/// is one `offered` (the session's current pickers list) names, else the
/// [`not_offered`] refusal to return. Before a session has attached,
/// `offered` is empty and every name is refused.
fn accept_or_refuse(what: &str, name: &str, offered: &[String]) -> Option<Response> {
if offered.iter().any(|o| o == name) {
None
} else {
Some(not_offered(what, offered))
}
}
/// Refuses a pick the ACP session does not offer.
fn not_offered(what: &str, offered: &[String]) -> Response {
let message = if offered.is_empty() {
format!("{what}: the agent's session offers no choice now")
} else {
format!("{what}: the agent's session offers {}", offered.join(", "))
};
error_response(StatusCode::BAD_REQUEST, &message)
}
#[derive(Deserialize)]
pub(super) struct MarkTodosDoneForm {
/// Comma-separated todo ids. Same "one field, JS joins the checked
@ -317,7 +220,7 @@ struct MarkTodosDoneBody {
#[cfg(test)]
mod tests {
use super::{accept_or_refuse, mark_ids_done, settle_mark_done};
use super::{mark_ids_done, settle_mark_done};
async fn body_text(response: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
@ -393,27 +296,4 @@ mod tests {
];
assert_eq!(settle_mark_done(answers).unwrap(), 2);
}
#[test]
fn an_offered_value_is_accepted() {
let offered = vec!["m/think".to_owned(), "m/plain".to_owned()];
assert!(accept_or_refuse("model", "m/plain", &offered).is_none());
}
#[test]
fn a_value_the_session_does_not_offer_is_refused_with_400() {
let offered = vec!["m/think".to_owned()];
let response = accept_or_refuse("model", "m/plain", &offered).unwrap();
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[test]
fn nothing_offered_yet_refuses_with_400() {
// Before a session has attached (or on the effort picker while a
// model switch is pending), `pickers()` returns an empty list —
// every name is refused, not just ones the session once offered
// and later dropped.
let response = accept_or_refuse("effort", "high", &[]).unwrap();
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
}

View file

@ -67,6 +67,28 @@ struct AppState {
interrupted: Arc<AtomicBool>,
}
/// The API routes, without the extra proxies and the static fallback.
fn routes() -> Router<AppState> {
Router::new()
.route("/api/state", get(state::api_state))
.route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history))
.route("/send", post(actions::post_send))
.route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact))
.route("/api/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos))
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon))
}
/// Bind the per-container web listener and serve the SPA.
///
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
@ -110,26 +132,7 @@ pub async fn serve(
gui_vnc_port,
interrupted,
};
let app: Router<AppState> = Router::new()
.route("/api/state", get(state::api_state))
.route("/api/dashboard-state", get(state::api_dashboard_state))
.route("/events/stream", get(stream::events_stream))
.route("/events/history", get(stream::events_history))
.route("/send", post(actions::post_send))
.route("/login/start", post(auth::post_login_start))
.route("/login/code", post(auth::post_login_code))
.route("/login/cancel", post(auth::post_login_cancel))
.route("/api/cancel", post(actions::post_cancel_turn))
.route("/api/compact", post(actions::post_compact))
.route("/api/model", post(actions::post_set_model))
.route("/api/effort", post(actions::post_set_effort))
.route("/api/new-session", post(actions::post_new_session))
.route("/api/logout", post(auth::post_logout))
.route("/api/todos", get(stats::api_todos))
.route("/api/todos/mark-done", post(actions::post_mark_todos_done))
.route("/api/stats", get(stats::api_stats))
.route("/screen/ws", get(screen::screen_ws))
.route("/icon", get(screen::serve_icon));
let app = routes();
// Mount any `services.hyperhive.agent.extraWebProxies` under `/extra/<name>/` before the
// static fallback so declared proxies win over `ServeDir`.
let app = proxy::mount_extra_proxies(app)
@ -444,9 +447,60 @@ mod tests {
use std::os::unix::process::CommandExt as _;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::{SigintOutcome, sigint_turn};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::ServiceExt;
use super::{AppState, SigintOutcome, routes, sigint_turn};
use crate::events::Bus;
use crate::login::LoginState;
fn form(path: &str, body: &'static str) -> Request<Body> {
Request::post(path)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
/// The swarm sets the model and effort, so the agent serves no route for
/// either.
#[test]
fn neither_model_nor_effort_has_a_route() {
let (model, effort, compact) = crate::paths::with_harness_dir(|| {
let state = AppState {
label: "test".to_owned(),
login: Arc::new(Mutex::new(LoginState::Online)),
session: Arc::new(Mutex::new(None)),
bus: Bus::new(),
socket: crate::paths::harness_dir().join("mcp.sock"),
gui_vnc_port: None,
interrupted: Arc::new(AtomicBool::new(false)),
};
let app = routes().with_state(state);
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let model = app.clone().oneshot(form("/api/model", "model=opus"));
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
// A route the router does serve, so the 404s above are not
// the router refusing everything.
let compact = app.oneshot(form("/api/compact", ""));
(
model.await.unwrap(),
effort.await.unwrap(),
compact.await.unwrap(),
)
})
});
assert_eq!(model.status(), StatusCode::NOT_FOUND);
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
assert_eq!(compact.status(), StatusCode::OK);
}
/// Poll `path` until it holds a complete, newline-terminated pid.
fn read_pid(path: &Path) -> i32 {

View file

@ -1,9 +1,8 @@
//! `/api/state` + `/api/dashboard-state` snapshot builders.
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::State;
use serde::Serialize;
use swarm_queue_client::agent_inference::Source;
use crate::login::LoginState;
use crate::login_session::drop_if_finished;
@ -48,7 +47,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
let context_window_tokens = state.bus.effective_context_window(&model);
let ctx_usage = state.bus.last_ctx_usage();
let cost_usage = state.bus.last_cost_usage();
let pickers = pickers(&state.bus);
let shown = shown(&state.bus);
axum::Json(StateSnapshot {
seq,
label: state.label.clone(),
@ -59,7 +58,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
inbox,
turn_state,
turn_state_since,
model: pickers.model,
model: shown.model,
model_source: state.bus.model_source(),
resolved_model,
context_window_tokens,
ctx_usage,
@ -70,9 +70,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
.filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(),
available_models: pickers.available_models,
effort: pickers.effort,
available_efforts: pickers.available_efforts,
effort: shown.effort,
effort_source: state.bus.effort_source(),
paused: crate::paths::paused_marker().exists(),
})
}
@ -138,10 +137,11 @@ pub(super) struct StateSnapshot {
/// client-side off this rather than tracking it from SSE events.
turn_state: crate::events::TurnState,
turn_state_since: i64,
/// Currently-active claude model name. Reflected on the page so
/// the operator can see what they just switched to (and what's
/// in flight). Mutable at runtime via `POST /api/model`.
/// Currently-active claude model name, shown on the page. The swarm
/// sets it (`crate::swarm_inference`); the page cannot.
model: String,
/// Where `model` comes from.
model_source: Source,
/// The concrete model id the most recent completed turn actually
/// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that
/// turn's assistant events — `model` above is the alias the
@ -182,27 +182,12 @@ pub(super) struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<String>,
/// Ordered list of model short-names the operator has declared as
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
/// (comma-separated, set by `services.hyperhive.availableModels`).
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
/// absent or empty. The frontend model quick-picker renders one button
/// per entry in this list, so operators can add new models or drop
/// ones they don't want without touching the frontend code.
/// On ACP, the models the session offers, narrowed to this same list
/// when at least one entry matches one it offers (see [`pickers`]).
available_models: Vec<String>,
/// Currently-active claude effort level. Reflected on the page so the
/// operator's effort picker shows the live selection. Mutable at
/// runtime via `POST /api/effort`; applies on the next session.
effort: String,
/// Selectable effort levels for the picker, ascending. Fixed set
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like
/// `available_models`. The frontend renders one button per entry.
/// On ACP, the levels the session offers instead, empty when it offers
/// none, which hides the picker (see [`pickers`]).
available_efforts: Vec<String>,
/// Currently-active claude effort level, shown on the page; `None` per
/// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page
/// cannot.
effort: Option<String>,
/// Where `effort` comes from.
effort_source: Source,
/// Whether this agent's turn loop is currently parked (the harness
/// keeps serving this page + its MCP daemons but drives no turns).
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
@ -426,133 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
}
}
/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by
/// `services.hyperhive.agent.availableModels`) into its entries. Empty
/// (absent env, or nothing left after trimming) means different things to
/// the two callers below, so neither the empty check nor a fallback lives
/// here.
fn parse_available_models_env() -> Vec<String> {
std::env::var("HIVE_AVAILABLE_MODELS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// The claude picker's model list. Falls back to `["haiku", "sonnet",
/// "opus"]` when the env var is absent or resolves to an empty list after
/// trimming.
fn available_models() -> Vec<String> {
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
let models = parse_available_models_env();
if models.is_empty() {
DEFAULT.iter().map(ToString::to_string).collect()
} else {
models
}
}
/// The operator's configured model list, for filtering the ACP picker.
/// `None` when the env is absent or empty, so [`filter_offered_models`] can
/// tell "nothing configured" apart from a real (if unmatched) list — unlike
/// [`available_models`], which folds both into its claude-only fallback.
fn configured_models() -> Option<Vec<String>> {
let models = parse_available_models_env();
(!models.is_empty()).then_some(models)
}
/// Set the first time [`filter_offered_models`] logs its "no match" warning.
/// `pickers()` runs on every `/api/state` poll (every 4s per open dashboard
/// tab) and on every `post_set_model`/`post_set_effort` call, and all of them
/// funnel through the one [`filter_offered_models`] call site in
/// [`offered_pickers`], so one flag guards every caller.
static MODEL_MISMATCH_WARNED: AtomicBool = AtomicBool::new(false);
/// Keep only the entries of `offered` (an ACP session's own model list, in
/// its own order) that `configured` names. `configured: None` (the env is
/// unset) or a `configured` list matching none of `offered` both leave
/// `offered` unfiltered — the latter is what an ACP agent that has never
/// set `services.hyperhive.agent.availableModels` hits, since the option's
/// default is the claude names, so it must show every model the session
/// offers rather than an empty picker. `warned` gates the mismatch warning
/// to once per process; tests pass their own instead of [`MODEL_MISMATCH_WARNED`]
/// so they don't observe each other's state.
fn filter_offered_models(
offered: Vec<String>,
configured: Option<&[String]>,
warned: &AtomicBool,
) -> Vec<String> {
let Some(configured) = configured else {
return offered;
};
let matching: Vec<String> = offered
.iter()
.filter(|model| configured.iter().any(|c| c == *model))
.cloned()
.collect();
if matching.is_empty() {
if !warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
configured = configured.join(", "),
"HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \
showing every model it offers instead"
);
}
offered
} else {
matching
}
}
/// The model and effort pickers: the value each shows and the values it
/// offers.
/// The model and effort the page shows. No effort on an ACP agent whose
/// session offers no effort level now.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Pickers {
pub(super) struct Shown {
pub(super) model: String,
pub(super) available_models: Vec<String>,
pub(super) effort: String,
pub(super) available_efforts: Vec<String>,
pub(super) effort: Option<String>,
}
/// On claude, the requested model and effort, the configured models and
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers
/// (see [`offered_pickers`]).
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
/// On claude, the requested model and effort. On ACP, see [`offered_shown`].
pub(super) fn shown(bus: &crate::events::Bus) -> Shown {
let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() {
Some(choices) => {
offered_pickers(model, effort, choices.get(), configured_models().as_deref())
}
None => Pickers {
Some(choices) => offered_shown(model, effort, choices.get()),
None => Shown {
model,
available_models: available_models(),
effort,
available_efforts: crate::harness_state::EFFORT_LEVELS
.iter()
.map(ToString::to_string)
.collect(),
effort: Some(effort),
},
}
}
/// Pickers for what an ACP session offers. The runtime sets a requested value
/// on the next turn, and only if the session offers it, so each picker shows
/// the requested value when offered and not refused by the agent, and the
/// session's own otherwise. The effort levels on offer come with the model,
/// so while a new model waits for the next turn the effort picker is empty. `configured` narrows the
/// model list per [`filter_offered_models`]; it never narrows which value is
/// shown as current, only which values the picker offers alongside it.
fn offered_pickers(
model: String,
effort: String,
offered: hive_runtime::SessionChoices,
configured: Option<&[String]>,
) -> Pickers {
/// What an ACP agent shows. The runtime sets a requested value on the next
/// turn, and only if the session offers it, so the model and the effort each
/// show the requested value when offered and not refused by the agent, and
/// the session's own otherwise. The effort levels on offer come with the
/// model, so while a new model waits for the next turn no effort shows.
fn offered_shown(model: String, effort: String, offered: hive_runtime::SessionChoices) -> Shown {
let settable = |wanted: &String, c: &hive_runtime::Choice| {
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
};
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
let pick = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
Some(c) if !settable(&wanted, c) => c.current.clone(),
_ => wanted,
};
@ -561,24 +449,17 @@ fn offered_pickers(
.as_ref()
.is_some_and(|c| c.current != model && settable(&model, c));
let effort_choice = offered.effort.filter(|_| !model_pending);
Pickers {
model: shown(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()),
available_models: offered
.model
.map(|c| filter_offered_models(c.values, configured, &MODEL_MISMATCH_WARNED))
.unwrap_or_default(),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
Shown {
model: pick(model, offered.model.as_ref()),
effort: effort_choice.map(|c| pick(effort, Some(&c))),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, filter_offered_models, offered_pickers};
use super::{Shown, offered_shown};
fn choice(current: &str, values: &[&str]) -> Choice {
Choice {
@ -588,52 +469,52 @@ mod tests {
}
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_owned()).collect()
fn shown(model: &str, effort: Option<&str>) -> Shown {
Shown {
model: model.to_owned(),
effort: effort.map(str::to_owned),
}
}
#[test]
fn acp_pickers_list_what_the_session_offers() {
fn acp_shows_the_requested_values_the_session_offers() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
// `haiku` and `max` are not on offer: the session's own values show.
assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone(), None),
Pickers {
model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "low".into(),
available_efforts: strings(&["low", "high"]),
}
offered_shown("haiku".into(), "max".into(), offered.clone()),
shown("m/think", Some("low"))
);
assert_eq!(
offered_shown("m/think".into(), "high".into(), offered),
shown("m/think", Some("high"))
);
let picked = offered_pickers("m/think".into(), "high".into(), offered, None);
assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
}
#[test]
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() {
let plain = SessionChoices {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None);
assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty());
fn acp_shows_no_effort_without_one_on_offer_or_while_a_model_is_pending() {
let think = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
let pending = offered_pickers("m/plain".into(), "high".into(), think, None);
assert_eq!(pending.model, "m/plain");
assert!(pending.available_efforts.is_empty());
assert_eq!(
offered_shown("m/plain".into(), "max".into(), think),
shown("m/plain", None)
);
let plain = SessionChoices {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
assert_eq!(
offered_shown("m/plain".into(), "high".into(), plain),
shown("m/plain", None)
);
}
#[test]
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
fn acp_shows_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
let refused = SessionChoices {
model: Some(Choice {
refused: Some("m/plain".into()),
@ -642,85 +523,16 @@ mod tests {
effort: Some(choice("low", &["low", "high"])),
};
assert_eq!(
offered_pickers("m/plain".into(), "high".into(), refused, None),
Pickers {
model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
effort: "high".into(),
available_efforts: strings(&["low", "high"]),
}
offered_shown("m/plain".into(), "high".into(), refused),
shown("m/think", Some("high"))
);
}
#[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() {
let none = offered_pickers(
"haiku".into(),
"high".into(),
SessionChoices::default(),
None,
);
assert!(none.available_models.is_empty() && none.available_efforts.is_empty());
}
#[test]
fn unconfigured_model_list_leaves_the_session_offer_untouched() {
let warned = AtomicBool::new(false);
fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() {
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), None, &warned),
strings(&["m/think", "m/plain"])
offered_shown("haiku".into(), "high".into(), SessionChoices::default()),
shown("haiku", None)
);
}
#[test]
fn configured_model_list_keeps_only_the_matches_in_the_session_s_order() {
let configured = strings(&["m/plain", "m/other"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/plain"])
);
}
#[test]
fn configured_model_list_matching_nothing_falls_back_to_every_offered_model() {
// The claude names on an ACP agent that never set `availableModels`.
let configured = strings(&["haiku", "sonnet", "opus"]);
let warned = AtomicBool::new(false);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned),
strings(&["m/think", "m/plain"])
);
assert!(
warned.load(Ordering::Relaxed),
"the mismatch warning should have fired once"
);
}
#[test]
fn a_second_no_match_call_does_not_warn_again() {
// `tracing::warn!` output isn't captured in this crate's tests (no
// subscriber fixture exists), so this pins the guard itself: once
// `warned` is set, a second no-match call leaves it set rather than
// toggling or re-arming, which is what keeps the log line from
// repeating on every `/api/state` poll.
let configured = strings(&["haiku"]);
let warned = AtomicBool::new(false);
filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert!(warned.load(Ordering::Relaxed));
let second = filter_offered_models(strings(&["m/think"]), Some(&configured), &warned);
assert_eq!(second, strings(&["m/think"]));
assert!(warned.load(Ordering::Relaxed));
}
#[test]
fn offered_pickers_applies_the_configured_model_filter() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: None,
};
let configured = strings(&["m/plain"]);
let filtered = offered_pickers("m/think".into(), "high".into(), offered, Some(&configured));
assert_eq!(filtered.available_models, strings(&["m/plain"]));
}
}

View file

@ -383,10 +383,9 @@ fn status_if_running(
/// Read the active Claude model from `hyperhive-harness.json` (the
/// consolidated harness state file in the agent's state dir). Written by
/// the harness on startup and on every `set_model` / `emit_status` call,
/// so it always reflects the resolved priority (nix config > runtime
/// override > default). Returns `None` when the field is absent or the
/// harness has not yet started a turn.
/// the harness on startup and on every `apply_model` / `emit_status` call,
/// so it always reflects the model the agent runs. Returns `None` when the
/// field is absent or the harness has not yet started a turn.
fn read_active_model(name: &hive_types::Ident) -> Option<String> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
let raw = std::fs::read_to_string(path).ok()?;

View file

@ -65,10 +65,9 @@ in
description = ''
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
environment variable; the harness applies it at boot and it takes
priority over any persisted runtime override. The operator can still
switch the model at runtime via the per-agent web UI — that choice
is tracked in the state dir for the current session but is reset by
any rebuild that changes this option.
priority over a `hyperhive-model` file in the harness dir. A model the
swarm sets for the agent (the swarm UI's agents page, or
`swarmctl agent inference set`) wins over this option.
Valid values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
@ -91,22 +90,13 @@ in
"opus"
];
description = ''
Models offered in the per-agent web UI's model quick-picker. Rendered
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
which the harness surfaces to the agent UI, so the picker lists exactly
these models instead of a hardcoded set.
Configure hive-wide by setting a shared default (e.g. in your
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
still set by `services.hyperhive.agent.model` and remains switchable at runtime via the
UI; this option only controls which choices the picker presents.
Models this agent may run. `services.hyperhive.agent.model` must be one
of them, and the subagent daemon refuses a `start` or `continue` on any
other model (rendered into its `HIVE_AVAILABLE_MODELS`) unless the swarm
sets `subagent_models` for the agent. An empty list restricts nothing.
Values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). On an ACP
agent this same list also filters the model picker, matched against
the model ids opencode reports (`<provider-id>/<model-id>`); a list
that matches none of them shows every model the session offers.
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
'';
};
@ -123,15 +113,15 @@ in
description = ''
Baseline claude effort level for this agent. Rendered into the
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
effort as operator-override-file → this env → built-in `"medium"`,
and passes the result to `claude --effort` at turn launch.
effort as an existing `hyperhive-effort` file → this env → built-in
`"medium"`, and passes the result to `claude --effort` at turn launch.
An effort the swarm sets for the agent (the swarm UI's agents page, or
`swarmctl agent inference set`) wins over all of these.
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
(default — balanced), `"high"` (platform default), `"xhigh"`
(recommended for autonomous coding on capable models), `"max"`
(maximum thinking budget, highest cost). The operator can override
at runtime per-agent via the web UI (applied on the next session);
any rebuild that changes this option resets that override.
(maximum thinking budget, highest cost).
'';
};
@ -390,8 +380,8 @@ in
assertion = config.services.hyperhive.agent.model != "";
message = "services.hyperhive.agent.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
}
# The current model must appear in the quick-picker menu, otherwise the
# UI would offer no way back to the model the agent is actually running.
# The current model must be one this agent may run; the subagent daemon
# checks its own picks against the same list.
{
assertion =
config.services.hyperhive.agent.availableModels == [ ]
@ -436,16 +426,12 @@ in
env = lib.mkIf (preset != null) (lib.mkDefault preset.env);
};
# HIVE_DEFAULT_MODEL seeds the initial model selection when no
# persisted model choice exists in the state dir.
# HIVE_DEFAULT_MODEL is the model the agent runs while the swarm sets
# none; it wins over a `hyperhive-model` file.
environment.variables = {
HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model;
# Comma-separated menu for the per-agent UI model quick-picker
# (see services.hyperhive.agent.availableModels). The harness surfaces it to the
# frontend; an empty value falls back to the built-in default list.
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.services.hyperhive.agent.availableModels;
# Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The
# harness resolves operator-override-file → this env → "medium"
# harness resolves a `hyperhive-effort` file → this env → "medium"
# and passes it to claude --effort at turn launch.
HIVE_DEFAULT_EFFORT = config.services.hyperhive.agent.effortLevel;
}

View file

@ -274,7 +274,7 @@ in
# un-overridable by user/project/CLI) — so the harness doesn't pass
# `--settings` or copy the blob per turn. effortLevel is
# deliberately NOT shipped here: effort is controlled live via the
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the swarm's effort setting),
# which managed scope would otherwise override and lock.
# Hive-enforced settings merged with a per-agent `env` block at BUILD
# time via `jq` (not eval-time `readFile`, which would be import-from-

View file

@ -415,13 +415,11 @@ in
HIVE_ACP_API_KEY_ENV = acpApiKeyEnv;
# Same `services.hyperhive.agent.availableModels` the harness's own assertions gate
# the primary session's model against, so a subagent can't be spawned
# on a model the operator didn't make available to this agent. The
# option renders into the *global* environment for the web UI's
# quick-picker, which a systemd unit doesn't inherit, so set it here
# from the option directly. `null` for an empty list, which systemd
# drops: the daemon reads an absent var as "no restriction" (this is
# a safety rail, not a security boundary), matching the harness
# assertion that an empty list waives too.
# on a model the operator didn't make available to this agent. `null`
# for an empty list, which systemd drops: the daemon reads an absent
# var as "no restriction" (this is a safety rail, not a security
# boundary), matching the harness assertion that an empty list waives
# too.
HIVE_AVAILABLE_MODELS =
if config.services.hyperhive.agent.availableModels == [ ] then
null

View file

@ -81,8 +81,8 @@ pub enum Source {
Swarm,
/// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix.
AgentNix,
/// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or
/// `/effort` pick in its web UI.
/// A `hyperhive-model` or `hyperhive-effort` file in its harness dir, which
/// the agent only reads.
LocalFile,
/// The harness's built-in default.
Default,

View file

@ -214,8 +214,8 @@ enum AgentVerb {
/// 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;
/// 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.
Inference {