// — minimal agent creation form, POSTs to // swarm-controller's `POST /api/agents`. That endpoint only queues a // `CreateIdentity` job and returns its node id — the identity isn't // guaranteed to exist yet when this gets a response — so this shows // the queued confirmation and links to the Jobs page to watch it // settle, rather than duplicating JobqGraph's per-node polling here. // // Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, not a // standalone route: the roster it feeds is the natural home for the action // that populates it. It is a stepper (see `CreateAgentSteps`): identity, // instructions, permissions, resources and inference, then a summary that // says what will be sent and holds **create**. Every step is gone through, in // order; the header, step tabs and footer stay put while the step scrolls. // // `hive` field added because agent creation had no way to record which // hive an agent runs on. `POST /api/agents` now requires it, so the // roster fetched off `GET /api/hives` backs a required select here // rather than a free-text field — a spawn target has to be one of the // swarm's actual hives, same reasoning `Ident.parse` gets client-side // pattern validation for `name`. // // Its name field, hive select, and submit button all come from the // shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than // page-scoped input/button chrome — see that kit's own comments for why. // // Permissions: checkboxes from `GET /api/permissions`, on the default set plus // `extraPermissions`; sent only when that differs from the default. import { useEffect, useState } from "preact/hooks"; import { Link } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Panel } from "../ui/panel/Panel.js"; import { TextField } from "../ui/text-field/TextField.js"; import { SelectField, type SelectOption, } from "../ui/select-field/SelectField.js"; import { TextAreaField } from "../ui/text-area-field/TextAreaField.js"; import { Button } from "../ui/button/Button.js"; import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js"; import { draftBody, draftProblem, EMPTY_INFERENCE_DRAFT, InferenceFields, type InferenceDraft, } from "../agent/detail/AgentInference.js"; import { FormField } from "../ui/form-field/FormField.js"; import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js"; import { initialSelection, requestPermissions, togglePermission, } from "./CreateAgentPermissions.js"; import { archetypeKey, prefill, requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js"; import { NAME_PATTERN, nextStep, previousStep, STEP_TITLES, STEPS, stepProblem, summaryRows, type Step, } from "./CreateAgentSteps.js"; import "./CreateAgentForm.css"; // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` // consumes off `/api/hives/status`, but this page hits the plain // `/api/hives` roster (no status/freshness needed, just "what hives // exist to spawn into"). interface HiveEntry { name: string; domain: string; } interface CreateAgentResponse { node_id: number; } // Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`). interface PermissionCatalogue { all: string[]; default: string[]; } type SubmitState = | { status: "idle" } | { status: "submitting" } | { status: "done"; nodeId: number } | { status: "error"; problem: ProblemDetails }; export function CreateAgentForm({ onClose, initialName = "", extraPermissions = [], }: { /** The name field's starting value, for a caller that suggests one. */ initialName?: string; /** Permissions checked on top of the default set when the form opens. */ extraPermissions?: string[]; /** Rendered as the form panel's own close button — this form always * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * floats its own. */ onClose?: () => void; }) { const [step, setStep] = useState("identity"); const [name, setName] = useState(initialName); const [hive, setHive] = useState(""); // `null` = still loading, `[]` = loaded but empty (a real, if unusual, // swarm state) — distinct from "not fetched yet" so the placeholder // option's label can tell the two apart. const [hives, setHives] = useState(null); const [hivesError, setHivesError] = useState(null); const [result, setResult] = useState({ status: "idle" }); // The optional settings sections, left empty unless the operator fills them. const [cpu, setCpu] = useState(""); const [memory, setMemory] = useState(""); const [inference, setInference] = useState( EMPTY_INFERENCE_DRAFT, ); const inferenceProblem = draftProblem(inference); // `null` until `/api/permissions` answers; `selected` starts on its default. const [catalogue, setCatalogue] = useState(null); const [catalogueError, setCatalogueError] = useState( null, ); const [selected, setSelected] = useState([]); // `null` until `/api/archetypes` answers; `archetype` is the picked one's // `archetypeKey`, `""` for none. const [archetypes, setArchetypes] = useState(null); const [archetypesError, setArchetypesError] = useState( null, ); const [archetype, setArchetype] = useState(""); // The text the agent's `AGENTS.md` starts with; empty sends none. const [agentsMd, setAgentsMd] = useState(""); const picked = archetypes?.archetypes.find( (a) => archetypeKey(a) === archetype, ); // Loaded once, not re-fetched on submit: the roster changes rarely // enough (a nix-level swarm config change) that staleness within one // page visit isn't worth a second round-trip per keystroke/submit. useEffect(() => { (async () => { const r = await fetch("/api/hives"); if (!r.ok) { setHivesError(await readApiError(r)); return; } const data = (await r.json()) as HiveEntry[]; setHives(data); // Single-hive swarms are the common case — default it rather than // making the operator pick the only option. Multi-hive swarms get // no default (the placeholder stays selected), so `required` on // the select below forces an explicit choice per mara's scope. if (data.length === 1) setHive(data[0].name); })().catch((e: unknown) => setHivesError({ detail: String(e) })); }, []); useEffect(() => { (async () => { const r = await fetch("/api/permissions"); if (!r.ok) { setCatalogueError(await readApiError(r)); return; } const data = (await r.json()) as PermissionCatalogue; setCatalogue(data); setSelected(initialSelection(data.all, data.default, extraPermissions)); })().catch((e: unknown) => setCatalogueError({ detail: String(e) })); }, []); useEffect(() => { (async () => { const r = await fetch("/api/archetypes"); if (!r.ok) { setArchetypesError(await readApiError(r)); return; } setArchetypes((await r.json()) as ArchetypeList); })().catch((e: unknown) => setArchetypesError({ detail: String(e) })); }, []); // Fills permissions, caps and inference from the picked archetype; each // stays editable, and the request carries only those fields. function pickArchetype(key: string) { setArchetype(key); const p = prefill( archetypes?.archetypes.find((a) => archetypeKey(a) === key), catalogue?.default ?? [], ); setSelected(p.permissions); setCpu(p.cpu); setMemory(p.memory); setInference(p.inference); } const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({ value: h.name, label: h.name, })); const hivesReady = hives !== null && hives.length > 0; // The controller's size refusal of the AGENTS.md text. It shows under the // instructions box, and `submit` sends the dialog back to that step. const refusal = result.status === "error" ? agentsMdRefusal(result.problem) : null; async function submit() { setResult({ status: "submitting" }); try { // A section left empty is left out, so the request is the plain // name-and-hive one unless the operator set something. const limits = capsBody(cpu, memory); const settings = draftBody(inference); const r = await fetch("/api/agents", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, hive, ...(Object.keys(limits).length > 0 ? { limits } : {}), ...(Object.keys(settings).length > 0 ? { inference: settings } : {}), permissions: catalogue ? requestPermissions(selected, catalogue.default) : undefined, archetype: requestArchetype(picked), ...agentsMdBody(agentsMd), }), }); if (!r.ok) { const problem = await readApiError(r); setResult({ status: "error", problem }); // The text's own refusal shows beside its box, so go back to it. if (agentsMdRefusal(problem) !== null) setStep("instructions"); return; } const data = (await r.json()) as CreateAgentResponse; setResult({ status: "done", nodeId: data.node_id }); setName(""); setAgentsMd(""); pickArchetype(""); if (catalogue) setSelected( initialSelection(catalogue.all, catalogue.default, extraPermissions), ); } catch (err) { setResult({ status: "error", problem: { detail: String(err) } }); } } const problem = stepProblem(step, { name, hive, permissionsReady: catalogue !== null, inferenceProblem, }); const done = result.status === "done"; const at = STEPS.indexOf(step); // Enter goes on to the next step, and creates only from the summary. function onSubmit(e: Event) { e.preventDefault(); if (done || result.status === "submitting") return; if (step === "summary") void submit(); else if (problem === null) setStep(nextStep(step)); } function createAnother() { setResult({ status: "idle" }); setStep("identity"); } return (
    {STEPS.map((s, i) => (
  1. ))}
{hivesError && ( )} {catalogueError && ( )} {archetypesError && ( )} {done && (

Queued as job node #{result.nodeId} —{" "} watch it in jobs.

)} {!done && step === "identity" && (

A new agent's swarm-level identity: its name, the hive it runs on, and optionally an archetype that fills in the settings of the later steps. A name set up before keeps the settings stored for it.

{archetypes && archetypes.archetypes.length > 0 && ( <> ({ value: archetypeKey(a), label: a.name, })), ]} /> {picked && (

{picked.description} An agent created from an archetype has no config repo: its hive builds it from the archetype's module.

)} )}
)} {!done && step === "instructions" && (

The text the agent's AGENTS.md starts with. Optional: leave it empty and the agent starts without the file.

{refusal !== null && (

{refusal}

)}
)} {!done && step === "permissions" && (

What the swarm lets the agent do. Leaving the default set ticked keeps the agent on the default if that changes.

{catalogue ? ( setSelected((s) => togglePermission(s, v)) } /> ) : ( {catalogueError ? "could not load permissions" : "loading…"} )}
)} {!done && step === "resources" && (

Optional: leave a box empty to set nothing, and the hive's or the agent's own value applies.

resource caps

inference

)} {!done && step === "summary" && (
{summaryRows({ name, hive, archetype: picked?.name, permissions: catalogue ? requestPermissions(selected, catalogue.default) : undefined, caps: capsBody(cpu, memory), inference: draftBody(inference), agentsMd, }).map((row) => (
{row.label}
{row.value}
))}
{/* Describes the intended end state (a running agent), not today's literal behaviour: mara's call on this copy was to read as finished, not as a running commentary on partial implementation. */}

what this creates

Creating queues everything a new agent needs: a swarm-level identity, a config repo on the forge with the operator added as a collaborator, and a container running the agent on the hive above. An agent created from an archetype gets no config repo: its container runs the archetype's module. Watch it all settle on jobs.

{result.status === "error" && refusal === null && ( )}
)}
); }