Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
33 changed files with 812 additions and 315 deletions

1
Cargo.lock generated
View file

@ -1725,7 +1725,6 @@ 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 a claude model name. The harness only Single-line text file holding the claude model name currently
reads it, at boot in `Bus::new` and again when the swarm clears the selected for this agent (default `haiku` when absent). Written by
model it set. It holds the model the agent runs when neither the swarm nor `Bus::set_model` whenever the operator flips it via `/model
`HIVE_DEFAULT_MODEL` (agent.nix) names one. Nothing in the harness <name>` in the web terminal. Read once at harness boot in
writes it. Path overridable via `HYPERHIVE_MODEL_FILE`. Survives `Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
destroy/recreate, gone on `--purge`. Survives destroy/recreate, gone on `--purge`.
### `/harness/paused` (per agent) ### `/harness/paused` (per agent)
@ -318,8 +318,7 @@ 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` / `hyperhive-effort` — single-line model name and - `hyperhive-model` — single-line model name override file.
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,16 +20,14 @@ 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). The swarm UI's agents page edits the same settings; see [UI](ui.md).
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
@ -49,15 +47,11 @@ 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 `hyperhive-model` or `hyperhive-effort` file in the agent's harness dir | | `local_file` | a `/model` or `/effort` pick in the agent's web UI |
| `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
a `hyperhive-model` file, and a `hyperhive-effort` file wins over its its `/model` pick, and its `/effort` pick wins over its agent.nix effort.
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
@ -69,11 +63,13 @@ 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**: its agent.nix or a - **Its own value is the one it runs without the swarm**, from a `/model`
`hyperhive-model` / `hyperhive-effort` file. Setting a swarm value doesn't or `/effort` pick in its web UI or from its agent.nix. Setting a swarm
overwrite it. value doesn't 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,6 +337,8 @@ 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 (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. 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.
**Usage:** `swarmctl agent inference <COMMAND>` **Usage:** `swarmctl agent inference <COMMAND>`

View file

@ -44,18 +44,19 @@ 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 swarm's effort setting) controls effort (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider) 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 an existing `/harness/hyperhive-model` file, then `"haiku"`, if back to `"haiku"` if unset). The operator can flip it at runtime
unset). The swarm changes the model at runtime, not the web with `/model <name>` in the web terminal — the next turn picks it
terminal: a model or effort the swarm sets for the agent replaces the up. The harness persists the choice to `/harness/hyperhive-model` so it
`Bus` value from the next turn, and the harness doesn't persist it; survives restart; override path: `HYPERHIVE_MODEL_FILE` env var
clearing it goes back to the local value → for tests. A model or effort the swarm sets for the agent replaces the
[`swarm/inference.md`](../swarm/inference.md). `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).
`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,20 +47,18 @@ 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 and effort badges** (`model · <name>`, `effort · <level>`): - **Model badge** (`model · <name> ▾`): a real picker — selecting it opens
display only. Each tooltip says where the value comes from a `Dropdown` of `state.available_models`, selecting one POSTs
(`state.model_source` / `state.effort_source`: set on the swarm, `/api/model` immediately (same endpoint the `/model <name>` slash
agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or command uses). A header badge: the control and its display sit
the built-in default); the model's also says what the last turn together.
resolved it to. Both are set on the swarm: the swarm UI's agents page, - **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`,
or `swarmctl agent inference set` shown when `state.available_efforts` is non-empty.
([`inference.md`](../swarm/inference.md)). - On an ACP agent both pickers list what its session offers (its
- On an ACP agent each badge shows the session's own value when it `model` and `thought_level` config options), and are empty until the
doesn't offer the requested one. The effort badge is hidden first turn has attached a session. The effort picker is hidden when the
(`state.effort` is `null`) until the first turn has attached a model offers no effort levels, and while a newly picked model waits for
session, when the model offers no effort levels, and while a newly the next turn, since the levels come with the model.
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).
@ -84,8 +82,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, and both. The dashboard link lives in this 🔗 popover; the model/effort
`/new-session` / `/logout` are pickers are real badges above; and `/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
@ -240,6 +238,14 @@ 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.
@ -274,6 +280,17 @@ 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
@ -307,7 +324,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`,
`model`, `model_source`, `effort`, `effort_source`, `links`, and `available_models`, `effort`, `available_efforts`, `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
@ -380,10 +397,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. The harness emits it - `model_changed { model }` — drives the model chip.
when it applies or clears a swarm value. - `effort_changed { effort }` — drives the effort picker chip.
- `effort_changed { effort }` — drives the effort chip. The harness Emitted from `Bus::set_effort` on `POST /api/effort`; applies on
emits it when it applies or clears a swarm value. the next session start.
- `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,6 +26,7 @@ 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,
@ -219,6 +220,11 @@ 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}
@ -302,9 +308,15 @@ 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}
modelSource={state.model_source} availableModels={state.available_models}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort} effort={state.effort}
effortSource={state.effort_source} availableEfforts={state.available_efforts}
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
(pause) while a plain display `<span>` badge (model, effort, (model/effort pickers, pause) while a plain display `<span>` badge
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,7 +1,6 @@
// <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 and effort (display-only: the swarm sets // its dropdown), the model + effort pickers, and context/cost usage.
// 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) —
@ -14,7 +13,6 @@
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 {
@ -27,11 +25,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;
/** Where `model` comes from (`/api/state` `model_source`). */ availableModels: string[];
modelSource?: InferenceSource; onSelectModel: (name: string) => void;
/** Hidden when empty: an ACP session that offers no effort now. */ effort: string;
effort?: string | null; availableEfforts: string[];
effortSource?: InferenceSource; onSelectEffort: (level: string) => void;
ctxLabel?: string; ctxLabel?: string;
costLabel?: string; costLabel?: string;
paused: boolean; paused: boolean;
@ -52,12 +50,66 @@ export interface StatusChipsProps {
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
} }
const SOURCE_LABEL: Record<InferenceSource, string> = { const MODEL_DESCRIPTIONS: Record<string, string> = {
swarm: "set on the swarm", haiku: "fast",
agent_nix: "from agent.nix", sonnet: "balanced",
local_file: "from its own earlier pick", opus: "powerful",
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
@ -183,9 +235,11 @@ export function StatusChips({
statusTooltip, statusTooltip,
model, model,
resolvedModel, resolvedModel,
modelSource, availableModels,
onSelectModel,
effort, effort,
effortSource, availableEfforts,
onSelectEffort,
ctxLabel, ctxLabel,
costLabel, costLabel,
paused, paused,
@ -209,23 +263,21 @@ export function StatusChips({
onNewSession={onNewSession} onNewSession={onNewSession}
onLogout={onLogout} onLogout={onLogout}
/> />
<Badge <Picker
label="model" label="model"
value={model} value={model}
title={ options={availableModels}
[ descriptions={MODEL_DESCRIPTIONS}
modelSource ? SOURCE_LABEL[modelSource] : null, onSelect={onSelectModel}
resolvedModel ? `resolved to ${resolvedModel}` : null, title={resolvedModel ? `resolved to ${resolvedModel}` : undefined}
]
.filter(Boolean)
.join(" · ") || undefined
}
/> />
{effort ? ( {availableEfforts.length ? (
<Badge <Picker
label="effort" label="effort"
value={effort} value={effort}
title={effortSource ? SOURCE_LABEL[effortSource] : undefined} options={availableEfforts}
descriptions={EFFORT_DESCRIPTIONS}
onSelect={onSelectEffort}
/> />
) : null} ) : null}
{ctxLabel ? ( {ctxLabel ? (

View file

@ -11,6 +11,7 @@
// (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,
@ -31,6 +32,14 @@ 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)",
@ -75,7 +84,8 @@ export function TermInput({
function handleSlash(line: string): boolean { function handleSlash(line: string): boolean {
const trimmed = line.trim(); const trimmed = line.trim();
const [cmd] = trimmed.split(/\s+/); const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd; const wasArmed = armedRef.current === cmd;
armedRef.current = null; armedRef.current = null;
@ -121,6 +131,26 @@ 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 pause/resume POST). */ /** Force an immediate refresh (e.g. right after a model/effort 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 `termActions.ts` — none of // broad-2xx-4xx-treated-as-ok contract as `modelEffort.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

@ -0,0 +1,39 @@
// 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,7 +5,8 @@
// 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. `credentials: // doesn't need to read it, only trigger a state refresh afterward (same
// 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,5 +1,6 @@
// Same-origin fetch POST helpers for the term-input slash commands // Same-origin fetch POST helpers for the term-input slash commands —
// ({ ok, detail } rather than throwing), reused by TermInput.tsx. // same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
// throwing), reused by TermInput.tsx.
async function post( async function post(
path: string, path: string,
body?: URLSearchParams, body?: URLSearchParams,

View file

@ -4,9 +4,6 @@
// 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;
@ -27,9 +24,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;
model_source: InferenceSource; available_models: string[];
effort: string | null; effort: string;
effort_source: InferenceSource; available_efforts: string[];
paused: boolean; paused: boolean;
inbox: InboxRow[]; inbox: InboxRow[];
links: AgentLink[]; links: AgentLink[];

View file

@ -2,7 +2,8 @@
// 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). Not a native `<dialog>` — a // `docs/web-ui/design-guide.md` calls out by name against the old
// 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,7 +167,8 @@ 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, demoed here with sample data. The wrapper // meant to land — the model/effort picker shape from the per-agent
// 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() {
@ -527,7 +528,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"> <Sample label="interactive, opens a Dropdown (model/effort picker shape)">
<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 // Presentational only, same split as `StatusChips`'s `Picker`/
// `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