Files
NetMesh/application/state/useExternalMcpToggleState.ts

434 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import { useCallback, useEffect, useState } from 'react';
import {
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
// Always-mounted top-bar consumers can poll before App finishes startup reconcile.
// Gate runtime auto-clear until that reconcile has run on the main window.
let externalMcpStartupReady = false;
let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
/** Bumps on every intentional enable/disable so stale startup applies lose. */
let externalMcpEnableGeneration = 0;
function getWindowHash(hash?: string): string {
if (typeof hash === 'string') return hash;
return typeof window !== 'undefined' ? window.location.hash : '';
}
function isPeerSessionWindowLocation(hash?: string): boolean {
return getWindowHash(hash).startsWith('#/session-window');
}
/** Only the main App shell runs startup reconcile and owns the ready gate. */
export function shouldWaitForExternalMcpStartupReady(hash?: string): boolean {
const current = getWindowHash(hash);
if (!current || current === '#' || current === '#/') return true;
// Peer windows never own External MCP lifecycle.
if (current.startsWith('#/session-window')) return false;
// Settings / tray / popup windows mount the shared hook but never run App reconcile.
if (current.startsWith('#/settings')) return false;
if (current.startsWith('#/tray')) return false;
if (current.startsWith('#/terminal-popup')) return false;
return true;
}
export function markExternalMcpStartupReady(): void {
if (externalMcpStartupReady) return;
externalMcpStartupReady = true;
const resolve = externalMcpStartupReadyWaitResolve;
externalMcpStartupReadyWaitResolve = null;
externalMcpStartupReadyWaitPromise = null;
resolve?.();
}
export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReady = false;
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpStartupSyncPromise = null;
}
export function getExternalMcpEnableGenerationForTests(): number {
return externalMcpEnableGeneration;
}
export function bumpExternalMcpEnableGenerationForTests(): number {
externalMcpEnableGeneration += 1;
return externalMcpEnableGeneration;
}
export function isExternalMcpStartupReady(): boolean {
return externalMcpStartupReady;
}
/** Exposed for tests: single-flight waiter count is 0 or 1. */
export function getExternalMcpStartupReadyWaiterCountForTests(): number {
return externalMcpStartupReadyWaitResolve ? 1 : 0;
}
export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
// Non-main routes intentionally skip App reconcile; do not block forever there.
if (!shouldWaitForExternalMcpStartupReady(hash) || externalMcpStartupReady) {
return Promise.resolve();
}
if (!externalMcpStartupReadyWaitPromise) {
externalMcpStartupReadyWaitPromise = new Promise<void>((resolve) => {
externalMcpStartupReadyWaitResolve = resolve;
});
}
return externalMcpStartupReadyWaitPromise;
}
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
};
type ExternalMcpBridge = {
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
externalMcpGetStatus?: () => Promise<{
ok?: boolean;
enabled?: boolean;
state?: string;
error?: string | null;
} | undefined>;
};
export type ExternalMcpStartupSyncPlan = {
config: ExternalMcpConfig;
runtimeEnabled: boolean;
storedEnabled: boolean;
shouldPersistStoredEnabled: boolean;
};
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
return value === 'persistent' ? 'persistent' : 'temporary';
}
function readAIPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
return stored === 'observer' || stored === 'auto' ? stored : 'confirm';
}
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
return Math.min(
MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.max(
MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.round(value ?? DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
),
);
}
export function readExternalMcpStoredEnabled(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) ?? false;
}
export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
const normalized = normalizeExternalMcpMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
return normalized;
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
return normalized;
}
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
export function readExternalMcpFocusOnHostOpen(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
}
export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN, focusOnHostOpen);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
}
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
export function readExternalMcpSilentSessions(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
}
export function writeExternalMcpSilentSessions(silentSessions: boolean): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS, silentSessions);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS);
}
export function normalizeSessionIdleTimeoutMinutes(value: number | null): number {
if (!Number.isFinite(value)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
return Math.min(
MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.max(
MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
Math.round(value ?? DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES),
),
);
}
export function readSessionIdleTimeoutMinutes(): number {
return normalizeSessionIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
);
}
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
return normalized;
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
}: {
enabled: boolean;
mode: ExternalMcpMode;
}): boolean {
return mode === 'persistent' && enabled;
}
export function readExternalMcpStartupEnabled(): boolean {
return shouldStartExternalMcpOnStartup({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
});
}
export function createExternalMcpStartupSyncPlan({
enabled,
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
}: {
enabled: boolean;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
return {
config: {
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
},
runtimeEnabled,
storedEnabled,
shouldPersistStoredEnabled: storedEnabled !== enabled,
};
}
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
return createExternalMcpStartupSyncPlan({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
});
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
});
}
/**
* App-startup reconcile: only persistent+enabled starts the runtime.
* Temporary mode never auto-starts, and we clear a stale stored enabled flag
* so Settings remounts cannot accidentally re-enable temporary mode.
*/
export async function syncExternalMcpStartupState(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<ExternalMcpStartupSyncPlan> {
// Snapshot once for config push; re-read after awaits so a concurrent top-bar
// toggle during boot wins over a stale enable/disable decision.
const startupGeneration = externalMcpEnableGeneration;
const initialPlan = readExternalMcpStartupSyncPlan();
try {
// External MCP discovery is written while enabling, so restore the saved
// permission first instead of exposing the main-process default (confirm).
await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode()));
} catch {
// Permission sync is best-effort; the main process keeps its safe default.
}
try {
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
} catch {
// Config sync is best-effort; continue with enable/disable reconcile.
}
// A user toggle during config await invalidates this startup apply.
if (externalMcpEnableGeneration !== startupGeneration) {
return readExternalMcpStartupSyncPlan();
}
const plan = readExternalMcpStartupSyncPlan();
if (plan.shouldPersistStoredEnabled) {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}
// Capture generation again immediately before applying runtime enable/disable.
const applyGeneration = externalMcpEnableGeneration;
if (applyGeneration !== startupGeneration) {
return plan;
}
try {
await Promise.resolve(bridge?.externalMcpSetEnabled?.(plan.runtimeEnabled));
} catch {
// Keep stored preference on transient enable failure; runtime status + error
// surface can recover without wiping always-on intent.
}
return plan;
}
let externalMcpStartupSyncPromise: Promise<ExternalMcpStartupSyncPlan> | null = null;
/**
* Startup reconcile runs exactly once per renderer load. A remounted App must
* await the same settled promise instead of pushing a second enable/disable to
* the main process, which would fight the user's live toggle.
*/
export function syncExternalMcpStartupStateOnce(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<ExternalMcpStartupSyncPlan> {
if (!externalMcpStartupSyncPromise) {
externalMcpStartupSyncPromise = syncExternalMcpStartupState(bridge);
}
return externalMcpStartupSyncPromise;
}
export function resetExternalMcpStartupSyncOnceForTests(): void {
externalMcpStartupSyncPromise = null;
}
export function useExternalMcpToggleState() {
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
// decides whether temporary mode should clear/persist and start the runtime.
const isPeerSessionWindow = isPeerSessionWindowLocation();
const [enabled, setEnabledRaw] = useState<boolean>(() => readExternalMcpStoredEnabled());
const persistEnabled = useCallback((nextEnabled: boolean) => {
setEnabledRaw(nextEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, nextEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}, []);
const setEnabled = useCallback((nextEnabled: boolean) => {
persistEnabled(nextEnabled);
// Peer session windows can mirror the stored switch, but they must not own
// the main-process External MCP lifecycle.
if (isPeerSessionWindow) return;
externalMcpEnableGeneration += 1;
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
// event, which can still observe the previous enabled state.
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
});
}, [isPeerSessionWindow, persistEnabled]);
useEffect(() => {
const syncFromStorage = () => {
const nextEnabled = readExternalMcpStoredEnabled();
setEnabledRaw(nextEnabled);
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage();
};
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage();
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
useEffect(() => {
// Peer windows must never auto-clear the shared stored switch from runtime status.
if (isPeerSessionWindow || !enabled) return;
let cancelled = false;
const syncRuntimeStatus = async () => {
try {
// Wait until App has finished startup reconcile so a still-disabled runtime
// is not mistaken for "user turned it off / idle timeout".
await waitForExternalMcpStartupReady();
if (cancelled) return;
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
if (cancelled) return;
// Only clear the shared switch for an intentional runtime-off (idle /
// explicit disable). Start failures set error and should keep preference.
if (status?.ok && !status.enabled && !status.error) {
persistEnabled(false);
}
} catch {
// Keep the user's stored switch state during transient bridge errors.
}
};
const intervalId = window.setInterval(() => {
void syncRuntimeStatus();
}, EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS);
void syncRuntimeStatus();
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [enabled, isPeerSessionWindow, persistEnabled]);
return { enabled, setEnabled };
}