diff --git a/CLAUDE.md b/CLAUDE.md index 255f4f94..2b4909e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select on `LaneCard`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index c64c7d67..c98e21b2 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -137,40 +137,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] { return locks; } -const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const; - -function useLaneIntegrations( - laneId: number, - available: boolean -): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null { - const [state, setState] = useState | null>(null); - - useEffect(() => { - if (!available) { - setState(null); - return; - } - let alive = true; - Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name))) - .then((results) => { - if (!alive) return; - const next: Record = {}; - INTEGRATION_NAMES.forEach((name, i) => { - next[name] = results[i]?.enabled ?? false; - }); - setState(next); - }) - .catch(() => { - if (alive) setState(null); - }); - return () => { - alive = false; - }; - }, [laneId, available]); - - return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null; -} - /** * Which AI Router this lane runs against, and the one place it can be changed * after the lane exists — the Add-lane modal is the only other one, and a lane @@ -184,7 +150,7 @@ function useLaneIntegrations( * the AI Router tab has exactly the built-in `native` profile, and a select * with one option is a control that cannot do anything. */ -function LaneRouterRow({ lane }: { lane: Lane }) { +export function LaneRouterRow({ lane }: { lane: Lane }) { const { t } = useTranslation(["lanes"]); const [routers, setRouters] = useState(null); const [current, setCurrent] = useState(lane.router_profile || "native"); @@ -234,26 +200,34 @@ function LaneRouterRow({ lane }: { lane: Lane }) { }; return ( -
- +
+ ); } @@ -425,7 +399,6 @@ export default function LaneCard({ const git = useLaneGitFacts(lane.id); const runtime = useLaneRuntime(lane.id, runtimeBump); const locks = useLaneLocks(lane.slot); - const integrations = useLaneIntegrations(lane.id, runtime?.available === true); /** * Live boot feedback. A build can run for minutes, and a card showing only a @@ -566,32 +539,33 @@ export default function LaneCard({ )} -
- {git?.available && ( -
-
- ⑂ {git.branch} - {git.head} -
-
- {git.subject} -
- {(git.dirty > 0 || git.untracked > 0) && ( -
- {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} -
- )} -
- )} - {/* The lane's own recorded branch, shown only when git could not be - read — otherwise it duplicates the live branch above. */} - {!git?.available && lane.branch &&
⑂ {lane.branch}
} -
- {lane.cwd} + {/* One line, because every part of it is short and the panel is + narrow: branch, head, subject, and the uncommitted counts read as a + sentence rather than a four-row block. The cwd is NOT here — the + lane-detail header above this card already spells it out. */} + {git?.available && ( +
+ ⑂ {git.branch} + {git.head} + {git.subject} + {(git.dirty > 0 || git.untracked > 0) && ( + + {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} + + )}
-
- - + )} + {/* The lane's own recorded branch, shown only when git could not be + read — otherwise it duplicates the live branch above. */} + {!git?.available && lane.branch && ( +
+ ⑂ {lane.branch} +
+ )} {childWorktrees && childWorktrees.length > 0 && (
)} - {integrations && ( -
- {(["tracker", "dev_qc", "ci_wait"] as const).map((name) => ( - - {t(`integrations.${name}`)} - - ))} -
- )} - {/* mt-auto pins the controls to the bottom so cards of differing height in one grid row still line their buttons up. Repo tooling and the remove button live in the Workspace page header/pipeline row now — diff --git a/client/src/components/lanes/LaneStripCard.tsx b/client/src/components/lanes/LaneStripCard.tsx index 1bc0753d..529a2ddd 100644 --- a/client/src/components/lanes/LaneStripCard.tsx +++ b/client/src/components/lanes/LaneStripCard.tsx @@ -1,10 +1,12 @@ /** * @file The compact lane tile used in the Workspace's horizontal lane strip. * It carries only what you need to pick a lane — which lane, is it alive, - * what stage, how far — because the full card, its controls and its - * working-copy facts live in the detail panel below it. Keeping the tile - * small and fixed-width is what lets many lanes stay scannable in one - * scrolling row. + * what stage — because the full card, its controls and its working-copy facts + * live in the detail panel below it. Stage badges ride on the id row rather + * than a line of their own, and progress (bar and percent) is deliberately + * absent: the detail panel's pipeline map below shows the same thing with the + * stage names attached. Keeping the tile small and fixed-width is what lets + * many lanes stay scannable in one scrolling row. * @author Nguyễn Ngọc Trí Vĩ */ @@ -66,23 +68,11 @@ export default function LaneStripCard({ : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3" }`} > -
+
- + {t("cardId", { id: lane.id })} - {lane.needs_action && ( - - ⚠ - - )} -
- -
- {lane.title || lane.cwd} -
- -
{lane.stage} @@ -100,16 +90,16 @@ export default function LaneStripCard({ {lane.detected_stage} )} - {lane.progress > 0 && ( - {lane.progress}% + {lane.needs_action && ( + + ⚠ + )}
- {lane.progress > 0 && ( -
-
-
- )} +
+ {lane.title || lane.cwd} +
); } diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 3eb95bb3..aee45937 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import LaneCard, { LaneToolsMenu } from "../LaneCard"; +import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard"; import type { Lane, LaneRuntime } from "../../../lib/types"; import { api } from "../../../lib/api"; @@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({ up: vi.fn(), down: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }), - integration: vi.fn(), agentsInstall: vi.fn(), mcpSync: vi.fn(), syncBaseCheck: vi.fn(), @@ -45,8 +44,6 @@ beforeEach(() => { // stack, so the runtime strip and its button stay absent unless a test opts in. vi.mocked(api.lanes.runtime).mockReset(); vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); - vi.mocked(api.lanes.integration).mockReset(); - vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false }); // Only the built-in profile by default: a user who never opened the AI Router // tab has nothing to switch between, so the row stays out of every other test. vi.mocked(api.aiRouters.list).mockReset(); @@ -348,40 +345,6 @@ describe("LaneCard — named locks", () => { }); }); -describe("LaneCard — integration badges", () => { - beforeEach(() => { - // Only the fields this describe block's assertions read; `as LaneRuntime` - // keeps the double from having to restate a shape the component never - // touches here. - vi.mocked(api.lanes.runtime).mockResolvedValue({ - available: true as const, - provisioned: true as const, - up: false, - slot: 1, - profileDir: "/work/demo/.agenthub/profile", - ports: {}, - } as unknown as LaneRuntime); - vi.mocked(api.lanes.integration).mockImplementation((_id, name) => - Promise.resolve({ enabled: name === "tracker" }) - ); - }); - - it("shows integration badges reflecting each toggle's state", async () => { - render(); - const tracker = await screen.findByTestId("lane-integration-tracker"); - const devQc = await screen.findByTestId("lane-integration-dev_qc"); - expect(tracker.className).toContain("status-success"); - expect(devQc.className).not.toContain("status-success"); - }); - - it("hides integration badges when the lane has no profile", async () => { - vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); - render(); - await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled()); - expect(screen.queryByTestId("lane-integration-tracker")).toBeNull(); - }); -}); - describe("LaneToolsMenu", () => { beforeEach(() => { vi.mocked(api.lanes.runtime).mockResolvedValue({ @@ -449,7 +412,7 @@ describe("LaneToolsMenu", () => { }); }); -describe("LaneCard — the AI Router picker", () => { +describe("LaneRouterRow — the Model picker", () => { const native = { name: "native", label: "Native (Claude / Codex)", @@ -469,14 +432,14 @@ describe("LaneCard — the AI Router picker", () => { it("stays out of the card when native is the only profile", async () => { vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] }); - render(); + render(); await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled()); expect(screen.queryByTestId("lane-router-1")).toBeNull(); }); it("shows the lane's current profile selected", async () => { vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); - render(); + render(); expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument(); }); @@ -487,7 +450,7 @@ describe("LaneCard — the AI Router picker", () => { file: "/work/demo/.claude/settings.local.json", profile: "9router", }); - render(); + render(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", @@ -499,12 +462,12 @@ describe("LaneCard — the AI Router picker", () => { it("puts the selection back when the apply fails", async () => { vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON")); - render(); + render(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", ]); - await waitFor(() => expect(screen.getByText("EBADJSON")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument()); expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); }); }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 0fd274d0..4fc198d8 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -98,7 +98,7 @@ "kind.managed": "managed", "laneDetail.hide": "Hide details", "laneDetail.show": "Lane details", - "laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", + "laneHeader": "Lane {{id}} · {{title}}", "locks.held_one": "{{count}} lock held", "locks.held_other": "{{count}} locks held", "moreActions": "More actions", @@ -116,9 +116,6 @@ "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", "runtime.stop": "■ down", "runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)", - "integrations.ci_wait": "CI wait", - "integrations.dev_qc": "dev QC", - "integrations.tracker": "tracker", "stageUndeclared": "not declared", "status.failed": "failed", "status.idle": "idle", @@ -143,6 +140,6 @@ "addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.", "addLaneSetupRouter": "Model endpoint", "addLaneRouterLoading": "Loading router profiles…", - "router.label": "AI Router", + "router.label": "Model", "router.applied": "Applied. A session already running in this lane keeps the old one until it restarts." } diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index afe28874..01cbb0a9 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -98,7 +98,7 @@ "kind.managed": "được quản lý", "laneDetail.hide": "Ẩn chi tiết", "laneDetail.show": "Chi tiết lane", - "laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", + "laneHeader": "Làn đường {{id}} · {{title}}", "locks.held_one": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa", "moreActions": "Thêm hành động", @@ -116,9 +116,6 @@ "runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", "runtime.stop": "■ dừng", "runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)", - "integrations.ci_wait": "CI wait", - "integrations.dev_qc": "dev QC", - "integrations.tracker": "tracker", "stageUndeclared": "chưa khai báo", "status.failed": "thất bại", "status.idle": "rảnh", @@ -143,6 +140,6 @@ "addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.", "addLaneSetupRouter": "Endpoint model", "addLaneRouterLoading": "Đang tải danh sách router…", - "router.label": "AI Router", + "router.label": "Model", "router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại." } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 19740d32..93e02272 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2497,8 +2497,6 @@ export const api = { `/lanes/${id}/profile/init`, { method: "POST", body: JSON.stringify({ force }) } ), - integration: (id: number, name: string) => - request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`), syncBaseCheck: (id: number, branch?: string) => request<{ code: number; diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 42fe4217..ec667b06 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -58,7 +58,12 @@ import { eventBus } from "../lib/eventBus"; import { keepIfSame } from "../lib/keepIfSame"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; -import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard"; +import LaneCard, { + since, + LIVENESS_DOT, + LaneToolsMenu, + LaneRouterRow, +} from "../components/lanes/LaneCard"; import LaneStripCard from "../components/lanes/LaneStripCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; @@ -576,27 +581,99 @@ export function Workspace() { data-testid="lane-detail" className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`} > + {/* One header row, not two. The pipeline picker, the stage badges, + the feature picker and the router select used to sit on a second + row below this one; every one of them is a per-lane control that + belongs beside the lane's own name, and stacking them cost a whole + row of height on a panel whose point is the pipeline map further + down. The pipeline's NAME is no longer interpolated into + `laneHeader` either — the picker right next to it already says it. + They stay gated on `showInfo`, same as before the merge. */} {currentLane && ( -
-
- - - {currentLane.liveness === "dead" - ? tLanes("statusDead") - : tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })} - - - {tLanes("laneHeader", { - id: currentLane.id, - title: currentLane.title || currentLane.cwd, - pipeline: currentLane.pipeline_name, - })} - - - {since(currentLane.stage_seconds)} - -
-
+
+ + + {currentLane.liveness === "dead" + ? tLanes("statusDead") + : tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })} + + + {tLanes("laneHeader", { + id: currentLane.id, + title: currentLane.title || currentLane.cwd, + })} + + + {since(currentLane.stage_seconds)} + + {showInfo && ( + <> + + + {/* `stage` defaults to the DB sentinel "idle" until the driving + session ever calls `agenthub stage` — that string collides with + `status`'s own "idle"/"running" vocabulary, so a lane that is + actively running but has never declared a stage read as if it + were sitting idle. Show a distinct label instead of the raw + sentinel whenever it doesn't match any node this pipeline + actually has. */} + + {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) + ? currentLane.stage + : tLanes("stageUndeclared")} + + {currentLane.detected_stage && ( + + {tLanes("autoStage", { stage: currentLane.detected_stage })} + + )} + {features.length > 0 && ( + + )} + + )} +
+ )}
)} {currentLane && showInfo && (
-
- - {/* `stage` defaults to the DB sentinel "idle" until the driving - session ever calls `agenthub stage` — that string collides with - `status`'s own "idle"/"running" vocabulary, so a lane that is - actively running but has never declared a stage read as if it - were sitting idle. Show a distinct label instead of the raw - sentinel whenever it doesn't match any node this pipeline - actually has. */} - - {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) - ? currentLane.stage - : tLanes("stageUndeclared")} - - {currentLane.detected_stage && ( - - {tLanes("autoStage", { stage: currentLane.detected_stage })} - - )} - {features.length > 0 && ( - - )} - -
.config.toml` layer plus a `-p ` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's card in Workspace carries an **AI Router** select showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the card claiming a router the lane did not get. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles. +Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/.config.toml` layer plus a `-p ` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles. Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run. @@ -1516,7 +1516,7 @@ A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED agenthub lanes integration tracker # exit 0 = on, 1 = off ``` -This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. +This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing. ### Post-merge CI status: agenthub lanes ci status