diff --git a/BOOCODER.md b/BOOCODER.md
index 96432a7..960e094 100644
--- a/BOOCODER.md
+++ b/BOOCODER.md
@@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
+
+## Provider lifecycle (v2.3)
+
+BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
+
+### Config file: `data/coder-providers.json`
+
+Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **tracked in git** via a `.gitignore` exception (the rest of `data/*` is ignored). A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
+
+```json
+{
+ "providers": {
+ "goose": { "enabled": false },
+ "amp-acp": {
+ "extends": "acp",
+ "label": "Amp",
+ "description": "ACP wrapper for Amp",
+ "command": ["amp-acp"],
+ "enabled": true
+ }
+ }
+}
+```
+
+Per-provider override fields (all optional):
+
+| Field | Meaning |
+|-------|---------|
+| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
+| `label` | Display name (required for custom) |
+| `description` | Sub-label shown in the picker / settings |
+| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
+| `env` | Extra env vars merged into the spawn |
+| `enabled` | Default `true`; `false` hides it from the composer |
+| `order` | UI sort key |
+| `models` / `additionalModels` | Replace / merge onto the discovered model list |
+
+A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
+
+### Refresh contract
+
+The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
+
+### Enable / disable
+
+Two ways:
+- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
+- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
+
+A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
+
+### Adding a custom ACP provider
+
+- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
+- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
+
+Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
+
+### Known limitation — subset refresh
+
+`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
+
+### Deploy + smoke
+
+Two deploy targets:
+- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
+- **Web UI (container):** `docker compose up --build -d boocode`
+
+Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
+
+Smoke (via Tailscale):
+
+```bash
+curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
+curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
+# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
+# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc47932..fd8ac50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
+## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
+
+Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
+
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx
index a342441..2dab033 100644
--- a/apps/web/src/components/AgentComposerBar.tsx
+++ b/apps/web/src/components/AgentComposerBar.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
-import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
+import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
@@ -176,8 +176,12 @@ interface Props {
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
const allEntries = useProviderSnapshot(projectPath);
+ // 5.5 — the composer picker only offers ENABLED providers that are ready (or
+ // still loading). Disabled (enabled:false) and unavailable/error providers are
+ // hidden here and managed in Settings → Providers. Native boocode is always
+ // enabled+ready, so it always appears.
const entries = useMemo(
- () => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
+ () => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
onChange(resolveConfig(entry, prefs));
}, [entries, onChange, value.provider]);
+ // If the active provider is disabled in the settings drawer it drops out of
+ // `entries` (the 5.5 filter) — fall back to boocode so the composer never
+ // strands on an unselectable provider with empty model/mode pickers.
+ useEffect(() => {
+ if (!entries?.length) return;
+ if (entries.some((e) => e.name === value.provider)) return;
+ const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
+ if (!fallback) return;
+ onChange(resolveConfig(fallback, loadPrefs()));
+ }, [entries, value.provider, onChange]);
+
+ // 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
+ // return), refetch until terminal. Capped; no provider_snapshot_updated WS
+ // frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
+ const pollsRef = useRef(0);
+ useEffect(() => {
+ const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
+ if (!anyLoading) {
+ pollsRef.current = 0;
+ return;
+ }
+ if (pollsRef.current >= 10) return;
+ const t = setTimeout(() => {
+ pollsRef.current += 1;
+ void refreshProviderSnapshot(projectPath);
+ }, 2000);
+ return () => clearTimeout(t);
+ }, [allEntries, projectPath]);
+
const currentEntry = useMemo(
() => entries?.find((e) => e.name === value.provider),
[entries, value.provider],
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider}
options={providerOptions}
onPick={pickProvider}
- icon={providerIcon(value.provider)}
+ icon={
+ currentEntry?.status === 'loading'
+ ?
+ : providerIcon(value.provider)
+ }
/>
void;
+ /** Fired after a successful add so the parent can refetch the snapshot. */
+ onAdded: (id: string) => void;
+}
+
+/**
+ * v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a
+ * provider: PATCH /api/providers/config with its custom-ACP override, then
+ * refresh that one provider. Adding only edits config — it does NOT install the
+ * binary, so the provider shows "Not installed" until the CLI is on PATH.
+ */
+export function AddProviderModal({ open, onOpenChange, onAdded }: Props) {
+ const [query, setQuery] = useState('');
+ const [busyId, setBusyId] = useState(null);
+ const [error, setError] = useState(null);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return ACP_PROVIDER_CATALOG;
+ return ACP_PROVIDER_CATALOG.filter(
+ (e) =>
+ e.id.toLowerCase().includes(q) ||
+ e.label.toLowerCase().includes(q) ||
+ e.description.toLowerCase().includes(q),
+ );
+ }, [query]);
+
+ async function add(entry: AcpCatalogEntry): Promise {
+ setBusyId(entry.id);
+ setError(null);
+ try {
+ await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry));
+ await api.coder.refreshProviders([entry.id]);
+ onAdded(entry.id);
+ onOpenChange(false);
+ } catch (err) {
+ // 422 from PATCH (invalid override) surfaces here as ApiError.message.
+ setError(err instanceof Error ? err.message : 'failed to add provider');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/coder/ProvidersSettings.tsx b/apps/web/src/components/coder/ProvidersSettings.tsx
new file mode 100644
index 0000000..13acc18
--- /dev/null
+++ b/apps/web/src/components/coder/ProvidersSettings.tsx
@@ -0,0 +1,218 @@
+import { useEffect, useRef, useState } from 'react';
+import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react';
+import { api } from '@/api/client';
+import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types';
+import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
+import { Button } from '@/components/ui/button';
+import { AddProviderModal } from './AddProviderModal';
+import { cn } from '@/lib/utils';
+
+/** Map a snapshot entry to a status badge (design.md §7.1 labels). */
+function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } {
+ if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' };
+ if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' };
+ if (e.status === 'ready')
+ return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' };
+ if (e.status === 'error')
+ return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
+ if (!e.installed)
+ return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' };
+ return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' };
+}
+
+/**
+ * v2.3 — provider management as a Settings tab section (design.md §7.1). Lists
+ * ALL registered providers (including the disabled/unavailable ones the composer
+ * picker hides). Per row: label + model count, status badge, per-id refresh,
+ * diagnostic, and an enable/disable toggle. Native boocode is always-on.
+ *
+ * Uses the home-cwd snapshot (no project arg) — provider management is global,
+ * not per-project (design.md §4.5).
+ */
+export function ProvidersSettings() {
+ const allEntries = useProviderSnapshot();
+ const [config, setConfig] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+ const [error, setError] = useState(null);
+ const [addOpen, setAddOpen] = useState(false);
+ const [diagId, setDiagId] = useState(null);
+ const [diagText, setDiagText] = useState(null);
+
+ // The raw config is needed to preserve a provider's FULL override when
+ // toggling: the PATCH replaces an id's override wholesale, so a bare
+ // { enabled } would wipe a custom ACP provider's command/label.
+ useEffect(() => {
+ api.coder
+ .getProvidersConfig()
+ .then(setConfig)
+ .catch(() => setConfig({ providers: {} }));
+ }, []);
+
+ // While any entry is loading, refetch until terminal (capped, no WS frame).
+ const pollsRef = useRef(0);
+ useEffect(() => {
+ const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
+ if (!anyLoading) {
+ pollsRef.current = 0;
+ return;
+ }
+ if (pollsRef.current >= 10) return;
+ const t = setTimeout(() => {
+ pollsRef.current += 1;
+ void refreshProviderSnapshot();
+ }, 2000);
+ return () => clearTimeout(t);
+ }, [allEntries]);
+
+ async function toggle(e: ProviderSnapshotEntry): Promise {
+ setBusyId(e.name);
+ setError(null);
+ try {
+ const existing: ProviderOverride = config?.providers[e.name] ?? {};
+ const resp = await api.coder.patchProvidersConfig({
+ providers: { [e.name]: { ...existing, enabled: !e.enabled } },
+ });
+ setConfig({ providers: resp.providers });
+ await refreshProviderSnapshot();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'failed to update provider');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function refreshOne(id: string): Promise {
+ setBusyId(id);
+ setError(null);
+ try {
+ await api.coder.refreshProviders([id]);
+ await refreshProviderSnapshot();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'failed to refresh');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function openDiagnostic(id: string): Promise {
+ if (diagId === id) {
+ setDiagId(null);
+ setDiagText(null);
+ return;
+ }
+ setDiagId(id);
+ setDiagText('Loading…');
+ try {
+ const { diagnostic } = await api.coder.getProviderDiagnostic(id);
+ setDiagText(diagnostic);
+ } catch (err) {
+ setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic');
+ }
+ }
+
+ const entries = allEntries ?? [];
+
+ return (
+
+
+
+ Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
+ hidden from the composer picker but managed here.
+