You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/03 09:29:57 UTC

(casbin-gateway) branch master updated (30f2e3d -> 7698770)

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


 discard 30f2e3d  feat(agent): redesign the agent cards, four to a row
     new 7698770  feat(agent): redesign the agent cards, four to a row

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (30f2e3d)
            \
             N -- N -- N   refs/heads/master (7698770)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 web/src/components/AuthenticityScore.tsx | 27 ++++++++++++++++++++++-----
 1 file changed, 22 insertions(+), 5 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 01/01: feat(agent): redesign the agent cards, four to a row

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 76987704a1d06a87c8593f630c16aa464aaadc7d
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Thu Sep 3 17:25:04 2026 +0800

    feat(agent): redesign the agent cards, four to a row
---
 web/src/components/AgentCatalog.tsx         |  134 +--
 web/src/components/AgentGridCard.tsx        |  787 +++++++------
 web/src/components/AgentInstances.tsx       |  627 ++++++-----
 web/src/components/AgentRunControl.tsx      |  221 ++--
 web/src/components/AuthenticityOverview.tsx |  348 +++---
 web/src/components/AuthenticityScore.tsx    |   27 +-
 web/src/components/ToolInstallRow.tsx       |  214 ++--
 web/src/lib/agents.ts                       | 1580 +++++++++++++--------------
 web/src/pages/HomePage.tsx                  |  619 ++++++-----
 9 files changed, 2282 insertions(+), 2275 deletions(-)

