Files
Claude a3a5c272a2 Add the pi harness: lightweight local-model agents with full gate parity
Model backend rows gain a harness column (claude | pi). A pi-harness row runs
the agent through the pi coding agent instead of the claude binary — pi speaks
the OpenAI Completions API natively, so a bare vLLM/llama.cpp/Ollama endpoint
needs no LiteLLM/claude-code-router translation proxy, and the loop is far
lighter for slow local token throughput. The Claude subscription and existing
claude-harness backends are untouched.

Parity comes from generated per-agent artifacts under ~/.handler-pi (outside
the repo tree, so the clean-tree gate never trips): models.json + settings.json
render the row as a pi provider pinned as the default model; a bundled bridge
extension (pi_bridge.ts) adapts pi's events to the exact stdin/stdout contract
of `python -m handler.hooks` — the Stop/completion gate re-prompts pi with
blockers via a follow-up message, git push runs the test/build/approval gates
and denies on failure, questions defer through an ask_operator tool into the
normal answer/resume flow, and memory recall is injected at session start. The
memory tools are registered natively (pi has no MCP), shelling to a new
`python -m handler.mcpserver --call <tool>` seam that reuses the MCP server's
implementations. Skills reuse the same ~/.claude/skills sync (pi implements the
same SKILL.md standard) plus the repo's committed .claude/skills.

Sessions are single JSONL files pre-assigned via --session, so cross-worker
resume archives/materializes exactly like claude's; the prompt travels on stdin
(pi has no -- separator). The supervisor normalizes pi's event stream on the
fly: assistant message_end feeds last_output, the final agent_end becomes the
run result. The whole chain was validated live against pi 0.84.1 with a stub
OpenAI endpoint: memory injection, push-gate denial (including the protected-
branch approval gate), stop-gate block loop, and ask_operator pause all ran
end to end through the real hooks and DB.

