import { useCallback, useEffect, useState } from 'react'; import type { AIToolIntegrationMode } from '../../infrastructure/ai/types'; import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import { AI_STATE_CHANGED_EVENT } from './aiStateEvents'; import { readExternalMcpStoredEnabled } from './useExternalMcpToggleState'; /** Right after an enable toggle the runtime may still be starting; poll briefly. */ const EXTERNAL_MCP_READY_RETRY_LIMIT = 3; const EXTERNAL_MCP_READY_RETRY_DELAY_MS = 1200; /** * After the fast retry budget is exhausted, keep polling at the shared runtime * status cadence while the persisted switch and the runtime disagree, so a * persistently enabled runtime that takes longer than ~3.6s to reach `running` * still converges the guidance without an explicit state event. */ const EXTERNAL_MCP_SLOW_POLL_DELAY_MS = 3000; export type ToolAccessGuidanceState = { /** Path of the local Netcatty skill file (Skills + CLI mode). */ skillPath: string | null; /** Human-readable CLI command prefix shown under the skill path. */ commandPrefix: string; /** Launcher path of the External MCP host (null while not enabled/ready). */ mcpLauncherPath: string | null; /** Discovery file path handed to the external MCP host. */ mcpDiscoveryPath: string | null; }; /** * Resolves Tool Access guidance data for the settings view. * * Owns the External MCP lifecycle synchronization (status fetch, persisted * enable-key subscriptions and start-up retry polling) so view components only * render the resolved guidance state. The status paths are only trusted while * External MCP is actually enabled and running: the host keeps reporting * launcher and discovery paths as soon as the switch is on (even while the * runtime is still starting or after an idle shutdown). */ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState { const [skillPath, setSkillPath] = useState(null); const [commandPrefix, setCommandPrefix] = useState(''); const [mcpLauncherPath, setMcpLauncherPath] = useState(null); const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState(null); const refreshMcpStatus = useCallback(async (): Promise => { const status = await netcattyBridge.get()?.externalMcpGetStatus?.(); if (!status?.ok) return undefined; // Do not trust `enabled` alone: the main process flips `enabled` before // host credentials are resolved and the discovery file is written, and it // keeps reporting the configured discovery/launcher paths while starting. // Only treat the runtime as guidance-ready once it is actually running, // otherwise the prompt could reference a discovery file that does not // exist yet (and a launcher that exits because of it). const ready = Boolean(status.enabled) && status.state === 'running'; const launcherPath = ready ? status.launcherPath ?? null : null; const discoveryPath = ready ? status.discoveryPath ?? null : null; setMcpLauncherPath(launcherPath); setMcpDiscoveryPath(discoveryPath); return launcherPath && discoveryPath ? launcherPath : null; }, []); // Initial one-shot fetch for the skills mode. useEffect(() => { if (mode !== 'skills') return; let cancelled = false; void netcattyBridge .get() ?.aiSkillsCliGetInvocation?.() .then((result) => { if (cancelled || !result?.ok) return; setSkillPath(result.skillPath ?? null); setCommandPrefix(result.commandPrefix || ''); }); return () => { cancelled = true; }; }, [mode]); // Keep MCP guidance in sync with the shared persisted enable switch and // retry while the runtime finishes starting (fast retries, then a slower // fallback poll until switch and runtime agree). The fetch also runs on // mount through the retrying `refetch()` path: when the settings window // mounts while a persistently enabled runtime is still being restored, the // startup reconciliation emits no enable-key event, so without an initial // retrying fetch the fallback prompt would stay stale after the runtime // reaches `running`. useEffect(() => { if (mode === 'skills') return; let cancelled = false; let retryTimer: number | undefined; let retries = 0; const refetch = async () => { if (cancelled) return; const storedEnabled = readExternalMcpStoredEnabled(); const readyLauncherPath = await refreshMcpStatus().catch(() => undefined); if (cancelled) return; // The persisted switch is off but the status check failed: drop any // cached launcher/discovery paths. The stored credentials were revoked // by the disable, and falling through to the comparison below would see // "off" vs "not ready" as synchronized and keep a copyable prompt that // references the revoked credentials. if (!storedEnabled && readyLauncherPath === undefined) { setMcpLauncherPath(null); setMcpDiscoveryPath(null); } // Retry while the fetched runtime status disagrees with the persisted // switch: after an enable the runtime may still be starting, and the // disable event is emitted before the lifecycle IPC settles, so a stale // "enabled" status can survive the immediate refetch. const statusReady = readyLauncherPath != null; if (storedEnabled === statusReady) return; if (retries < EXTERNAL_MCP_READY_RETRY_LIMIT) { retries += 1; retryTimer = window.setTimeout(() => { void refetch(); }, EXTERNAL_MCP_READY_RETRY_DELAY_MS); return; } // Startup can take longer than the fast retry budget (a persistent // runtime restoring while the settings view mounts): the startup // reconciliation emits no state event, so keep a slower fallback poll // running until switch and runtime agree again. retryTimer = window.setTimeout(() => { void refetch(); }, EXTERNAL_MCP_SLOW_POLL_DELAY_MS); }; const refetchOnChange = () => { retries = 0; if (retryTimer) window.clearTimeout(retryTimer); void refetch(); }; const handleAIStateChanged = (event: Event) => { const key = (event as CustomEvent<{ key?: string }>).detail?.key; if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return; refetchOnChange(); }; const handleStorage = (event: StorageEvent) => { if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return; refetchOnChange(); }; window.addEventListener(AI_STATE_CHANGED_EVENT, handleAIStateChanged); window.addEventListener('storage', handleStorage); refetchOnChange(); return () => { cancelled = true; if (retryTimer) window.clearTimeout(retryTimer); window.removeEventListener(AI_STATE_CHANGED_EVENT, handleAIStateChanged); window.removeEventListener('storage', handleStorage); }; }, [mode, refreshMcpStatus]); return { skillPath, commandPrefix, mcpLauncherPath, mcpDiscoveryPath }; }