diff --git a/web/src/components/AgentCatalog.tsx b/web/src/components/AgentCatalog.tsx
index 55c418f..df8cd55 100644
--- a/web/src/components/AgentCatalog.tsx
+++ b/web/src/components/AgentCatalog.tsx
@@ -1,67 +1,67 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import i18next from "i18next";
-
-import {ToolInstallRow} from "@/components/ToolInstallRow";
-import {useAgentCatalog, type useAgentInstall} from "@/lib/agents";
-import type {Agent} from "@/types";
-
-/**
- * The agents Gateway supports that this machine has none of, each with the
- * click that installs it here. Without them a fresh machine says only that
- * nothing was found, which is the one thing it cannot act on.
- */
-export function AgentCatalog({
-  agents,
-  enabled = true,
-  installer,
-}: {
-  agents: Agent[];
-  enabled?: boolean;
-  /**
-   * The page's own installer. It is passed in rather than made here: the page
-   * upgrades the agents it already has through the same one, and two of them
-   * would report the same finished job twice.
-   */
-  installer: ReturnType<typeof useAgentInstall>;
-}) {
-  const missing = useAgentCatalog(agents, enabled);
-  const {jobs, busyId, install} = installer;
-
-  if (missing.length === 0) {
-    return null;
-  }
-
-  return (
-    <section className="space-y-3">
-      <div>
-        <h2 className="font-medium">{i18next.t("agent:Not installed")}</h2>
-        <p className="text-muted-foreground text-sm">{i18next.t("agent:Not installed hint")}</p>
-      </div>
-
-      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
-        {missing.map(item => (
-          <ToolInstallRow
-            key={item.agentId}
-            entry={item}
-            job={jobs[item.agentId]}
-            busy={busyId === item.agentId}
-            onInstall={install}
-          />
-        ))}
-      </div>
-    </section>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import i18next from "i18next";
+
+import {ToolInstallRow} from "@/components/ToolInstallRow";
+import {useAgentCatalog, type useAgentInstall} from "@/lib/agents";
+import type {Agent} from "@/types";
+
+/**
+ * The agents Gateway supports that this machine has none of, each with the
+ * click that installs it here. Without them a fresh machine says only that
+ * nothing was found, which is the one thing it cannot act on.
+ */
+export function AgentCatalog({
+  agents,
+  enabled = true,
+  installer,
+}: {
+  agents: Agent[];
+  enabled?: boolean;
+  /**
+   * The page's own installer. It is passed in rather than made here: the page
+   * upgrades the agents it already has through the same one, and two of them
+   * would report the same finished job twice.
+   */
+  installer: ReturnType<typeof useAgentInstall>;
+}) {
+  const missing = useAgentCatalog(agents, enabled);
+  const {jobs, busyId, install} = installer;
+
+  if (missing.length === 0) {
+    return null;
+  }
+
+  return (
+    <section className="space-y-3">
+      <div>
+        <h2 className="text-sm font-medium">{i18next.t("agent:Not installed")}</h2>
+        <p className="text-muted-foreground text-xs">{i18next.t("agent:Not installed hint")}</p>
+      </div>
+
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+        {missing.map(item => (
+          <ToolInstallRow
+            key={item.agentId}
+            entry={item}
+            job={jobs[item.agentId]}
+            busy={busyId === item.agentId}
+            onInstall={install}
+          />
+        ))}
+      </div>
+    </section>
+  );
+}
diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx
index 31d1657..adc9985 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -1,396 +1,391 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import {Link} from "react-router-dom";
-import {Bot, ChevronRight, UserRound} from "lucide-react";
-import i18next from "i18next";
-
-import {AgentIcon} from "@/components/AgentIcon";
-import {AgentCardInstances} from "@/components/AgentInstances";
-import {RunBadge, RunButton} from "@/components/AgentRunControl";
-import {ProviderIcon} from "@/components/ProviderIcon";
-import {QuotaBadge} from "@/components/ProviderQuota";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {Badge} from "@/components/ui/badge";
-import {Card, CardContent} from "@/components/ui/card";
-import {Progress} from "@/components/ui/progress";
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import {Switch} from "@/components/ui/switch";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {
-  agentBuiltin,
-  agentCanUse,
-  agentDetailPath,
-  builtinProvider,
-  directMode,
-  monitorAgentId,
-  type AgentActivity,
-  type AgentInstanceControls,
-} from "@/lib/agents";
-import {providerIdOf, providerProtocol} from "@/lib/providers";
-import {formatCost, formatTokens} from "@/lib/usage";
-import type {
-  Agent,
-  AgentAccount,
-  AgentRuntime,
-  AgentUsageStat,
-  LlmAgentStat,
-  Provider,
-  ProviderHealth,
-  ProviderQuota,
-} from "@/types";
-
-/** One number of the card, and what it is. */
-function Metric({
-  label,
-  value,
-  hint,
-  to,
-}: {
-  label: React.ReactNode;
-  value: React.ReactNode;
-  hint?: React.ReactNode;
-  to?: string;
-}) {
-  const shown = (
-    <span className="block truncate text-sm font-semibold tabular-nums">{value}</span>
-  );
-
-  return (
-    <div className="min-w-0">
-      <span className="text-muted-foreground block truncate text-xs">{label}</span>
-      {hint ? (
-        <SimpleTooltip title={hint}>
-          <span className="block min-w-0">{shown}</span>
-        </SimpleTooltip>
-      ) : to ? (
-        <Link to={to} className="hover:text-primary block min-w-0">
-          {shown}
-        </Link>
-      ) : (
-        shown
-      )}
-    </div>
-  );
-}
-
-/** The label for a signed-in account: its name, or its email when unnamed. */
-export function accountLabel(account: AgentAccount) {
-  return account.name || account.email || "";
-}
-
-/** The signed-in account of an agent, shown under its path. */
-function AccountLine({account}: {account?: AgentAccount}) {
-  const label = account ? accountLabel(account) : "";
-  if (!label) {
-    return null;
-  }
-  const title = account?.email && account.email !== label ? `${label} · ${account.email}` : label;
-
-  return (
-    <SimpleTooltip title={title}>
-      <p className="text-muted-foreground mt-0.5 flex items-center gap-1 truncate text-xs">
-        <UserRound className="size-3 shrink-0" />
-        <span className="truncate">{label}</span>
-      </p>
-    </SimpleTooltip>
-  );
-}
-
-/** What the vendor of the bound provider says is left on the plan. */
-function PlanUsage({quota}: {quota?: ProviderQuota}) {
-  // A provider nobody can read a balance from has nothing to show here.
-  if (!quota || (!quota.supported && quota.error === "")) {
-    return null;
-  }
-
-  const percent =
-    quota.total !== null && quota.total > 0 && quota.used !== null
-      ? Math.min(100, (quota.used / quota.total) * 100)
-      : null;
-
-  return (
-    <div className="space-y-1.5">
-      <div className="flex items-center justify-between gap-2">
-        <span className="text-muted-foreground text-xs">{i18next.t("provider:Plan usage")}</span>
-        <QuotaBadge quota={quota} />
-      </div>
-      {percent === null ? null : (
-        <Progress
-          value={percent}
-          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "success"}
-          className="h-1.5"
-        />
-      )}
-    </div>
-  );
-}
-
-/**
- * One agent installed on this machine, as the card the home page is built out
- * of: what it is on now, what it has spent there, and the controls that change
- * both. Picking another provider is the one click the page exists for.
- */
-export function AgentGridCard({
-  agent,
-  agents,
-  providers,
-  health,
-  quota,
-  stats,
-  usage,
-  activity,
-  status,
-  instances,
-  recording,
-  busy,
-  runBusy,
-  onEnable,
-  onToggleRunning,
-  onTogglePatch,
-}: {
-  agent: Agent;
-  /** Every installation, which is what decides whether the link needs a path. */
-  agents: Agent[];
-  providers: Provider[];
-  health: ProviderHealth[];
-  quota?: ProviderQuota;
-  stats?: LlmAgentStat;
-  /**
-   * What this agent's own transcripts say it spent, which counts the requests
-   * it made straight to its vendor as well as the ones Gateway relayed.
-   */
-  usage?: AgentUsageStat;
-  activity?: AgentActivity;
-  status?: AgentRuntime;
-  /** The extra copies of this agent, for the agents that can run more than one. */
-  instances?: AgentInstanceControls;
-  /** False while llmRecordMode is off, when a zero would be a lie. */
-  recording: boolean;
-  busy: boolean;
-  runBusy: boolean;
-  onEnable: (providerId: string) => void;
-  onToggleRunning: (agent: Agent, running: boolean) => void;
-  onTogglePatch: () => void;
-}) {
-  const bound = providers.find(provider => providerIdOf(provider) === agent.provider);
-  const boundHealth = health.find(item => item.provider === agent.provider);
-  // An agent bound directly reads the provider's own answers, so only the ones
-  // it can speak to are offered; through the gateway they all are. Whatever it
-  // is on stays listed either way, so the box shows where it points today.
-  const options = providers.filter(
-    provider => agentCanUse(agent, provider) || providerIdOf(provider) === agent.provider,
-  );
-  const patchAction = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`);
-  const monitorId = monitorAgentId(agent.agentId);
-  const dash = <span className="text-muted-foreground">-</span>;
-  const offHint = recording ? undefined : i18next.t("llm:Recording is off");
-  // An agent's own transcript accounts for every request it made, the ones that
-  // never came near Gateway included, so it is the fuller of the two and wins
-  // wherever it has anything to say. The relayed totals answer for the agents
-  // that keep no transcript, and for the failures a transcript cannot show.
-  const spent = usage && usage.requests > 0 ? usage : undefined;
-  const sourceHint = spent ? i18next.t("agent:Read from the agent's own logs") : offHint;
-  // Off is only the truth about the relayed totals; a transcript is written
-  // whether or not Gateway is recording anything.
-  const counted = spent !== undefined || recording;
-  const lastModel = spent?.lastModel || stats?.lastModel;
-  const lastTime = spent?.lastTime || stats?.lastTime;
-
-  return (
-    <Card className="gap-0 py-0">
-      <CardContent className="flex h-full flex-col gap-3 p-4">
-        <div className="flex items-start gap-2.5">
-          <AgentIcon
-            agent={agent.agentId || agent.name}
-            size={26}
-            fallback={<Bot className="text-muted-foreground size-6" />}
-          />
-          <div className="min-w-0 flex-1">
-            <div className="flex min-w-0 items-center gap-2">
-              <Link
-                to={agentDetailPath(agent, agents)}
-                className="min-w-0 truncate font-medium hover:underline"
-              >
-                {agent.name}
-              </Link>
-              <Badge variant="muted">{agent.version || i18next.t("agent:Unknown")}</Badge>
-            </div>
-            <SimpleTooltip title={agent.path}>
-              <p className="text-muted-foreground truncate font-mono text-xs">{agent.path}</p>
-            </SimpleTooltip>
-            <AccountLine account={agent.account} />
-          </div>
-          <RunBadge status={status} />
-        </div>
-
-        <div className="space-y-1.5">
-          <div className="flex items-center justify-between gap-2">
-            <span className="text-muted-foreground text-xs">{i18next.t("agent:Provider")}</span>
-            <span className="flex flex-wrap items-center gap-1.5">
-              {bound === undefined ? null : (
-                <Badge variant="muted">
-                  {i18next.t(agent.mode === directMode ? "agent:Direct" : "agent:Gateway")}
-                </Badge>
-              )}
-              {boundHealth && !boundHealth.healthy ? (
-                <SimpleTooltip title={boundHealth.lastError}>
-                  <span>
-                    <Badge variant="warning">{i18next.t("agent:Cooling down")}</Badge>
-                  </span>
-                </SimpleTooltip>
-              ) : null}
-            </span>
-          </div>
-
-          <Select
-            value={agent.provider === "" ? builtinProvider : agent.provider}
-            disabled={busy}
-            onValueChange={value => onEnable(value === builtinProvider ? "" : value)}
-          >
-            <SelectTrigger className="w-full">
-              <SelectValue />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value={builtinProvider}>
-                {agentBuiltin(agent)}
-                <span className="text-muted-foreground ml-2 text-xs">
-                  {i18next.t("agent:Built-in")}
-                </span>
-              </SelectItem>
-              {options.map(provider => (
-                <SelectItem key={providerIdOf(provider)} value={providerIdOf(provider)}>
-                  <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} size={16} />
-                  {provider.displayName || provider.name}
-                  <span className="text-muted-foreground ml-2 text-xs">
-                    {providerProtocol(provider.type)}
-                  </span>
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
-        </div>
-
-        <div className="flex items-center justify-between gap-2">
-          <span className="text-muted-foreground text-xs">{i18next.t("agent:Model")}</span>
-          {lastModel ? (
-            <SimpleTooltip
-              title={`${i18next.t("agent:Last activity")}: ${new Date(lastTime ?? "").toLocaleString()}`}
-            >
-              <span className="min-w-0 truncate font-mono text-xs">{lastModel}</span>
-            </SimpleTooltip>
-          ) : (
-            <span className="text-muted-foreground text-xs">-</span>
-          )}
-        </div>
-
-        <div className="grid grid-cols-4 gap-2 rounded-md border p-2.5">
-          <Metric
-            label={i18next.t("llm:Requests")}
-            value={counted ? ((spent ?? stats)?.requests ?? 0).toLocaleString() : dash}
-            hint={
-              sourceHint ??
-              (stats && stats.failed > 0
-                ? `${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}`
-                : undefined)
-            }
-          />
-          <Metric
-            label={i18next.t("llm:Tokens")}
-            value={counted ? formatTokens(spent?.totalTokens ?? stats?.tokens ?? 0) : dash}
-            hint={sourceHint}
-          />
-          <Metric
-            label={i18next.t("llm:Cost")}
-            value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : dash}
-            hint={
-              spent && spent.unpriced > 0
-                ? i18next
-                  .t("llm:{count} of these requests have no list price")
-                  .replace("{count}", spent.unpriced.toLocaleString())
-                : sourceHint
-            }
-          />
-          <Metric
-            label={i18next.t("agent:Records")}
-            value={agent.patched ? (activity?.recordCount ?? 0).toLocaleString() : dash}
-            hint={
-              agent.patched
-                ? activity
-                  ? `${activity.sessionCount.toLocaleString()} ${i18next.t("agent:Agent Sessions")}`
-                  : undefined
-                : i18next.t("agent:Turn on monitoring to collect activity")
-            }
-            to={
-              agent.patched
-                ? `/agent-records?agent=${encodeURIComponent(monitorId)}`
-                : undefined
-            }
-          />
-        </div>
-
-        <PlanUsage quota={quota} />
-
-        {instances ? <AgentCardInstances agent={agent} controls={instances} /> : null}
-
-        <div className="mt-auto flex flex-wrap items-center gap-3 border-t pt-3">
-          <RunButton agent={agent} status={status} busy={runBusy} onToggle={onToggleRunning} />
-
-          <label className="text-muted-foreground flex items-center gap-1.5 text-xs">
-            {i18next.t("agent:Monitored")}
-            {agent.supported ? (
-              <ConfirmDialog
-                title={`${patchAction} ${agent.name}?`}
-                description={[agent.notice, agent.followup].filter(Boolean).join(" ") || undefined}
-                confirmText={patchAction}
-                variant={agent.patched ? "destructive" : "default"}
-                onConfirm={onTogglePatch}
-              >
-                {/* The dialog owns the click, so the switch only ever mirrors
-                    what the last scan reported. */}
-                <Switch
-                  checked={agent.patched}
-                  disabled={busy}
-                  aria-label={patchAction}
-                  onCheckedChange={() => undefined}
-                />
-              </ConfirmDialog>
-            ) : (
-              <SimpleTooltip title={agent.detail || i18next.t("agent:Not supported")}>
-                <span>
-                  <Switch checked={false} disabled aria-label={i18next.t("agent:Patch")} />
-                </span>
-              </SimpleTooltip>
-            )}
-          </label>
-
-          <Link
-            to={agentDetailPath(agent, agents)}
-            className="text-primary ml-auto inline-flex items-center text-sm hover:underline"
-          >
-            {i18next.t("agent:Details")}
-            <ChevronRight className="size-4" />
-          </Link>
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Link} from "react-router-dom";
+import {Bot, ChevronRight, UserRound} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {AgentCardInstances} from "@/components/AgentInstances";
+import {RunButton, RunDot} from "@/components/AgentRunControl";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {QuotaBadge} from "@/components/ProviderQuota";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Card} from "@/components/ui/card";
+import {Progress} from "@/components/ui/progress";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  agentBuiltin,
+  agentCanUse,
+  agentDetailPath,
+  builtinProvider,
+  directMode,
+  type AgentInstanceControls,
+} from "@/lib/agents";
+import {providerIdOf, providerProtocol} from "@/lib/providers";
+import {formatCost, formatTokens} from "@/lib/usage";
+import type {
+  Agent,
+  AgentAccount,
+  AgentRuntime,
+  AgentUsageStat,
+  LlmAgentStat,
+  Provider,
+  ProviderHealth,
+  ProviderQuota,
+} from "@/types";
+
+/** A hairline across the whole card, drawn out of a section that is padded. */
+function Divider() {
+  return <div className="border-border -mx-4 border-t" />;
+}
+
+/** One labelled line of the card, with the value pinned right. */
+function Row({label, children}: {label: React.ReactNode; children: React.ReactNode}) {
+  return (
+    <div className="flex min-w-0 items-center justify-between gap-2">
+      <span className="text-muted-foreground shrink-0 text-[11px]">{label}</span>
+      {children}
+    </div>
+  );
+}
+
+/** One number of the card, and what it is. */
+function Metric({
+  label,
+  value,
+  hint,
+}: {
+  label: React.ReactNode;
+  value: React.ReactNode;
+  hint?: React.ReactNode;
+}) {
+  const shown = <span className="block truncate text-[13px] font-medium tabular-nums">{value}</span>;
+
+  return (
+    <div className="min-w-0">
+      <span className="text-muted-foreground block truncate text-[11px]">{label}</span>
+      {hint ? (
+        <SimpleTooltip title={hint}>
+          <span className="block min-w-0">{shown}</span>
+        </SimpleTooltip>
+      ) : (
+        shown
+      )}
+    </div>
+  );
+}
+
+/** The label for a signed-in account: its name, or its email when unnamed. */
+export function accountLabel(account: AgentAccount) {
+  return account.name || account.email || "";
+}
+
+/** The signed-in account of an agent, shown under its path. */
+function AccountLine({account}: {account?: AgentAccount}) {
+  const label = account ? accountLabel(account) : "";
+  if (!label) {
+    return null;
+  }
+  const title = account?.email && account.email !== label ? `${label} · ${account.email}` : label;
+
+  return (
+    <SimpleTooltip title={title}>
+      <p className="text-muted-foreground flex items-center gap-1 truncate text-[11px]">
+        <UserRound className="size-3 shrink-0" />
+        <span className="truncate">{label}</span>
+      </p>
+    </SimpleTooltip>
+  );
+}
+
+/** What the vendor of the bound provider says is left on the plan. */
+function PlanUsage({quota}: {quota?: ProviderQuota}) {
+  // A provider nobody can read a balance from has nothing to show here.
+  if (!quota || (!quota.supported && quota.error === "")) {
+    return null;
+  }
+
+  const percent =
+    quota.total !== null && quota.total > 0 && quota.used !== null
+      ? Math.min(100, (quota.used / quota.total) * 100)
+      : null;
+
+  return (
+    <div className="space-y-1.5">
+      <Row label={i18next.t("provider:Plan usage")}>
+        <QuotaBadge quota={quota} />
+      </Row>
+      {percent === null ? null : (
+        <Progress
+          value={percent}
+          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "success"}
+          className="h-1"
+        />
+      )}
+    </div>
+  );
+}
+
+/**
+ * One agent installed on this machine, as the card the home page is built out
+ * of: what it is on now, what it has spent there, and the controls that change
+ * both. Picking another provider is the one click the page exists for.
+ */
+export function AgentGridCard({
+  agent,
+  agents,
+  providers,
+  health,
+  quota,
+  stats,
+  usage,
+  status,
+  instances,
+  recording,
+  busy,
+  runBusy,
+  onEnable,
+  onToggleRunning,
+  onTogglePatch,
+}: {
+  agent: Agent;
+  /** Every installation, which is what decides whether the link needs a path. */
+  agents: Agent[];
+  providers: Provider[];
+  health: ProviderHealth[];
+  quota?: ProviderQuota;
+  stats?: LlmAgentStat;
+  /**
+   * What this agent's own transcripts say it spent, which counts the requests
+   * it made straight to its vendor as well as the ones Gateway relayed.
+   */
+  usage?: AgentUsageStat;
+  status?: AgentRuntime;
+  /** The extra copies of this agent, for the agents that can run more than one. */
+  instances?: AgentInstanceControls;
+  /** False while llmRecordMode is off, when a zero would be a lie. */
+  recording: boolean;
+  busy: boolean;
+  runBusy: boolean;
+  onEnable: (providerId: string) => void;
+  onToggleRunning: (agent: Agent, running: boolean) => void;
+  onTogglePatch: () => void;
+}) {
+  const bound = providers.find(provider => providerIdOf(provider) === agent.provider);
+  const boundHealth = health.find(item => item.provider === agent.provider);
+  // An agent bound directly reads the provider's own answers, so only the ones
+  // it can speak to are offered; through the gateway they all are. Whatever it
+  // is on stays listed either way, so the box shows where it points today.
+  const options = providers.filter(
+    provider => agentCanUse(agent, provider) || providerIdOf(provider) === agent.provider,
+  );
+  const patchAction = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`);
+  const dash = <span className="text-muted-foreground">-</span>;
+  const offHint = recording ? undefined : i18next.t("llm:Recording is off");
+  // An agent's own transcript accounts for every request it made, the ones that
+  // never came near Gateway included, so it is the fuller of the two and wins
+  // wherever it has anything to say. The relayed totals answer for the agents
+  // that keep no transcript, and for the failures a transcript cannot show.
+  const spent = usage && usage.requests > 0 ? usage : undefined;
+  const sourceHint = spent ? i18next.t("agent:Read from the agent's own logs") : offHint;
+  // Off is only the truth about the relayed totals; a transcript is written
+  // whether or not Gateway is recording anything.
+  const counted = spent !== undefined || recording;
+  const lastModel = spent?.lastModel || stats?.lastModel;
+  const lastTime = spent?.lastTime || stats?.lastTime;
+
+  return (
+    <Card className="hover:border-foreground/25 gap-0 overflow-hidden py-0 shadow-xs transition-colors">
+      <div className="flex flex-1 flex-col gap-3 p-4">
+        <div className="flex items-start gap-2.5">
+          <AgentIcon
+            agent={agent.agentId || agent.name}
+            size={22}
+            fallback={<Bot className="text-muted-foreground size-5" />}
+          />
+          <div className="min-w-0 flex-1">
+            <Link
+              to={agentDetailPath(agent, agents)}
+              className="block truncate text-sm font-medium hover:underline"
+            >
+              {agent.name}
+            </Link>
+            <SimpleTooltip title={agent.path}>
+              <p className="text-muted-foreground/80 truncate font-mono text-[11px]">
+                <span className="tabular-nums">{agent.version || i18next.t("agent:Unknown")}</span>
+                <span className="mx-1">·</span>
+                {agent.path}
+              </p>
+            </SimpleTooltip>
+            <AccountLine account={agent.account} />
+          </div>
+          <RunDot status={status} />
+        </div>
+
+        <Divider />
+
+        <div className="space-y-1.5">
+          <Row label={i18next.t("agent:Provider")}>
+            <span className="flex min-w-0 items-center gap-2">
+              {boundHealth && !boundHealth.healthy ? (
+                <SimpleTooltip title={boundHealth.lastError}>
+                  <span className="text-warning truncate text-[11px]">
+                    {i18next.t("agent:Cooling down")}
+                  </span>
+                </SimpleTooltip>
+              ) : null}
+              {bound === undefined ? null : (
+                <span className="text-muted-foreground truncate text-[11px]">
+                  {i18next.t(agent.mode === directMode ? "agent:Direct" : "agent:Gateway")}
+                </span>
+              )}
+            </span>
+          </Row>
+
+          <Select
+            value={agent.provider === "" ? builtinProvider : agent.provider}
+            disabled={busy}
+            onValueChange={value => onEnable(value === builtinProvider ? "" : value)}
+          >
+            <SelectTrigger size="sm" className="w-full text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={builtinProvider}>
+                {agentBuiltin(agent)}
+                <span className="text-muted-foreground ml-2 text-xs">
+                  {i18next.t("agent:Built-in")}
+                </span>
+              </SelectItem>
+              {options.map(provider => (
+                <SelectItem key={providerIdOf(provider)} value={providerIdOf(provider)}>
+                  <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} size={16} />
+                  {provider.displayName || provider.name}
+                  <span className="text-muted-foreground ml-2 text-xs">
+                    {providerProtocol(provider.type)}
+                  </span>
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+
+          <Row label={i18next.t("agent:Model")}>
+            {lastModel ? (
+              <SimpleTooltip
+                title={`${i18next.t("agent:Last activity")}: ${new Date(lastTime ?? "").toLocaleString()}`}
+              >
+                <span className="min-w-0 truncate font-mono text-[11px]">{lastModel}</span>
+              </SimpleTooltip>
+            ) : (
+              <span className="text-muted-foreground text-[11px]">-</span>
+            )}
+          </Row>
+        </div>
+
+        <Divider />
+
+        <div className="space-y-3">
+          <div className="grid grid-cols-3 gap-1.5">
+            <Metric
+              label={i18next.t("llm:Requests")}
+              value={counted ? ((spent ?? stats)?.requests ?? 0).toLocaleString() : dash}
+              hint={
+                sourceHint ??
+                (stats && stats.failed > 0
+                  ? `${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}`
+                  : undefined)
+              }
+            />
+            <Metric
+              label={i18next.t("llm:Tokens")}
+              value={counted ? formatTokens(spent?.totalTokens ?? stats?.tokens ?? 0) : dash}
+              hint={sourceHint}
+            />
+            <Metric
+              label={i18next.t("llm:Cost")}
+              value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : dash}
+              hint={
+                spent && spent.unpriced > 0
+                  ? i18next
+                    .t("llm:{count} of these requests have no list price")
+                    .replace("{count}", spent.unpriced.toLocaleString())
+                  : sourceHint
+              }
+            />
+          </div>
+
+          <PlanUsage quota={quota} />
+
+          {instances ? <AgentCardInstances agent={agent} controls={instances} /> : null}
+        </div>
+      </div>
+
+      <div className="bg-muted/40 mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 border-t px-3 py-2">
+        <RunButton
+          agent={agent}
+          status={status}
+          busy={runBusy}
+          className="h-7 px-2 text-xs"
+          onToggle={onToggleRunning}
+        />
+
+        <label className="text-muted-foreground flex items-center gap-1.5 text-[11px]">
+          {i18next.t("agent:Monitored")}
+          {agent.supported ? (
+            <ConfirmDialog
+              title={`${patchAction} ${agent.name}?`}
+              description={[agent.notice, agent.followup].filter(Boolean).join(" ") || undefined}
+              confirmText={patchAction}
+              variant={agent.patched ? "destructive" : "default"}
+              onConfirm={onTogglePatch}
+            >
+              {/* The dialog owns the click, so the switch only ever mirrors
+                  what the last scan reported. */}
+              <Switch
+                checked={agent.patched}
+                disabled={busy}
+                aria-label={patchAction}
+                onCheckedChange={() => undefined}
+              />
+            </ConfirmDialog>
+          ) : (
+            <SimpleTooltip title={agent.detail || i18next.t("agent:Not supported")}>
+              <span>
+                <Switch checked={false} disabled aria-label={i18next.t("agent:Patch")} />
+              </span>
+            </SimpleTooltip>
+          )}
+        </label>
+
+        <Link
+          to={agentDetailPath(agent, agents)}
+          className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center text-xs"
+        >
+          {i18next.t("agent:Details")}
+          <ChevronRight className="size-3.5" />
+        </Link>
+      </div>
+    </Card>
+  );
+}
diff --git a/web/src/components/AgentInstances.tsx b/web/src/components/AgentInstances.tsx
index 757bf7c..1332164 100644
--- a/web/src/components/AgentInstances.tsx
+++ b/web/src/components/AgentInstances.tsx
@@ -1,314 +1,313 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import {Play, Plus, RefreshCw, Square, Trash2, UserPlus, Users} from "lucide-react";
-import i18next from "i18next";
-
-import {accountLabel} from "@/components/AgentGridCard";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {DataTable, type Column} from "@/components/shared/data-table";
-import {CodeText} from "@/components/shared/misc";
-import {Badge} from "@/components/ui/badge";
-import {Button} from "@/components/ui/button";
-import {Input} from "@/components/ui/input";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {instancesOf, useAgentInstances, type AgentInstanceControls} from "@/lib/agents";
-import {cn} from "@/lib/utils";
-import type {Agent, AgentInstance} from "@/types";
-
-/** The label of one instance: whoever is signed in to it, or what it was named. */
-function instanceLabel(instance: AgentInstance) {
-  const signedIn = instance.account ? accountLabel(instance.account) : "";
-  return signedIn || instance.displayName || instance.instance;
-}
-
-/** Adds one more copy, which the server names and lays out on its own. */
-function AddInstanceButton({
-  agent,
-  controls,
-  compact = false,
-}: {
-  agent: Agent;
-  controls: AgentInstanceControls;
-  compact?: boolean;
-}) {
-  const label = i18next.t("agent:Add instance");
-  const busy = controls.busyName === agent.agentId;
-
-  if (!compact) {
-    return (
-      <Button size="sm" loading={busy} onClick={() => controls.add(agent)}>
-        <Plus />
-        {label}
-      </Button>
-    );
-  }
-  return (
-    <SimpleTooltip title={label}>
-      <Button
-        size="icon"
-        variant="ghost"
-        className="size-6"
-        loading={busy}
-        aria-label={label}
-        onClick={() => controls.add(agent)}
-      >
-        <Plus />
-      </Button>
-    </SimpleTooltip>
-  );
-}
-
-/**
- * The name of one instance, editable where it is listed. A copy is added in one
- * click and numbered by the server, so this is where it is given a name worth
- * reading - once there is an account in it to name it after.
- */
-function InstanceName({
-  instance,
-  onRename,
-}: {
-  instance: AgentInstance;
-  onRename: (instance: AgentInstance, displayName: string) => void;
-}) {
-  const [value, setValue] = React.useState(instance.displayName);
-
-  // A reload brings the stored name back, which is what a rejected rename has
-  // to fall back to.
-  React.useEffect(() => setValue(instance.displayName), [instance.displayName]);
-
-  return (
-    <Input
-      value={value}
-      placeholder={instance.instance}
-      aria-label={i18next.t("general:Name")}
-      className="hover:border-input h-7 border-transparent px-1 font-medium shadow-none"
-      onChange={event => setValue(event.target.value)}
-      onBlur={() => {
-        const named = value.trim();
-        if (named !== instance.displayName) {
-          onRename(instance, named);
-        }
-      }}
-      onKeyDown={event => {
-        if (event.key === "Enter") {
-          event.currentTarget.blur();
-        } else if (event.key === "Escape") {
-          setValue(instance.displayName);
-        }
-      }}
-    />
-  );
-}
-
-/** Starts or stops one copy, as the icon that says which it would do. */
-function InstanceRunButton({
-  instance,
-  busy,
-  onToggle,
-}: {
-  instance: AgentInstance;
-  busy: boolean;
-  onToggle: (instance: AgentInstance) => void;
-}) {
-  const label = i18next.t(instance.running ? "agent:Stop" : "agent:Start");
-  return (
-    <SimpleTooltip title={instance.running || instance.canStart ? label : instance.detail}>
-      <span>
-        <Button
-          size="icon"
-          variant="ghost"
-          className="size-6"
-          disabled={!instance.running && !instance.canStart}
-          loading={busy}
-          aria-label={label}
-          onClick={() => onToggle(instance)}
-        >
-          {instance.running ? <Square /> : <Play />}
-        </Button>
-      </span>
-    </SimpleTooltip>
-  );
-}
-
-/**
- * The extra copies of one agent. Each is started against a state directory of
- * its own, so each signs in separately and the two run side by side without
- * seeing each other's sessions.
- */
-export function AgentInstances({agent, enabled = true}: {agent: Agent; enabled?: boolean}) {
-  const controls = useAgentInstances(agent.agentId, enabled && agent.supportsInstances === true);
-
-  if (!agent.supportsInstances) {
-    return null;
-  }
-
-  const columns: Column<AgentInstance>[] = [
-    {
-      title: i18next.t("general:Name"),
-      key: "instance",
-      dataIndex: "instance",
-      render: (_value, record) => <InstanceName instance={record} onRename={controls.rename} />,
-    },
-    {
-      title: i18next.t("agent:Account"),
-      key: "account",
-      render: (_value, record) =>
-        record.account && accountLabel(record.account) ? (
-          <div className="flex flex-col">
-            <span>{accountLabel(record.account)}</span>
-            {record.account.email && record.account.email !== accountLabel(record.account) ? (
-              <code className="text-muted-foreground text-xs">{record.account.email}</code>
-            ) : null}
-          </div>
-        ) : (
-          <SimpleTooltip title={i18next.t("agent:Not signed in hint")}>
-            <span className="text-muted-foreground inline-flex items-center gap-1">
-              <UserPlus className="size-3.5" />
-              {i18next.t("agent:Not signed in")}
-            </span>
-          </SimpleTooltip>
-        ),
-    },
-    {
-      title: i18next.t("agent:Run Status"),
-      key: "running",
-      render: (_value, record) =>
-        record.running ? (
-          <SimpleTooltip title={`${i18next.t("agent:Processes")}: ${record.pids.join(", ")}`}>
-            <span>
-              <Badge variant="success">{i18next.t("agent:Running")}</Badge>
-            </span>
-          </SimpleTooltip>
-        ) : (
-          <SimpleTooltip title={record.detail}>
-            <span>
-              <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
-            </span>
-          </SimpleTooltip>
-        ),
-    },
-    {
-      title: i18next.t("agent:State directory"),
-      key: "dataDir",
-      dataIndex: "dataDir",
-      ellipsis: true,
-      render: (value: string) => <CodeText copyable>{value}</CodeText>,
-    },
-    {
-      title: i18next.t("general:Action"),
-      key: "action",
-      render: (_value, record) => (
-        <div className="flex items-center gap-2">
-          <InstanceRunButton
-            instance={record}
-            busy={controls.busyName === record.name}
-            onToggle={controls.toggleRunning}
-          />
-          <ConfirmDialog
-            title={`${i18next.t("agent:Remove instance")}: ${instanceLabel(record)}?`}
-            description={i18next.t("agent:Remove instance hint")}
-            confirmText={i18next.t("agent:Remove instance")}
-            variant="destructive"
-            onConfirm={() => controls.remove(record)}
-          >
-            <Button size="icon" variant="ghost" className="size-6">
-              <Trash2 />
-            </Button>
-          </ConfirmDialog>
-        </div>
-      ),
-    },
-  ];
-
-  return (
-    <DataTable
-      title={i18next.t("agent:Instances")}
-      description={i18next.t("agent:Instances hint")}
-      columns={columns}
-      dataSource={controls.instances}
-      rowKey={instance => instance.name}
-      loading={controls.loading}
-      pageSize={0}
-      emptyText={i18next.t("agent:No instances yet")}
-      toolbar={
-        <div className="flex items-center gap-2">
-          <Button
-            variant="outline"
-            size="sm"
-            onClick={() => controls.reload(true)}
-            loading={controls.loading}
-          >
-            <RefreshCw />
-            {i18next.t("agent:Scan")}
-          </Button>
-          <AddInstanceButton agent={agent} controls={controls} />
-        </div>
-      }
-    />
-  );
-}
-
-/**
- * The instances of one agent as a grid card shows them: an icon for the section,
- * one line per copy with the account it is signed in to, and the control that
- * starts or stops it. The card has no room for words about any of it, so the
- * names live in the tooltips and the renaming on the detail page.
- */
-export function AgentCardInstances({
-  agent,
-  controls,
-}: {
-  agent: Agent;
-  controls: AgentInstanceControls;
-}) {
-  if (!agent.supportsInstances) {
-    return null;
-  }
-
-  const instances = instancesOf(controls.instances, agent);
-  return (
-    <div className="space-y-1.5">
-      <div className="flex items-center justify-between gap-2">
-        <SimpleTooltip title={i18next.t("agent:Instances")}>
-          <span className="text-muted-foreground flex items-center gap-1 text-xs">
-            <Users className="size-3.5" />
-            {instances.length > 0 ? instances.length : null}
-          </span>
-        </SimpleTooltip>
-        <AddInstanceButton agent={agent} controls={controls} compact />
-      </div>
-
-      {instances.map(instance => (
-        <div key={instance.name} className="flex items-center gap-2">
-          <span
-            className={cn(
-              "size-1.5 shrink-0 rounded-full",
-              instance.running ? "bg-success" : "bg-muted-foreground/40",
-            )}
-          />
-          <SimpleTooltip title={instance.account ? instance.account.email : instance.dataDir}>
-            <span className="min-w-0 flex-1 truncate text-xs">{instanceLabel(instance)}</span>
-          </SimpleTooltip>
-          <InstanceRunButton
-            instance={instance}
-            busy={controls.busyName === instance.name}
-            onToggle={controls.toggleRunning}
-          />
-        </div>
-      ))}
-    </div>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Play, Plus, RefreshCw, Square, Trash2, UserPlus, Users} from "lucide-react";
+import i18next from "i18next";
+
+import {accountLabel} from "@/components/AgentGridCard";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeText} from "@/components/shared/misc";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {instancesOf, useAgentInstances, type AgentInstanceControls} from "@/lib/agents";
+import {cn} from "@/lib/utils";
+import type {Agent, AgentInstance} from "@/types";
+
+/** The label of one instance: whoever is signed in to it, or what it was named. */
+function instanceLabel(instance: AgentInstance) {
+  const signedIn = instance.account ? accountLabel(instance.account) : "";
+  return signedIn || instance.displayName || instance.instance;
+}
+
+/** Adds one more copy, which the server names and lays out on its own. */
+function AddInstanceButton({
+  agent,
+  controls,
+  compact = false,
+}: {
+  agent: Agent;
+  controls: AgentInstanceControls;
+  compact?: boolean;
+}) {
+  const label = i18next.t("agent:Add instance");
+  const busy = controls.busyName === agent.agentId;
+
+  if (!compact) {
+    return (
+      <Button size="sm" loading={busy} onClick={() => controls.add(agent)}>
+        <Plus />
+        {label}
+      </Button>
+    );
+  }
+  return (
+    <SimpleTooltip title={label}>
+      <Button
+        size="icon"
+        variant="ghost"
+        className="size-6"
+        loading={busy}
+        aria-label={label}
+        onClick={() => controls.add(agent)}
+      >
+        <Plus />
+      </Button>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The name of one instance, editable where it is listed. A copy is added in one
+ * click and numbered by the server, so this is where it is given a name worth
+ * reading - once there is an account in it to name it after.
+ */
+function InstanceName({
+  instance,
+  onRename,
+}: {
+  instance: AgentInstance;
+  onRename: (instance: AgentInstance, displayName: string) => void;
+}) {
+  const [value, setValue] = React.useState(instance.displayName);
+
+  // A reload brings the stored name back, which is what a rejected rename has
+  // to fall back to.
+  React.useEffect(() => setValue(instance.displayName), [instance.displayName]);
+
+  return (
+    <Input
+      value={value}
+      placeholder={instance.instance}
+      aria-label={i18next.t("general:Name")}
+      className="hover:border-input h-7 border-transparent px-1 font-medium shadow-none"
+      onChange={event => setValue(event.target.value)}
+      onBlur={() => {
+        const named = value.trim();
+        if (named !== instance.displayName) {
+          onRename(instance, named);
+        }
+      }}
+      onKeyDown={event => {
+        if (event.key === "Enter") {
+          event.currentTarget.blur();
+        } else if (event.key === "Escape") {
+          setValue(instance.displayName);
+        }
+      }}
+    />
+  );
+}
+
+/** Starts or stops one copy, as the icon that says which it would do. */
+function InstanceRunButton({
+  instance,
+  busy,
+  onToggle,
+}: {
+  instance: AgentInstance;
+  busy: boolean;
+  onToggle: (instance: AgentInstance) => void;
+}) {
+  const label = i18next.t(instance.running ? "agent:Stop" : "agent:Start");
+  return (
+    <SimpleTooltip title={instance.running || instance.canStart ? label : instance.detail}>
+      <span>
+        <Button
+          size="icon"
+          variant="ghost"
+          className="size-6"
+          disabled={!instance.running && !instance.canStart}
+          loading={busy}
+          aria-label={label}
+          onClick={() => onToggle(instance)}
+        >
+          {instance.running ? <Square /> : <Play />}
+        </Button>
+      </span>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The extra copies of one agent. Each is started against a state directory of
+ * its own, so each signs in separately and the two run side by side without
+ * seeing each other's sessions.
+ */
+export function AgentInstances({agent, enabled = true}: {agent: Agent; enabled?: boolean}) {
+  const controls = useAgentInstances(agent.agentId, enabled && agent.supportsInstances === true);
+
+  if (!agent.supportsInstances) {
+    return null;
+  }
+
+  const columns: Column<AgentInstance>[] = [
+    {
+      title: i18next.t("general:Name"),
+      key: "instance",
+      dataIndex: "instance",
+      render: (_value, record) => <InstanceName instance={record} onRename={controls.rename} />,
+    },
+    {
+      title: i18next.t("agent:Account"),
+      key: "account",
+      render: (_value, record) =>
+        record.account && accountLabel(record.account) ? (
+          <div className="flex flex-col">
+            <span>{accountLabel(record.account)}</span>
+            {record.account.email && record.account.email !== accountLabel(record.account) ? (
+              <code className="text-muted-foreground text-xs">{record.account.email}</code>
+            ) : null}
+          </div>
+        ) : (
+          <SimpleTooltip title={i18next.t("agent:Not signed in hint")}>
+            <span className="text-muted-foreground inline-flex items-center gap-1">
+              <UserPlus className="size-3.5" />
+              {i18next.t("agent:Not signed in")}
+            </span>
+          </SimpleTooltip>
+        ),
+    },
+    {
+      title: i18next.t("agent:Run Status"),
+      key: "running",
+      render: (_value, record) =>
+        record.running ? (
+          <SimpleTooltip title={`${i18next.t("agent:Processes")}: ${record.pids.join(", ")}`}>
+            <span>
+              <Badge variant="success">{i18next.t("agent:Running")}</Badge>
+            </span>
+          </SimpleTooltip>
+        ) : (
+          <SimpleTooltip title={record.detail}>
+            <span>
+              <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
+            </span>
+          </SimpleTooltip>
+        ),
+    },
+    {
+      title: i18next.t("agent:State directory"),
+      key: "dataDir",
+      dataIndex: "dataDir",
+      ellipsis: true,
+      render: (value: string) => <CodeText copyable>{value}</CodeText>,
+    },
+    {
+      title: i18next.t("general:Action"),
+      key: "action",
+      render: (_value, record) => (
+        <div className="flex items-center gap-2">
+          <InstanceRunButton
+            instance={record}
+            busy={controls.busyName === record.name}
+            onToggle={controls.toggleRunning}
+          />
+          <ConfirmDialog
+            title={`${i18next.t("agent:Remove instance")}: ${instanceLabel(record)}?`}
+            description={i18next.t("agent:Remove instance hint")}
+            confirmText={i18next.t("agent:Remove instance")}
+            variant="destructive"
+            onConfirm={() => controls.remove(record)}
+          >
+            <Button size="icon" variant="ghost" className="size-6">
+              <Trash2 />
+            </Button>
+          </ConfirmDialog>
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <DataTable
+      title={i18next.t("agent:Instances")}
+      description={i18next.t("agent:Instances hint")}
+      columns={columns}
+      dataSource={controls.instances}
+      rowKey={instance => instance.name}
+      loading={controls.loading}
+      pageSize={0}
+      emptyText={i18next.t("agent:No instances yet")}
+      toolbar={
+        <div className="flex items-center gap-2">
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => controls.reload(true)}
+            loading={controls.loading}
+          >
+            <RefreshCw />
+            {i18next.t("agent:Scan")}
+          </Button>
+          <AddInstanceButton agent={agent} controls={controls} />
+        </div>
+      }
+    />
+  );
+}
+
+/**
+ * The instances of one agent as a grid card shows them: an icon for the section,
+ * one line per copy with the account it is signed in to, and the control that
+ * starts or stops it. The card has no room for words about any of it, so the
+ * names live in the tooltips and the renaming on the detail page.
+ */
+export function AgentCardInstances({
+  agent,
+  controls,
+}: {
+  agent: Agent;
+  controls: AgentInstanceControls;
+}) {
+  if (!agent.supportsInstances) {
+    return null;
+  }
+
+  const instances = instancesOf(controls.instances, agent);
+  return (
+    <div className="space-y-1">
+      <div className="flex items-center justify-between gap-2">
+        <span className="text-muted-foreground flex items-center gap-1 text-[11px]">
+          <Users className="size-3" />
+          {i18next.t("agent:Instances")}
+          {instances.length > 0 ? ` · ${instances.length}` : null}
+        </span>
+        <AddInstanceButton agent={agent} controls={controls} compact />
+      </div>
+
+      {instances.map(instance => (
+        <div key={instance.name} className="flex items-center gap-2">
+          <span
+            className={cn(
+              "size-1.5 shrink-0 rounded-full",
+              instance.running ? "bg-success" : "bg-muted-foreground/40",
+            )}
+          />
+          <SimpleTooltip title={instance.account ? instance.account.email : instance.dataDir}>
+            <span className="min-w-0 flex-1 truncate text-[11px]">{instanceLabel(instance)}</span>
+          </SimpleTooltip>
+          <InstanceRunButton
+            instance={instance}
+            busy={controls.busyName === instance.name}
+            onToggle={controls.toggleRunning}
+          />
+        </div>
+      ))}
+    </div>
+  );
+}
diff --git a/web/src/components/AgentRunControl.tsx b/web/src/components/AgentRunControl.tsx
index 6734a13..25d1924 100644
--- a/web/src/components/AgentRunControl.tsx
+++ b/web/src/components/AgentRunControl.tsx
@@ -1,95 +1,126 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Play, Square} from "lucide-react";
-import i18next from "i18next";
-
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {Badge} from "@/components/ui/badge";
-import {Button} from "@/components/ui/button";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import type {Agent, AgentRuntime} from "@/types";
-
-/** Whether the installation has live processes, with its pids on hover. */
-export function RunBadge({status}: {status?: AgentRuntime}) {
-  if (!status) {
-    return <span className="text-muted-foreground">-</span>;
-  }
-  if (!status.running) {
-    return (
-      <SimpleTooltip title={status.detail}>
-        <span>
-          <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
-        </span>
-      </SimpleTooltip>
-    );
-  }
-  return (
-    <SimpleTooltip title={`${i18next.t("agent:Processes")}: ${status.pids.join(", ")}`}>
-      <span>
-        <Badge variant="success">{i18next.t("agent:Running")}</Badge>
-      </span>
-    </SimpleTooltip>
-  );
-}
-
-/**
- * The start/stop control. Starting is harmless enough to happen on the click,
- * while stopping ends work in progress and is confirmed first.
- */
-export function RunButton({
-  agent,
-  status,
-  busy,
-  onToggle,
-}: {
-  agent: Agent;
-  status?: AgentRuntime;
-  busy: boolean;
-  onToggle: (agent: Agent, running: boolean) => void;
-}) {
-  if (status?.running) {
-    return (
-      <ConfirmDialog
-        title={`${i18next.t("agent:Stop")} ${agent.name}?`}
-        description={i18next.t("agent:Stop hint")}
-        confirmText={i18next.t("agent:Stop")}
-        onConfirm={() => onToggle(agent, true)}
-      >
-        <Button size="sm" variant="outline" loading={busy}>
-          <Square />
-          {i18next.t("agent:Stop")}
-        </Button>
-      </ConfirmDialog>
-    );
-  }
-
-  const startable = status?.canStart === true;
-  return (
-    <SimpleTooltip title={startable ? "" : status?.detail}>
-      <span>
-        <Button
-          size="sm"
-          variant="outline"
-          disabled={!startable}
-          loading={busy}
-          onClick={() => onToggle(agent, false)}
-        >
-          <Play />
-          {i18next.t("agent:Start")}
-        </Button>
-      </span>
-    </SimpleTooltip>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Play, Square} from "lucide-react";
+import i18next from "i18next";
+
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import type {Agent, AgentRuntime} from "@/types";
+
+/** Whether the installation has live processes, with its pids on hover. */
+export function RunBadge({status}: {status?: AgentRuntime}) {
+  if (!status) {
+    return <span className="text-muted-foreground">-</span>;
+  }
+  if (!status.running) {
+    return (
+      <SimpleTooltip title={status.detail}>
+        <span>
+          <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
+        </span>
+      </SimpleTooltip>
+    );
+  }
+  return (
+    <SimpleTooltip title={`${i18next.t("agent:Processes")}: ${status.pids.join(", ")}`}>
+      <span>
+        <Badge variant="success">{i18next.t("agent:Running")}</Badge>
+      </span>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The same state as a dot, for the cards, where a filled badge shouts and there
+ * is no width for a word. The button below it says which of the two it is in
+ * full, so the dot is the glance rather than the only telling.
+ */
+export function RunDot({status}: {status?: AgentRuntime}) {
+  if (!status) {
+    return null;
+  }
+  const state = i18next.t(status.running ? "agent:Running" : "agent:Stopped");
+  const detail = status.running
+    ? `${i18next.t("agent:Processes")}: ${status.pids.join(", ")}`
+    : status.detail;
+
+  return (
+    <SimpleTooltip title={detail ? `${state} · ${detail}` : state}>
+      <span
+        className={cn(
+          "mt-1.5 size-1.5 shrink-0 rounded-full",
+          status.running ? "bg-success ring-success/20 ring-2" : "bg-muted-foreground/40",
+        )}
+      />
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The start/stop control. Starting is harmless enough to happen on the click,
+ * while stopping ends work in progress and is confirmed first.
+ */
+export function RunButton({
+  agent,
+  status,
+  busy,
+  className,
+  onToggle,
+}: {
+  agent: Agent;
+  status?: AgentRuntime;
+  busy: boolean;
+  /** The cards run it smaller than a table row does. */
+  className?: string;
+  onToggle: (agent: Agent, running: boolean) => void;
+}) {
+  if (status?.running) {
+    return (
+      <ConfirmDialog
+        title={`${i18next.t("agent:Stop")} ${agent.name}?`}
+        description={i18next.t("agent:Stop hint")}
+        confirmText={i18next.t("agent:Stop")}
+        onConfirm={() => onToggle(agent, true)}
+      >
+        <Button size="sm" variant="outline" className={className} loading={busy}>
+          <Square />
+          {i18next.t("agent:Stop")}
+        </Button>
+      </ConfirmDialog>
+    );
+  }
+
+  const startable = status?.canStart === true;
+  return (
+    <SimpleTooltip title={startable ? "" : status?.detail}>
+      <span>
+        <Button
+          size="sm"
+          variant="outline"
+          className={className}
+          disabled={!startable}
+          loading={busy}
+          onClick={() => onToggle(agent, false)}
+        >
+          <Play />
+          {i18next.t("agent:Start")}
+        </Button>
+      </span>
+    </SimpleTooltip>
+  );
+}
diff --git a/web/src/components/AuthenticityOverview.tsx b/web/src/components/AuthenticityOverview.tsx
index 8824dba..921fcc6 100644
--- a/web/src/components/AuthenticityOverview.tsx
+++ b/web/src/components/AuthenticityOverview.tsx
@@ -1,174 +1,174 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import {Link} from "react-router-dom";
-import {ShieldCheck} from "lucide-react";
-import i18next from "i18next";
-
-import * as ProviderBackend from "@/backend/ProviderBackend";
-import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore";
-import {ProviderIcon} from "@/components/ProviderIcon";
-import {Badge} from "@/components/ui/badge";
-import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
-import {cn} from "@/lib/utils";
-import {gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity";
-import {providerIdOf} from "@/lib/providers";
-import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types";
-
-/** How often the scores are read again while the page is open. */
-const refreshMs = 20000;
-
-/** Fills {name} placeholders, which is all the interpolation these strings need. */
-function fill(key: string, values: Record<string, string | number>) {
-  return Object.entries(values).reduce(
-    (text, [name, value]) => text.split(`{${name}}`).join(String(value)),
-    i18next.t(key),
-  );
-}
-
-/** One provider's line: who it is, what it scored, and what failed if anything. */
-function ProviderScore({provider, probe}: {provider: Provider; probe?: ProviderProbe}) {
-  const {alerts, warnings} = probeFindings(probe);
-  const name = provider.displayName || provider.name;
-
-  return (
-    <div className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2">
-      <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} size={18} />
-      <span className="truncate text-sm">{name}</span>
-      <div className="ml-auto flex shrink-0 items-center gap-2">
-        {alerts.length > 0 ? (
-          <Badge variant="danger">{fill("audit:Failed cases", {count: alerts.length})}</Badge>
-        ) : warnings.length > 0 ? (
-          <Badge variant="warning">{fill("audit:Flagged cases", {count: warnings.length})}</Badge>
-        ) : null}
-        <ScoreBadge probe={probe} showLabel={false} />
-      </div>
-    </div>
-  );
-}
-
-/**
- * Whether the APIs on this machine are what they are sold as, in one card. It
- * is measured without being asked for: every provider is probed when it is
- * added and again once its report goes stale, so this says something before
- * anyone thinks to check.
- */
-export function AuthenticityOverview({
-  providers,
-  showLink = true,
-  className,
-}: {
-  providers: Provider[];
-  /** False on the page this links to, where the link would point at itself. */
-  showLink?: boolean;
-  className?: string;
-}) {
-  const [probes, setProbes] = React.useState<ProviderProbe[]>([]);
-  const [mode, setMode] = React.useState<ProviderProbeMode>("auto");
-  const [loaded, setLoaded] = React.useState(false);
-
-  React.useEffect(() => {
-    const load = () => {
-      ProviderBackend.getProviderProbes()
-        .then(res => {
-          if (res.status !== "ok") {
-            return;
-          }
-          setProbes(res.data ?? []);
-          setMode(res.data2 ?? "auto");
-        })
-        .catch(() => undefined)
-        .then(() => setLoaded(true));
-    };
-
-    load();
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, []);
-
-  const configured = providers.filter(provider => provider.status !== "disabled");
-  const byProvider = new Map(probes.map(probe => [probe.provider, probe]));
-  const scored = configured
-    .map(provider => ({provider: provider, probe: byProvider.get(providerIdOf(provider))}))
-    .sort((left, right) => {
-      const score = (entry: {probe?: ProviderProbe}) =>
-        entry.probe?.ok && entry.probe.grade !== "unknown" ? entry.probe.score : Number.POSITIVE_INFINITY;
-      return score(left) - score(right);
-    });
-
-  const summary = summarizeAuthenticity(
-    scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe),
-    configured.length,
-  );
-  // The headline grade is the worst one held, not the average: a machine with
-  // four honest providers and one that is not is not four fifths fine.
-  const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe;
-  const style = gradeStyleOf(worst?.grade);
-
-  if (configured.length === 0) {
-    return null;
-  }
-
-  return (
-    <Card className={className}>
-      <CardContent className="flex flex-col gap-4 p-4">
-        <div className="flex flex-wrap items-center gap-4">
-          <ScoreDial probe={worst} size={88} />
-          <div className="flex min-w-0 flex-col gap-1">
-            <div className="flex items-center gap-2">
-              <ShieldCheck className={cn("size-4", style.text)} />
-              <span className="text-sm font-semibold">{i18next.t("audit:Authenticity")}</span>
-            </div>
-            <span className={cn("text-sm", style.text)}>
-              {summary.graded === 0
-                ? i18next.t(mode === "off" ? "audit:Probing is off" : "audit:Measuring now")
-                : i18next.t(style.verdict)}
-            </span>
-            <span className="text-muted-foreground text-xs">
-              {fill("audit:Graded providers", {
-                graded: summary.graded,
-                total: configured.length,
-              })}
-              {summary.alerting > 0 ? ` · ${fill("audit:Providers alerting", {count: summary.alerting})}` : ""}
-              {summary.ungraded > 0 ? ` · ${fill("audit:Providers unmeasured", {count: summary.ungraded})}` : ""}
-            </span>
-          </div>
-
-          {showLink ? (
-            <Button asChild variant="outline" className="ml-auto">
-              <Link to="/authenticity">
-                <ShieldCheck />
-                {i18next.t("audit:See the evidence")}
-              </Link>
-            </Button>
-          ) : null}
-        </div>
-
-        {loaded ? (
-          <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
-            {scored.map(entry => (
-              <ProviderScore
-                key={providerIdOf(entry.provider)}
-                provider={entry.provider}
-                probe={entry.probe}
-              />
-            ))}
-          </div>
-        ) : null}
-      </CardContent>
-    </Card>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Link} from "react-router-dom";
+import {ShieldCheck} from "lucide-react";
+import i18next from "i18next";
+
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import {cn} from "@/lib/utils";
+import {gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity";
+import {providerIdOf} from "@/lib/providers";
+import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types";
+
+/** How often the scores are read again while the page is open. */
+const refreshMs = 20000;
+
+/** Fills {name} placeholders, which is all the interpolation these strings need. */
+function fill(key: string, values: Record<string, string | number>) {
+  return Object.entries(values).reduce(
+    (text, [name, value]) => text.split(`{${name}}`).join(String(value)),
+    i18next.t(key),
+  );
+}
+
+/** One provider's line: who it is, what it scored, and what failed if anything. */
+function ProviderScore({provider, probe}: {provider: Provider; probe?: ProviderProbe}) {
+  const {alerts, warnings} = probeFindings(probe);
+  const name = provider.displayName || provider.name;
+
+  return (
+    <div className="hover:border-foreground/20 flex min-w-0 items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors">
+      <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} size={16} />
+      <span className="truncate text-xs">{name}</span>
+      <div className="ml-auto flex shrink-0 items-center gap-1.5">
+        {alerts.length > 0 ? (
+          <Badge variant="danger">{fill("audit:Failed cases", {count: alerts.length})}</Badge>
+        ) : warnings.length > 0 ? (
+          <Badge variant="warning">{fill("audit:Flagged cases", {count: warnings.length})}</Badge>
+        ) : null}
+        <ScoreBadge probe={probe} showLabel={false} />
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Whether the APIs on this machine are what they are sold as, in one card. It
+ * is measured without being asked for: every provider is probed when it is
+ * added and again once its report goes stale, so this says something before
+ * anyone thinks to check.
+ */
+export function AuthenticityOverview({
+  providers,
+  showLink = true,
+  className,
+}: {
+  providers: Provider[];
+  /** False on the page this links to, where the link would point at itself. */
+  showLink?: boolean;
+  className?: string;
+}) {
+  const [probes, setProbes] = React.useState<ProviderProbe[]>([]);
+  const [mode, setMode] = React.useState<ProviderProbeMode>("auto");
+  const [loaded, setLoaded] = React.useState(false);
+
+  React.useEffect(() => {
+    const load = () => {
+      ProviderBackend.getProviderProbes()
+        .then(res => {
+          if (res.status !== "ok") {
+            return;
+          }
+          setProbes(res.data ?? []);
+          setMode(res.data2 ?? "auto");
+        })
+        .catch(() => undefined)
+        .then(() => setLoaded(true));
+    };
+
+    load();
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, []);
+
+  const configured = providers.filter(provider => provider.status !== "disabled");
+  const byProvider = new Map(probes.map(probe => [probe.provider, probe]));
+  const scored = configured
+    .map(provider => ({provider: provider, probe: byProvider.get(providerIdOf(provider))}))
+    .sort((left, right) => {
+      const score = (entry: {probe?: ProviderProbe}) =>
+        entry.probe?.ok && entry.probe.grade !== "unknown" ? entry.probe.score : Number.POSITIVE_INFINITY;
+      return score(left) - score(right);
+    });
+
+  const summary = summarizeAuthenticity(
+    scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe),
+    configured.length,
+  );
+  // The headline grade is the worst one held, not the average: a machine with
+  // four honest providers and one that is not is not four fifths fine.
+  const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe;
+  const style = gradeStyleOf(worst?.grade);
+
+  if (configured.length === 0) {
+    return null;
+  }
+
+  return (
+    <Card className={cn("gap-0 py-0 shadow-xs", className)}>
+      <CardContent className="flex flex-col gap-3 p-4">
+        <div className="flex flex-wrap items-center gap-3">
+          <ScoreDial probe={worst} size={56} />
+          <div className="flex min-w-0 flex-col gap-0.5">
+            <div className="flex items-center gap-1.5">
+              <ShieldCheck className={cn("size-3.5", style.text)} />
+              <span className="text-sm font-medium">{i18next.t("audit:Authenticity")}</span>
+            </div>
+            <span className={cn("text-xs", style.text)}>
+              {summary.graded === 0
+                ? i18next.t(mode === "off" ? "audit:Probing is off" : "audit:Measuring now")
+                : i18next.t(style.verdict)}
+            </span>
+            <span className="text-muted-foreground text-[11px]">
+              {fill("audit:Graded providers", {
+                graded: summary.graded,
+                total: configured.length,
+              })}
+              {summary.alerting > 0 ? ` · ${fill("audit:Providers alerting", {count: summary.alerting})}` : ""}
+              {summary.ungraded > 0 ? ` · ${fill("audit:Providers unmeasured", {count: summary.ungraded})}` : ""}
+            </span>
+          </div>
+
+          {showLink ? (
+            <Button asChild variant="outline" size="sm" className="ml-auto">
+              <Link to="/authenticity">
+                <ShieldCheck />
+                {i18next.t("audit:See the evidence")}
+              </Link>
+            </Button>
+          ) : null}
+        </div>
+
+        {loaded ? (
+          <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+            {scored.map(entry => (
+              <ProviderScore
+                key={providerIdOf(entry.provider)}
+                provider={entry.provider}
+                probe={entry.probe}
+              />
+            ))}
+          </div>
+        ) : null}
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/components/AuthenticityScore.tsx b/web/src/components/AuthenticityScore.tsx
index 314f86b..801c811 100644
--- a/web/src/components/AuthenticityScore.tsx
+++ b/web/src/components/AuthenticityScore.tsx
@@ -43,6 +43,15 @@ export function ScoreDial({
   const radius = (size - stroke) / 2;
   const circumference = 2 * Math.PI * radius;
 
+  // Everything written here has to fit the square that fits inside the ring,
+  // which is what a small dial has no room for: it carries the letter alone
+  // rather than pushing "Grade A" out under the stroke.
+  const inner = (size - 2 * stroke) / Math.SQRT2;
+  const letter = gradeLetter(grade);
+  const label = `${i18next.t("audit:Grade")} ${letter}`;
+  const labelSize = Math.max(9, size / 8);
+  const spelled = label.length * labelSize * 0.62 <= inner;
+
   return (
     <div className={cn("relative shrink-0", className)} style={{width: size, height: size}}>
       <svg width={size} height={size} className="-rotate-90">
@@ -66,13 +75,21 @@ export function ScoreDial({
           stroke="currentColor"
         />
       </svg>
-      <div className="absolute inset-0 flex flex-col items-center justify-center">
-        <span className={cn("font-semibold tabular-nums", style.text)} style={{fontSize: size / 3.4}}>
+      <div className="absolute inset-0 flex flex-col items-center justify-center gap-1">
+        <span
+          className={cn("font-semibold tabular-nums leading-none", style.text)}
+          style={{fontSize: size / 3.8}}
+        >
           {formatScore(probe)}
         </span>
-        <span className={cn("text-[10px] font-medium tracking-wide uppercase", style.text)}>
-          {i18next.t("audit:Grade")} {gradeLetter(grade)}
-        </span>
+        {spelled || measured ? (
+          <span
+            className={cn("font-medium tracking-wide uppercase leading-none", style.text)}
+            style={{fontSize: labelSize}}
+          >
+            {spelled ? label : letter}
+          </span>
+        ) : null}
       </div>
     </div>
   );
diff --git a/web/src/components/ToolInstallRow.tsx b/web/src/components/ToolInstallRow.tsx
index 5cedb20..81205e1 100644
--- a/web/src/components/ToolInstallRow.tsx
+++ b/web/src/components/ToolInstallRow.tsx
@@ -1,107 +1,107 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Bot, Download, ExternalLink} from "lucide-react";
-import i18next from "i18next";
-
-import {AgentIcon} from "@/components/AgentIcon";
-import {InstallOutput} from "@/components/ToolUpgradeConfirmDialog";
-import {Badge} from "@/components/ui/badge";
-import {Button} from "@/components/ui/button";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import type {AgentCatalogEntry, AgentInstallJob} from "@/types";
-
-/**
- * One agent this machine does not have, with the click that installs it. The
- * install runs on the host with the package manager that is already there, so
- * the row shows which one it will use and what that manager is printing; an
- * agent no manager here publishes keeps the link to its vendor, which is then
- * all there is.
- */
-export function ToolInstallRow({
-  entry,
-  job,
-  busy,
-  onInstall,
-}: {
-  entry: AgentCatalogEntry;
-  /** The running or last install of this agent, when there was one. */
-  job?: AgentInstallJob;
-  busy: boolean;
-  onInstall: (agentId: string) => void;
-}) {
-  const plan = entry.install;
-  const running = job?.running === true;
-  const failed = job !== undefined && !job.running && !job.ok;
-
-  return (
-    <div className="space-y-2 rounded-lg border p-3">
-      <div className="flex items-center gap-2.5">
-        {/* The icons are keyed by vendor name, which every entry has. */}
-        <AgentIcon
-          agent={entry.name}
-          size={22}
-          fallback={<Bot className="text-muted-foreground size-5" />}
-        />
-        <span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
-
-        {entry.installUrl ? (
-          <SimpleTooltip title={i18next.t("agent:Install page")}>
-            <a
-              href={entry.installUrl}
-              target="_blank"
-              rel="noreferrer"
-              className="text-muted-foreground hover:text-primary shrink-0"
-              aria-label={i18next.t("agent:Install page")}
-            >
-              <ExternalLink className="size-4" />
-            </a>
-          </SimpleTooltip>
-        ) : null}
-
-        <SimpleTooltip
-          title={plan.available ? plan.command : plan.detail}
-        >
-          <span className="shrink-0">
-            <Button
-              size="sm"
-              variant={failed ? "outline" : "default"}
-              disabled={!plan.available || running}
-              loading={busy || running}
-              onClick={() => onInstall(entry.agentId)}
-            >
-              <Download />
-              {i18next.t(
-                running ? "agent:Installing" : failed ? "agent:Retry" : "agent:Install",
-              )}
-            </Button>
-          </span>
-        </SimpleTooltip>
-      </div>
-
-      <div className="flex flex-wrap items-center gap-2">
-        {plan.available ? (
-          <Badge variant="muted">{plan.manager}</Badge>
-        ) : (
-          <span className="text-muted-foreground text-xs">{plan.detail}</span>
-        )}
-        {plan.available ? (
-          <code className="text-muted-foreground min-w-0 truncate text-xs">{plan.command}</code>
-        ) : null}
-      </div>
-
-      <InstallOutput job={job} />
-    </div>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Bot, Download, ExternalLink} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {InstallOutput} from "@/components/ToolUpgradeConfirmDialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {AgentCatalogEntry, AgentInstallJob} from "@/types";
+
+/**
+ * One agent this machine does not have, with the click that installs it. The
+ * install runs on the host with the package manager that is already there, so
+ * the row shows which one it will use and what that manager is printing; an
+ * agent no manager here publishes keeps the link to its vendor, which is then
+ * all there is.
+ */
+export function ToolInstallRow({
+  entry,
+  job,
+  busy,
+  onInstall,
+}: {
+  entry: AgentCatalogEntry;
+  /** The running or last install of this agent, when there was one. */
+  job?: AgentInstallJob;
+  busy: boolean;
+  onInstall: (agentId: string) => void;
+}) {
+  const plan = entry.install;
+  const running = job?.running === true;
+  const failed = job !== undefined && !job.running && !job.ok;
+
+  return (
+    <div className="bg-card hover:border-foreground/25 space-y-2 rounded-xl border p-3 shadow-xs transition-colors">
+      <div className="flex items-center gap-2.5">
+        {/* The icons are keyed by vendor name, which every entry has. */}
+        <AgentIcon
+          agent={entry.name}
+          size={20}
+          fallback={<Bot className="text-muted-foreground size-5" />}
+        />
+        <span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
+
+        {entry.installUrl ? (
+          <SimpleTooltip title={i18next.t("agent:Install page")}>
+            <a
+              href={entry.installUrl}
+              target="_blank"
+              rel="noreferrer"
+              className="text-muted-foreground hover:text-primary shrink-0"
+              aria-label={i18next.t("agent:Install page")}
+            >
+              <ExternalLink className="size-4" />
+            </a>
+          </SimpleTooltip>
+        ) : null}
+
+        <SimpleTooltip
+          title={plan.available ? plan.command : plan.detail}
+        >
+          <span className="shrink-0">
+            <Button
+              size="sm"
+              variant={failed ? "outline" : "default"}
+              disabled={!plan.available || running}
+              loading={busy || running}
+              onClick={() => onInstall(entry.agentId)}
+            >
+              <Download />
+              {i18next.t(
+                running ? "agent:Installing" : failed ? "agent:Retry" : "agent:Install",
+              )}
+            </Button>
+          </span>
+        </SimpleTooltip>
+      </div>
+
+      <div className="flex flex-wrap items-center gap-2">
+        {plan.available ? (
+          <Badge variant="muted" className="text-[11px]">{plan.manager}</Badge>
+        ) : (
+          <span className="text-muted-foreground text-[11px]">{plan.detail}</span>
+        )}
+        {plan.available ? (
+          <code className="text-muted-foreground/80 min-w-0 truncate text-[11px]">{plan.command}</code>
+        ) : null}
+      </div>
+
+      <InstallOutput job={job} />
+    </div>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index a1d53b9..2010d3c 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -1,804 +1,776 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import i18next from "i18next";
-
-import * as AgentBackend from "@/backend/AgentBackend";
-import * as Setting from "@/Setting";
-import type {BadgeVariant} from "@/components/ui/badge";
-import {providerProtocol, servesResponsesApi} from "@/lib/providers";
-import type {
-  Agent,
-  AgentCatalogEntry,
-  AgentInstallJob,
-  AgentInstance,
-  AgentRuntime,
-  AgentSession,
-  AgentUsage,
-  AgentUsageStat,
-  Provider,
-} from "@/types";
-
-/** How long a started app is given before its process is looked for again. */
-const runtimeSettleMs = 2000;
-
-/** Radix rejects an empty item value, so "the agent's own model" needs one. */
-export const builtinProvider = "-";
-
-/** Routing through the local proxy, which is what makes a switch hot. */
-export const gatewayMode = "gateway";
-export const directMode = "direct";
-
-/**
- * What the agent talks to with nothing bound: the model its own configuration
- * names, or the service it signs in to. Binding nothing puts it back on this.
- */
-export function agentBuiltin(agent: Agent) {
-  return agent.providerConfig?.builtin || i18next.t("agent:Built-in model");
-}
-
-/** The monitor keys the two Codex front ends under one agent id. */
-export function monitorAgentId(agentId: string) {
-  return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
-}
-
-/**
- * agentKey identifies one installation. The same agent can be installed twice
- * on a host - once per user account, or once per install method - so neither
- * the agent id nor the path is unique on its own.
- */
-export function agentKey(agent: Pick<Agent, "owner" | "path">) {
-  return `${agent.owner}:${agent.path}`;
-}
-
-/**
- * The base URL an agent is pointed at to reach its own provider. One URL serves
- * every wire format: an OpenAI client appends /chat/completions to it, Codex
- * appends /responses, and an Anthropic one appends /v1/messages.
- */
-export function agentProxyBaseUrl(agentId: string) {
-  return `${Setting.ServerUrl || window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
-}
-
-/**
- * A config file that carries the same setting for good, for the agents that
- * have one. Empty when only the environment is known to work.
- */
-export function agentSetupNoteKey(agentId: string) {
-  switch (agentId) {
-  case "claude-code":
-    return "agent:Claude Code config hint";
-  case "claude-desktop":
-    return "agent:Claude Desktop sandbox hint";
-  case "codex-cli":
-    return "agent:Codex CLI config hint";
-  case "opencode":
-  case "opencode-desktop":
-    return "agent:opencode config hint";
-  case "gemini-cli":
-    return "agent:Gemini CLI config hint";
-  default:
-    return "";
-  }
-}
-
-/**
- * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
- * OpenAI Responses API, so it reaches a chat completions provider through
- * Gateway, which translates, or not at all.
- */
-export function agentNeedsResponsesApi(agentId: string) {
-  return agentId.startsWith("codex");
-}
-
-/**
- * Why an agent can only be routed through the gateway, as the key of the line
- * that says so. Empty for an agent that can also be bound to a provider
- * directly.
- */
-export function agentGatewayOnlyKey(agent: Agent, bound: Provider | undefined) {
-  if (agentProtocol(agent) === "gemini") {
-    return "agent:Gemini gateway only hint";
-  }
-  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(bound)) {
-    return "agent:Gateway only hint";
-  }
-  return "";
-}
-
-/**
- * The wire format an agent's client speaks, as the server reports it. Empty for
- * an agent Gateway does not know, which is then not filtered on.
- */
-export function agentProtocol(agent: Agent) {
-  return agent.providerConfig?.protocol ?? "";
-}
-
-/**
- * Whether an agent can be pointed at a provider serving this wire format.
- * Through the gateway any provider will do: the proxy translates between the
- * APIs. A direct binding writes the provider's own URL into the agent config,
- * so there the two have to speak the same one.
- */
-export function agentSpeaks(agent: Agent, protocol: string) {
-  if ((agent.mode || gatewayMode) !== directMode) {
-    return true;
-  }
-  const spoken = agentProtocol(agent);
-  return spoken === "" || spoken === protocol;
-}
-
-/**
- * Whether this agent, routed the way it is now, can be pointed at this provider
- * at all. Through the gateway anything goes, since the proxy translates; a
- * direct binding hands the agent the provider's own URL, and there both the
- * wire format and, for Codex, the Responses API have to line up.
- */
-export function agentCanUse(agent: Agent, provider: Provider) {
-  if ((agent.mode || gatewayMode) !== directMode) {
-    return true;
-  }
-  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
-    return false;
-  }
-  return agentSpeaks(agent, providerProtocol(provider.type));
-}
-
-/**
- * The detail page route for one installation. The path is only spelled out when
- * the same agent is installed more than once, which is what makes the id alone
- * ambiguous; otherwise the URL stays short.
- */
-export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
-  const url = `/agents/${encodeURIComponent(agent.agentId)}`;
-  const duplicated = agents.filter(candidate => candidate.agentId === agent.agentId).length > 1;
-  return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
-}
-
-/**
- * useAgents owns the local-installation scan and the monitoring toggle, which
- * the dashboard, the list page and the detail page all need.
- */
-export function useAgents(enabled = true) {
-  const [agents, setAgents] = React.useState<Agent[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [error, setError] = React.useState("");
-  const [busyKey, setBusyKey] = React.useState("");
-  // The run state is keyed by installation and loaded on its own, so it can be
-  // refreshed after a start or a stop without scanning the disk again.
-  const [runtime, setRuntime] = React.useState<Record<string, AgentRuntime>>({});
-  const [runBusyKey, setRunBusyKey] = React.useState("");
-  // A scan inside a container reads the container's filesystem, not the host's,
-  // which is why it can come back empty on a machine full of agents.
-  const [inContainer, setInContainer] = React.useState(false);
-  // A scan is only "done" once the first response has landed, which is what
-  // tells an empty list apart from a list that has not arrived yet.
-  const [scanned, setScanned] = React.useState(false);
-
-  const scan = React.useCallback(
-    (forceRefresh = false) => {
-      if (!enabled) {
-        return;
-      }
-
-      setLoading(true);
-      setError("");
-      AgentBackend.getAgents(forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setAgents(res.data ?? []);
-            setInContainer(res.data2 === true);
-          } else {
-            setError(res.msg || i18next.t("agent:Failed to scan agents"));
-          }
-        })
-        .catch(err => setError(err.message || String(err)))
-        .then(() => {
-          setLoading(false);
-          setScanned(true);
-        });
-    },
-    [enabled],
-  );
-
-  const loadRuntime = React.useCallback((forceRefresh = false) => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentProcesses(forceRefresh)
-      .then(res => {
-        if (res.status === "ok") {
-          const next: Record<string, AgentRuntime> = {};
-          (res.data ?? []).forEach(item => {
-            next[agentKey(item)] = item;
-          });
-          setRuntime(next);
-        }
-      })
-      .catch(() => undefined);
-  }, [enabled]);
-
-  React.useEffect(() => {
-    scan();
-    loadRuntime();
-  }, [scan, loadRuntime]);
-
-  /**
-   * toggleRunning starts the agent or ends every process of it. A desktop app
-   * takes a moment to show up in the process table, so the run state is read
-   * again shortly after the call rather than only once.
-   */
-  const toggleRunning = React.useCallback(
-    (agent: Agent, running: boolean) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
-
-      setRunBusyKey(agentKey(agent));
-      (running ? AgentBackend.stopAgent(target) : AgentBackend.startAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent started")}: ${agent.name}`,
-            );
-            setTimeout(() => loadRuntime(true), runtimeSettleMs);
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg ||
-                i18next.t(running ? "agent:Failed to stop the agent" : "agent:Failed to start the agent"),
-            );
-          }
-          loadRuntime(true);
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setRunBusyKey(""));
-    },
-    [loadRuntime],
-  );
-
-  const togglePatch = React.useCallback(
-    (agent: Agent) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
-      const patched = agent.patched;
-
-      setBusyKey(agentKey(agent));
-      (patched ? AgentBackend.unpatchAgent(target) : AgentBackend.patchAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            const done = patched
-              ? i18next.t("agent:Monitoring disabled")
-              : i18next.t("agent:Monitoring enabled");
-            const followup = res.data?.followup || agent.followup;
-            Setting.showMessage(
-              "success",
-              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: ${agent.name}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to update agent patch"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * setRouting saves where an agent's requests go. Only the changed part is
-   * passed in; the rest of the routing is carried over from the agent so a
-   * fallback list is not dropped by a provider change.
-   */
-  const setRouting = React.useCallback(
-    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
-      const next: AgentBackend.AgentRouting = {
-        provider: routing.provider ?? agent.provider,
-        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
-        mode: routing.mode ?? agent.mode ?? gatewayMode,
-      };
-
-      setBusyKey(agentKey(agent));
-      AgentBackend.updateAgentRouting(agent.agentId, next)
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              next.provider === ""
-                ? `${i18next.t("agent:Built-in model restored")}: ${agentBuiltin(agent)}`
-                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to update agent provider"),
-            );
-            // The routing itself may already be stored: only writing the agent's
-            // own configuration file failed, and the page has to show that.
-            scan();
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * activateProvider is the one click the home page is built around: it stores
-   * the routing and, for an agent whose configuration file Gateway knows how to
-   * write, writes it, so the agent is on the new provider with nothing left to
-   * press.
-   */
-  const activateProvider = React.useCallback(
-    (agent: Agent, providerId: string) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
-      const routing: AgentBackend.AgentRouting = {
-        provider: providerId,
-        fallbacks: [],
-        mode: agent.mode || gatewayMode,
-      };
-      const writes = providerId !== "" && (agent.providerConfig?.supported ?? false);
-
-      setBusyKey(agentKey(agent));
-      AgentBackend.updateAgentRouting(agent.agentId, routing)
-        .then(res => {
-          if (res.status !== "ok") {
-            throw new Error(res.msg || i18next.t("agent:Failed to update agent provider"));
-          }
-          return writes ? AgentBackend.applyAgentProvider(target) : res;
-        })
-        .then(res => {
-          if (res.status !== "ok") {
-            throw new Error(res.msg || i18next.t("agent:Failed to write the agent configuration"));
-          }
-          Setting.showMessage(
-            "success",
-            providerId === ""
-              ? `${i18next.t("agent:Built-in model restored")}: ${agentBuiltin(agent)}`
-              : `${i18next.t("agent:Provider enabled")}: ${providerId}`,
-          );
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        // The routing may be stored even when the file write failed, so the
-        // scan runs either way and the page shows what actually happened.
-        .then(() => {
-          scan();
-          setBusyKey("");
-        });
-    },
-    [scan],
-  );
-
-  /**
-   * writeProvider writes the bound provider into the agent's own config file, or
-   * puts back what the file held before Gateway first wrote it.
-   */
-  const writeProvider = React.useCallback(
-    (agent: Agent, restore = false) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
-
-      setBusyKey(agentKey(agent));
-      (restore
-        ? AgentBackend.restoreAgentProvider(target)
-        : AgentBackend.applyAgentProvider(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              restore
-                ? i18next.t("agent:Configuration restored")
-                : i18next.t("agent:Configuration written"),
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to write the agent configuration"),
-            );
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  return {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-    setRouting,
-    activateProvider,
-    writeProvider,
-  };
-}
-
-/**
- * useAgentCatalog lists the agents Gateway knows how to work with that this
- * machine has none of. The catalogue is built into the binary, so it is read
- * once and only the scan it is compared against changes.
- */
-export function useAgentCatalog(agents: Agent[], enabled = true) {
-  const [catalog, setCatalog] = React.useState<AgentCatalogEntry[]>([]);
-
-  React.useEffect(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentCatalog()
-      .then(res => setCatalog(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
-  }, [enabled]);
-
-  return React.useMemo(
-    () => catalog.filter(item => !agents.some(agent => agent.agentId === item.agentId)),
-    [catalog, agents],
-  );
-}
-
-/** How often a running install is asked about while the manager works. */
-const installPollMs = 1500;
-
-/**
- * useAgentInstall drives the installs and upgrades Gateway runs with the host's
- * own package managers. A package manager takes minutes, so the request that
- * starts one returns at once and the job is polled until it ends: the button
- * that started it keeps showing what is happening, and a reload does too.
- *
- * `onFinished` is called after every job that ends, which is when a rescan will
- * see the agent that was just installed.
- */
-export function useAgentInstall(enabled = true, onFinished?: () => void) {
-  const [jobs, setJobs] = React.useState<Record<string, AgentInstallJob>>({});
-  const [busyId, setBusyId] = React.useState("");
-  // The jobs this page is waiting on, so the one that finishes is reported
-  // once - and one left running by an earlier page is picked up on load.
-  const watched = React.useRef<Record<string, boolean>>({});
-  const finished = React.useRef(onFinished);
-  finished.current = onFinished;
-
-  const load = React.useCallback(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentInstallJobs()
-      .then(res => {
-        const list = res.status === "ok" ? (res.data ?? []) : [];
-        setJobs(Object.fromEntries(list.map(job => [job.agentId, job])));
-      })
-      .catch(() => undefined);
-  }, [enabled]);
-
-  React.useEffect(() => load(), [load]);
-
-  const running = Object.values(jobs).some(job => job.running);
-
-  React.useEffect(() => {
-    if (!running) {
-      return;
-    }
-
-    const timer = window.setInterval(load, installPollMs);
-    return () => window.clearInterval(timer);
-  }, [running, load]);
-
-  React.useEffect(() => {
-    Object.values(jobs).forEach(job => {
-      if (job.running) {
-        watched.current[job.agentId] = true;
-        return;
-      }
-      if (!watched.current[job.agentId]) {
-        return;
-      }
-      delete watched.current[job.agentId];
-      Setting.showMessage(
-        job.ok ? "success" : "error",
-        job.ok
-          ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" : "agent:Agent installed")}: ${job.name}`
-          : `${i18next.t("agent:Failed to install the agent")}: ${job.error || job.name}`,
-      );
-      finished.current?.();
-    });
-  }, [jobs]);
-
-  const start = React.useCallback(
-    (agentId: string, call: () => Promise<{status: string; msg?: string; data?: AgentInstallJob}>) => {
-      setBusyId(agentId);
-      call()
-        .then(res => {
-          if (res.status === "ok" && res.data) {
-            watched.current[agentId] = true;
-            setJobs(current => ({...current, [agentId]: res.data as AgentInstallJob}));
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to install the agent"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyId(""));
-    },
-    [],
-  );
-
-  const install = React.useCallback(
-    (agentId: string) => start(agentId, () => AgentBackend.installAgent(agentId)),
-    [start],
-  );
-
-  const upgrade = React.useCallback(
-    (agent: Agent) =>
-      start(agent.agentId, () =>
-        AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path, owner: agent.owner}),
-      ),
-    [start],
-  );
-
-  return {jobs, busyId, install, upgrade, reload: load};
-}
-
-/** The run state of one installation, before the first listing has landed. */
-export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) {
-  return runtime[agentKey(agent)];
-}
-
-/** What one agent has been up to, derived from its monitoring sessions. */
-export interface AgentActivity {
-  sessionCount: number;
-  recordCount: number;
-  lastTime: string;
-}
-
-/**
- * useAgentSessions loads the live session summaries. Each summary already
- * carries its agent, record count and last activity, so the per-agent totals
- * the dashboard shows are folded out of this one request rather than out of the
- * far larger raw record list.
- */
-export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
-  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
-  const [error, setError] = React.useState("");
-
-  const load = React.useCallback(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentSessions(agent)
-      .then(res => {
-        if (res.status === "ok") {
-          setSessions(res.data ?? []);
-          setError("");
-        } else {
-          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
-        }
-      })
-      .catch(err => setError(err.message || String(err)));
-  }, [enabled, agent]);
-
-  React.useEffect(() => {
-    load();
-    if (!enabled || refreshMs <= 0) {
-      return undefined;
-    }
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, [enabled, load, refreshMs]);
-
-  const activity = React.useMemo(() => {
-    const totals: Record<string, AgentActivity> = {};
-    sessions.forEach(session => {
-      const current = totals[session.agent] ?? {sessionCount: 0, recordCount: 0, lastTime: ""};
-      totals[session.agent] = {
-        sessionCount: current.sessionCount + 1,
-        recordCount: current.recordCount + session.recordCount,
-        lastTime:
-          current.lastTime === "" || session.lastTime > current.lastTime
-            ? session.lastTime
-            : current.lastTime,
-      };
-    });
-    return totals;
-  }, [sessions]);
-
-  const recordCount = React.useMemo(
-    () => sessions.reduce((total, session) => total + session.recordCount, 0),
-    [sessions],
-  );
-
-  return {sessions, activity, recordCount, error, reload: load};
-}
-
-/** The activity of the installation, looked up under the id the monitor uses. */
-export function activityOf(activity: Record<string, AgentActivity>, agent: Agent) {
-  return activity[monitorAgentId(agent.agentId)];
-}
-
-/**
- * Transcripts are written per tool rather than per installation: every Codex
- * front end shares one directory, so all of them read the same usage. That is
- * also the limit of what the files can tell apart.
- */
-export function usageAgentId(agentId: string) {
-  return agentId.startsWith("codex") ? "codex-cli" : agentId;
-}
-
-/** What one installation spent, out of the totals read off the transcripts. */
-export function usageOf(usage: AgentUsage | undefined, agent: Agent): AgentUsageStat | undefined {
-  return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
-}
-
-/** The badge colour every record table paints an outcome with. */
-export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
-  return (
-    {
-      attempted: "info",
-      denied: "warning",
-      failure: "danger",
-      success: "success",
-    } as const
-  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
-}
-
-/**
- * useAgentInstances owns the extra copies of one agent: the ones stored, what
- * each is signed in to, and whether each is running. Each has a state directory
- * of its own, so they start, stop and sign in independently of one another.
- */
-export function useAgentInstances(agentId = "", enabled = true) {
-  const [instances, setInstances] = React.useState<AgentInstance[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [busyName, setBusyName] = React.useState("");
-
-  const load = React.useCallback(
-    (forceRefresh = false) => {
-      if (!enabled) {
-        return;
-      }
-
-      setLoading(true);
-      AgentBackend.getAgentInstances(agentId, forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setInstances(res.data ?? []);
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setLoading(false));
-    },
-    [agentId, enabled],
-  );
-
-  React.useEffect(() => {
-    load();
-  }, [load]);
-
-  /**
-   * Adds one copy. The server names and lays it out, so there is nothing to ask
-   * first; the name is edited afterwards, once an account has signed in to it.
-   * The agent id stands in for the row that does not exist yet while it runs.
-   */
-  const add = React.useCallback(
-    (agent: Agent) => {
-      setBusyName(agent.agentId);
-      AgentBackend.addAgentInstance({agentId: agent.agentId, path: agent.path, owner: agent.owner})
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t("agent:Instance added")}: ${res.data?.instance ?? ""}`,
-            );
-            load(true);
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to add the instance"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  const rename = React.useCallback(
-    (instance: AgentInstance, displayName: string) => {
-      AgentBackend.renameAgentInstance(instance.name, displayName)
-        .then(res => {
-          if (res.status !== "ok") {
-            Setting.showMessage("error", res.msg);
-          }
-          load();
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)));
-    },
-    [load],
-  );
-
-  const remove = React.useCallback(
-    (instance: AgentInstance) => {
-      setBusyName(instance.name);
-      AgentBackend.deleteAgentInstance(instance.name)
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage("success", `${i18next.t("agent:Instance removed")}: ${instance.instance}`);
-            load(true);
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  /** Starts or stops one copy, leaving whatever else is running alone. */
-  const toggleRunning = React.useCallback(
-    (instance: AgentInstance) => {
-      const running = instance.running;
-
-      setBusyName(instance.name);
-      (running
-        ? AgentBackend.stopAgentInstance(instance.name)
-        : AgentBackend.startAgentInstance(instance.name))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent started")}: ${
-                instance.displayName || instance.instance
-              }`,
-            );
-            // A desktop app takes a moment to show up in the process table, so
-            // the run state is read again shortly after the call.
-            setTimeout(() => load(true), runtimeSettleMs);
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg ||
-                i18next.t(running ? "agent:Failed to stop the agent" : "agent:Failed to start the agent"),
-            );
-          }
-          load(true);
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  return {instances, loading, busyName, reload: load, add, rename, remove, toggleRunning};
-}
-
-/** What one hook call returns, as the cards and the tables take it. */
-export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
-
-/** The instances belonging to one installation, out of a listing of them all. */
-export function instancesOf(instances: AgentInstance[], agent: Agent) {
-  return instances.filter(instance => instance.agentId === agent.agentId);
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import type {BadgeVariant} from "@/components/ui/badge";
+import {providerProtocol, servesResponsesApi} from "@/lib/providers";
+import type {
+  Agent,
+  AgentCatalogEntry,
+  AgentInstallJob,
+  AgentInstance,
+  AgentRuntime,
+  AgentSession,
+  AgentUsage,
+  AgentUsageStat,
+  Provider,
+} from "@/types";
+
+/** How long a started app is given before its process is looked for again. */
+const runtimeSettleMs = 2000;
+
+/** Radix rejects an empty item value, so "the agent's own model" needs one. */
+export const builtinProvider = "-";
+
+/** Routing through the local proxy, which is what makes a switch hot. */
+export const gatewayMode = "gateway";
+export const directMode = "direct";
+
+/**
+ * What the agent talks to with nothing bound: the model its own configuration
+ * names, or the service it signs in to. Binding nothing puts it back on this.
+ */
+export function agentBuiltin(agent: Agent) {
+  return agent.providerConfig?.builtin || i18next.t("agent:Built-in model");
+}
+
+/** The monitor keys the two Codex front ends under one agent id. */
+export function monitorAgentId(agentId: string) {
+  return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
+}
+
+/**
+ * agentKey identifies one installation. The same agent can be installed twice
+ * on a host - once per user account, or once per install method - so neither
+ * the agent id nor the path is unique on its own.
+ */
+export function agentKey(agent: Pick<Agent, "owner" | "path">) {
+  return `${agent.owner}:${agent.path}`;
+}
+
+/**
+ * The base URL an agent is pointed at to reach its own provider. One URL serves
+ * every wire format: an OpenAI client appends /chat/completions to it, Codex
+ * appends /responses, and an Anthropic one appends /v1/messages.
+ */
+export function agentProxyBaseUrl(agentId: string) {
+  return `${Setting.ServerUrl || window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
+}
+
+/**
+ * A config file that carries the same setting for good, for the agents that
+ * have one. Empty when only the environment is known to work.
+ */
+export function agentSetupNoteKey(agentId: string) {
+  switch (agentId) {
+  case "claude-code":
+    return "agent:Claude Code config hint";
+  case "claude-desktop":
+    return "agent:Claude Desktop sandbox hint";
+  case "codex-cli":
+    return "agent:Codex CLI config hint";
+  case "opencode":
+  case "opencode-desktop":
+    return "agent:opencode config hint";
+  case "gemini-cli":
+    return "agent:Gemini CLI config hint";
+  default:
+    return "";
+  }
+}
+
+/**
+ * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
+ * OpenAI Responses API, so it reaches a chat completions provider through
+ * Gateway, which translates, or not at all.
+ */
+export function agentNeedsResponsesApi(agentId: string) {
+  return agentId.startsWith("codex");
+}
+
+/**
+ * Why an agent can only be routed through the gateway, as the key of the line
+ * that says so. Empty for an agent that can also be bound to a provider
+ * directly.
+ */
+export function agentGatewayOnlyKey(agent: Agent, bound: Provider | undefined) {
+  if (agentProtocol(agent) === "gemini") {
+    return "agent:Gemini gateway only hint";
+  }
+  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(bound)) {
+    return "agent:Gateway only hint";
+  }
+  return "";
+}
+
+/**
+ * The wire format an agent's client speaks, as the server reports it. Empty for
+ * an agent Gateway does not know, which is then not filtered on.
+ */
+export function agentProtocol(agent: Agent) {
+  return agent.providerConfig?.protocol ?? "";
+}
+
+/**
+ * Whether an agent can be pointed at a provider serving this wire format.
+ * Through the gateway any provider will do: the proxy translates between the
+ * APIs. A direct binding writes the provider's own URL into the agent config,
+ * so there the two have to speak the same one.
+ */
+export function agentSpeaks(agent: Agent, protocol: string) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  const spoken = agentProtocol(agent);
+  return spoken === "" || spoken === protocol;
+}
+
+/**
+ * Whether this agent, routed the way it is now, can be pointed at this provider
+ * at all. Through the gateway anything goes, since the proxy translates; a
+ * direct binding hands the agent the provider's own URL, and there both the
+ * wire format and, for Codex, the Responses API have to line up.
+ */
+export function agentCanUse(agent: Agent, provider: Provider) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
+    return false;
+  }
+  return agentSpeaks(agent, providerProtocol(provider.type));
+}
+
+/**
+ * The detail page route for one installation. The path is only spelled out when
+ * the same agent is installed more than once, which is what makes the id alone
+ * ambiguous; otherwise the URL stays short.
+ */
+export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
+  const url = `/agents/${encodeURIComponent(agent.agentId)}`;
+  const duplicated = agents.filter(candidate => candidate.agentId === agent.agentId).length > 1;
+  return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
+}
+
+/**
+ * useAgents owns the local-installation scan and the monitoring toggle, which
+ * the dashboard, the list page and the detail page all need.
+ */
+export function useAgents(enabled = true) {
+  const [agents, setAgents] = React.useState<Agent[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [busyKey, setBusyKey] = React.useState("");
+  // The run state is keyed by installation and loaded on its own, so it can be
+  // refreshed after a start or a stop without scanning the disk again.
+  const [runtime, setRuntime] = React.useState<Record<string, AgentRuntime>>({});
+  const [runBusyKey, setRunBusyKey] = React.useState("");
+  // A scan inside a container reads the container's filesystem, not the host's,
+  // which is why it can come back empty on a machine full of agents.
+  const [inContainer, setInContainer] = React.useState(false);
+  // A scan is only "done" once the first response has landed, which is what
+  // tells an empty list apart from a list that has not arrived yet.
+  const [scanned, setScanned] = React.useState(false);
+
+  const scan = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      setError("");
+      AgentBackend.getAgents(forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setAgents(res.data ?? []);
+            setInContainer(res.data2 === true);
+          } else {
+            setError(res.msg || i18next.t("agent:Failed to scan agents"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          setLoading(false);
+          setScanned(true);
+        });
+    },
+    [enabled],
+  );
+
+  const loadRuntime = React.useCallback((forceRefresh = false) => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentProcesses(forceRefresh)
+      .then(res => {
+        if (res.status === "ok") {
+          const next: Record<string, AgentRuntime> = {};
+          (res.data ?? []).forEach(item => {
+            next[agentKey(item)] = item;
+          });
+          setRuntime(next);
+        }
+      })
+      .catch(() => undefined);
+  }, [enabled]);
+
+  React.useEffect(() => {
+    scan();
+    loadRuntime();
+  }, [scan, loadRuntime]);
+
+  /**
+   * toggleRunning starts the agent or ends every process of it. A desktop app
+   * takes a moment to show up in the process table, so the run state is read
+   * again shortly after the call rather than only once.
+   */
+  const toggleRunning = React.useCallback(
+    (agent: Agent, running: boolean) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
+
+      setRunBusyKey(agentKey(agent));
+      (running ? AgentBackend.stopAgent(target) : AgentBackend.startAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent started")}: ${agent.name}`,
+            );
+            setTimeout(() => loadRuntime(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : "agent:Failed to start the agent"),
+            );
+          }
+          loadRuntime(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setRunBusyKey(""));
+    },
+    [loadRuntime],
+  );
+
+  const togglePatch = React.useCallback(
+    (agent: Agent) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
+      const patched = agent.patched;
+
+      setBusyKey(agentKey(agent));
+      (patched ? AgentBackend.unpatchAgent(target) : AgentBackend.patchAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            const done = patched
+              ? i18next.t("agent:Monitoring disabled")
+              : i18next.t("agent:Monitoring enabled");
+            const followup = res.data?.followup || agent.followup;
+            Setting.showMessage(
+              "success",
+              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: ${agent.name}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to update agent patch"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * setRouting saves where an agent's requests go. Only the changed part is
+   * passed in; the rest of the routing is carried over from the agent so a
+   * fallback list is not dropped by a provider change.
+   */
+  const setRouting = React.useCallback(
+    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
+      const next: AgentBackend.AgentRouting = {
+        provider: routing.provider ?? agent.provider,
+        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
+        mode: routing.mode ?? agent.mode ?? gatewayMode,
+      };
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, next)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              next.provider === ""
+                ? `${i18next.t("agent:Built-in model restored")}: ${agentBuiltin(agent)}`
+                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to update agent provider"),
+            );
+            // The routing itself may already be stored: only writing the agent's
+            // own configuration file failed, and the page has to show that.
+            scan();
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * activateProvider is the one click the home page is built around: it stores
+   * the routing and, for an agent whose configuration file Gateway knows how to
+   * write, writes it, so the agent is on the new provider with nothing left to
+   * press.
+   */
+  const activateProvider = React.useCallback(
+    (agent: Agent, providerId: string) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
+      const routing: AgentBackend.AgentRouting = {
+        provider: providerId,
+        fallbacks: [],
+        mode: agent.mode || gatewayMode,
+      };
+      const writes = providerId !== "" && (agent.providerConfig?.supported ?? false);
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, routing)
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to update agent provider"));
+          }
+          return writes ? AgentBackend.applyAgentProvider(target) : res;
+        })
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to write the agent configuration"));
+          }
+          Setting.showMessage(
+            "success",
+            providerId === ""
+              ? `${i18next.t("agent:Built-in model restored")}: ${agentBuiltin(agent)}`
+              : `${i18next.t("agent:Provider enabled")}: ${providerId}`,
+          );
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        // The routing may be stored even when the file write failed, so the
+        // scan runs either way and the page shows what actually happened.
+        .then(() => {
+          scan();
+          setBusyKey("");
+        });
+    },
+    [scan],
+  );
+
+  /**
+   * writeProvider writes the bound provider into the agent's own config file, or
+   * puts back what the file held before Gateway first wrote it.
+   */
+  const writeProvider = React.useCallback(
+    (agent: Agent, restore = false) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: agent.owner};
+
+      setBusyKey(agentKey(agent));
+      (restore
+        ? AgentBackend.restoreAgentProvider(target)
+        : AgentBackend.applyAgentProvider(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              restore
+                ? i18next.t("agent:Configuration restored")
+                : i18next.t("agent:Configuration written"),
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to write the agent configuration"),
+            );
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  return {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    setRouting,
+    activateProvider,
+    writeProvider,
+  };
+}
+
+/**
+ * useAgentCatalog lists the agents Gateway knows how to work with that this
+ * machine has none of. The catalogue is built into the binary, so it is read
+ * once and only the scan it is compared against changes.
+ */
+export function useAgentCatalog(agents: Agent[], enabled = true) {
+  const [catalog, setCatalog] = React.useState<AgentCatalogEntry[]>([]);
+
+  React.useEffect(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentCatalog()
+      .then(res => setCatalog(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, [enabled]);
+
+  return React.useMemo(
+    () => catalog.filter(item => !agents.some(agent => agent.agentId === item.agentId)),
+    [catalog, agents],
+  );
+}
+
+/** How often a running install is asked about while the manager works. */
+const installPollMs = 1500;
+
+/**
+ * useAgentInstall drives the installs and upgrades Gateway runs with the host's
+ * own package managers. A package manager takes minutes, so the request that
+ * starts one returns at once and the job is polled until it ends: the button
+ * that started it keeps showing what is happening, and a reload does too.
+ *
+ * `onFinished` is called after every job that ends, which is when a rescan will
+ * see the agent that was just installed.
+ */
+export function useAgentInstall(enabled = true, onFinished?: () => void) {
+  const [jobs, setJobs] = React.useState<Record<string, AgentInstallJob>>({});
+  const [busyId, setBusyId] = React.useState("");
+  // The jobs this page is waiting on, so the one that finishes is reported
+  // once - and one left running by an earlier page is picked up on load.
+  const watched = React.useRef<Record<string, boolean>>({});
+  const finished = React.useRef(onFinished);
+  finished.current = onFinished;
+
+  const load = React.useCallback(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentInstallJobs()
+      .then(res => {
+        const list = res.status === "ok" ? (res.data ?? []) : [];
+        setJobs(Object.fromEntries(list.map(job => [job.agentId, job])));
+      })
+      .catch(() => undefined);
+  }, [enabled]);
+
+  React.useEffect(() => load(), [load]);
+
+  const running = Object.values(jobs).some(job => job.running);
+
+  React.useEffect(() => {
+    if (!running) {
+      return;
+    }
+
+    const timer = window.setInterval(load, installPollMs);
+    return () => window.clearInterval(timer);
+  }, [running, load]);
+
+  React.useEffect(() => {
+    Object.values(jobs).forEach(job => {
+      if (job.running) {
+        watched.current[job.agentId] = true;
+        return;
+      }
+      if (!watched.current[job.agentId]) {
+        return;
+      }
+      delete watched.current[job.agentId];
+      Setting.showMessage(
+        job.ok ? "success" : "error",
+        job.ok
+          ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" : "agent:Agent installed")}: ${job.name}`
+          : `${i18next.t("agent:Failed to install the agent")}: ${job.error || job.name}`,
+      );
+      finished.current?.();
+    });
+  }, [jobs]);
+
+  const start = React.useCallback(
+    (agentId: string, call: () => Promise<{status: string; msg?: string; data?: AgentInstallJob}>) => {
+      setBusyId(agentId);
+      call()
+        .then(res => {
+          if (res.status === "ok" && res.data) {
+            watched.current[agentId] = true;
+            setJobs(current => ({...current, [agentId]: res.data as AgentInstallJob}));
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to install the agent"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyId(""));
+    },
+    [],
+  );
+
+  const install = React.useCallback(
+    (agentId: string) => start(agentId, () => AgentBackend.installAgent(agentId)),
+    [start],
+  );
+
+  const upgrade = React.useCallback(
+    (agent: Agent) =>
+      start(agent.agentId, () =>
+        AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path, owner: agent.owner}),
+      ),
+    [start],
+  );
+
+  return {jobs, busyId, install, upgrade, reload: load};
+}
+
+/** The run state of one installation, before the first listing has landed. */
+export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) {
+  return runtime[agentKey(agent)];
+}
+
+/**
+ * useAgentSessions loads the live session summaries. Each summary already
+ * carries its agent, record count and last activity, so the totals a page shows
+ * are folded out of this one request rather than out of the far larger raw
+ * record list.
+ */
+export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
+  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
+  const [error, setError] = React.useState("");
+
+  const load = React.useCallback(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentSessions(agent)
+      .then(res => {
+        if (res.status === "ok") {
+          setSessions(res.data ?? []);
+          setError("");
+        } else {
+          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
+        }
+      })
+      .catch(err => setError(err.message || String(err)));
+  }, [enabled, agent]);
+
+  React.useEffect(() => {
+    load();
+    if (!enabled || refreshMs <= 0) {
+      return undefined;
+    }
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, [enabled, load, refreshMs]);
+
+  const recordCount = React.useMemo(
+    () => sessions.reduce((total, session) => total + session.recordCount, 0),
+    [sessions],
+  );
+
+  return {sessions, recordCount, error, reload: load};
+}
+
+/**
+ * Transcripts are written per tool rather than per installation: every Codex
+ * front end shares one directory, so all of them read the same usage. That is
+ * also the limit of what the files can tell apart.
+ */
+export function usageAgentId(agentId: string) {
+  return agentId.startsWith("codex") ? "codex-cli" : agentId;
+}
+
+/** What one installation spent, out of the totals read off the transcripts. */
+export function usageOf(usage: AgentUsage | undefined, agent: Agent): AgentUsageStat | undefined {
+  return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
+}
+
+/** The badge colour every record table paints an outcome with. */
+export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
+  return (
+    {
+      attempted: "info",
+      denied: "warning",
+      failure: "danger",
+      success: "success",
+    } as const
+  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
+}
+
+/**
+ * useAgentInstances owns the extra copies of one agent: the ones stored, what
+ * each is signed in to, and whether each is running. Each has a state directory
+ * of its own, so they start, stop and sign in independently of one another.
+ */
+export function useAgentInstances(agentId = "", enabled = true) {
+  const [instances, setInstances] = React.useState<AgentInstance[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [busyName, setBusyName] = React.useState("");
+
+  const load = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      AgentBackend.getAgentInstances(agentId, forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setInstances(res.data ?? []);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setLoading(false));
+    },
+    [agentId, enabled],
+  );
+
+  React.useEffect(() => {
+    load();
+  }, [load]);
+
+  /**
+   * Adds one copy. The server names and lays it out, so there is nothing to ask
+   * first; the name is edited afterwards, once an account has signed in to it.
+   * The agent id stands in for the row that does not exist yet while it runs.
+   */
+  const add = React.useCallback(
+    (agent: Agent) => {
+      setBusyName(agent.agentId);
+      AgentBackend.addAgentInstance({agentId: agent.agentId, path: agent.path, owner: agent.owner})
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t("agent:Instance added")}: ${res.data?.instance ?? ""}`,
+            );
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to add the instance"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  const rename = React.useCallback(
+    (instance: AgentInstance, displayName: string) => {
+      AgentBackend.renameAgentInstance(instance.name, displayName)
+        .then(res => {
+          if (res.status !== "ok") {
+            Setting.showMessage("error", res.msg);
+          }
+          load();
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)));
+    },
+    [load],
+  );
+
+  const remove = React.useCallback(
+    (instance: AgentInstance) => {
+      setBusyName(instance.name);
+      AgentBackend.deleteAgentInstance(instance.name)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage("success", `${i18next.t("agent:Instance removed")}: ${instance.instance}`);
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  /** Starts or stops one copy, leaving whatever else is running alone. */
+  const toggleRunning = React.useCallback(
+    (instance: AgentInstance) => {
+      const running = instance.running;
+
+      setBusyName(instance.name);
+      (running
+        ? AgentBackend.stopAgentInstance(instance.name)
+        : AgentBackend.startAgentInstance(instance.name))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent started")}: ${
+                instance.displayName || instance.instance
+              }`,
+            );
+            // A desktop app takes a moment to show up in the process table, so
+            // the run state is read again shortly after the call.
+            setTimeout(() => load(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : "agent:Failed to start the agent"),
+            );
+          }
+          load(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  return {instances, loading, busyName, reload: load, add, rename, remove, toggleRunning};
+}
+
+/** What one hook call returns, as the cards and the tables take it. */
+export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
+
+/** The instances belonging to one installation, out of a listing of them all. */
+export function instancesOf(instances: AgentInstance[], agent: Agent) {
+  return instances.filter(instance => instance.agentId === agent.agentId);
+}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 0066b5f..34ef077 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -1,313 +1,306 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import {Link} from "react-router-dom";
-import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
-import i18next from "i18next";
-
-import * as AgentBackend from "@/backend/AgentBackend";
-import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
-import * as ProviderBackend from "@/backend/ProviderBackend";
-import * as Setting from "@/Setting";
-import {AgentCatalog} from "@/components/AgentCatalog";
-import {AuthenticityOverview} from "@/components/AuthenticityOverview";
-import {AgentGridCard} from "@/components/AgentGridCard";
-import {EmptyState, ErrorState} from "@/components/shared/empty-state";
-import {Loading} from "@/components/shared/loading";
-import {UnauthorizedResult} from "@/components/shared/misc";
-import {PageContainer, PageHeader} from "@/components/shared/page-header";
-import {MessageAlert} from "@/components/ui/alert";
-import {Button} from "@/components/ui/button";
-import {Card} from "@/components/ui/card";
-import {
-  activityOf,
-  agentKey,
-  runtimeOf,
-  useAgentInstall,
-  useAgentInstances,
-  useAgents,
-  useAgentSessions,
-  usageOf,
-} from "@/lib/agents";
-import type {
-  Account,
-  AgentUsage,
-  LlmAgentStat,
-  Provider,
-  ProviderHealth,
-  ProviderQuota,
-} from "@/types";
-
-/** How often the numbers on the cards are read again. */
-const refreshMs = 10000;
-
-/**
- * How often the transcripts are added up again. Reading them walks every
- * session file on the machine, so it is asked for far less often than the
- * counters the proxy keeps in memory.
- */
-const usageRefreshMs = 60000;
-
-/**
- * The home screen: one card per agent installed on this machine. Each card
- * carries what that agent is on, what it has spent there and whether it is
- * running, and the provider box switches it over in one click — the routing is
- * stored and, where Gateway writes the agent's configuration file, that file is
- * rewritten with it.
- */
-export default function HomePage({account}: {account: Account}) {
-  const isAdmin = Setting.isAdminUser(account);
-  const {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-    activateProvider,
-  } = useAgents(isAdmin);
-  // Installs the agents this machine is missing, listed under the cards.
-  const installer = useAgentInstall(isAdmin, () => scan(true));
-  // Every agent's extra copies in one listing, so a page of cards costs one
-  // request rather than one per card.
-  const instances = useAgentInstances("", isAdmin);
-  const [providers, setProviders] = React.useState<Provider[]>([]);
-  const [health, setHealth] = React.useState<ProviderHealth[]>([]);
-  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
-  const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
-  const [usage, setUsage] = React.useState<AgentUsage>();
-  // Nothing is recorded until it is asked for, and until it is a zero on a card
-  // would read as "this agent relayed nothing" rather than "nobody counted".
-  const [recording, setRecording] = React.useState(true);
-  // False until the first listing lands, so "no providers" never flashes.
-  const [loaded, setLoaded] = React.useState(false);
-  // A listing that failed must not read as "you have no providers": that says
-  // the accounts holding your API keys are gone when the request simply
-  // dropped.
-  const [providerError, setProviderError] = React.useState("");
-  // The sessions feed is only worth polling once something is being watched.
-  const watching = agents.some(agent => agent.patched);
-  const {activity} = useAgentSessions(isAdmin && watching, "", refreshMs);
-
-  const loadProviders = React.useCallback(() => {
-    if (!isAdmin) {
-      return;
-    }
-    ProviderBackend.getProviders(account.name)
-      .then(res => {
-        if (res.status === "ok") {
-          setProviders(res.data ?? []);
-          setProviderError("");
-        } else {
-          setProviderError(res.msg || i18next.t("provider:Failed to get providers"));
-        }
-      })
-      .catch(failure => setProviderError(failure.message || String(failure)))
-      .then(() => setLoaded(true));
-
-    // What is already known paints at once; the refresh then asks only the
-    // vendors whose answer has gone stale, so a page view costs at most one
-    // request per provider every ten minutes.
-    ProviderBackend.getProviderQuotas()
-      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined)
-      .then(() => ProviderBackend.refreshProviderQuotas("", false))
-      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
-  }, [isAdmin, account.name]);
-
-  React.useEffect(() => {
-    loadProviders();
-  }, [loadProviders]);
-
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-    LlmRecordBackend.getLlmRecordStatus()
-      .then(res => setRecording(res.status !== "ok" || (res.data?.mode ?? "off") !== "off"))
-      .catch(() => undefined);
-  }, [isAdmin]);
-
-  // What the proxy has seen changes as requests are relayed, so the health and
-  // the per-agent totals are polled rather than read once.
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return undefined;
-    }
-
-    const load = () => {
-      ProviderBackend.getProviderHealth()
-        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setHealth([]));
-      LlmRecordBackend.getLlmAgentStats()
-        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setStats([]));
-    };
-
-    load();
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, [isAdmin]);
-
-  // What the agents wrote down themselves, which is the only account of the
-  // requests that never went through Gateway - and the only one there is at all
-  // before a provider is bound.
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return undefined;
-    }
-
-    const load = () => {
-      AgentBackend.getAgentUsage()
-        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : undefined))
-        .catch(() => undefined);
-    };
-
-    load();
-    const interval = setInterval(load, usageRefreshMs);
-    return () => clearInterval(interval);
-  }, [isAdmin]);
-
-  if (!isAdmin) {
-    return <UnauthorizedResult />;
-  }
-
-  const refresh = () => {
-    scan(true);
-    loadRuntime(true);
-    loadProviders();
-    instances.reload(true);
-  };
-
-  const rescan = (
-    <Button variant="outline" onClick={refresh} loading={loading}>
-      <RefreshCw />
-      {i18next.t("agent:Scan")}
-    </Button>
-  );
-
-  return (
-    <PageContainer>
-      <PageHeader
-        title={i18next.t("agent:Agents on this machine")}
-        description={account.hostname}
-        actions={
-          <>
-            <Button asChild variant="ghost">
-              <Link to="/agents">
-                <Table2 />
-                {i18next.t("agent:Advanced view")}
-              </Link>
-            </Button>
-            {rescan}
-          </>
-        }
-      />
-
-      {/* With no agents to show, the failure is the whole page and is rendered
-          below instead; here it sits above the cards that are still on screen. */}
-      {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : null}
-
-      {providerError !== "" ? (
-        <MessageAlert
-          title={i18next.t("provider:Failed to get providers")}
-          description={providerError}
-          action={
-            <Button variant="outline" size="sm" onClick={loadProviders}>
-              <RefreshCw />
-              {i18next.t("general:Retry")}
-            </Button>
-          }
-        />
-      ) : loaded && providers.length === 0 ? (
-        <MessageAlert
-          variant="info"
-          title={i18next.t("provider:No providers yet")}
-          description={i18next.t("provider:No providers yet detail")}
-          action={
-            <Button asChild size="sm">
-              <Link to="/providers">
-                <Plus />
-                {i18next.t("provider:New Provider")}
-              </Link>
-            </Button>
-          }
-        />
-      ) : null}
-
-      {/* Whether the keys behind these agents reach what they are sold as. It
-          is measured without being asked for, so it belongs above the fold
-          rather than behind a button on another page. */}
-      {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : null}
-
-      {!scanned ? (
-        <Loading tip={i18next.t("agent:Scan")} />
-      ) : error !== "" && agents.length === 0 ? (
-        // A scan that failed is not a machine without agents, and telling
-        // someone to install one they already have is the wrong instruction.
-        <Card className="py-0">
-          <ErrorState title={i18next.t("agent:Failed to scan agents")} error={error} onRetry={refresh} />
-        </Card>
-      ) : agents.length === 0 ? (
-        <Card className="py-0">
-          <EmptyState
-            icon={inContainer ? Container : Bot}
-            title={i18next.t(inContainer ? "agent:Running in a container" : "agent:No supported agents found")}
-            description={i18next.t(
-              inContainer
-                ? "agent:Running in a container detail"
-                : "agent:Install an AI agent on this machine, then scan again",
-            )}
-            action={rescan}
-          />
-        </Card>
-      ) : (
-        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
-          {agents.map(agent => (
-            <AgentGridCard
-              key={agentKey(agent)}
-              agent={agent}
-              agents={agents}
-              providers={providers}
-              health={health}
-              quota={quotas.find(item => item.provider === agent.provider)}
-              stats={stats.find(item => item.agent === agent.agentId)}
-              usage={usageOf(usage, agent)}
-              activity={activityOf(activity, agent)}
-              status={runtimeOf(runtime, agent)}
-              instances={instances}
-              recording={recording}
-              busy={busyKey === agentKey(agent)}
-              runBusy={runBusyKey === agentKey(agent)}
-              onEnable={providerId => activateProvider(agent, providerId)}
-              onToggleRunning={toggleRunning}
-              onTogglePatch={() => togglePatch(agent)}
-            />
-          ))}
-        </div>
-      )}
-
-      {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
-    </PageContainer>
-  );
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Link} from "react-router-dom";
+import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import * as Setting from "@/Setting";
+import {AgentCatalog} from "@/components/AgentCatalog";
+import {AuthenticityOverview} from "@/components/AuthenticityOverview";
+import {AgentGridCard} from "@/components/AgentGridCard";
+import {EmptyState, ErrorState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Card} from "@/components/ui/card";
+import {
+  agentKey,
+  runtimeOf,
+  useAgentInstall,
+  useAgentInstances,
+  useAgents,
+  usageOf,
+} from "@/lib/agents";
+import type {
+  Account,
+  AgentUsage,
+  LlmAgentStat,
+  Provider,
+  ProviderHealth,
+  ProviderQuota,
+} from "@/types";
+
+/** How often the numbers on the cards are read again. */
+const refreshMs = 10000;
+
+/**
+ * How often the transcripts are added up again. Reading them walks every
+ * session file on the machine, so it is asked for far less often than the
+ * counters the proxy keeps in memory.
+ */
+const usageRefreshMs = 60000;
+
+/**
+ * The home screen: one card per agent installed on this machine. Each card
+ * carries what that agent is on, what it has spent there and whether it is
+ * running, and the provider box switches it over in one click — the routing is
+ * stored and, where Gateway writes the agent's configuration file, that file is
+ * rewritten with it.
+ */
+export default function HomePage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    activateProvider,
+  } = useAgents(isAdmin);
+  // Installs the agents this machine is missing, listed under the cards.
+  const installer = useAgentInstall(isAdmin, () => scan(true));
+  // Every agent's extra copies in one listing, so a page of cards costs one
+  // request rather than one per card.
+  const instances = useAgentInstances("", isAdmin);
+  const [providers, setProviders] = React.useState<Provider[]>([]);
+  const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
+  const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
+  const [usage, setUsage] = React.useState<AgentUsage>();
+  // Nothing is recorded until it is asked for, and until it is a zero on a card
+  // would read as "this agent relayed nothing" rather than "nobody counted".
+  const [recording, setRecording] = React.useState(true);
+  // False until the first listing lands, so "no providers" never flashes.
+  const [loaded, setLoaded] = React.useState(false);
+  // A listing that failed must not read as "you have no providers": that says
+  // the accounts holding your API keys are gone when the request simply
+  // dropped.
+  const [providerError, setProviderError] = React.useState("");
+  const loadProviders = React.useCallback(() => {
+    if (!isAdmin) {
+      return;
+    }
+    ProviderBackend.getProviders(account.name)
+      .then(res => {
+        if (res.status === "ok") {
+          setProviders(res.data ?? []);
+          setProviderError("");
+        } else {
+          setProviderError(res.msg || i18next.t("provider:Failed to get providers"));
+        }
+      })
+      .catch(failure => setProviderError(failure.message || String(failure)))
+      .then(() => setLoaded(true));
+
+    // What is already known paints at once; the refresh then asks only the
+    // vendors whose answer has gone stale, so a page view costs at most one
+    // request per provider every ten minutes.
+    ProviderBackend.getProviderQuotas()
+      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined)
+      .then(() => ProviderBackend.refreshProviderQuotas("", false))
+      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, [isAdmin, account.name]);
+
+  React.useEffect(() => {
+    loadProviders();
+  }, [loadProviders]);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+    LlmRecordBackend.getLlmRecordStatus()
+      .then(res => setRecording(res.status !== "ok" || (res.data?.mode ?? "off") !== "off"))
+      .catch(() => undefined);
+  }, [isAdmin]);
+
+  // What the proxy has seen changes as requests are relayed, so the health and
+  // the per-agent totals are polled rather than read once.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      ProviderBackend.getProviderHealth()
+        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setHealth([]));
+      LlmRecordBackend.getLlmAgentStats()
+        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setStats([]));
+    };
+
+    load();
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
+  // What the agents wrote down themselves, which is the only account of the
+  // requests that never went through Gateway - and the only one there is at all
+  // before a provider is bound.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      AgentBackend.getAgentUsage()
+        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : undefined))
+        .catch(() => undefined);
+    };
+
+    load();
+    const interval = setInterval(load, usageRefreshMs);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const refresh = () => {
+    scan(true);
+    loadRuntime(true);
+    loadProviders();
+    instances.reload(true);
+  };
+
+  const rescan = (
+    <Button variant="outline" onClick={refresh} loading={loading}>
+      <RefreshCw />
+      {i18next.t("agent:Scan")}
+    </Button>
+  );
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("agent:Agents on this machine")}
+        description={account.hostname}
+        actions={
+          <>
+            <Button asChild variant="ghost">
+              <Link to="/agents">
+                <Table2 />
+                {i18next.t("agent:Advanced view")}
+              </Link>
+            </Button>
+            {rescan}
+          </>
+        }
+      />
+
+      {/* With no agents to show, the failure is the whole page and is rendered
+          below instead; here it sits above the cards that are still on screen. */}
+      {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : null}
+
+      {providerError !== "" ? (
+        <MessageAlert
+          title={i18next.t("provider:Failed to get providers")}
+          description={providerError}
+          action={
+            <Button variant="outline" size="sm" onClick={loadProviders}>
+              <RefreshCw />
+              {i18next.t("general:Retry")}
+            </Button>
+          }
+        />
+      ) : loaded && providers.length === 0 ? (
+        <MessageAlert
+          variant="info"
+          title={i18next.t("provider:No providers yet")}
+          description={i18next.t("provider:No providers yet detail")}
+          action={
+            <Button asChild size="sm">
+              <Link to="/providers">
+                <Plus />
+                {i18next.t("provider:New Provider")}
+              </Link>
+            </Button>
+          }
+        />
+      ) : null}
+
+      {/* Whether the keys behind these agents reach what they are sold as. It
+          is measured without being asked for, so it belongs above the fold
+          rather than behind a button on another page. */}
+      {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : null}
+
+      {!scanned ? (
+        <Loading tip={i18next.t("agent:Scan")} />
+      ) : error !== "" && agents.length === 0 ? (
+        // A scan that failed is not a machine without agents, and telling
+        // someone to install one they already have is the wrong instruction.
+        <Card className="py-0">
+          <ErrorState title={i18next.t("agent:Failed to scan agents")} error={error} onRetry={refresh} />
+        </Card>
+      ) : agents.length === 0 ? (
+        <Card className="py-0">
+          <EmptyState
+            icon={inContainer ? Container : Bot}
+            title={i18next.t(inContainer ? "agent:Running in a container" : "agent:No supported agents found")}
+            description={i18next.t(
+              inContainer
+                ? "agent:Running in a container detail"
+                : "agent:Install an AI agent on this machine, then scan again",
+            )}
+            action={rescan}
+          />
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+          {agents.map(agent => (
+            <AgentGridCard
+              key={agentKey(agent)}
+              agent={agent}
+              agents={agents}
+              providers={providers}
+              health={health}
+              quota={quotas.find(item => item.provider === agent.provider)}
+              stats={stats.find(item => item.agent === agent.agentId)}
+              usage={usageOf(usage, agent)}
+              status={runtimeOf(runtime, agent)}
+              instances={instances}
+              recording={recording}
+              busy={busyKey === agentKey(agent)}
+              runBusy={runBusyKey === agentKey(agent)}
+              onEnable={providerId => activateProvider(agent, providerId)}
+              onToggleRunning={toggleRunning}
+              onTogglePatch={() => togglePatch(agent)}
+            />
+          ))}
+        </div>
+      )}
+
+      {/* What is not here yet, so an empty machine has somewhere to go. */}
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
+    </PageContainer>
+  );
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org