diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 539014955..06cf49c0d 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -12,10 +12,24 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
-import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
-import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClientProvider } from "@tanstack/react-query";
-import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
+import {
+ getHostRuntimeStore,
+ useHosts,
+ useHostMutations,
+ useHostRuntimeSession,
+} from "@/runtime/host-runtime";
+import { SessionProvider } from "@/contexts/session-context";
+import type { HostProfile } from "@/types/host-connection";
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ type ReactNode,
+ useMemo,
+ useRef,
+} from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
@@ -51,8 +65,12 @@ import {
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
+import { attachConsole } from "@/utils/tauri-attach-console";
polyfillCrypto();
+attachConsole();
+const HostRuntimeBootstrapContext = createContext(false);
+
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
@@ -141,6 +159,79 @@ function PushNotificationRouter() {
return null;
}
+function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
+ const { client } = useHostRuntimeSession(daemon.serverId);
+
+ if (!client) {
+ return null;
+ }
+
+ return (
+
+ {null}
+
+ );
+}
+
+function HostSessionManager() {
+ const hosts = useHosts();
+
+ if (hosts.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {hosts.map((daemon) => (
+
+ ))}
+ >
+ );
+}
+
+function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const store = getHostRuntimeStore();
+
+ void store
+ .loadFromStorage()
+ .then(() => {
+ if (cancelled) {
+ return;
+ }
+ setReady(true);
+ void store.bootstrap();
+ })
+ .catch((error) => {
+ console.error("[HostRuntime] Failed to initialize store", error);
+ if (!cancelled) {
+ setReady(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+function useStoreReady(): boolean {
+ return useContext(HostRuntimeBootstrapContext);
+}
+
function QueryProvider({ children }: { children: ReactNode }) {
return {children};
}
@@ -157,7 +248,7 @@ function AppContainer({
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
const { theme } = useUnistyles();
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
@@ -310,8 +401,9 @@ function AppContainer({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
- const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
- const isLoading = settingsLoading || registryLoading;
+ const storeReady = useStoreReady();
+ const { upsertConnectionFromOfferUrl } = useHostMutations();
+ const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -330,7 +422,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
@@ -470,9 +562,9 @@ export default function RootLayout() {
-
+
-
+
@@ -506,7 +598,7 @@ export default function RootLayout() {
-
+
diff --git a/packages/app/src/app/index-startup.ts b/packages/app/src/app/index-startup.ts
index 337f4304c..627593fc7 100644
--- a/packages/app/src/app/index-startup.ts
+++ b/packages/app/src/app/index-startup.ts
@@ -1,16 +1,12 @@
export const WELCOME_ROUTE = '/welcome'
export function shouldWaitOnStartupRace(input: {
- registryLoading: boolean
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
}): boolean {
- if (input.registryLoading) {
- return true
- }
if (input.onlineServerId) {
return false
}
@@ -24,14 +20,13 @@ export function shouldWaitOnStartupRace(input: {
}
export function shouldRedirectToWelcome(input: {
- registryLoading: boolean
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
}): boolean {
- if (input.registryLoading || input.onlineServerId || !input.hasTimedOut) {
+ if (input.onlineServerId || !input.hasTimedOut) {
return false
}
if (input.pathname !== '/' && input.pathname !== '') {
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index 9333559e4..256934b72 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
-import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
+import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
@@ -57,7 +57,7 @@ function useAnyHostOnline(serverIds: string[]): string | null {
export default function Index() {
const router = useRouter()
const pathname = usePathname()
- const { daemons, isLoading: registryLoading } = useDaemonRegistry()
+ const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
@@ -83,9 +83,6 @@ export default function Index() {
}, [])
useEffect(() => {
- if (registryLoading) {
- return
- }
if (!onlineServerId) {
return
}
@@ -97,12 +94,11 @@ export default function Index() {
elapsedMs: Math.round(performance.now() - APP_START_TIME),
})
router.replace(buildHostRootRoute(onlineServerId) as any)
- }, [onlineServerId, pathname, registryLoading, router])
+ }, [onlineServerId, pathname, router])
useEffect(() => {
if (
!shouldRedirectToWelcome({
- registryLoading,
onlineServerId,
hasTimedOut,
pathname,
@@ -113,11 +109,10 @@ export default function Index() {
return
}
router.replace(WELCOME_ROUTE as any)
- }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, registryLoading, router])
+ }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
if (
shouldWaitOnStartupRace({
- registryLoading,
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx
index 60cd9989e..8bf7aceb3 100644
--- a/packages/app/src/app/pair-scan.tsx
+++ b/packages/app/src/app/pair-scan.tsx
@@ -5,11 +5,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
-import { probeConnection } from "@/utils/test-daemon-connection";
+import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
@@ -151,7 +151,8 @@ export default function PairScanScreen() {
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
- const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
+ const daemons = useHosts();
+ const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
@@ -241,7 +242,7 @@ export default function PairScanScreen() {
return;
}
- await probeConnection(
+ const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -250,6 +251,7 @@ export default function PairScanScreen() {
},
{ serverId: offer.serverId },
);
+ await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
@@ -311,7 +313,7 @@ export default function PairScanScreen() {
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
- void updateHost(serverId, { label }).finally(() => {
+ void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx
index 2d10eae19..a0bbe5a3d 100644
--- a/packages/app/src/app/settings.tsx
+++ b/packages/app/src/app/settings.tsx
@@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
- const { daemons, isLoading: registryLoading } = useDaemonRegistry();
+ const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
@@ -29,16 +29,16 @@ export default function LegacySettingsRoute() {
}, [daemons, preferences.serverId]);
useEffect(() => {
- if (registryLoading || preferencesLoading) {
+ if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
- }, [preferencesLoading, registryLoading, router, targetServerId]);
+ }, [preferencesLoading, router, targetServerId]);
- if (registryLoading || preferencesLoading) {
+ if (preferencesLoading) {
return (
undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index e725a7888..736f73a69 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -41,7 +41,7 @@ import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
@@ -105,7 +105,7 @@ export function FileExplorerPane({
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 56e45329d..457d677b8 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -19,8 +19,7 @@ import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { Combobox } from '@/components/ui/combobox'
-import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
-import { getHostRuntimeStore } from '@/runtime/host-runtime'
+import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
import {
@@ -53,7 +52,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
- const { daemons } = useDaemonRegistry()
+ const daemons = useHosts()
const runtime = getHostRuntimeStore()
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 13463b991..55dc22d61 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -356,6 +356,7 @@ export const MessageInput = forwardRef(funct
serverInfo,
mode: 'dictation',
})
+ const dictationReadiness = serverInfo?.capabilities?.voice?.dictation ?? null
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
@@ -415,7 +416,28 @@ export const MessageInput = forwardRef(funct
}, [dictationStatus, isDictating, isDictationProcessing])
const startDictationIfAvailable = useCallback(async () => {
+ console.info(
+ `[DictationGate] start requested ${JSON.stringify({
+ serverId: voiceServerId ?? null,
+ agentId: voiceAgentId ?? null,
+ socketConnected: client?.isConnected ?? false,
+ isReadyForDictation: isReadyForDictation ?? null,
+ disabled,
+ serverInfoPresent: Boolean(serverInfo),
+ capabilitiesPresent: Boolean(serverInfo?.capabilities),
+ dictationReadinessEnabled: dictationReadiness?.enabled ?? null,
+ dictationReadinessReason: dictationReadiness?.reason ?? null,
+ dictationUnavailableMessage,
+ })}`
+ )
if (dictationUnavailableMessage) {
+ console.info(
+ `[DictationGate] blocked before start ${JSON.stringify({
+ serverId: voiceServerId ?? null,
+ agentId: voiceAgentId ?? null,
+ dictationUnavailableMessage,
+ })}`
+ )
isDictatingRef.current = false
toast.error(dictationUnavailableMessage)
return
@@ -424,7 +446,18 @@ export const MessageInput = forwardRef(funct
// state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true
await startDictation()
- }, [dictationUnavailableMessage, startDictation, toast])
+ }, [
+ client,
+ dictationReadiness,
+ dictationUnavailableMessage,
+ disabled,
+ isReadyForDictation,
+ serverInfo,
+ startDictation,
+ toast,
+ voiceAgentId,
+ voiceServerId,
+ ])
// Animate overlay
useEffect(() => {
diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx
deleted file mode 100644
index 3fa0098df..000000000
--- a/packages/app/src/components/multi-daemon-session-host.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useEffect } from "react";
-import { SessionProvider } from "@/contexts/session-context";
-import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
-import {
- getHostRuntimeStore,
- useHostRuntimeSession,
-} from "@/runtime/host-runtime";
-
-function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
- const { client } = useHostRuntimeSession(daemon.serverId);
-
- if (!client) {
- return null;
- }
-
- return (
-
- {null}
-
- );
-}
-
-export function MultiDaemonSessionHost() {
- const { daemons } = useDaemonRegistry();
-
- useEffect(() => {
- const runtime = getHostRuntimeStore();
- runtime.syncHosts(daemons);
- }, [daemons]);
-
- if (daemons.length === 0) {
- return null;
- }
-
- return (
- <>
- {daemons.map((daemon) => (
-
- ))}
- >
- );
-}
diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx
index c49c35dea..540edce21 100644
--- a/packages/app/src/components/pair-link-modal.tsx
+++ b/packages/app/src/components/pair-link-modal.tsx
@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link } from "lucide-react-native";
-import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
+import type { HostProfile } from "@/types/host-connection";
+import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
-import { probeConnection } from "@/utils/test-daemon-connection";
+import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -52,7 +53,8 @@ export interface PairLinkModalProps {
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
const { theme } = useUnistyles();
- const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
+ const daemons = useHosts();
+ const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -122,7 +124,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
setIsSaving(true);
setErrorMessage("");
- const probeResult = await probeConnection(
+ const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -131,10 +133,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
},
{ serverId: parsedOffer.serverId },
);
+ await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw);
- onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost });
+ onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host";
diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx
index 68ba21f87..5554beb63 100644
--- a/packages/app/src/components/project-picker-modal.tsx
+++ b/packages/app/src/components/project-picker-modal.tsx
@@ -17,8 +17,7 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
-import { useHostRuntimeSession } from "@/runtime/host-runtime";
+import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
@@ -28,7 +27,7 @@ export function ProjectPickerModal() {
const { theme } = useUnistyles();
const toast = useToast();
const pathname = usePathname();
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx
index ed2165f2d..c1ed3fda2 100644
--- a/packages/app/src/components/voice-panel.tsx
+++ b/packages/app/src/components/voice-panel.tsx
@@ -3,11 +3,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
export function VoicePanel() {
const { theme } = useUnistyles();
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const {
volume,
isMuted,
diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx
index 49cdd87fb..6f59604f5 100644
--- a/packages/app/src/components/welcome-screen.tsx
+++ b/packages/app/src/components/welcome-screen.tsx
@@ -3,8 +3,8 @@ import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
-import type { HostProfile } from "@/contexts/daemon-registry-context";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import type { HostProfile } from "@/types/host-connection";
+import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -87,7 +87,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const router = useRouter();
- const { updateHost } = useDaemonRegistry();
+ const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
@@ -201,7 +201,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}}
onSave={(label) => {
const serverId = pendingRedirectServerId;
- void updateHost(pendingNameHost.serverId, { label }).finally(() => {
+ void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);
diff --git a/packages/app/src/contexts/daemon-registry-context.test.ts b/packages/app/src/contexts/daemon-registry-context.test.ts
deleted file mode 100644
index 407361220..000000000
--- a/packages/app/src/contexts/daemon-registry-context.test.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import {
- connectionFromListen,
- hostHasDirectEndpoint,
- registryHasDirectEndpoint,
- type HostProfile,
-} from './daemon-registry-context'
-
-function makeHost(input: Partial & Pick): HostProfile {
- const now = '2026-01-01T00:00:00.000Z'
- return {
- serverId: input.serverId,
- label: input.label ?? input.serverId,
- lifecycle: input.lifecycle ?? {},
- connections: input.connections ?? [],
- preferredConnectionId: input.preferredConnectionId ?? null,
- createdAt: input.createdAt ?? now,
- updatedAt: input.updatedAt ?? now,
- }
-}
-
-describe('connectionFromListen', () => {
- it('parses tcp listen targets', () => {
- expect(connectionFromListen('127.0.0.1:6767')).toEqual({
- id: 'direct:127.0.0.1:6767',
- type: 'directTcp',
- endpoint: '127.0.0.1:6767',
- })
- })
-
- it('parses unix socket listen targets', () => {
- expect(connectionFromListen('/tmp/paseo.sock')).toEqual({
- id: 'socket:/tmp/paseo.sock',
- type: 'directSocket',
- path: '/tmp/paseo.sock',
- })
- expect(connectionFromListen('unix:///tmp/paseo.sock')).toEqual({
- id: 'socket:/tmp/paseo.sock',
- type: 'directSocket',
- path: '/tmp/paseo.sock',
- })
- })
-
- it('parses named pipe listen targets', () => {
- expect(connectionFromListen(String.raw`\\.\pipe\paseo-test`)).toEqual({
- id: String.raw`pipe:\\.\pipe\paseo-test`,
- type: 'directPipe',
- path: String.raw`\\.\pipe\paseo-test`,
- })
- expect(connectionFromListen('pipe://paseo-test')).toEqual({
- id: 'pipe:paseo-test',
- type: 'directPipe',
- path: 'paseo-test',
- })
- })
-
- it('returns null for unsupported listen targets', () => {
- expect(connectionFromListen('')).toBeNull()
- expect(connectionFromListen('not-a-listen-target')).toBeNull()
- })
-})
-
-describe('hostHasDirectEndpoint', () => {
- it('returns true when host has matching direct endpoint', () => {
- const host = makeHost({
- serverId: 'srv_local',
- connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
- preferredConnectionId: 'direct:localhost:6767',
- })
-
- expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
- })
-
- it('returns false when only relay connections exist', () => {
- const host = makeHost({
- serverId: 'srv_relay',
- connections: [
- {
- id: 'relay:relay.example:443',
- type: 'relay',
- relayEndpoint: 'relay.example:443',
- daemonPublicKeyB64: 'abcd',
- },
- ],
- preferredConnectionId: 'relay:relay.example:443',
- })
-
- expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
- })
-})
-
-describe('registryHasDirectEndpoint', () => {
- it('returns true when any host contains the direct endpoint', () => {
- const hosts: HostProfile[] = [
- makeHost({
- serverId: 'srv_one',
- connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
- preferredConnectionId: 'direct:127.0.0.1:7777',
- }),
- makeHost({
- serverId: 'srv_two',
- connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
- preferredConnectionId: 'direct:localhost:6767',
- }),
- ]
-
- expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
- })
-
- it('returns false when no host has the endpoint', () => {
- const hosts: HostProfile[] = [
- makeHost({
- serverId: 'srv_one',
- connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
- preferredConnectionId: 'direct:127.0.0.1:7777',
- }),
- ]
-
- expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
- })
-})
diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx
deleted file mode 100644
index 4d64c6bbf..000000000
--- a/packages/app/src/contexts/daemon-registry-context.tsx
+++ /dev/null
@@ -1,714 +0,0 @@
-import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
-import type { ReactNode } from 'react'
-import AsyncStorage from '@react-native-async-storage/async-storage'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
-import { probeConnection } from '@/utils/test-daemon-connection'
-import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
-import {
- shouldUseManagedDesktopDaemon,
- startManagedDaemon,
-} from '@/desktop/managed-runtime/managed-runtime'
-
-const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
-const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
-const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
-const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
-const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
-const E2E_STORAGE_KEY = '@paseo:e2e'
-
-export type DirectTcpHostConnection = {
- id: string
- type: 'directTcp'
- endpoint: string
-}
-
-export type DirectSocketHostConnection = {
- id: string
- type: 'directSocket'
- path: string
-}
-
-export type DirectPipeHostConnection = {
- id: string
- type: 'directPipe'
- path: string
-}
-
-export type RelayHostConnection = {
- id: string
- type: 'relay'
- relayEndpoint: string
- daemonPublicKeyB64: string
-}
-
-export type HostConnection =
- | DirectTcpHostConnection
- | DirectSocketHostConnection
- | DirectPipeHostConnection
- | RelayHostConnection
-
-export type HostLifecycle = Record
-
-export type HostProfile = {
- serverId: string
- label: string
- lifecycle: HostLifecycle
- connections: HostConnection[]
- preferredConnectionId: string | null
- createdAt: string
- updatedAt: string
-}
-
-export type UpdateHostInput = Partial>
-
-interface DaemonRegistryContextValue {
- daemons: HostProfile[]
- isLoading: boolean
- error: unknown | null
- upsertDirectConnection: (input: {
- serverId: string
- endpoint: string
- label?: string
- }) => Promise
- upsertRelayConnection: (input: {
- serverId: string
- relayEndpoint: string
- daemonPublicKeyB64: string
- label?: string
- }) => Promise
- updateHost: (serverId: string, updates: UpdateHostInput) => Promise
- removeHost: (serverId: string) => Promise
- removeConnection: (serverId: string, connectionId: string) => Promise
- upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise
- upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise
-}
-
-const DaemonRegistryContext = createContext(null)
-
-function defaultLifecycle(): HostLifecycle {
- return {}
-}
-
-function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
- const trimmed = value?.trim() ?? ''
- return trimmed.length > 0 ? trimmed : serverId
-}
-
-function normalizeEndpointOrNull(endpoint: string): string | null {
- try {
- return normalizeHostPort(endpoint)
- } catch {
- return null
- }
-}
-
-export function connectionFromListen(listen: string): HostConnection | null {
- const normalizedListen = listen.trim()
- if (!normalizedListen) {
- return null
- }
-
- if (normalizedListen.startsWith('pipe://')) {
- const path = normalizedListen.slice('pipe://'.length).trim()
- return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
- }
-
- if (normalizedListen.startsWith('unix://')) {
- const path = normalizedListen.slice('unix://'.length).trim()
- return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
- }
-
- if (normalizedListen.startsWith('\\\\.\\pipe\\')) {
- return {
- id: `pipe:${normalizedListen}`,
- type: 'directPipe',
- path: normalizedListen,
- }
- }
-
- if (normalizedListen.startsWith('/')) {
- return {
- id: `socket:${normalizedListen}`,
- type: 'directSocket',
- path: normalizedListen,
- }
- }
-
- try {
- const endpoint = normalizeHostPort(normalizedListen)
- return {
- id: `direct:${endpoint}`,
- type: 'directTcp',
- endpoint,
- }
- } catch {
- return null
- }
-}
-
-function normalizeStoredConnection(connection: unknown): HostConnection | null {
- if (!connection || typeof connection !== 'object') {
- return null
- }
- const record = connection as Record
- const type = typeof record.type === 'string' ? record.type : null
- if (type === 'directTcp') {
- try {
- const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
- return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
- } catch {
- return null
- }
- }
- if (type === 'directSocket') {
- const path = String(record.path ?? '').trim()
- return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
- }
- if (type === 'directPipe') {
- const path = String(record.path ?? '').trim()
- return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
- }
- if (type === 'relay') {
- try {
- const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
- const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
- if (!daemonPublicKeyB64) return null
- return {
- id: `relay:${relayEndpoint}`,
- type: 'relay',
- relayEndpoint,
- daemonPublicKeyB64,
- }
- } catch {
- return null
- }
- }
-
- return null
-}
-
-function normalizeStoredLifecycle(_lifecycle: unknown): HostLifecycle {
- return defaultLifecycle()
-}
-
-function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
- if (!entry || typeof entry !== 'object') {
- return null
- }
- const record = entry as Record
- const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
- if (!serverId) {
- return null
- }
-
- const rawConnections = Array.isArray(record.connections) ? record.connections : []
- const connections = rawConnections
- .map((connection) => normalizeStoredConnection(connection))
- .filter((connection): connection is HostConnection => connection !== null)
- if (connections.length === 0) {
- return null
- }
-
- const lifecycle = normalizeStoredLifecycle(record.lifecycle)
- const now = new Date().toISOString()
- const label = normalizeHostLabel(
- typeof record.label === 'string' ? record.label : null,
- serverId
- )
- const preferredConnectionId =
- typeof record.preferredConnectionId === 'string' &&
- connections.some((connection) => connection.id === record.preferredConnectionId)
- ? record.preferredConnectionId
- : connections[0]?.id ?? null
-
- return {
- serverId,
- label,
- lifecycle,
- connections,
- preferredConnectionId,
- createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
- updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
- }
-}
-
-function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
- if (left.type !== right.type || left.id !== right.id) {
- return false
- }
-
- if (left.type === 'directTcp' && right.type === 'directTcp') {
- return left.endpoint === right.endpoint
- }
- if (left.type === 'directSocket' && right.type === 'directSocket') {
- return left.path === right.path
- }
- if (left.type === 'directPipe' && right.type === 'directPipe') {
- return left.path === right.path
- }
- if (left.type === 'relay' && right.type === 'relay') {
- return (
- left.relayEndpoint === right.relayEndpoint &&
- left.daemonPublicKeyB64 === right.daemonPublicKeyB64
- )
- }
-
- return false
-}
-
-function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
- return JSON.stringify(left) === JSON.stringify(right)
-}
-
-function upsertHostConnectionInProfiles(input: {
- profiles: HostProfile[]
- serverId: string
- label?: string
- connection: HostConnection
- now?: string
-}): HostProfile[] {
- const serverId = input.serverId.trim()
- if (!serverId) {
- throw new Error('serverId is required')
- }
-
- const now = input.now ?? new Date().toISOString()
- const labelTrimmed = input.label?.trim() ?? ''
- const derivedLabel = labelTrimmed || serverId
- const existing = input.profiles
- const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
-
- if (idx === -1) {
- const profile: HostProfile = {
- serverId,
- label: derivedLabel,
- lifecycle: defaultLifecycle(),
- connections: [input.connection],
- preferredConnectionId: input.connection.id,
- createdAt: now,
- updatedAt: now,
- }
- return [...existing, profile]
- }
-
- const prev = existing[idx]!
- const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
- const hadConnection = connectionIdx !== -1
- const connectionChanged =
- connectionIdx === -1
- ? true
- : !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
- const nextConnections =
- connectionIdx === -1
- ? [...prev.connections, input.connection]
- : connectionChanged
- ? prev.connections.map((connection, index) =>
- index === connectionIdx ? input.connection : connection
- )
- : prev.connections
-
- const nextLifecycle = prev.lifecycle
- const nextLabel = labelTrimmed ? labelTrimmed : prev.label
- const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
- const changed =
- nextLabel !== prev.label ||
- nextPreferredConnectionId !== prev.preferredConnectionId ||
- !hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
- !hadConnection ||
- connectionChanged
-
- if (!changed) {
- return existing
- }
-
- const nextProfile: HostProfile = {
- ...prev,
- label: nextLabel,
- lifecycle: nextLifecycle,
- connections: nextConnections,
- preferredConnectionId: nextPreferredConnectionId,
- updatedAt: now,
- }
-
- const next = [...existing]
- next[idx] = nextProfile
- return next
-}
-
-export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
- const normalized = normalizeEndpointOrNull(endpoint)
- if (!normalized) {
- return false
- }
- return host.connections.some(
- (connection) => connection.type === 'directTcp' && connection.endpoint === normalized
- )
-}
-
-export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
- return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
-}
-
-export function useDaemonRegistry(): DaemonRegistryContextValue {
- const ctx = useContext(DaemonRegistryContext)
- if (!ctx) {
- throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
- }
- return ctx
-}
-
-export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
- const queryClient = useQueryClient()
- const desktopStartupAttemptedRef = useRef(false)
- const localhostBootstrapAttemptedRef = useRef(false)
- const {
- data: daemons = [],
- isPending,
- error,
- } = useQuery({
- queryKey: DAEMON_REGISTRY_QUERY_KEY,
- queryFn: loadDaemonRegistryFromStorage,
- staleTime: Infinity,
- gcTime: Infinity,
- })
-
- const persist = useCallback(
- async (profiles: HostProfile[]) => {
- queryClient.setQueryData(DAEMON_REGISTRY_QUERY_KEY, profiles)
- await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
- },
- [queryClient]
- )
-
- const readDaemons = useCallback(() => {
- return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
- }, [queryClient, daemons])
-
- const updateHost = useCallback(
- async (serverId: string, updates: UpdateHostInput) => {
- const next = readDaemons().map((daemon) =>
- daemon.serverId === serverId
- ? {
- ...daemon,
- ...updates,
- updatedAt: new Date().toISOString(),
- }
- : daemon
- )
- await persist(next)
- },
- [persist, readDaemons]
- )
-
- const removeHost = useCallback(
- async (serverId: string) => {
- const existing = readDaemons()
- const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
- await persist(remaining)
- },
- [persist, readDaemons]
- )
-
- const removeConnection = useCallback(
- async (serverId: string, connectionId: string) => {
- const existing = readDaemons()
- const now = new Date().toISOString()
- const next = existing
- .map((daemon) => {
- if (daemon.serverId !== serverId) return daemon
- const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
- if (remaining.length === 0) {
- return null
- }
- const preferred =
- daemon.preferredConnectionId === connectionId
- ? (remaining[0]?.id ?? null)
- : daemon.preferredConnectionId
- return {
- ...daemon,
- connections: remaining,
- preferredConnectionId: preferred,
- updatedAt: now,
- } satisfies HostProfile
- })
- .filter((entry): entry is HostProfile => entry !== null)
- await persist(next)
- },
- [persist, readDaemons]
- )
-
- const upsertHostConnection = useCallback(
- async (input: {
- serverId: string
- label?: string
- connection: HostConnection
- }) => {
- const now = new Date().toISOString()
- const next = upsertHostConnectionInProfiles({
- profiles: readDaemons(),
- serverId: input.serverId,
- label: input.label,
- connection: input.connection,
- now,
- })
- await persist(next)
- return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
- },
- [persist, readDaemons]
- )
-
- const upsertDirectConnection = useCallback(
- async (input: { serverId: string; endpoint: string; label?: string }) => {
- const endpoint = normalizeHostPort(input.endpoint)
- return upsertHostConnection({
- serverId: input.serverId,
- label: input.label,
- connection: {
- id: `direct:${endpoint}`,
- type: 'directTcp',
- endpoint,
- },
- })
- },
- [upsertHostConnection]
- )
-
- useEffect(() => {
- if (isPending) return
- if (!shouldUseManagedDesktopDaemon()) return
- if (desktopStartupAttemptedRef.current) return
- desktopStartupAttemptedRef.current = true
-
- let cancelled = false
-
- const bootstrapDesktopStartup = async () => {
- const t0 = performance.now()
- const elapsed = () => Math.round(performance.now() - t0)
- try {
- const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
- if (cancelled || isE2E) {
- return
- }
-
- console.info('[DaemonRegistry] desktop bootstrap: starting', { elapsedMs: elapsed() })
- await Promise.allSettled([
- (async () => {
- console.info('[DaemonRegistry] desktop bootstrap: startManagedDaemon', { elapsedMs: elapsed() })
- const daemon = await startManagedDaemon()
- console.info('[DaemonRegistry] desktop bootstrap: managed daemon ready', { elapsedMs: elapsed(), serverId: daemon.serverId })
- if (cancelled || !daemon.serverId) {
- return
- }
-
- const connection = connectionFromListen(daemon.listen)
- if (!connection) {
- return
- }
-
- await upsertHostConnection({
- serverId: daemon.serverId,
- label: daemon.hostname ?? undefined,
- connection,
- })
- console.info('[DaemonRegistry] desktop bootstrap: host connection upserted', { elapsedMs: elapsed() })
- })().catch((managedBootstrapError) => {
- if (!cancelled) {
- console.warn(
- '[DaemonRegistry] Failed to bootstrap desktop daemon connection',
- { elapsedMs: elapsed() },
- managedBootstrapError
- )
- }
- }),
- (async () => {
- console.info('[DaemonRegistry] desktop bootstrap: probing localhost', { elapsedMs: elapsed() })
- const { serverId, hostname } = await probeConnection(
- {
- id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
- type: 'directTcp',
- endpoint: DEFAULT_LOCALHOST_ENDPOINT,
- },
- { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
- )
- console.info('[DaemonRegistry] desktop bootstrap: localhost probe succeeded', { elapsedMs: elapsed(), serverId })
- if (cancelled) {
- return
- }
-
- await upsertDirectConnection({
- serverId,
- endpoint: DEFAULT_LOCALHOST_ENDPOINT,
- label: hostname ?? undefined,
- })
- console.info('[DaemonRegistry] desktop bootstrap: direct connection upserted', { elapsedMs: elapsed() })
- })().catch(() => {
- console.info('[DaemonRegistry] desktop bootstrap: localhost probe failed', { elapsedMs: elapsed() })
- }),
- ])
- } catch (bootstrapError) {
- if (cancelled) return
- console.warn(
- '[DaemonRegistry] Failed to bootstrap desktop startup host connections',
- bootstrapError
- )
- }
- }
-
- void bootstrapDesktopStartup()
-
- return () => {
- cancelled = true
- }
- }, [isPending, upsertDirectConnection, upsertHostConnection])
-
- useEffect(() => {
- if (isPending) return
- if (shouldUseManagedDesktopDaemon()) return
- if (localhostBootstrapAttemptedRef.current) return
- localhostBootstrapAttemptedRef.current = true
-
- let cancelled = false
-
- const bootstrapLocalhost = async () => {
- try {
- const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
- if (cancelled || isE2E) {
- return
- }
-
- const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
- if (cancelled || alreadyHandled) {
- return
- }
-
- const existing = readDaemons()
- if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
- await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
- return
- }
-
- try {
- const { serverId, hostname } = await probeConnection(
- {
- id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
- type: 'directTcp',
- endpoint: DEFAULT_LOCALHOST_ENDPOINT,
- },
- { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
- )
- if (cancelled) return
-
- await upsertDirectConnection({
- serverId,
- endpoint: DEFAULT_LOCALHOST_ENDPOINT,
- label: hostname ?? undefined,
- })
- await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
- } catch {
- // Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
- }
- } catch (bootstrapError) {
- if (cancelled) return
- console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
- }
- }
-
- void bootstrapLocalhost()
-
- return () => {
- cancelled = true
- }
- }, [isPending, readDaemons, upsertDirectConnection])
-
- const upsertRelayConnection = useCallback(
- async (input: {
- serverId: string
- relayEndpoint: string
- daemonPublicKeyB64: string
- label?: string
- }) => {
- const relayEndpoint = normalizeHostPort(input.relayEndpoint)
- const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
- if (!daemonPublicKeyB64) {
- throw new Error('daemonPublicKeyB64 is required')
- }
- return upsertHostConnection({
- serverId: input.serverId,
- label: input.label,
- connection: {
- id: `relay:${relayEndpoint}`,
- type: 'relay',
- relayEndpoint,
- daemonPublicKeyB64,
- },
- })
- },
- [upsertHostConnection]
- )
-
- const upsertDaemonFromOffer = useCallback(
- async (offer: ConnectionOffer) => {
- return upsertRelayConnection({
- serverId: offer.serverId,
- relayEndpoint: offer.relay.endpoint,
- daemonPublicKeyB64: offer.daemonPublicKeyB64,
- })
- },
- [upsertRelayConnection]
- )
-
- const upsertDaemonFromOfferUrl = useCallback(
- async (offerUrlOrFragment: string) => {
- const marker = '#offer='
- const idx = offerUrlOrFragment.indexOf(marker)
- if (idx === -1) {
- throw new Error('Missing #offer= fragment')
- }
- const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
- if (!encoded) {
- throw new Error('Offer payload is empty')
- }
- const payload = decodeOfferFragmentPayload(encoded)
- const offer = ConnectionOfferSchema.parse(payload)
- return upsertDaemonFromOffer(offer)
- },
- [upsertDaemonFromOffer]
- )
-
- const value: DaemonRegistryContextValue = {
- daemons,
- isLoading: isPending,
- error: error ?? null,
- upsertDirectConnection,
- upsertRelayConnection,
- updateHost,
- removeHost,
- removeConnection,
- upsertDaemonFromOffer,
- upsertDaemonFromOfferUrl,
- }
-
- return {children}
-}
-
-async function loadDaemonRegistryFromStorage(): Promise {
- try {
- const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
- if (!stored) {
- return []
- }
-
- const parsed = JSON.parse(stored) as unknown
- if (!Array.isArray(parsed)) {
- return []
- }
-
- return parsed
- .map((entry) => normalizeStoredHostProfile(entry))
- .filter((entry): entry is HostProfile => entry !== null)
- } catch (error) {
- console.error('[DaemonRegistry] Failed to load daemon registry', error)
- throw error
- }
-}
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 441448d73..7d6b0c722 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -1100,6 +1100,19 @@ function SessionProviderInternal({
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
+ console.info(
+ `[Session] server_info received ${JSON.stringify({
+ serverId,
+ payloadServerId: serverInfo.serverId,
+ hostname: serverInfo.hostname,
+ version: serverInfo.version,
+ capabilitiesPresent: Boolean(serverInfo.capabilities),
+ dictationCapabilityEnabled:
+ serverInfo.capabilities?.voice?.dictation?.enabled ?? null,
+ dictationCapabilityReason:
+ serverInfo.capabilities?.voice?.dictation?.reason ?? null,
+ })}`
+ );
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index 63ece2c50..40ec499f3 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -9,7 +9,7 @@ import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
@@ -327,7 +327,7 @@ export function useAgentFormState(
updateProviderPreferences,
} = useFormPreferences();
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(
diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts
index 6b9934260..176fee769 100644
--- a/packages/app/src/hooks/use-aggregated-agents.ts
+++ b/packages/app/src/hooks/use-aggregated-agents.ts
@@ -1,10 +1,9 @@
import { useMemo, useCallback, useSyncExternalStore } from "react";
import { useShallow } from "zustand/shallow";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
-import { getHostRuntimeStore } from "@/runtime/host-runtime";
+import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -22,7 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
}): AggregatedAgentsResult {
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const runtimeVersion = useSyncExternalStore(
diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts
index 914941480..5c4ab7dce 100644
--- a/packages/app/src/hooks/use-all-agents-list.ts
+++ b/packages/app/src/hooks/use-all-agents-list.ts
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
@@ -74,7 +74,7 @@ export function useAllAgentsList(options?: {
serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult {
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts
index 56aae64f5..f220550fa 100644
--- a/packages/app/src/hooks/use-command-center.ts
+++ b/packages/app/src/hooks/use-command-center.ts
@@ -3,7 +3,7 @@ import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
@@ -104,7 +104,7 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const inputRef = useRef(null);
diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts
index 2b983f76e..4de92ac57 100644
--- a/packages/app/src/runtime/host-runtime.test.ts
+++ b/packages/app/src/runtime/host-runtime.test.ts
@@ -5,7 +5,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@server/client/daemon-client";
-import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
+import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore } from "@/stores/session-store";
import {
HostRuntimeController,
@@ -72,6 +72,10 @@ class FakeDaemonClient {
});
}
+ async ping(): Promise<{ rttMs: number }> {
+ return { rttMs: 0 };
+ }
+
setConnectionState(next: ConnectionState): void {
this.state = next;
if (next.status === "disconnected") {
@@ -194,7 +198,7 @@ function makeDeps(
createdClients.push(client);
return client as unknown as DaemonClient;
},
- measureLatency: async ({ connection }) => {
+ connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id];
if (value instanceof Error) {
throw value;
@@ -202,7 +206,16 @@ function makeDeps(
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
- return value;
+ const client = new FakeDaemonClient();
+ client.connectCalls = 1;
+ client.setConnectionState({ status: "connected" });
+ client.ping = async () => ({ rttMs: value });
+ createdClients.push(client);
+ return {
+ client: client as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ };
},
getClientId: async () => "cid_test_runtime",
};
@@ -222,8 +235,24 @@ function createDeferred() {
};
}
+function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
+ const client = new FakeDaemonClient();
+ client.connectCalls = 1;
+ client.setConnectionState({ status: "connected" });
+ client.ping = async () => ({ rttMs: latencyMs });
+ return client;
+}
+
+function clearProbeBackoff(controller: HostRuntimeController): void {
+ (
+ controller as unknown as {
+ connectionLastProbedAt: Map;
+ }
+ ).connectionLastProbedAt.clear();
+}
+
describe("HostRuntimeController", () => {
- it("keeps known hosts in connecting when client reports idle during connect", async () => {
+ it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
connections: [
{
@@ -236,7 +265,7 @@ describe("HostRuntimeController", () => {
const idleClient = new FakeDaemonClient();
const deps: HostRuntimeControllerDeps = {
createClient: () => idleClient as unknown as DaemonClient,
- measureLatency: async () => {
+ connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
@@ -251,7 +280,11 @@ describe("HostRuntimeController", () => {
// Intentionally do not emit a connected state; stay in idle.
};
- await controller.start({ autoProbe: false });
+ await (
+ controller as unknown as {
+ switchToConnection: (input: { connectionId: string }) => Promise;
+ }
+ ).switchToConnection({ connectionId: "direct:lan:6767" });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
@@ -277,18 +310,24 @@ describe("HostRuntimeController", () => {
seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient;
},
- measureLatency: async () => 10,
+ connectToDaemon: async () => {
+ throw new Error("probe unavailable");
+ },
getClientId: async () => "cid_runtime_stable",
},
});
- await controller.start({ autoProbe: false });
+ await (
+ controller as unknown as {
+ switchToConnection: (input: { connectionId: string }) => Promise;
+ }
+ ).switchToConnection({ connectionId: "direct:lan:6767" });
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
- it("selects the lowest-latency connection on startup", async () => {
+ it("adopts the first successful probe on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record = {
@@ -303,10 +342,62 @@ describe("HostRuntimeController", () => {
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
- expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
+ expect(snapshot.activeConnectionId).toBe("direct:lan:6767");
expect(snapshot.connectionStatus).toBe("online");
- expect(clients).toHaveLength(1);
+ expect(clients).toHaveLength(2);
+ expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient);
expect(clients[0]?.connectCalls).toBe(1);
+ expect(clients[1]?.closeCalls).toBe(1);
+ });
+
+ it("activates the first successful probe without waiting for slower probes", async () => {
+ const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
+ const slowPing = createDeferred();
+ const clients: FakeDaemonClient[] = [];
+
+ const controller = new HostRuntimeController({
+ host,
+ deps: {
+ createClient: () => {
+ throw new Error("should adopt the probe client");
+ },
+ connectToDaemon: async ({ host, connection }) => {
+ const client = makeConnectedProbeClient(
+ connection.id === "direct:lan:6767" ? 12 : 30
+ );
+ if (connection.id === "relay:relay.paseo.sh:443") {
+ client.ping = async () => ({ rttMs: await slowPing.promise });
+ }
+ clients.push(client);
+ return {
+ client: client as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ };
+ },
+ getClientId: async () => "cid_test_runtime",
+ },
+ });
+
+ const probeCycle = controller.runProbeCycleNow();
+
+ const timeoutAt = Date.now() + 200;
+ while (Date.now() < timeoutAt) {
+ const snapshot = controller.getSnapshot();
+ if (
+ snapshot.activeConnectionId === "direct:lan:6767" &&
+ snapshot.connectionStatus === "online"
+ ) {
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+
+ expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
+ expect(controller.getSnapshot().connectionStatus).toBe("online");
+
+ slowPing.resolve(30);
+ await probeCycle;
});
it("fails over when active connection becomes unavailable", async () => {
@@ -323,17 +414,19 @@ describe("HostRuntimeController", () => {
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
- expect(clients).toHaveLength(1);
+ const initialClient = controller.getSnapshot().client;
+ expect(initialClient).toBeTruthy();
latencies["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42;
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
- expect(clients).toHaveLength(2);
- expect(clients[0]?.closeCalls).toBe(1);
+ expect(snapshot.client).not.toBe(initialClient);
+ expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
});
it("switches only after the faster alternative wins consecutive probes", async () => {
@@ -353,15 +446,24 @@ describe("HostRuntimeController", () => {
latencies["direct:lan:6767"] = 95;
latencies["relay:relay.paseo.sh:443"] = 30;
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
- await controller.runProbeCycleNow();
- expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
- expect(clients).toHaveLength(2);
+ let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
+ for (let index = 0; index < 6 && !switched; index += 1) {
+ clearProbeBackoff(controller);
+ await controller.runProbeCycleNow();
+ switched =
+ controller.getSnapshot().activeConnectionId ===
+ "relay:relay.paseo.sh:443";
+ }
+ expect(switched).toBe(true);
+ expect(controller.getSnapshot().client).not.toBeNull();
});
it("does not switch on a transient latency spike", async () => {
@@ -381,24 +483,35 @@ describe("HostRuntimeController", () => {
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 20;
latencies["relay:relay.paseo.sh:443"] = 90;
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
+ clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
- await controller.runProbeCycleNow();
- expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
+ let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
+ for (let index = 0; index < 6 && !switched; index += 1) {
+ clearProbeBackoff(controller);
+ await controller.runProbeCycleNow();
+ switched =
+ controller.getSnapshot().activeConnectionId ===
+ "relay:relay.paseo.sh:443";
+ }
+ expect(switched).toBe(true);
});
it("exposes one snapshot with active connection and status from same source", async () => {
@@ -604,7 +717,11 @@ describe("HostRuntimeController", () => {
createdClients.push(client);
return client as unknown as DaemonClient;
},
- measureLatency: async () => 10,
+ connectToDaemon: async ({ host }) => ({
+ client: makeConnectedProbeClient(10) as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
@@ -681,21 +798,32 @@ describe("HostRuntimeController", () => {
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
- measureLatency: async () => {
+ connectToDaemon: async ({ host }) => {
probeCalls += 1;
- if (probeCalls === 1) {
- return await slowProbe.promise;
- }
- if (probeCalls === 2) {
- return await fastProbe.promise;
- }
- throw new Error("unexpected probe call");
+ const client = new FakeDaemonClient();
+ client.connectCalls = 1;
+ client.setConnectionState({ status: "connected" });
+ client.ping = async () => {
+ if (probeCalls === 1) {
+ return { rttMs: await slowProbe.promise };
+ }
+ if (probeCalls === 2) {
+ return { rttMs: await fastProbe.promise };
+ }
+ throw new Error("unexpected probe call");
+ };
+ return {
+ client: client as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ };
},
getClientId: async () => "cid_test_runtime",
},
});
const first = controller.runProbeCycleNow();
+ clearProbeBackoff(controller);
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
@@ -719,7 +847,7 @@ describe("HostRuntimeController", () => {
});
});
- it("keeps active client generation stable while overlapping probes run", async () => {
+ it("keeps active client generation stable during background probe cycles", async () => {
const host = makeHost({
connections: [
{
@@ -729,10 +857,7 @@ describe("HostRuntimeController", () => {
},
],
});
- const slowProbe = createDeferred();
- const fastProbe = createDeferred();
const createdClients: FakeDaemonClient[] = [];
- let probeCalls = 0;
const controller = new HostRuntimeController({
host,
@@ -742,18 +867,13 @@ describe("HostRuntimeController", () => {
createdClients.push(client);
return client as unknown as DaemonClient;
},
- measureLatency: async () => {
- probeCalls += 1;
- if (probeCalls === 1) {
- return 10;
- }
- if (probeCalls === 2) {
- return await slowProbe.promise;
- }
- if (probeCalls === 3) {
- return await fastProbe.promise;
- }
- return 10;
+ connectToDaemon: async ({ host }) => {
+ const client = makeConnectedProbeClient(10);
+ return {
+ client: client as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ };
},
getClientId: async () => "cid_test_runtime",
},
@@ -763,24 +883,13 @@ describe("HostRuntimeController", () => {
const activeClientBeforeProbes = controller.getSnapshot().client;
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
- const first = controller.runProbeCycleNow();
- const second = controller.runProbeCycleNow();
-
- fastProbe.resolve(12);
- await second;
+ clearProbeBackoff(controller);
+ await controller.runProbeCycleNow();
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(
generationBeforeProbes
);
-
- slowProbe.resolve(999);
- await first;
- expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
- expect(controller.getSnapshot().clientGeneration).toBe(
- generationBeforeProbes
- );
- expect(createdClients).toHaveLength(1);
- expect(createdClients[0]?.closeCalls).toBe(0);
+ expect(createdClients).toHaveLength(0);
});
});
@@ -796,10 +905,15 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
+ fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
- measureLatency: async () => 5,
+ connectToDaemon: async ({ host }) => ({
+ client: fakeClient as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
getClientId: async () => "cid_test_runtime",
},
});
@@ -832,9 +946,9 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
- it("defers directory bootstrap until session store is initialized for that server", async () => {
+ it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
const host = makeHost({
- serverId: "srv_deferred",
+ serverId: "srv_no_session",
connections: [
{
id: "direct:lan:6767",
@@ -844,26 +958,22 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
+ fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
- measureLatency: async () => 5,
+ connectToDaemon: async ({ host }) => ({
+ client: fakeClient as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
- await new Promise((resolve) => setTimeout(resolve, 250));
- expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
-
- useSessionStore.getState().initializeSession(
- host.serverId,
- fakeClient as unknown as DaemonClient,
- null as any
- );
-
- const timeoutAt = Date.now() + 600;
+ const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
@@ -872,12 +982,11 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
- subscribe: { subscriptionId: "app:srv_deferred" },
+ subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 },
});
store.syncHosts([]);
- useSessionStore.getState().clearSession(host.serverId);
});
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
@@ -892,6 +1001,7 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
+ fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
@@ -923,7 +1033,11 @@ describe("HostRuntimeStore", () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
- measureLatency: async () => 5,
+ connectToDaemon: async ({ host }) => ({
+ client: fakeClient as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
getClientId: async () => "cid_test_runtime",
},
});
@@ -991,10 +1105,15 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
+ fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
- measureLatency: async () => 5,
+ connectToDaemon: async ({ host }) => ({
+ client: fakeClient as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
getClientId: async () => "cid_test_runtime",
},
});
@@ -1041,7 +1160,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
- it("surfaces startup failures as error instead of leaving host idle", async () => {
+ it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [
{
@@ -1056,7 +1175,7 @@ describe("HostRuntimeStore", () => {
createClient: () => {
throw new Error("create client failed");
},
- measureLatency: async () => {
+ connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
@@ -1066,12 +1185,51 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
let snapshot = store.getSnapshot(host.serverId);
const timeoutAt = Date.now() + 100;
- while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) {
+ while (
+ snapshot?.probeByConnectionId.get("direct:lan:6767")?.status !== "unavailable" &&
+ Date.now() < timeoutAt
+ ) {
await new Promise((resolve) => setTimeout(resolve, 0));
snapshot = store.getSnapshot(host.serverId);
}
- expect(snapshot?.connectionStatus).toBe("error");
- expect(snapshot?.lastError).toBe("create client failed");
+ expect(snapshot?.connectionStatus).toBe("connecting");
+ expect(snapshot?.lastError).toBeNull();
+ expect(snapshot?.probeByConnectionId.get("direct:lan:6767")).toEqual({
+ status: "unavailable",
+ latencyMs: null,
+ });
+ });
+
+ it("renameHost updates label in memory", async () => {
+ const store = new HostRuntimeStore({
+ deps: {
+ createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
+ connectToDaemon: async ({ host }) => ({
+ client: makeConnectedProbeClient(5) as unknown as DaemonClient,
+ serverId: host.serverId,
+ hostname: host.label ?? null,
+ }),
+ getClientId: async () => "cid_test_runtime",
+ },
+ });
+
+ // upsertDirectConnection goes through setHostsAndSync, which both sets
+ // this.hosts and syncs controllers — matching the real init path.
+ await store.upsertDirectConnection({
+ serverId: "srv_rename",
+ endpoint: "lan:6767",
+ label: "old name",
+ });
+ expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name");
+
+ // persistHosts may throw in test env (no AsyncStorage/window), but the
+ // in-memory state should still be updated by setHostsAndSync.
+ await store.renameHost("srv_rename", "new name").catch(() => undefined);
+
+ const renamed = store.getHosts().find((h) => h.serverId === "srv_rename");
+ expect(renamed?.label).toBe("new name");
+
+ store.syncHosts([]);
});
});
diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts
index 4c3e66cf7..217cf4d58 100644
--- a/packages/app/src/runtime/host-runtime.ts
+++ b/packages/app/src/runtime/host-runtime.ts
@@ -1,16 +1,30 @@
-import { useSyncExternalStore } from "react";
+import { useSyncExternalStore, useCallback, useMemo } from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
import {
DaemonClient,
type ConnectionState,
type DaemonClientDiagnosticsEvent,
type FetchAgentsOptions,
} from "@server/client/daemon-client";
-import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
+import {
+ connectionFromListen,
+ normalizeStoredHostProfile,
+ upsertHostConnectionInProfiles,
+ registryHasDirectEndpoint,
+ type HostConnection,
+ type HostProfile,
+} from "@/types/host-connection";
+import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
+import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
+import {
+ shouldUseManagedDesktopDaemon,
+ startManagedDaemon,
+} from "@/desktop/managed-runtime/managed-runtime";
+import { connectToDaemon } from "@/utils/test-daemon-connection";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
-import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { getOrCreateClientId } from "@/utils/client-id";
import {
selectBestConnection,
@@ -132,15 +146,23 @@ export type HostRuntimeControllerDeps = {
clientId: string;
runtimeGeneration: number;
}) => DaemonClient;
- measureLatency: (input: {
+ connectToDaemon: (input: {
host: HostProfile;
connection: HostConnection;
- }) => Promise;
+ }) => Promise<{
+ client: DaemonClient;
+ serverId: string;
+ hostname: string | null;
+ }>;
getClientId: () => Promise;
};
export type HostRuntimeStartOptions = {
autoProbe?: boolean;
+ initialConnection?: {
+ connectionId: string;
+ existingClient: DaemonClient;
+ };
};
const PROBE_TICK_MS = 2_000;
@@ -149,7 +171,7 @@ const PROBE_MAX_BACKOFF_MS = 30_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
-const AGENT_DIRECTORY_SESSION_RETRY_MS = 150;
+
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [
{ key: "updated_at", direction: "desc" },
];
@@ -493,8 +515,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
},
});
},
- measureLatency: ({ host, connection }) =>
- measureConnectionLatency(connection, { serverId: host.serverId }),
+ connectToDaemon: ({ host, connection }) =>
+ connectToDaemon(connection, { serverId: host.serverId }),
getClientId: () => getOrCreateClientId(),
};
}
@@ -560,6 +582,12 @@ export class HostRuntimeController {
}
this.started = true;
this.trackConnectionFirstSeen();
+ if (options?.initialConnection) {
+ await this.switchToConnection({
+ connectionId: options.initialConnection.connectionId,
+ existingClient: options.initialConnection.existingClient,
+ });
+ }
await this.runProbeCycleNow();
if (options?.autoProbe !== false) {
this.probeIntervalHandle = setInterval(() => {
@@ -645,6 +673,13 @@ export class HostRuntimeController {
});
}
+ async activateConnection(input: {
+ connectionId: string;
+ existingClient: DaemonClient;
+ }): Promise {
+ await this.switchToConnection(input);
+ }
+
async runProbeCycleNow(): Promise {
const requestVersion = ++this.probeRequestVersion;
if (this.host.connections.length === 0) {
@@ -679,73 +714,103 @@ export class HostRuntimeController {
}
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
- await Promise.all(
- connectionsToProbe.map(async (connection) => {
- this.connectionLastProbedAt.set(connection.id, performance.now());
- try {
- const latencyMs = await this.deps.measureLatency({
- host: this.host,
- connection,
+ for (const connection of connectionsToProbe) {
+ this.connectionLastProbedAt.set(connection.id, performance.now());
+ probeByConnectionId.set(connection.id, {
+ status: "pending",
+ latencyMs: null,
+ });
+ }
+ this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
+
+ let remaining = connectionsToProbe.length;
+ let activationLock: Promise | null = null;
+
+ const publishProbeState = (): void => {
+ if (!this.isCurrentProbeRequest(requestVersion)) {
+ return;
+ }
+ this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
+ };
+
+ const maybeActivateFirstAvailable = async (
+ connectionId: string,
+ client: DaemonClient
+ ): Promise => {
+ while (!this.snapshot.activeConnectionId) {
+ if (!activationLock) {
+ activationLock = this.switchToConnection({
+ connectionId,
+ expectedProbeVersion: requestVersion,
+ existingClient: client,
+ }).finally(() => {
+ activationLock = null;
});
- probeByConnectionId.set(connection.id, {
- status: "available",
- latencyMs,
- });
- } catch {
- probeByConnectionId.set(connection.id, {
- status: "unavailable",
- latencyMs: null,
+ await activationLock;
+ return this.snapshot.activeConnectionId === connectionId;
+ }
+ await activationLock;
+ }
+ return false;
+ };
+
+ const finalizeProbeCycle = async (): Promise => {
+ if (remaining > 0 || !this.isCurrentProbeRequest(requestVersion)) {
+ return;
+ }
+
+ const currentActiveConnectionId = this.snapshot.activeConnectionId;
+ const activeProbe = currentActiveConnectionId
+ ? probeByConnectionId.get(currentActiveConnectionId)
+ : null;
+
+ if (
+ !currentActiveConnectionId ||
+ !findConnectionById(this.host, currentActiveConnectionId)
+ ) {
+ const nextConnectionId = selectBestConnection({
+ candidates: buildConnectionCandidates(this.host),
+ probeByConnectionId,
+ });
+ if (nextConnectionId) {
+ await this.switchToConnection({
+ connectionId: nextConnectionId,
+ expectedProbeVersion: requestVersion,
});
}
- })
- );
-
- if (!this.isCurrentProbeRequest(requestVersion)) {
- return;
- }
- this.updateSnapshot({ probeByConnectionId });
-
- const currentActiveConnectionId = this.snapshot.activeConnectionId;
- const activeProbe = currentActiveConnectionId
- ? probeByConnectionId.get(currentActiveConnectionId)
- : null;
-
- if (!currentActiveConnectionId || !findConnectionById(this.host, currentActiveConnectionId)) {
- const nextConnectionId = selectBestConnection({
- candidates: buildConnectionCandidates(this.host),
- probeByConnectionId,
- });
- if (nextConnectionId) {
- await this.switchToConnection({
- connectionId: nextConnectionId,
- expectedProbeVersion: requestVersion,
- });
+ return;
}
- return;
- }
- if (activeProbe?.status === "unavailable") {
- const nextConnectionId = selectBestConnection({
- candidates: buildConnectionCandidates(this.host),
- probeByConnectionId,
- });
- if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) {
- await this.switchToConnection({
- connectionId: nextConnectionId,
- expectedProbeVersion: requestVersion,
+ if (activeProbe?.status === "unavailable") {
+ const nextConnectionId = selectBestConnection({
+ candidates: buildConnectionCandidates(this.host),
+ probeByConnectionId,
});
+ if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) {
+ await this.switchToConnection({
+ connectionId: nextConnectionId,
+ expectedProbeVersion: requestVersion,
+ });
+ }
+ this.switchCandidateConnectionId = null;
+ this.switchCandidateHitCount = 0;
+ return;
+ }
+
+ if (!activeProbe || activeProbe.status !== "available") {
+ return;
}
- this.switchCandidateConnectionId = null;
- this.switchCandidateHitCount = 0;
- return;
- }
- if (activeProbe && activeProbe.status === "available") {
const available = Array.from(probeByConnectionId.entries())
- .filter(([, probe]) => probe.status === "available")
+ .filter(
+ (
+ entry
+ ): entry is [string, Extract] =>
+ entry[1].status === "available"
+ )
.map(([connectionId, probe]) => ({
connectionId,
- latencyMs: (probe as { status: "available"; latencyMs: number }).latencyMs,
+ latencyMs: probe.latencyMs,
}))
.sort((left, right) => left.latencyMs - right.latencyMs);
@@ -756,8 +821,7 @@ export class HostRuntimeController {
return;
}
- const activeLatency = activeProbe.latencyMs;
- const improvement = activeLatency - fastest.latencyMs;
+ const improvement = activeProbe.latencyMs - fastest.latencyMs;
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
this.switchCandidateConnectionId = null;
this.switchCandidateHitCount = 0;
@@ -772,8 +836,7 @@ export class HostRuntimeController {
}
if (
- this.switchCandidateHitCount >=
- ADAPTIVE_SWITCH_CONSECUTIVE_PROBES
+ this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES
) {
this.switchCandidateConnectionId = null;
this.switchCandidateHitCount = 0;
@@ -782,7 +845,63 @@ export class HostRuntimeController {
expectedProbeVersion: requestVersion,
});
}
- }
+ };
+
+ await new Promise((resolve) => {
+ const settleProbe = (): void => {
+ remaining -= 1;
+ void finalizeProbeCycle().finally(() => {
+ if (remaining === 0) {
+ resolve();
+ }
+ });
+ };
+
+ for (const connection of connectionsToProbe) {
+ void (async () => {
+ let connectedClient: DaemonClient | null = null;
+ let handedOffClient = false;
+ try {
+ const { client } = await this.deps.connectToDaemon({
+ host: this.host,
+ connection,
+ });
+ connectedClient = client;
+
+ if (!this.isCurrentProbeRequest(requestVersion)) {
+ return;
+ }
+
+ const activated = await maybeActivateFirstAvailable(connection.id, client);
+ handedOffClient = activated;
+
+ const { rttMs } = await client.ping({ timeoutMs: 5000 });
+ if (!this.isCurrentProbeRequest(requestVersion)) {
+ return;
+ }
+
+ probeByConnectionId.set(connection.id, {
+ status: "available",
+ latencyMs: rttMs,
+ });
+ publishProbeState();
+ } catch {
+ if (this.isCurrentProbeRequest(requestVersion)) {
+ probeByConnectionId.set(connection.id, {
+ status: "unavailable",
+ latencyMs: null,
+ });
+ publishProbeState();
+ }
+ } finally {
+ if (connectedClient && !handedOffClient) {
+ await connectedClient.close().catch(() => undefined);
+ }
+ settleProbe();
+ }
+ })();
+ }
+ });
}
private updateSnapshot(
@@ -880,13 +999,20 @@ export class HostRuntimeController {
private async switchToConnection(input: {
connectionId: string;
expectedProbeVersion?: number;
+ existingClient?: DaemonClient;
}): Promise {
if (!this.canProceedForProbe(input.expectedProbeVersion)) {
+ if (input.existingClient) {
+ await input.existingClient.close().catch(() => undefined);
+ }
return;
}
- const { connectionId, expectedProbeVersion } = input;
+ const { connectionId, expectedProbeVersion, existingClient } = input;
const connection = findConnectionById(this.host, connectionId);
if (!connection) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
return;
}
const requestVersion = ++this.switchRequestVersion;
@@ -895,6 +1021,9 @@ export class HostRuntimeController {
try {
clientId = await this.resolveClientId();
} catch (error) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
if (!this.isCurrentSwitchRequest(requestVersion)) {
return;
}
@@ -910,9 +1039,15 @@ export class HostRuntimeController {
}
if (!this.isCurrentSwitchRequest(requestVersion)) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
return;
}
if (!this.canProceedForProbe(expectedProbeVersion)) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
return;
}
@@ -926,14 +1061,20 @@ export class HostRuntimeController {
await previousClient.close().catch(() => undefined);
}
if (!this.isCurrentSwitchRequest(requestVersion)) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
return;
}
if (!this.canProceedForProbe(expectedProbeVersion)) {
+ if (existingClient) {
+ await existingClient.close().catch(() => undefined);
+ }
return;
}
const nextGeneration = this.snapshot.clientGeneration + 1;
- const client = this.deps.createClient({
+ const client = existingClient ?? this.deps.createClient({
host: this.host,
connection,
clientId,
@@ -1000,7 +1141,9 @@ export class HostRuntimeController {
});
try {
- await client.connect();
+ if (!existingClient) {
+ await client.connect();
+ }
} catch (error) {
if (
!this.isCurrentSwitchRequest(requestVersion) ||
@@ -1030,18 +1173,25 @@ export class HostRuntimeController {
}
}
+const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
+const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767";
+const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
+const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
+const E2E_STORAGE_KEY = "@paseo:e2e";
+
export class HostRuntimeStore {
private controllers = new Map();
private serverListeners = new Map void>>();
private globalListeners = new Set<() => void>();
+ private hostListListeners = new Set<() => void>();
private version = 0;
+ private hostListVersion = 0;
+ private hosts: HostProfile[] = [];
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map();
private agentDirectoryBootstrapInFlight = new Map>();
- private agentDirectorySessionRetryTimerByServer = new Map<
- string,
- ReturnType
- >();
+ private storageLoaded = false;
+ private bootstrapAttempted = false;
constructor(input?: {
deps?: HostRuntimeControllerDeps;
@@ -1049,7 +1199,335 @@ export class HostRuntimeStore {
this.deps = input?.deps ?? createDefaultDeps();
}
- syncHosts(hosts: HostProfile[]): void {
+ // --- Host registry ---
+
+ getHosts(): HostProfile[] {
+ return this.hosts;
+ }
+
+ subscribeHostList(listener: () => void): () => void {
+ this.hostListListeners.add(listener);
+ return () => {
+ this.hostListListeners.delete(listener);
+ };
+ }
+
+ getHostListVersion(): number {
+ return this.hostListVersion;
+ }
+
+ async loadFromStorage(): Promise {
+ if (this.storageLoaded) {
+ return;
+ }
+ this.storageLoaded = true;
+ try {
+ const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
+ if (!stored) {
+ return;
+ }
+ const parsed = JSON.parse(stored) as unknown;
+ if (!Array.isArray(parsed)) {
+ return;
+ }
+ const profiles = parsed
+ .map((entry) => normalizeStoredHostProfile(entry))
+ .filter((entry): entry is HostProfile => entry !== null);
+ this.hosts = profiles;
+ this.syncHosts(profiles);
+ this.emitHostList();
+ } catch (error) {
+ console.error("[HostRuntime] Failed to load host registry from storage", error);
+ }
+ }
+
+ async bootstrap(): Promise {
+ if (this.bootstrapAttempted) {
+ return;
+ }
+ this.bootstrapAttempted = true;
+
+ try {
+ const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
+ if (isE2E) {
+ return;
+ }
+ } catch {
+ return;
+ }
+
+ if (shouldUseManagedDesktopDaemon()) {
+ await this.bootstrapDesktop();
+ } else {
+ await this.bootstrapLocalhost();
+ }
+ }
+
+ private async bootstrapDesktop(): Promise {
+ const t0 = performance.now();
+ const elapsed = () => Math.round(performance.now() - t0);
+ try {
+ console.info("[HostRuntime] desktop bootstrap: starting", { elapsedMs: elapsed() });
+ await Promise.allSettled([
+ (async () => {
+ console.info("[HostRuntime] desktop bootstrap: startManagedDaemon", { elapsedMs: elapsed() });
+ const daemon = await startManagedDaemon();
+ console.info("[HostRuntime] desktop bootstrap: managed daemon ready", { elapsedMs: elapsed(), serverId: daemon.serverId });
+ if (!daemon.serverId) {
+ return;
+ }
+ const connection = connectionFromListen(daemon.listen);
+ if (!connection) {
+ return;
+ }
+ await this.upsertHostConnection({
+ serverId: daemon.serverId,
+ label: daemon.hostname ?? undefined,
+ connection,
+ });
+ console.info("[HostRuntime] desktop bootstrap: host connection upserted", { elapsedMs: elapsed() });
+ })().catch((error) => {
+ console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", { elapsedMs: elapsed() }, error);
+ }),
+ (async () => {
+ console.info("[HostRuntime] desktop bootstrap: probing localhost", { elapsedMs: elapsed() });
+ const { client, serverId, hostname } = await connectToDaemon(
+ {
+ id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
+ type: "directTcp",
+ endpoint: DEFAULT_LOCALHOST_ENDPOINT,
+ },
+ { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
+ );
+ console.info("[HostRuntime] desktop bootstrap: localhost probe succeeded", { elapsedMs: elapsed(), serverId });
+ await this.upsertDirectConnection({
+ serverId,
+ endpoint: DEFAULT_LOCALHOST_ENDPOINT,
+ label: hostname ?? undefined,
+ existingClient: client,
+ });
+ console.info("[HostRuntime] desktop bootstrap: direct connection upserted", { elapsedMs: elapsed() });
+ })().catch(() => {
+ console.info("[HostRuntime] desktop bootstrap: localhost probe failed", { elapsedMs: elapsed() });
+ }),
+ ]);
+ } catch (error) {
+ console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error);
+ }
+ }
+
+ private async bootstrapLocalhost(): Promise {
+ try {
+ const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY);
+ if (alreadyHandled) {
+ return;
+ }
+
+ if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) {
+ await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
+ return;
+ }
+
+ try {
+ const { client, serverId, hostname } = await connectToDaemon(
+ {
+ id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
+ type: "directTcp",
+ endpoint: DEFAULT_LOCALHOST_ENDPOINT,
+ },
+ { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
+ );
+
+ await this.upsertDirectConnection({
+ serverId,
+ endpoint: DEFAULT_LOCALHOST_ENDPOINT,
+ label: hostname ?? undefined,
+ existingClient: client,
+ });
+ await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
+ } catch {
+ // Best-effort bootstrap only
+ }
+ } catch (error) {
+ console.warn("[HostRuntime] Failed to bootstrap host connections", error);
+ }
+ }
+
+ async upsertDirectConnection(input: {
+ serverId: string;
+ endpoint: string;
+ label?: string;
+ existingClient?: DaemonClient;
+ }): Promise {
+ const endpoint = normalizeHostPort(input.endpoint);
+ return this.upsertHostConnection({
+ serverId: input.serverId,
+ label: input.label,
+ connection: {
+ id: `direct:${endpoint}`,
+ type: "directTcp",
+ endpoint,
+ },
+ existingClient: input.existingClient,
+ });
+ }
+
+ async upsertRelayConnection(input: {
+ serverId: string;
+ relayEndpoint: string;
+ daemonPublicKeyB64: string;
+ label?: string;
+ }): Promise {
+ const relayEndpoint = normalizeHostPort(input.relayEndpoint);
+ const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
+ if (!daemonPublicKeyB64) {
+ throw new Error("daemonPublicKeyB64 is required");
+ }
+ return this.upsertHostConnection({
+ serverId: input.serverId,
+ label: input.label,
+ connection: {
+ id: `relay:${relayEndpoint}`,
+ type: "relay",
+ relayEndpoint,
+ daemonPublicKeyB64,
+ },
+ });
+ }
+
+ async upsertConnectionFromOffer(offer: ConnectionOffer): Promise {
+ return this.upsertRelayConnection({
+ serverId: offer.serverId,
+ relayEndpoint: offer.relay.endpoint,
+ daemonPublicKeyB64: offer.daemonPublicKeyB64,
+ });
+ }
+
+ async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise {
+ const marker = "#offer=";
+ const idx = offerUrlOrFragment.indexOf(marker);
+ if (idx === -1) {
+ throw new Error("Missing #offer= fragment");
+ }
+ const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
+ if (!encoded) {
+ throw new Error("Offer payload is empty");
+ }
+ const payload = decodeOfferFragmentPayload(encoded);
+ const offer = ConnectionOfferSchema.parse(payload);
+ return this.upsertConnectionFromOffer(offer);
+ }
+
+ async renameHost(serverId: string, label: string): Promise {
+ const next = this.hosts.map((h) =>
+ h.serverId === serverId
+ ? { ...h, label, updatedAt: new Date().toISOString() }
+ : h
+ );
+ this.setHostsAndSync(next);
+ await this.persistHosts();
+ }
+
+ async removeHost(serverId: string): Promise {
+ const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
+ this.setHostsAndSync(remaining);
+ await this.persistHosts();
+ }
+
+ async removeConnection(serverId: string, connectionId: string): Promise {
+ const now = new Date().toISOString();
+ const next = this.hosts
+ .map((daemon) => {
+ if (daemon.serverId !== serverId) return daemon;
+ const remaining = daemon.connections.filter((conn) => conn.id !== connectionId);
+ if (remaining.length === 0) {
+ return null;
+ }
+ const preferred =
+ daemon.preferredConnectionId === connectionId
+ ? (remaining[0]?.id ?? null)
+ : daemon.preferredConnectionId;
+ return {
+ ...daemon,
+ connections: remaining,
+ preferredConnectionId: preferred,
+ updatedAt: now,
+ } satisfies HostProfile;
+ })
+ .filter((entry): entry is HostProfile => entry !== null);
+ this.setHostsAndSync(next);
+ await this.persistHosts();
+ }
+
+ private async upsertHostConnection(input: {
+ serverId: string;
+ label?: string;
+ connection: HostConnection;
+ existingClient?: DaemonClient;
+ }): Promise {
+ const now = new Date().toISOString();
+ const next = upsertHostConnectionInProfiles({
+ profiles: this.hosts,
+ serverId: input.serverId,
+ label: input.label,
+ connection: input.connection,
+ now,
+ });
+ this.setHostsAndSync(next, {
+ initialConnectionByServerId: input.existingClient
+ ? new Map([
+ [
+ input.serverId,
+ {
+ connectionId: input.connection.id,
+ existingClient: input.existingClient,
+ },
+ ],
+ ])
+ : undefined,
+ });
+ void this.persistHosts();
+ return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile;
+ }
+
+ private setHostsAndSync(
+ hosts: HostProfile[],
+ options?: {
+ initialConnectionByServerId?: Map<
+ string,
+ { connectionId: string; existingClient: DaemonClient }
+ >;
+ }
+ ): void {
+ this.hosts = hosts;
+ this.syncHosts(hosts, options);
+ this.emitHostList();
+ }
+
+ private async persistHosts(): Promise {
+ try {
+ await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
+ } catch (error) {
+ console.error("[HostRuntime] Failed to persist host registry", error);
+ }
+ }
+
+ private emitHostList(): void {
+ this.hostListVersion += 1;
+ for (const listener of this.hostListListeners) {
+ listener();
+ }
+ }
+
+ syncHosts(
+ hosts: HostProfile[],
+ options?: {
+ initialConnectionByServerId?: Map<
+ string,
+ { connectionId: string; existingClient: DaemonClient }
+ >;
+ }
+ ): void {
const nextIds = new Set(hosts.map((host) => host.serverId));
for (const [serverId, controller] of this.controllers) {
if (nextIds.has(serverId)) {
@@ -1058,15 +1536,20 @@ export class HostRuntimeStore {
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
- this.clearAgentDirectorySessionRetry(serverId);
void controller.stop();
this.emit(serverId);
}
for (const host of hosts) {
+ const initialConnection = options?.initialConnectionByServerId?.get(host.serverId);
const existing = this.controllers.get(host.serverId);
if (existing) {
void existing.updateHost(host);
+ if (initialConnection) {
+ void existing.activateConnection(initialConnection).catch(() => {
+ void initialConnection.existingClient.close().catch(() => undefined);
+ });
+ }
continue;
}
const controller = new HostRuntimeController({
@@ -1082,7 +1565,13 @@ export class HostRuntimeStore {
this.maybeAutoBootstrapAgentDirectory(host.serverId);
this.emit(host.serverId);
});
- void controller.start().catch((error) => {
+ void controller.start({
+ ...(initialConnection
+ ? {
+ initialConnection,
+ }
+ : {}),
+ }).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
controller.markStartupError(message);
});
@@ -1109,21 +1598,14 @@ export class HostRuntimeStore {
// Runtime owns directory bootstrap policy, including reconnect and delayed
// session initialization races.
if (snapshot.connectionStatus !== "online") {
- this.clearAgentDirectorySessionRetry(serverId);
return;
}
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
- this.clearAgentDirectorySessionRetry(serverId);
return;
}
if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
return;
}
- if (!useSessionStore.getState().sessions[serverId]) {
- this.scheduleAgentDirectorySessionRetry(serverId);
- return;
- }
- this.clearAgentDirectorySessionRetry(serverId);
const bootstrap = Promise.resolve()
.then(() =>
@@ -1150,26 +1632,6 @@ export class HostRuntimeStore {
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
}
- private scheduleAgentDirectorySessionRetry(serverId: string): void {
- if (this.agentDirectorySessionRetryTimerByServer.has(serverId)) {
- return;
- }
- const handle = setTimeout(() => {
- this.agentDirectorySessionRetryTimerByServer.delete(serverId);
- this.maybeAutoBootstrapAgentDirectory(serverId);
- }, AGENT_DIRECTORY_SESSION_RETRY_MS);
- this.agentDirectorySessionRetryTimerByServer.set(serverId, handle);
- }
-
- private clearAgentDirectorySessionRetry(serverId: string): void {
- const handle = this.agentDirectorySessionRetryTimerByServer.get(serverId);
- if (!handle) {
- return;
- }
- clearTimeout(handle);
- this.agentDirectorySessionRetryTimerByServer.delete(serverId);
- }
-
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null;
}
@@ -1384,3 +1846,47 @@ export function useHostRuntimeSession(serverId: string): {
isConnected: isHostRuntimeConnected(snapshot),
};
}
+
+export function useHosts(): HostProfile[] {
+ const store = getHostRuntimeStore();
+ return useSyncExternalStore(
+ (onStoreChange) => store.subscribeHostList(onStoreChange),
+ () => store.getHosts(),
+ () => store.getHosts()
+ );
+}
+
+export interface HostMutations {
+ upsertDirectConnection: (input: {
+ serverId: string;
+ endpoint: string;
+ label?: string;
+ }) => Promise;
+ upsertRelayConnection: (input: {
+ serverId: string;
+ relayEndpoint: string;
+ daemonPublicKeyB64: string;
+ label?: string;
+ }) => Promise;
+ upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise;
+ upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise;
+ renameHost: (serverId: string, label: string) => Promise;
+ removeHost: (serverId: string) => Promise;
+ removeConnection: (serverId: string, connectionId: string) => Promise;
+}
+
+export function useHostMutations(): HostMutations {
+ const store = getHostRuntimeStore();
+ return useMemo(
+ () => ({
+ upsertDirectConnection: (input) => store.upsertDirectConnection(input),
+ upsertRelayConnection: (input) => store.upsertRelayConnection(input),
+ upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
+ upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
+ renameHost: (serverId, label) => store.renameHost(serverId, label),
+ removeHost: (serverId) => store.removeHost(serverId),
+ removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
+ }),
+ [store]
+ );
+}
diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx
index 65abda30f..3396c04ac 100644
--- a/packages/app/src/screens/agent/agent-ready-screen.tsx
+++ b/packages/app/src/screens/agent/agent-ready-screen.tsx
@@ -21,7 +21,7 @@ import {
ExplorerSidebarAnimationProvider,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
-import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
+import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import {
useHostRuntimeSession,
@@ -94,7 +94,7 @@ export function AgentReadyScreen({
}) {
const resolvedAgentId = agentId?.trim() || undefined;
const resolvedServerId = serverId?.trim() || undefined;
- const { daemons } = useDaemonRegistry();
+ const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
const {
snapshot: runtimeSnapshot,
diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx
index 3f5f07e6c..a91a9ad4f 100644
--- a/packages/app/src/screens/agent/draft-agent-screen.tsx
+++ b/packages/app/src/screens/agent/draft-agent-screen.tsx
@@ -25,7 +25,7 @@ import {
checkoutStatusQueryKey,
} from '@/hooks/use-checkout-status-query'
import { useAllAgentsList } from '@/hooks/use-all-agents-list'
-import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
+import { useHosts } from '@/runtime/host-runtime'
import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions'
import { shortenPath } from '@/utils/shorten-path'
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
@@ -138,7 +138,7 @@ function DraftAgentScreenContent({
const { theme } = useUnistyles()
const router = useRouter()
const insets = useSafeAreaInsets()
- const { daemons } = useDaemonRegistry()
+ const daemons = useHosts()
const runtime = getHostRuntimeStore()
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index 9d16eb07b..d976cc8e6 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -13,7 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
-import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
+import type { HostProfile, HostConnection } from "@/types/host-connection";
+import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
@@ -487,12 +488,8 @@ export default function SettingsScreen() {
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const {
- daemons,
- isLoading: daemonLoading,
- updateHost,
- removeHost,
- removeConnection,
- } = useDaemonRegistry();
+ daemons, renameHost, removeHost, removeConnection,
+ } = { daemons: useHosts(), ...useHostMutations() };
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -503,7 +500,7 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
- const isLoading = settingsLoading || daemonLoading;
+ const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef(null);
const isDesktop = Platform.OS === "web";
@@ -615,7 +612,7 @@ export default function SettingsScreen() {
try {
setIsSavingEdit(true);
- await updateHost(editingServerId, { label: nextLabel });
+ await renameHost(editingServerId, nextLabel);
handleCloseEditDaemon();
} catch (error) {
console.error("[Settings] Failed to rename host", error);
@@ -623,7 +620,7 @@ export default function SettingsScreen() {
} finally {
setIsSavingEdit(false);
}
- }, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
+ }, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]);
const handleRemoveConnection = useCallback(
async (serverId: string, connectionId: string) => {
@@ -767,7 +764,7 @@ export default function SettingsScreen() {
hostname={pendingNameHostname}
onSkip={() => setPendingNameHost(null)}
onSave={(label) => {
- void updateHost(pendingNameHost.serverId, { label }).finally(() => {
+ void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
});
}}
diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts
index 91d223518..1007db1fb 100644
--- a/packages/app/src/stores/download-store.ts
+++ b/packages/app/src/stores/download-store.ts
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
-import type { HostProfile } from "@/contexts/daemon-registry-context";
+import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url";
diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts
new file mode 100644
index 000000000..ef3b57408
--- /dev/null
+++ b/packages/app/src/types/host-connection.ts
@@ -0,0 +1,303 @@
+import { normalizeHostPort } from '@server/shared/daemon-endpoints'
+
+export type DirectTcpHostConnection = {
+ id: string
+ type: 'directTcp'
+ endpoint: string
+}
+
+export type DirectSocketHostConnection = {
+ id: string
+ type: 'directSocket'
+ path: string
+}
+
+export type DirectPipeHostConnection = {
+ id: string
+ type: 'directPipe'
+ path: string
+}
+
+export type RelayHostConnection = {
+ id: string
+ type: 'relay'
+ relayEndpoint: string
+ daemonPublicKeyB64: string
+}
+
+export type HostConnection =
+ | DirectTcpHostConnection
+ | DirectSocketHostConnection
+ | DirectPipeHostConnection
+ | RelayHostConnection
+
+export type HostLifecycle = Record
+
+export type HostProfile = {
+ serverId: string
+ label: string
+ lifecycle: HostLifecycle
+ connections: HostConnection[]
+ preferredConnectionId: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+export function defaultLifecycle(): HostLifecycle {
+ return {}
+}
+
+export function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
+ const trimmed = value?.trim() ?? ''
+ return trimmed.length > 0 ? trimmed : serverId
+}
+
+function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
+ if (left.type !== right.type || left.id !== right.id) {
+ return false
+ }
+
+ if (left.type === 'directTcp' && right.type === 'directTcp') {
+ return left.endpoint === right.endpoint
+ }
+ if (left.type === 'directSocket' && right.type === 'directSocket') {
+ return left.path === right.path
+ }
+ if (left.type === 'directPipe' && right.type === 'directPipe') {
+ return left.path === right.path
+ }
+ if (left.type === 'relay' && right.type === 'relay') {
+ return (
+ left.relayEndpoint === right.relayEndpoint &&
+ left.daemonPublicKeyB64 === right.daemonPublicKeyB64
+ )
+ }
+
+ return false
+}
+
+function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
+ return JSON.stringify(left) === JSON.stringify(right)
+}
+
+export function upsertHostConnectionInProfiles(input: {
+ profiles: HostProfile[]
+ serverId: string
+ label?: string
+ connection: HostConnection
+ now?: string
+}): HostProfile[] {
+ const serverId = input.serverId.trim()
+ if (!serverId) {
+ throw new Error('serverId is required')
+ }
+
+ const now = input.now ?? new Date().toISOString()
+ const labelTrimmed = input.label?.trim() ?? ''
+ const derivedLabel = labelTrimmed || serverId
+ const existing = input.profiles
+ const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
+
+ if (idx === -1) {
+ const profile: HostProfile = {
+ serverId,
+ label: derivedLabel,
+ lifecycle: defaultLifecycle(),
+ connections: [input.connection],
+ preferredConnectionId: input.connection.id,
+ createdAt: now,
+ updatedAt: now,
+ }
+ return [...existing, profile]
+ }
+
+ const prev = existing[idx]!
+ const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
+ const hadConnection = connectionIdx !== -1
+ const connectionChanged =
+ connectionIdx === -1
+ ? true
+ : !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
+ const nextConnections =
+ connectionIdx === -1
+ ? [...prev.connections, input.connection]
+ : connectionChanged
+ ? prev.connections.map((connection, index) =>
+ index === connectionIdx ? input.connection : connection
+ )
+ : prev.connections
+
+ const nextLifecycle = prev.lifecycle
+ const nextLabel = labelTrimmed ? labelTrimmed : prev.label
+ const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
+ const changed =
+ nextLabel !== prev.label ||
+ nextPreferredConnectionId !== prev.preferredConnectionId ||
+ !hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
+ !hadConnection ||
+ connectionChanged
+
+ if (!changed) {
+ return existing
+ }
+
+ const nextProfile: HostProfile = {
+ ...prev,
+ label: nextLabel,
+ lifecycle: nextLifecycle,
+ connections: nextConnections,
+ preferredConnectionId: nextPreferredConnectionId,
+ updatedAt: now,
+ }
+
+ const next = [...existing]
+ next[idx] = nextProfile
+ return next
+}
+
+export function connectionFromListen(listen: string): HostConnection | null {
+ const normalizedListen = listen.trim()
+ if (!normalizedListen) {
+ return null
+ }
+
+ if (normalizedListen.startsWith('pipe://')) {
+ const path = normalizedListen.slice('pipe://'.length).trim()
+ return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
+ }
+
+ if (normalizedListen.startsWith('unix://')) {
+ const path = normalizedListen.slice('unix://'.length).trim()
+ return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
+ }
+
+ if (normalizedListen.startsWith('\\\\.\\pipe\\')) {
+ return {
+ id: `pipe:${normalizedListen}`,
+ type: 'directPipe',
+ path: normalizedListen,
+ }
+ }
+
+ if (normalizedListen.startsWith('/')) {
+ return {
+ id: `socket:${normalizedListen}`,
+ type: 'directSocket',
+ path: normalizedListen,
+ }
+ }
+
+ try {
+ const endpoint = normalizeHostPort(normalizedListen)
+ return {
+ id: `direct:${endpoint}`,
+ type: 'directTcp',
+ endpoint,
+ }
+ } catch {
+ return null
+ }
+}
+
+function normalizeStoredConnection(connection: unknown): HostConnection | null {
+ if (!connection || typeof connection !== 'object') {
+ return null
+ }
+ const record = connection as Record
+ const type = typeof record.type === 'string' ? record.type : null
+ if (type === 'directTcp') {
+ try {
+ const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
+ return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
+ } catch {
+ return null
+ }
+ }
+ if (type === 'directSocket') {
+ const path = String(record.path ?? '').trim()
+ return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
+ }
+ if (type === 'directPipe') {
+ const path = String(record.path ?? '').trim()
+ return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
+ }
+ if (type === 'relay') {
+ try {
+ const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
+ const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
+ if (!daemonPublicKeyB64) return null
+ return {
+ id: `relay:${relayEndpoint}`,
+ type: 'relay',
+ relayEndpoint,
+ daemonPublicKeyB64,
+ }
+ } catch {
+ return null
+ }
+ }
+
+ return null
+}
+
+export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
+ if (!entry || typeof entry !== 'object') {
+ return null
+ }
+ const record = entry as Record
+ const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
+ if (!serverId) {
+ return null
+ }
+
+ const rawConnections = Array.isArray(record.connections) ? record.connections : []
+ const connections = rawConnections
+ .map((connection) => normalizeStoredConnection(connection))
+ .filter((connection): connection is HostConnection => connection !== null)
+ if (connections.length === 0) {
+ return null
+ }
+
+ const now = new Date().toISOString()
+ const label = normalizeHostLabel(
+ typeof record.label === 'string' ? record.label : null,
+ serverId
+ )
+ const preferredConnectionId =
+ typeof record.preferredConnectionId === 'string' &&
+ connections.some((connection) => connection.id === record.preferredConnectionId)
+ ? record.preferredConnectionId
+ : connections[0]?.id ?? null
+
+ return {
+ serverId,
+ label,
+ lifecycle: defaultLifecycle(),
+ connections,
+ preferredConnectionId,
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
+ }
+}
+
+export function normalizeEndpointOrNull(endpoint: string): string | null {
+ try {
+ return normalizeHostPort(endpoint)
+ } catch {
+ return null
+ }
+}
+
+export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
+ const normalized = normalizeEndpointOrNull(endpoint)
+ if (!normalized) {
+ return false
+ }
+ return host.connections.some(
+ (connection) => connection.type === 'directTcp' && connection.endpoint === normalized
+ )
+}
+
+export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
+ return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
+}
diff --git a/packages/app/src/utils/connection-selection.test.ts b/packages/app/src/utils/connection-selection.test.ts
index 348a0beb3..392163015 100644
--- a/packages/app/src/utils/connection-selection.test.ts
+++ b/packages/app/src/utils/connection-selection.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { type HostConnection } from "@/contexts/daemon-registry-context";
+import type { HostConnection } from "@/types/host-connection";
import {
selectBestConnection,
type ConnectionCandidate,
diff --git a/packages/app/src/utils/connection-selection.ts b/packages/app/src/utils/connection-selection.ts
index c4bb1949f..2ab3ea072 100644
--- a/packages/app/src/utils/connection-selection.ts
+++ b/packages/app/src/utils/connection-selection.ts
@@ -1,4 +1,4 @@
-import type { HostConnection } from "@/contexts/daemon-registry-context";
+import type { HostConnection } from "@/types/host-connection";
export type ConnectionCandidate = {
connectionId: string;
diff --git a/packages/app/src/utils/tauri-attach-console.ts b/packages/app/src/utils/tauri-attach-console.ts
new file mode 100644
index 000000000..0b86b504e
--- /dev/null
+++ b/packages/app/src/utils/tauri-attach-console.ts
@@ -0,0 +1,55 @@
+import { getTauri } from "@/utils/tauri";
+
+let attached = false;
+
+export function attachConsole(): void {
+ if (attached || !getTauri()) {
+ return;
+ }
+ attached = true;
+
+ const originalLog = console.log;
+ const originalInfo = console.info;
+ const originalWarn = console.warn;
+ const originalError = console.error;
+ const originalDebug = console.debug;
+
+ function formatArgs(args: unknown[]): string {
+ return args
+ .map((a) =>
+ typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
+ )
+ .join(" ");
+ }
+
+ function forwardToRust(level: number, args: unknown[]): void {
+ const invoke = getTauri()?.core?.invoke;
+ if (typeof invoke !== "function") return;
+ try {
+ void invoke("webview_log", { level, message: formatArgs(args) });
+ } catch {
+ // silently ignore IPC errors
+ }
+ }
+
+ console.log = (...args: unknown[]) => {
+ originalLog.apply(console, args);
+ forwardToRust(0, args); // debug
+ };
+ console.info = (...args: unknown[]) => {
+ originalInfo.apply(console, args);
+ forwardToRust(1, args); // info
+ };
+ console.warn = (...args: unknown[]) => {
+ originalWarn.apply(console, args);
+ forwardToRust(2, args); // warn
+ };
+ console.error = (...args: unknown[]) => {
+ originalError.apply(console, args);
+ forwardToRust(3, args); // error
+ };
+ console.debug = (...args: unknown[]) => {
+ originalDebug.apply(console, args);
+ forwardToRust(0, args); // debug
+ };
+}
diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts
index 02500eec8..2c31889d5 100644
--- a/packages/app/src/utils/test-daemon-connection.test.ts
+++ b/packages/app/src/utils/test-daemon-connection.test.ts
@@ -60,40 +60,44 @@ vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
-describe("test-daemon-connection probe client identity", () => {
+describe("test-daemon-connection connectToDaemon", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
clientIdMock.getOrCreateClientId.mockClear();
});
- it("reuses the app clientId for direct latency probes", async () => {
+ it("reuses the app clientId for direct connections", async () => {
const mod = await import("./test-daemon-connection");
- await mod.measureConnectionLatency({
- id: "direct:lan:6767",
- type: "directTcp",
- endpoint: "lan:6767",
- });
- await mod.measureConnectionLatency({
+ const first = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
+ await first.client.close();
- const [first, second] = daemonClientMock.createdConfigs;
- expect(first?.clientId).toBe("cid_shared_probe_test");
- expect(second?.clientId).toBe("cid_shared_probe_test");
+ const second = await mod.connectToDaemon({
+ id: "direct:lan:6767",
+ type: "directTcp",
+ endpoint: "lan:6767",
+ });
+ await second.client.close();
+
+ const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
+ expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
+ expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
- it("encodes the local socket target into the probe client config", async () => {
+ it("encodes the local socket target into the client config", async () => {
const mod = await import("./test-daemon-connection");
- await mod.measureConnectionLatency({
+ const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
+ await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts
index 507edaad4..a08acfc65 100644
--- a/packages/app/src/utils/test-daemon-connection.ts
+++ b/packages/app/src/utils/test-daemon-connection.ts
@@ -1,6 +1,6 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
-import type { HostConnection } from "@/contexts/daemon-registry-context";
+import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
@@ -45,7 +45,7 @@ export class DaemonConnectionTestError extends Error {
}
}
-async function buildClientConfig(
+export async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise {
@@ -96,7 +96,7 @@ async function buildClientConfig(
};
}
-function connectAndProbe(
+export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
@@ -152,26 +152,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000;
}
-export async function probeConnection(
+export async function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
-): Promise<{ serverId: string; hostname: string | null }> {
+): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId);
- const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
- await client.close().catch(() => undefined);
- return { serverId, hostname };
-}
-
-export async function measureConnectionLatency(
- connection: HostConnection,
- options?: ProbeOptions,
-): Promise {
- const config = await buildClientConfig(connection, options?.serverId);
- const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
- try {
- const { rttMs } = await client.ping({ timeoutMs: 5000 });
- return rttMs;
- } finally {
- await client.close().catch(() => undefined);
- }
+ return connectAndProbe(config, resolveTimeout(connection, options));
}
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index cb2782353..ede64482d 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -1,6 +1,9 @@
import { defineConfig, configDefaults } from "vitest/config";
import path from "path";
+const appNodeModules = path.resolve(__dirname, "node_modules");
+const rootNodeModules = path.resolve(__dirname, "../../node_modules");
+
export default defineConfig({
test: {
environment: "node",
@@ -12,12 +15,27 @@ export default defineConfig({
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
*/
pool: "forks",
+ server: {
+ deps: {
+ fallbackCJS: true,
+ inline: [
+ "zustand",
+ "@tanstack/react-query",
+ "react-native-web",
+ ],
+ },
+ },
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@server": path.resolve(__dirname, "../server/src"),
- "react-native": "react-native-web",
+ // Point to the ESM build so Vite can transform its imports and apply the
+ // react alias below (the CJS build uses require('react') which bypasses
+ // Vite alias resolution).
+ "react-native": path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
+ react: path.resolve(appNodeModules, "react"),
+ "react-dom": path.resolve(appNodeModules, "react-dom"),
},
},
});
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index fec05eaec..da58b69f7 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -496,6 +496,16 @@ async fn garbage_collect_attachment_files(
.map_err(|error| format!("Attachment GC task failed: {error}"))?
}
+#[tauri::command]
+fn webview_log(level: u8, message: String) {
+ match level {
+ 0 => log::debug!("[webview] {}", message),
+ 1 => log::info!("[webview] {}", message),
+ 2 => log::warn!("[webview] {}", message),
+ _ => log::error!("[webview] {}", message),
+ }
+}
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -533,7 +543,8 @@ pub fn run() {
copy_attachment_file,
read_file_base64,
delete_attachment_file,
- garbage_collect_attachment_files
+ garbage_collect_attachment_files,
+ webview_log
])
.setup(|app| {
let setup_start = std::time::Instant::now();
@@ -558,9 +569,6 @@ pub fn run() {
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
- .target(tauri_plugin_log::Target::new(
- tauri_plugin_log::TargetKind::Webview,
- ))
.build(),
)?;
diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs
index 0bfdab16b..8d1612a77 100644
--- a/packages/desktop/src-tauri/src/runtime_manager.rs
+++ b/packages/desktop/src-tauri/src/runtime_manager.rs
@@ -604,6 +604,14 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result