Also: harness selector in the dashboard Models form, pi baked into the control
image (NodeSource 22 for pi's node >= 22.19 floor), PI_BIN override, docs in
docs/local-models.md, fake_pi fixture + 12 tests (361 total green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdGv3u3DfTsP1S188KDhVH
2026-08-12 18:21:35 +00:00

807 lines
28 KiB
TypeScript

/* Claude — the management page for the Claude Code install agents run on: the account
* login (moved here from the old Claude Login page), plus the operator-managed skills,
* MCP connectors, plugins, and permission overrides. Everything except the login is a
* plain DB write that the control container applies at the NEXT launch of every agent —
* skills sync to the workers' user-level ~/.claude/skills, connectors become each run's
* --mcp-config file, and plugins/permissions fold into the generated settings.json. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import type { ConnectorBody, ModelBody, PluginBody, SkillBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
import type { ClaudeConnector, ClaudeModel, ClaudePlugin, ClaudeSkill } from "@/lib/api";
import { ClaudeLoginPanel } from "@/components/sections/LoginSection";
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
function parseKeyValues(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
function formatKeyValues(map: Record<string, string> | null | undefined): string {
return Object.entries(map ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function parseLines(text: string): string[] {
return text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
/* ---- Skills ------------------------------------------------------------------------ */
const emptySkill = { name: "", description: "", content: "", enabled: true };
/* Install-from-prompt: paste the "install prompt" a marketplace page (SkillsMP etc.)
* shows, and the worker runs it through a one-off headless claude, importing whatever
* it fetches as managed skills. Headless = nobody to answer questions, so the run makes
* the choices a human would be asked (scope, options) itself — always user scope,
* sensible defaults — and reports them; the import lands below for review/editing. */
function InstallCard() {
const s = useDashboard();
const [prompt, setPrompt] = useState("");
const run = async () => {
const ok = await s.installClaudeSkill(prompt.trim());
if (ok) setPrompt("");
};
return (
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Install from a marketplace prompt
</span>
</div>
<Textarea
label="Paste the skill's install prompt (from SkillsMP or any marketplace page)"
value={prompt}
onChange={setPrompt}
rows={5}
placeholder={"Install the pdf-tools skill from https://…\n(the whole prompt the marketplace tells you to paste into Claude)"}
/>
<div className="faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
Runs headlessly on the worker nobody can answer questions mid-install, so when
the instructions offer choices (user vs repo scope, optional variants) Claude
picks the defaults itself: skills here are always <b>user scope</b> (Handler
syncs them to every worker), and recommended options win. What it chose is
reported in the result review the imported skill below and edit or disable it
if a choice was wrong.
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !prompt.trim()} onClick={run}>
{s.cmd.busy ? "Installing…" : "Run install"}
</Button>
</div>
</Card>
);
}
function SkillsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptySkill);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptySkill);
setEditingId(null);
};
const save = async () => {
const body: SkillBody = { ...form };
const ok =
editingId != null
? await s.updateClaudeSkill(editingId, body)
: await s.createClaudeSkill(body);
if (ok) reset();
};
const edit = (sk: ClaudeSkill) => {
setForm({
name: sk.name,
description: sk.description ?? "",
content: sk.content,
enabled: sk.enabled,
});
setEditingId(sk.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Custom Claude Code skills, synced to every worker&apos;s{" "}
<span className="mono">~/.claude/skills</span> at each launch. The description is
what makes Claude pick the skill up say when to use it. Install one from a
marketplace prompt, or author one by hand below.
</div>
<InstallCard />
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit skill · ${form.name}` : "Add a skill"}
</span>
</div>
<div className="form-grid">
<Input
label="Name (slug — becomes the skill directory)"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="deploy-checklist"
/>
<Input
label="Description (when should Claude use it?)"
value={form.description}
onChange={(v) => setForm({ ...form, description: v })}
placeholder="Use when preparing or reviewing a deploy."
/>
</div>
<div style={{ marginTop: 10 }}>
<Textarea
label="SKILL.md body (markdown)"
value={form.content}
onChange={(v) => setForm({ ...form, content: v })}
rows={8}
placeholder={"# Deploy checklist\n\n1. ..."}
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.content.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add skill"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeSkills.length === 0 && <div className="empty">No custom skills yet.</div>}
{s.claudeSkills.map((sk) => (
<Card key={sk.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{sk.name}
</span>
<div className="hstack">
<Badge tone={sk.enabled ? "success" : "neutral"}>
{sk.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={sk.enabled} onClick={() => s.updateClaudeSkill(sk.id, { enabled: !sk.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(sk)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeSkill(sk.id)}>
Remove
</Button>
</div>
</div>
{sk.description && (
<div className="faint" style={{ fontSize: "var(--text-sm)", marginTop: 8 }}>
{sk.description}
</div>
)}
{sk.files.length > 0 && (
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
ships with: {sk.files.join(" · ")}
</div>
)}
</Card>
))}
</>
);
}
/* ---- Connectors (MCP servers) ------------------------------------------------------ */
const TRANSPORT_OPTS = [
{ value: "stdio", label: "stdio (run a command)" },
{ value: "http", label: "http (remote server)" },
{ value: "sse", label: "sse (remote server, legacy)" },
];
const emptyConnector = {
name: "",
transport: "stdio" as ConnectorBody["transport"],
command: "",
args: "",
env: "",
url: "",
headers: "",
enabled: true,
};
function ConnectorsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyConnector);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyConnector);
setEditingId(null);
};
const save = async () => {
const body: ConnectorBody = {
name: form.name,
transport: form.transport,
command: form.command.trim() || null,
args: parseLines(form.args),
env: parseKeyValues(form.env),
url: form.url.trim() || null,
headers: parseKeyValues(form.headers),
enabled: form.enabled,
};
const ok =
editingId != null
? await s.updateClaudeConnector(editingId, body)
: await s.createClaudeConnector(body);
if (ok) reset();
};
const edit = (c: ClaudeConnector) => {
setForm({
name: c.name,
transport: c.transport,
command: c.command ?? "",
args: (c.args ?? []).join("\n"),
env: formatKeyValues(c.env),
url: c.url ?? "",
headers: formatKeyValues(c.headers),
enabled: c.enabled,
});
setEditingId(c.id);
};
const stdio = form.transport === "stdio";
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
MCP servers agents can reach. Passed to each run as its{" "}
<span className="mono">--mcp-config</span> file, so nothing lands in the
repository tree. stdio commands run inside the control container.
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit connector · ${form.name}` : "Add a connector"}
</span>
</div>
<div className="form-grid">
<Input
label="Name"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="github"
/>
<Select
label="Transport"
value={form.transport}
onChange={(v) => setForm({ ...form, transport: v as ConnectorBody["transport"] })}
options={TRANSPORT_OPTS}
/>
{stdio ? (
<>
<Input
label="Command"
value={form.command}
onChange={(v) => setForm({ ...form, command: v })}
placeholder="npx"
/>
<Textarea
label="Arguments (one per line)"
value={form.args}
onChange={(v) => setForm({ ...form, args: v })}
rows={3}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
<Textarea
label="Environment (KEY=VALUE per line)"
value={form.env}
onChange={(v) => setForm({ ...form, env: v })}
rows={3}
placeholder="GITHUB_TOKEN=ghp_..."
/>
</>
) : (
<>
<Input
label="URL"
value={form.url}
onChange={(v) => setForm({ ...form, url: v })}
placeholder="https://mcp.example.com/mcp"
/>
<Textarea
label="Headers (KEY=VALUE per line)"
value={form.headers}
onChange={(v) => setForm({ ...form, headers: v })}
rows={3}
placeholder="Authorization=Bearer ..."
/>
</>
)}
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || (stdio ? !form.command.trim() : !form.url.trim())}
onClick={save}
>
{editingId != null ? "Save changes" : "Add connector"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeConnectors.length === 0 && <div className="empty">No connectors yet.</div>}
{s.claudeConnectors.map((c) => (
<Card key={c.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{c.name}
</span>
<div className="hstack">
<Badge tone="info">{c.transport}</Badge>
<Badge tone={c.enabled ? "success" : "neutral"}>
{c.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={c.enabled} onClick={() => s.updateClaudeConnector(c.id, { enabled: !c.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(c)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeConnector(c.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{c.transport === "stdio"
? [c.command, ...(c.args ?? [])].join(" ")
: c.url}
</div>
</Card>
))}
</>
);
}
/* ---- Plugins ----------------------------------------------------------------------- */
const emptyPlugin = { name: "", marketplace: "", marketplace_repo: "", enabled: true };
function PluginsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyPlugin);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyPlugin);
setEditingId(null);
};
const save = async () => {
const body: PluginBody = { ...form };
const ok =
editingId != null
? await s.updateClaudePlugin(editingId, body)
: await s.createClaudePlugin(body);
if (ok) reset();
};
const edit = (p: ClaudePlugin) => {
setForm({
name: p.name,
marketplace: p.marketplace,
marketplace_repo: p.marketplace_repo,
enabled: p.enabled,
});
setEditingId(p.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Claude Code plugins, pinned to the marketplace serving them. Generated settings
declare the marketplace and enable the plugin, so headless runs install both on
boot.
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit plugin · ${form.name}` : "Add a plugin"}
</span>
</div>
<div className="form-grid">
<Input
label="Plugin name"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="code-reviewer"
/>
<Input
label="Marketplace key"
value={form.marketplace}
onChange={(v) => setForm({ ...form, marketplace: v })}
placeholder="acme-tools"
/>
<Input
label="Marketplace repo (owner/repo or git URL)"
value={form.marketplace_repo}
onChange={(v) => setForm({ ...form, marketplace_repo: v })}
placeholder="acme/claude-marketplace"
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.marketplace.trim() || !form.marketplace_repo.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add plugin"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudePlugins.length === 0 && <div className="empty">No plugins yet.</div>}
{s.claudePlugins.map((p) => (
<Card key={p.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{p.name}@{p.marketplace}
</span>
<div className="hstack">
<Badge tone={p.enabled ? "success" : "neutral"}>
{p.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={p.enabled} onClick={() => s.updateClaudePlugin(p.id, { enabled: !p.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudePlugin(p.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{p.marketplace_repo}
</div>
</Card>
))}
</>
);
}
/* ---- Model backends ---------------------------------------------------------------- */
const emptyModel = {
name: "",
base_url: "",
model: "",
small_fast_model: "",
harness: "claude" as "claude" | "pi",
api_key: "",
env: "",
enabled: true,
};
const HARNESS_OPTS = [
{ value: "claude", label: "claude — Anthropic-compatible endpoint (LiteLLM/gateway)" },
{ value: "pi", label: "pi — bare OpenAI-compatible endpoint (vLLM/llama.cpp/Ollama)" },
];
function ModelsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyModel);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyModel);
setEditingId(null);
};
const save = async () => {
const body: ModelBody = {
name: form.name,
base_url: form.base_url.trim(),
model: form.model.trim(),
small_fast_model: form.small_fast_model.trim() || null,
harness: form.harness,
api_key: form.api_key.trim() || null,
env: parseKeyValues(form.env),
enabled: form.enabled,
};
const ok =
editingId != null
? await s.updateClaudeModel(editingId, body)
: await s.createClaudeModel(body);
if (ok) reset();
};
const edit = (m: ClaudeModel) => {
setForm({
name: m.name,
base_url: m.base_url,
model: m.model,
small_fast_model: m.small_fast_model ?? "",
harness: m.harness ?? "claude",
api_key: "", // write-only; blank = keep the stored key
env: formatKeyValues(m.env),
enabled: m.enabled,
});
setEditingId(m.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Alternative model backends the spawn dropdown offers next to the Claude
subscription. The <b>claude</b> harness is the same{" "}
<span className="mono">claude</span> binary pointed at a different endpoint via{" "}
<span className="mono">ANTHROPIC_BASE_URL</span> the endpoint must speak the{" "}
<b>Anthropic Messages API including tool use</b>, so front a bare
OpenAI-compatible server with LiteLLM or claude-code-router. The <b>pi</b>{" "}
harness runs the lightweight pi coding agent instead, which speaks{" "}
<b>OpenAI-compatible endpoints natively</b> (vLLM, llama.cpp, Ollama no
translation proxy) hooks, gates, memory, and skills still apply via
handler&apos;s bridge. See <span className="mono">docs/local-models.md</span>.
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit model · ${form.name}` : "Add a model backend"}
</span>
</div>
<div className="form-grid">
<Input
label="Name (what the spawn dropdown shows)"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="qwen3-coder"
/>
<Input
label="Base URL (Anthropic-compatible /v1/messages endpoint)"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="http://llm.lan:4000"
/>
<Input
label="Model id (as the endpoint serves it)"
value={form.model}
onChange={(v) => setForm({ ...form, model: v })}
placeholder="qwen3-coder-30b"
/>
<Input
label="Small/fast model id (optional — defaults to the main model)"
value={form.small_fast_model}
onChange={(v) => setForm({ ...form, small_fast_model: v })}
placeholder="qwen3-1.7b"
/>
<Select
label="Harness (which agent binary runs against this endpoint)"
value={form.harness}
onChange={(v) => setForm({ ...form, harness: v as "claude" | "pi" })}
options={HARNESS_OPTS}
/>
<Input
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
value={form.api_key}
onChange={(v) => setForm({ ...form, api_key: v })}
placeholder="sk-… (encrypted at rest, never shown again)"
/>
<Textarea
label="Extra env overrides (KEY=VALUE per line, optional)"
value={form.env}
onChange={(v) => setForm({ ...form, env: v })}
rows={3}
placeholder={"API_TIMEOUT_MS=600000\nCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192"}
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.base_url.trim() || !form.model.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add model"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeModels.length === 0 && (
<div className="empty">No model backends yet agents run on the Claude subscription.</div>
)}
{s.claudeModels.map((m) => (
<Card key={m.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{m.name}
</span>
<div className="hstack">
{m.harness === "pi" && <Badge tone="info">pi harness</Badge>}
{m.has_api_key && <Badge tone="info">key stored</Badge>}
<Badge tone={m.enabled ? "success" : "neutral"}>
{m.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={m.enabled} onClick={() => s.updateClaudeModel(m.id, { enabled: !m.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(m)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeModel(m.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{m.model}
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} @ {m.base_url}
</div>
</Card>
))}
</>
);
}
/* ---- Permissions ------------------------------------------------------------------- */
const MODE_OPTS = [
{ value: "", label: "(keep server baseline)" },
{ value: "default", label: "default" },
{ value: "acceptEdits", label: "acceptEdits" },
{ value: "plan", label: "plan" },
{ value: "bypassPermissions", label: "bypassPermissions" },
];
function PermissionsPanel() {
const s = useDashboard();
const p = s.claudePermissions;
const [form, setForm] = useState<{ mode: string; allow: string; deny: string; ask: string } | null>(null);
// Seed the form from the loaded permissions once; afterwards the operator's draft wins.
if (form === null && p !== null) {
setForm({
mode: p.default_mode ?? "",
allow: p.allow.join("\n"),
deny: p.deny.join("\n"),
ask: p.ask.join("\n"),
});
return null;
}
if (form === null || p === null) {
return <div className="empty">Loading permissions</div>;
}
const save = () =>
s.saveClaudePermissions({
default_mode: form.mode || null,
allow: parseLines(form.allow),
deny: parseLines(form.deny),
ask: parseLines(form.ask),
});
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Overrides merged over the server baseline into every generated{" "}
<span className="mono">settings.json</span>. Headless runs auto-deny anything that
would prompt, so allow rules are what let work proceed; the PreToolUse/Stop hooks
stay the hard gate regardless.
</div>
<Card>
<div className="form-grid">
<Select
label={`Default mode (baseline: ${p.base_mode})`}
value={form.mode}
onChange={(v) => setForm({ ...form, mode: v })}
options={MODE_OPTS}
/>
<div className="field">
<span className="field-label">Baseline allow rules (from server env)</span>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", padding: "6px 0" }}>
{p.base_allow.length ? p.base_allow.join(" · ") : "—"}
</div>
</div>
<Textarea
label="Extra allow rules (one per line)"
value={form.allow}
onChange={(v) => setForm({ ...form, allow: v })}
rows={4}
placeholder={"Bash(npm *)\nWebFetch(domain:docs.example.com)"}
/>
<Textarea
label="Deny rules (one per line)"
value={form.deny}
onChange={(v) => setForm({ ...form, deny: v })}
rows={4}
placeholder={"Bash(rm -rf *)\nRead(./secrets/**)"}
/>
<Textarea
label="Ask rules (one per line — headless runs deny these)"
value={form.ask}
onChange={(v) => setForm({ ...form, ask: v })}
rows={4}
placeholder="Bash(git push *)"
/>
</div>
<div className="hstack mt14">
<Button variant="primary" onClick={save}>
Save permissions
</Button>
</div>
</Card>
</>
);
}
/* ---- The page ---------------------------------------------------------------------- */
const TABS = [
{ value: "account", label: "Account" },
{ value: "models", label: "Models" },
{ value: "skills", label: "Skills" },
{ value: "connectors", label: "Connectors" },
{ value: "plugins", label: "Plugins" },
{ value: "permissions", label: "Permissions" },
];
export function ClaudeSection() {
const [tab, setTab] = useState("account");
return (
<>
<div className="section-head">
<div className="section-title">Claude</div>
<div className="section-desc">
Manage the Claude Code install agents run on: the account login, alternative
model backends, plus skills, MCP connectors, plugins, and permissions. Changes
apply to the next launch of every agent.
</div>
</div>
<div className="section-body">
<div style={{ marginBottom: 16 }}>
<Tabs tabs={TABS} value={tab} onChange={setTab} />
</div>
{tab === "account" && <ClaudeLoginPanel />}
{tab === "models" && <ModelsPanel />}
{tab === "skills" && <SkillsPanel />}
{tab === "connectors" && <ConnectorsPanel />}
{tab === "plugins" && <PluginsPanel />}
{tab === "permissions" && <PermissionsPanel />}
</div>
</>
);
}