From f2a7386b6158e306afdaa25778cff763f1af9b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6tz=20Jensen?= Date: Sat, 8 Aug 2026 15:54:32 +0200 Subject: [PATCH] feat: add enable/disable toggle for Claude Desktop routing Adds an on/off toggle to the Claude Desktop tab, mirroring the Claude Code connection toggle. When off, the 3P config files are removed from Claude Desktop's config library; when on, they are written back from the saved profile. Server: - src/types.ts: add desktopEnabled flag to OcxClaudeCodeConfig - src/claude/desktop-3p.ts: new clearDesktop3pConfig() to remove the opencodex entry from Claude Desktop's _meta.json and delete the config JSON file - src/server/management/native-integration-routes.ts: add claude-desktop to native integration client IDs, desktopStatus() probe, and handleDesktopToggle() PUT route with single-flight guard - src/server/management/agent-settings-routes.ts: include enabled field in GET /api/claude-desktop and /status responses GUI: - gui/src/pages/ClaudeDesktop.tsx: Switch toggle above the toolbar; when OFF, hides Save & Apply (only Save remains) and shows a disabled notice - gui/src/pages/integrations/native-api.ts: add claude-desktop to valid client IDs - gui/src/pages/integrations/integration-api.ts: extract enabled from the status payload - gui/src/pages/integrations/overview-clients.ts: add enabled? to ClaudeDesktopPayload i18n: 4 new keys (toggleFailed, enabledLabel, toggleAria, disabledNotice) in all 6 locales --- gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/pages/ClaudeDesktop.tsx | 63 ++++++- gui/src/pages/integrations/integration-api.ts | 2 + gui/src/pages/integrations/native-api.ts | 4 +- .../pages/integrations/overview-clients.ts | 1 + src/claude/desktop-3p.ts | 42 ++++- .../management/agent-settings-routes.ts | 3 +- .../management/native-integration-routes.ts | 173 ++++++++++++++++-- src/types.ts | 2 + 14 files changed, 293 insertions(+), 21 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10..0b19e0ef1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1748,6 +1748,10 @@ export const de: Record = { "claudeDesktop.retry": "Erneut versuchen", "claudeDesktop.saveFailed": "Claude-Desktop-Profil konnte nicht gespeichert werden.", "claudeDesktop.applyFailed": "Das Profil wurde gespeichert, konnte aber nicht angewendet werden.", + "claudeDesktop.toggleFailed": "Fehler beim Umschalten des Claude-Desktop-Routings.", + "claudeDesktop.enabledLabel": "Claude-Desktop-Routing", + "claudeDesktop.toggleAria": "Claude-Desktop-Routing ein- oder ausschalten", + "claudeDesktop.disabledNotice": "Claude-Desktop-Routing ist ausgeschaltet. Schalten Sie es ein, um Änderungen auf Claude Desktop anzuwenden.", "claudeDesktop.updateFailed": "Claude-Desktop-Aktualisierung fehlgeschlagen.", "claudeDesktop.savedApplied": "Profil gespeichert und auf Claude Desktop angewendet.", "claudeDesktop.savedAppliedAnnounce": "Claude-Desktop-Profil gespeichert und angewendet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index df67c7502..f849172d8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1781,6 +1781,10 @@ export const en = { "claudeDesktop.retry": "Retry", "claudeDesktop.saveFailed": "Failed to save Claude Desktop profile.", "claudeDesktop.applyFailed": "Profile was saved, but could not be applied.", + "claudeDesktop.toggleFailed": "Failed to toggle Claude Desktop routing.", + "claudeDesktop.enabledLabel": "Claude Desktop routing", + "claudeDesktop.toggleAria": "Toggle Claude Desktop routing on or off", + "claudeDesktop.disabledNotice": "Claude Desktop routing is off. Toggle it on to apply changes to Claude Desktop.", "claudeDesktop.updateFailed": "Claude Desktop update failed.", "claudeDesktop.savedApplied": "Profile saved and applied to Claude Desktop.", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop profile saved and applied.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index eb98a71eb..0ebe7e780 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1629,6 +1629,10 @@ export const ja: Record = { "claudeDesktop.retry": "再試行", "claudeDesktop.saveFailed": "Claude Desktop プロファイルの保存に失敗しました。", "claudeDesktop.applyFailed": "プロファイルは保存されましたが、適用できませんでした。", + "claudeDesktop.toggleFailed": "Claude Desktop ルーティングの切り替えに失敗しました。", + "claudeDesktop.enabledLabel": "Claude Desktop ルーティング", + "claudeDesktop.toggleAria": "Claude Desktop ルーティングをオンまたはオフに切り替え", + "claudeDesktop.disabledNotice": "Claude Desktop ルーティングはオフです。オンに切り替えると、変更が Claude Desktop に適用されます。", "claudeDesktop.updateFailed": "Claude Desktop の更新に失敗しました。", "claudeDesktop.savedApplied": "プロファイルを保存し、Claude Desktop に適用しました。", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop プロファイルを保存して適用しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 339cdbe97..c247dbd85 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1775,6 +1775,10 @@ export const ko: Record = { "claudeDesktop.retry": "다시 시도", "claudeDesktop.saveFailed": "Claude Desktop 프로필을 저장하지 못했습니다.", "claudeDesktop.applyFailed": "프로필은 저장했지만 적용하지 못했습니다.", + "claudeDesktop.toggleFailed": "Claude Desktop 라우팅 전환에 실패했습니다.", + "claudeDesktop.enabledLabel": "Claude Desktop 라우팅", + "claudeDesktop.toggleAria": "Claude Desktop 라우팅 켜기/끄기", + "claudeDesktop.disabledNotice": "Claude Desktop 라우팅이 꺼져 있습니다. 켜면 변경 사항이 Claude Desktop에 적용됩니다.", "claudeDesktop.updateFailed": "Claude Desktop 업데이트에 실패했습니다.", "claudeDesktop.savedApplied": "프로필을 저장하고 Claude Desktop에 적용했습니다.", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop 프로필 저장과 적용을 마쳤습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7904e27c3..8cf13de74 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1671,6 +1671,10 @@ export const ru: Record = { "claudeDesktop.retry": "Повторить", "claudeDesktop.saveFailed": "Не удалось сохранить профиль Claude Desktop.", "claudeDesktop.applyFailed": "Профиль сохранён, но применить его не удалось.", + "claudeDesktop.toggleFailed": "Не удалось переключить маршрутизацию Claude Desktop.", + "claudeDesktop.enabledLabel": "Маршрутизация Claude Desktop", + "claudeDesktop.toggleAria": "Включить или выключить маршрутизацию Claude Desktop", + "claudeDesktop.disabledNotice": "Маршрутизация Claude Desktop отключена. Включите её, чтобы применить изменения.", "claudeDesktop.updateFailed": "Не удалось обновить Claude Desktop.", "claudeDesktop.savedApplied": "Профиль сохранён и применён к Claude Desktop.", "claudeDesktop.savedAppliedAnnounce": "Профиль Claude Desktop сохранён и применён.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b16dbe6c..e6583c49d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1768,6 +1768,10 @@ export const zh: Record = { "claudeDesktop.retry": "重试", "claudeDesktop.saveFailed": "无法保存 Claude Desktop 配置。", "claudeDesktop.applyFailed": "配置已保存,但无法应用。", + "claudeDesktop.toggleFailed": "切换 Claude Desktop 路由失败。", + "claudeDesktop.enabledLabel": "Claude Desktop 路由", + "claudeDesktop.toggleAria": "开启或关闭 Claude Desktop 路由", + "claudeDesktop.disabledNotice": "Claude Desktop 路由已关闭。开启以将更改应用到 Claude Desktop。", "claudeDesktop.updateFailed": "Claude Desktop 更新失败。", "claudeDesktop.savedApplied": "配置已保存并应用到 Claude Desktop。", "claudeDesktop.savedAppliedAnnounce": "Claude Desktop 配置已保存并应用。", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 32b3a41f5..ca80e5915 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty import { LANE_PAGE, defaultCollapsedFamilies, laneView, rowStartsOpen } from "./claude-desktop-lane"; import { makeCollapseStore, toggleInSet } from "./collapse-store"; import { IconChevron } from "../icons"; -import { EmptyState, Notice } from "../ui"; +import { EmptyState, Notice, Switch } from "../ui"; import { LOCALES, useI18n, type TFn, type TKey } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -60,6 +60,7 @@ interface DesktopResponse { models: DesktopModel[]; rendered: unknown[]; port: number; + enabled: boolean; } type PendingAction = "save" | "apply" | null; @@ -164,6 +165,9 @@ export default function ClaudeDesktop({ const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); const [pending, setPending] = useState(null); + const [desktopEnabled, setDesktopEnabled] = useState(() => cached?.data?.enabled !== false); + const [connectionPending, setConnectionPending] = useState(false); + const connectionInFlight = useRef(false); // Lane density: search and paging are RENDER-ONLY. modelsByFamily and effectiveDefaults must // keep seeing every model — filtering the source arrays would silently change which model is // the effective default, turning a view filter into a data mutation. @@ -195,6 +199,7 @@ export default function ClaudeDesktop({ setProfile(normalized); setSavedProfile(cloneProfile(normalized)); setDestinations(Object.fromEntries(payload.models.map(model => [model.route, normalized.assignments[model.route]?.family ?? "opus"]))); + setDesktopEnabled(payload.enabled !== false); // Fold empty families on load, but only while the user has no stored preference. // Doing it here rather than per render means a later move or import can never // re-fold a section the user opened. @@ -304,6 +309,39 @@ export default function ClaudeDesktop({ setCollapsedFamilies(next); }; + /** + * Immediate on/off toggle for the Desktop 3P config files. + * Waits for the response — writing to another program's config library is + * not something we should optimistically claim succeeded. + */ + const toggleDesktop = async () => { + if (connectionInFlight.current) return; + connectionInFlight.current = true; + setConnectionPending(true); + setMessage(null); + const next = !desktopEnabled; + try { + const response = await fetch(`${apiBase}/api/native-integrations/claude-desktop`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), + }); + const body = await readJsonOrThrow<{ ok?: boolean; message?: string; state?: string }>(response, t("claudeDesktop.toggleFailed")); + setDesktopEnabled(next); + if (body.message) { + setMessage({ tone: "ok", text: body.message }); + setAnnouncement(body.message); + } + void desktopResource.refresh(); + void statusResource.refresh(); + } catch (error) { + setMessage({ tone: "err", text: error instanceof Error ? error.message : t("claudeDesktop.toggleFailed") }); + } finally { + connectionInFlight.current = false; + setConnectionPending(false); + } + }; + const save = async (applyAfter: boolean) => { if (!profile || pending) return; setPending("save"); @@ -392,6 +430,15 @@ export default function ClaudeDesktop({ return ( <> {/* Title/subtitle live on Claude.tsx above the Code/Desktop strip. */} +
+ {t("claudeDesktop.enabledLabel")} + void toggleDesktop()} + disabled={connectionPending} + label={t("claudeDesktop.toggleAria")} + /> +
void importProfile(event)} /> @@ -400,6 +447,10 @@ export default function ClaudeDesktop({
+ {!desktopEnabled && ( + {t("claudeDesktop.disabledNotice")} + )} + {/* Always mount the bar (pending strut when status is still cold) so a late /status response cannot insert a full row under the title and shove the lanes down. */}
{dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")}
- - + {desktopEnabled && ( + + )}
diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 927f1417a..e32cd31a6 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -316,6 +316,7 @@ export async function loadClaudeCodeStatus(apiBase: string, signal?: AbortSignal export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSignal) { const body = await readOptional<{ + enabled?: unknown; applied?: unknown; stale?: unknown; activeProfile?: unknown; @@ -323,6 +324,7 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig }>(fetch(`${apiBase}/api/claude-desktop/status`, { signal })); if (!body) return null; return { + enabled: body.enabled !== false, applied: body.applied === true, stale: body.stale === true, // Tri-state on purpose: `null` means undeterminable, which must not be diff --git a/gui/src/pages/integrations/native-api.ts b/gui/src/pages/integrations/native-api.ts index 05fec0e8f..502ce84bb 100644 --- a/gui/src/pages/integrations/native-api.ts +++ b/gui/src/pages/integrations/native-api.ts @@ -8,7 +8,7 @@ import { readJsonIfOk } from "../../fetch-json"; * nothing caught it locally because GUI typecheck runs from its own tsconfig — * `bun x tsc --noEmit` at the repository root does not read this file. CI did. */ -export type NativeIntegrationClientId = "claude" | "grok" | "codex"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; export type NativeIntegrationState = "absent" | "current" | "unsafe"; export type NativeRefusalReason = | "not_installed" @@ -58,7 +58,7 @@ export type NativeErrorBody = NativeErrorEnvelope | NativeRefusalEnvelope; // Widening the type alone would leave this guard rejecting a `codex` response at // runtime, so the set moves with it. -const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex"]); +const NATIVE_CLIENTS: ReadonlySet = new Set(["claude", "grok", "codex", "claude-desktop"]); const NATIVE_REFUSAL_CODES: ReadonlySet = new Set([ "native_integration_refused", "native_integration_failed", diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 6fe89d5ec..085cf6425 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -96,6 +96,7 @@ export interface ClaudeCodePayload { authMode?: string; } export interface ClaudeDesktopPayload { + enabled?: boolean; applied?: boolean; stale?: boolean; activeProfile?: boolean | null; diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index f8a36c602..5d5cfbfe4 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; @@ -379,3 +379,43 @@ export function atomicReplaceDesktopConfig( writer(path, content); return existsSync(backupPath) ? { backupPath } : {}; } + +/** + * Remove the opencodex entry from Claude Desktop's 3P config library. + * + * Deletes the UUID-named config JSON and strips the entry from _meta.json. + * Safe to call when the library directory or metadata does not exist — it + * returns `cleared: false` rather than throwing. + */ +export function clearDesktop3pConfig(): { cleared: boolean; path: string; reason?: string } { + const libraryPath = resolveDesktop3pConfigLibraryPath(); + const metadataPath = join(libraryPath, "_meta.json"); + const sentinel = join(libraryPath, "_meta.json"); + try { + if (!existsSync(metadataPath)) { + return { cleared: false, path: sentinel, reason: "no metadata" }; + } + const metadata = parseMetadata(metadataPath); + const entry = metadata.entries.find(e => e?.name === "opencodex" && typeof e.id === "string"); + if (!entry || typeof entry.id !== "string") { + return { cleared: false, path: sentinel, reason: "no opencodex entry" }; + } + const configPath = join(libraryPath, `${entry.id}.json`); + // Remove the config file — best-effort; a missing file is not a failure. + if (existsSync(configPath)) { + unlinkSync(configPath); + } + // Strip the entry from metadata. + const entries = metadata.entries.filter(e => e !== entry); + const nextMeta: Desktop3pMetadata = { ...metadata, entries }; + // If Desktop is currently serving our profile, clear the appliedId too. + if (nextMeta.appliedId === entry.id) { + delete nextMeta.appliedId; + } + atomicWriteFile(metadataPath, JSON.stringify(nextMeta, null, 2) + "\n"); + return { cleared: true, path: configPath }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { cleared: false, path: sentinel, reason }; + } +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d06d25630..9919ef287 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -662,7 +662,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const state = await buildClaudeDesktopState(config); const runtimePort = Number(url.port) || config.port; - return jsonResponse({ ...state, port: runtimePort }); + return jsonResponse({ ...state, port: runtimePort, enabled: config.claudeCode?.desktopEnabled !== false }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } @@ -809,6 +809,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise stale, activeProfile, health, + enabled: config.claudeCode?.desktopEnabled !== false, }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 72d0098ec..be0e7cf17 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -3,16 +3,11 @@ * * The six file clients live in `integration-routes.ts` and go through * `src/integrations/writer.ts`, which merges config fragments and journals every - * write. These do not: Claude Code is a flag in our own config, and Grok owns a - * fenced region of a file we do not otherwise write. Neither has a merged - * fragment to own, so neither needs a snapshot, a journal row, or a restore - * route — turning them back on is the undo. - * - * That conclusion cost eleven audit rounds; the reasoning is in - * devlog/_fin/260803_integrations_toggle_all/, and 007 records why Codex and - * Claude Desktop are NOT here: their state spans several artifacts and a live - * database, so they need a durable operation record this module deliberately - * does not have. + * write. These do not: Claude Code is a flag in our own config, Grok owns a + * fenced region of a file we do not otherwise write, and Claude Desktop owns + * an entry in its 3P config library. None has a merged fragment to own, so none + * needs a snapshot, a journal row, or a restore route — turning them back on is + * the undo. * * Design of record: devlog/_fin/260803_integrations_toggle_all/030 (routes), * 011 (Claude Code), 012 (Grok). @@ -23,12 +18,16 @@ import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../g import { inspectGrokConfig } from "../../grok/inspect"; import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; +import { writeDesktop3pConfig, clearDesktop3pConfig } from "../../claude/desktop-3p"; +import { claudeDesktopConfigLibraryDir } from "../../claude/desktop-3p-paths"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { OcxConfig } from "../../types"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -export type NativeIntegrationClientId = "claude" | "grok" | "codex"; +export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; /** Every reason this module can decline, in one place (audit r3 #6). */ export type NativeRefusalReason = @@ -103,6 +102,31 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): }; } +/** Absent means ON: the proxy writes 3P files unless explicitly off (as with claudeCode). */ +function desktopEnabled(config: ManagementContext["config"]): boolean { + return config.claudeCode?.desktopEnabled !== false; +} + +function desktopStatus(config: ManagementContext["config"]): NativeStatus { + const libraryPath = claudeDesktopConfigLibraryDir(); + const metaPath = join(libraryPath, "_meta.json"); + let state: NativeStatus["state"] = "absent"; + if (existsSync(metaPath)) { + try { + const meta = JSON.parse(readFileSync(metaPath, "utf8")); + const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined; + if (entry) state = "current"; + } catch { /* unreadable metadata */ } + } + return { + clientId: "claude-desktop", + state: desktopEnabled(config) ? state : "absent", + installed: true, + configPath: metaPath, + disableBlocked: null, + }; +} + /** * Grok's GET row (030 §field table). `disableBlocked` is ADVISORY — the file * can change before the PUT, which re-checks with the same inspector and whose @@ -505,13 +529,134 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +/** + * Single-flight guard for the Desktop toggle — same pattern as Grok and Codex. + * Serializes concurrent PUTs to the 3P config library so two overlapping calls + * cannot race on _meta.json or the profile JSON. + */ +let desktopToggleFlight: Promise | null = null; + +async function handleDesktopToggle(ctx: ManagementContext): Promise { + const { req, config, deps } = ctx; + if (desktopToggleFlight) { + return refusal(409, "claude-desktop", "config_busy", + "Another Claude Desktop change is already in flight. Nothing was written — try again in a moment."); + } + desktopToggleFlight = (async (): Promise => { + let body: { enabled?: unknown }; + try { + body = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + if (typeof body.enabled !== "boolean") { + return jsonResponse({ error: "enabled must be a boolean" }, 400); + } + const enabled = body.enabled; + + if (desktopEnabled(config) === enabled) { + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: false, + state: enabled ? "current" : "absent", + message: enabled ? "Claude Desktop routing is already on" : "Claude Desktop routing is already off", + } satisfies NativeToggleEnvelope); + } + + // Persist the decision before touching files, so a restart honours it. + const next = { ...(config.claudeCode ?? {}), desktopEnabled: enabled }; + config.claudeCode = next; + const persist = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + try { + persist(config); + } catch (error) { + if (isConfigLockError(error)) { + return isLockContention(error) + ? refusal(409, "claude-desktop", "config_busy", + "Another process is saving the configuration right now. Try again in a moment.") + : refusal(500, "claude-desktop", "write_failed", + `The configuration lock could not be acquired: ${error instanceof Error ? error.message : String(error)}`); + } + throw error; + } + + if (enabled) { + // Enable: write the 3P profile files using the saved profile (if any). + const profile = config.claudeCode?.desktopProfile; + if (profile) { + const runtime = (deps.readRuntimePort ?? readRuntimePort)(process.pid); + const port = runtime?.port ?? config.port; + const { desktopVisibleNativeSlugs } = await import("../../codex/catalog"); + const fetchModels = deps.fetchAllModels ?? defaultFetchAllModels; + const routed = filterCatalogVisibleModels(await fetchModels(config), config).map(model => ({ + provider: model.provider, + id: model.id, + contextWindow: model.contextWindow, + })); + const result = writeDesktop3pConfig( + port, + desktopVisibleNativeSlugs(config), + routed, + config.apiKeys?.[0]?.key, + "static", + profile, + ); + if (!result.written) { + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, + state: "current", + message: `Claude Desktop routing enabled, but writing the 3P config failed: ${result.reason ?? "unknown error"}. The profile is saved and will be applied on retry.`, + } satisfies NativeToggleEnvelope); + } + // Persist applied fingerprint so the status bar shows "Applied" immediately. + if (result.fingerprint) { + config.claudeCode = { ...config.claudeCode, desktopProfile: { ...profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; + persist(config); + } + } + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, + state: "current", + message: "Claude Desktop routing enabled.", + } satisfies NativeToggleEnvelope); + } + + // Disable: remove the 3P files. + const result = clearDesktop3pConfig(); + // Clear the applied fingerprint so the status bar no longer claims "Applied to Desktop". + if (config.claudeCode?.desktopProfile?.appliedFingerprint) { + config.claudeCode = { + ...config.claudeCode, + desktopProfile: { ...config.claudeCode.desktopProfile }, + }; + delete config.claudeCode.desktopProfile!.appliedFingerprint; + delete config.claudeCode.desktopProfile!.appliedAt; + persist(config); + } + // clearDesktop3pConfig reports failure details but never throws; + // a missing file is not an error — the user wanted it gone. + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, + state: "absent", + message: result.cleared + ? "Claude Desktop routing disabled — the 3P config was removed." + : `Claude Desktop routing disabled, but clearing the 3P config did not complete: ${result.reason ?? "unknown error"}. You can remove the files manually from ${result.path}.`, + } satisfies NativeToggleEnvelope); + })(); + try { + return await desktopToggleFlight; + } finally { + desktopToggleFlight = null; + } +} + export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { const { getConfigPath } = await import("../../config"); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus()], + clients: [claudeStatus(config, getConfigPath()), desktopStatus(config), grokStatus()], } satisfies NativeStatusListEnvelope); } @@ -583,5 +728,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return handleCodexToggle(ctx); } + if (url.pathname === "/api/native-integrations/claude-desktop" && req.method === "PUT") { + return handleDesktopToggle(ctx); + } + return null; } diff --git a/src/types.ts b/src/types.ts index 171628caf..ac1d2ef66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -464,6 +464,8 @@ export interface OcxClaudeCodeConfig { visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; /** Persisted Claude Desktop four-family routing profile. */ desktopProfile?: OcxClaudeDesktopProfile; + /** When false, opencodex removes its 3P config from Claude Desktop's library. Default: enabled. */ + desktopEnabled?: boolean; /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ desktopAutoApply?: boolean; /**