diff --git a/mcp-server/src/legal_mcp/court_fetch_service/server.py b/mcp-server/src/legal_mcp/court_fetch_service/server.py
index 28108ff..50d0992 100644
--- a/mcp-server/src/legal_mcp/court_fetch_service/server.py
+++ b/mcp-server/src/legal_mcp/court_fetch_service/server.py
@@ -288,6 +288,76 @@ async def fetch(request: web.Request) -> web.Response:
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
+# ─── adapter-migration: host-side runner for scripts/migrate_agent_adapter.py ───
+# The legal-ai container can't perform the migration itself (it needs the host
+# filesystem — generated instruction copies, the gemini settings file — plus the
+# embedded board DB), so the dashboard proxies the action here. Mutating, so it
+# requires the Bearer secret like /pm2/control. We launch exactly one fixed,
+# in-repo script with create_subprocess_exec (no shell) and an action allowlist;
+# every other argument is passed through opaque and validated by the script
+# itself. Kept deliberately symbol-light so this host bridge stays generic.
+_MIGRATE_SCRIPT = "/home/chaim/legal-ai/scripts/migrate_agent_adapter.py"
+_MIGRATE_PYTHON = "/home/chaim/legal-ai/mcp-server/.venv/bin/python"
+_MIGRATE_ACTIONS = {"check", "apply", "revert", "verify"}
+
+
+async def adapter_migration(request: web.Request) -> web.Response:
+ """Run scripts/migrate_agent_adapter.py on the host and relay its result."""
+ unauth = _check_bearer(request)
+ if unauth is not None:
+ return unauth
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"error": "invalid JSON body"}, status=400)
+
+ action = str(body.get("action", "")).strip()
+ if action not in _MIGRATE_ACTIONS:
+ return web.json_response(
+ {"error": f"action must be one of {sorted(_MIGRATE_ACTIONS)}"}, status=400
+ )
+
+ argv = [_MIGRATE_PYTHON, _MIGRATE_SCRIPT, f"--{action}"]
+ agent = str(body.get("agent", "")).strip()
+ target = str(body.get("to", "")).strip()
+ model = str(body.get("model", "")).strip()
+ if action in ("check", "apply", "revert"):
+ if not agent:
+ return web.json_response({"error": "agent required"}, status=400)
+ argv += ["--agent", agent]
+ if action in ("check", "apply"):
+ if not target:
+ return web.json_response({"error": "to (target) required"}, status=400)
+ argv += ["--to", target]
+ if model:
+ argv += ["--model", model]
+ if bool(body.get("relax_tools")):
+ argv += ["--relax-tools"]
+
+ import asyncio as _asyncio
+
+ env = {**os.environ, "HOME": "/home/chaim"}
+ try:
+ proc = await _asyncio.create_subprocess_exec(
+ *argv, cwd="/home/chaim/legal-ai", env=env,
+ stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
+ )
+ out, err = await _asyncio.wait_for(proc.communicate(), timeout=180)
+ except _asyncio.TimeoutError:
+ return web.json_response({"ok": False, "error": "migration timed out"}, status=504)
+ except Exception as e: # never throw — relay the failure
+ return web.json_response({"ok": False, "error": f"launch failed: {e}"}, status=502)
+
+ # 200 regardless of exit code: a non-zero --check (preflight refusal) is an
+ # informative result the caller renders, not a transport error.
+ return web.json_response({
+ "ok": (proc.returncode == 0),
+ "exit_code": proc.returncode,
+ "stdout": out.decode("utf-8", "replace"),
+ "stderr": err.decode("utf-8", "replace"),
+ })
+
+
def build_app() -> web.Application:
app = web.Application(client_max_size=64 * 1024 * 1024)
app.router.add_get("/health", health)
@@ -295,6 +365,7 @@ def build_app() -> web.Application:
app.router.add_get("/usage", usage_status)
app.router.add_post("/pm2/control", pm2_control)
app.router.add_post("/fetch", fetch)
+ app.router.add_post("/adapter-migration", adapter_migration)
return app
diff --git a/web-ui/src/app/operations/page.tsx b/web-ui/src/app/operations/page.tsx
index 483e797..f67c5e6 100644
--- a/web-ui/src/app/operations/page.tsx
+++ b/web-ui/src/app/operations/page.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { SystemHealthSection } from "@/components/operations/system-health-section";
+import { AgentAdaptersPanel } from "@/components/operations/agent-adapters-panel";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -839,6 +840,9 @@ export default function OperationsPage() {
סוכנים פעילים
+ מתאמי-סוכנים
+
+
שירותים
diff --git a/web-ui/src/components/operations/agent-adapters-panel.tsx b/web-ui/src/components/operations/agent-adapters-panel.tsx
new file mode 100644
index 0000000..d235b1a
--- /dev/null
+++ b/web-ui/src/components/operations/agent-adapters-panel.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+/**
+ * מתאמי-סוכנים (Adapter) — /operations panel.
+ *
+ * Migrate any committee agent between run-engines (Claude / Gemini / DeepSeek)
+ * in BOTH companies at once, with a preflight that prevents the silent crash
+ * (frontmatter `---` breaks the gemini/deepseek CLI; model must match provider;
+ * gemini excludeTools is global). Built to the Claude Design mockup
+ * `02d-operations-adapters.html`. The actual migration runs host-side
+ * (scripts/migrate_agent_adapter.py) via the court-fetch bridge — see
+ * useMigrateAdapter / POST /api/operations/agents/migrate-adapter.
+ */
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ usePaperclipAgents,
+ type AgentPair,
+} from "@/lib/api/paperclip-agents";
+import {
+ useMigrateAdapter,
+ type MigrateAdapterRequest,
+ type MigrateAdapterResult,
+} from "@/lib/api/operations";
+
+const ADAPTERS = [
+ { value: "claude_local", label: "Claude", cls: "bg-info-bg text-info border-info/40" },
+ { value: "gemini_local", label: "Gemini", cls: "bg-warn-bg text-warn border-warn/40" },
+ { value: "deepseek_local", label: "DeepSeek", cls: "bg-gold-wash text-gold-deep border-gold/40" },
+] as const;
+
+function adapterCls(a: string | null | undefined): string {
+ return ADAPTERS.find((x) => x.value === a)?.cls ?? "bg-rule-soft text-ink-muted border-rule";
+}
+function adapterLabel(a: string | null | undefined): string {
+ return ADAPTERS.find((x) => x.value === a)?.label ?? a ?? "—";
+}
+
+/** A pending confirmation: the apply/revert to run, plus its preflight result. */
+type PendingDialog = {
+ title: string;
+ body: MigrateAdapterRequest; // action is "apply" or "revert"
+ preflight: MigrateAdapterResult | null;
+ loadingPreflight: boolean;
+ relax: boolean;
+ showRelax: boolean;
+};
+
+export function AgentAdaptersPanel() {
+ const { data, isLoading } = usePaperclipAgents();
+ const migrate = useMigrateAdapter();
+ const [targets, setTargets] = useState>({});
+ const [dlg, setDlg] = useState(null);
+ const busy = migrate.isPending;
+
+ const currentOf = (p: AgentPair) =>
+ p.master?.adapter_type ?? p.mirror?.adapter_type ?? null;
+ const modelOf = (p: AgentPair) => p.master?.model ?? p.mirror?.model ?? "";
+ const isAsymmetric = (p: AgentPair) =>
+ !!p.master && !!p.mirror && p.master.adapter_type !== p.mirror.adapter_type;
+ const targetFor = (p: AgentPair) => targets[p.name] ?? currentOf(p) ?? "claude_local";
+
+ // Run a preflight (check) and open the confirm dialog pre-loaded with its output.
+ async function openMigrate(agent: string, to: string, title: string) {
+ setDlg({ title, body: { action: "apply", agent, to, relax_tools: true },
+ preflight: null, loadingPreflight: true, relax: true, showRelax: false });
+ try {
+ const res = await migrate.mutateAsync({ action: "check", agent, to });
+ const conflict = /excludeTools|כלי-כתיבה/.test(res.stdout);
+ setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false,
+ showRelax: conflict, relax: conflict });
+ } catch (e) {
+ setDlg((d) => d && { ...d, loadingPreflight: false,
+ preflight: { ok: false, exit_code: -1, stdout: "", stderr: String(e) } });
+ }
+ }
+
+ function openRevert(agent: string, title: string) {
+ setDlg({ title, body: { action: "revert", agent }, preflight: null,
+ loadingPreflight: false, relax: false, showRelax: false });
+ }
+
+ // Confirm → run the dialog's apply/revert and toast on the script's exit code.
+ async function confirmDialog() {
+ if (!dlg) return;
+ const body = { ...dlg.body, relax_tools: dlg.relax };
+ try {
+ const res = await migrate.mutateAsync(body);
+ if (res.exit_code === 0) {
+ toast.success(dlg.body.action === "revert" ? "הוחזר ל-Claude" : "המעבר הושלם בשתי החברות");
+ } else {
+ toast.error("הפעולה נכשלה — ראה פלט");
+ // keep dialog open so the operator sees the failure output
+ setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false });
+ return;
+ }
+ } catch (e) {
+ toast.error(`שגיאה: ${String(e)}`);
+ return;
+ }
+ setDlg(null);
+ }
+
+ const preflightBlocked = !!dlg?.preflight && dlg.body.action === "apply" && dlg.preflight.exit_code !== 0;
+
+ return (
+
+
+
+ העברת כל סוכן בין מנועי-ההרצה (Claude · Gemini · DeepSeek), בשתי החברות יחד. כל מעבר
+ עובר preflight שמונע קריסה. מעבר ל-Gemini/DeepSeek = fallback מופחת-איכות — להחזיר ל-Claude
+ כשטוקני-Claude חוזרים.
+
+
+ {/* Emergency fallback bar */}
+
+
+
מצב-חירום · טוקני-Claude
+
+ כשנגמרים הטוקנים — להפיל את כל הצוות ל-Gemini בלחיצה, ולהחזיר כשחוזרים.
+