feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
+10 -3
View File
@@ -162,9 +162,16 @@ export const gotoHome = async (page: Page) => {
};
export const openSettings = async (page: Page) => {
// Navigate directly to settings page
await page.goto('/settings');
await expect(page).toHaveURL(/\/settings$/);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
@@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Shortcut } from '@/components/ui/shortcut'
import { Autocomplete } from '@/components/ui/autocomplete'
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
type QueuedMessage = {
id: string
@@ -77,10 +78,14 @@ export function AgentInputArea({
(s) => s.clearMessageInputActionRequest
)
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
const voice = useVoiceOptional()
const isConnected = client?.isConnected ?? false
const isDictationReady =
isConnected &&
(snapshot?.agentDirectoryStatus === 'ready' ||
snapshot?.agentDirectoryStatus === 'revalidating' ||
snapshot?.agentDirectoryStatus === 'error_after_ready')
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
@@ -672,6 +677,7 @@ export function AgentInputArea({
onAddImages={addImages}
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder="Message agent..."
autoFocus={autoFocus}
disabled={isSubmitLoading}
+14 -12
View File
@@ -14,7 +14,7 @@ import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -94,22 +94,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
string[]
>([]);
const {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({
} = useSidebarAgentsList({
isOpen,
serverId: activeServerId,
selectedProjectKeys,
selectedProjectFilterKeys,
});
useEffect(() => {
setSelectedProjectKeys([]);
setSelectedProjectFilterKeys([]);
}, [activeServerId]);
const {
translateX,
@@ -373,9 +375,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -499,9 +501,9 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKeys={selectedProjectFilterKeys}
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
@@ -55,6 +55,8 @@ export interface MessageInputProps {
onAddImages?: (images: ImageAttachment[]) => void
onRemoveImage?: (index: number) => void
client: DaemonClient | null
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean
placeholder?: string
autoFocus?: boolean
disabled?: boolean
@@ -121,6 +123,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onAddImages,
onRemoveImage,
client,
isReadyForDictation,
placeholder = 'Message...',
autoFocus = false,
disabled = false,
@@ -262,14 +265,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
return socketConnected && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage])
const readyForDictation = isReadyForDictation ?? socketConnected
return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage, isReadyForDictation])
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
return socketConnected
}, [client])
const isConnected = client?.isConnected ?? false
const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled
const {
isRecording: isDictating,
@@ -719,7 +724,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
disabled={!isConnected || disabled}
disabled={!isDictationStartEnabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
@@ -732,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}
style={[
styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled,
(!isDictationStartEnabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
@@ -1,28 +1,13 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import {
getHostRuntimeStore,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { toDaemonConnectionUpdateFromRuntime } from "@/runtime/host-runtime-bridge";
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { snapshot, client } = useHostRuntimeSession(daemon.serverId);
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
const hasConnectionRecord = connectionStates.has(daemon.serverId);
useEffect(() => {
if (!hasConnectionRecord) {
return;
}
updateConnectionStatus(
daemon.serverId,
toDaemonConnectionUpdateFromRuntime(snapshot)
);
}, [daemon.serverId, hasConnectionRecord, snapshot, updateConnectionStatus]);
const { client } = useHostRuntimeSession(daemon.serverId);
if (!client) {
return null;
@@ -47,20 +32,6 @@ export function MultiDaemonSessionHost() {
runtime.syncHosts(daemons);
}, [daemons]);
useEffect(() => {
const runtime = getHostRuntimeStore();
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
return;
}
runtime.ensureConnectedAll();
void runtime.runProbeCycleNow();
});
return () => {
subscription.remove();
};
}, []);
if (daemons.length === 0) {
return null;
}
@@ -43,8 +43,8 @@ import {
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
type SidebarAgentListEntry,
type SidebarProjectOption,
} from "@/hooks/use-sidebar-agents-grouped";
type SidebarProjectFilterOption,
} from "@/hooks/use-sidebar-agents-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -59,9 +59,9 @@ type EntryData = SidebarAgentListEntry;
interface SidebarAgentListProps {
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
selectedProjectKeys: string[];
onSelectedProjectKeysChange: (keys: string[]) => void;
projectFilterOptions: SidebarProjectFilterOption[];
selectedProjectFilterKeys: string[];
onSelectedProjectFilterKeysChange: (keys: string[]) => void;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
@@ -72,7 +72,7 @@ interface SidebarAgentListProps {
}
interface ProjectFilterOptionRowProps {
option: SidebarProjectOption;
option: SidebarProjectFilterOption;
selected: boolean;
iconDataUri: string | null;
displayName: string;
@@ -381,15 +381,15 @@ function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) {
}
function resolveSelectedProjectLabel(input: {
selectedProjectKeys: string[];
projectOptions: SidebarProjectOption[];
selectedProjectFilterKeys: string[];
projectFilterOptions: SidebarProjectFilterOption[];
}): string {
if (input.selectedProjectKeys.length === 0) {
if (input.selectedProjectFilterKeys.length === 0) {
return "Project";
}
if (input.selectedProjectKeys.length === 1) {
const selected = input.projectOptions.find(
(option) => option.projectKey === input.selectedProjectKeys[0]
if (input.selectedProjectFilterKeys.length === 1) {
const selected = input.projectFilterOptions.find(
(option) => option.projectKey === input.selectedProjectFilterKeys[0]
);
if (selected) {
return deriveProjectDisplayName({
@@ -399,7 +399,7 @@ function resolveSelectedProjectLabel(input: {
});
}
return deriveProjectDisplayName({
projectKey: input.selectedProjectKeys[0] ?? "",
projectKey: input.selectedProjectFilterKeys[0] ?? "",
projectName: "",
remoteUrl: null,
});
@@ -444,9 +444,9 @@ function deriveProjectDisplayName(input: {
export function SidebarAgentList({
entries,
projectOptions,
selectedProjectKeys,
onSelectedProjectKeysChange,
projectFilterOptions,
selectedProjectFilterKeys,
onSelectedProjectFilterKeysChange,
isRefreshing = false,
onRefresh,
selectedAgentId,
@@ -479,23 +479,23 @@ export function SidebarAgentList({
);
const selectedProjectLabel = useMemo(
() => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }),
[projectOptions, selectedProjectKeys]
() => resolveSelectedProjectLabel({ selectedProjectFilterKeys, projectFilterOptions }),
[projectFilterOptions, selectedProjectFilterKeys]
);
const selectedProjectOption = useMemo(() => {
if (selectedProjectKeys.length !== 1) {
if (selectedProjectFilterKeys.length !== 1) {
return null;
}
return (
projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null
projectFilterOptions.find((option) => option.projectKey === selectedProjectFilterKeys[0]) ?? null
);
}, [projectOptions, selectedProjectKeys]);
const showProjectFilters = projectOptions.length > 0;
}, [projectFilterOptions, selectedProjectFilterKeys]);
const showProjectFilters = projectFilterOptions.length > 0;
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
if (!option.serverId || !option.workingDir) {
continue;
}
@@ -513,7 +513,7 @@ export function SidebarAgentList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [entries, projectOptions]);
}, [entries, projectFilterOptions]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -559,7 +559,7 @@ export function SidebarAgentList({
const projectIconByProjectKey = useMemo(() => {
const map = new Map<string, string | null>();
for (const option of projectOptions) {
for (const option of projectFilterOptions) {
map.set(
option.projectKey,
projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null
@@ -577,7 +577,7 @@ export function SidebarAgentList({
);
}
return map;
}, [entries, projectIconByQueryKey, projectOptions]);
}, [entries, projectIconByQueryKey, projectFilterOptions]);
const selectedProjectIconUri = useMemo(() => {
if (!selectedProjectOption) {
@@ -588,20 +588,20 @@ export function SidebarAgentList({
const handleToggleProject = useCallback(
(projectKey: string) => {
const next = new Set(selectedProjectKeys);
const next = new Set(selectedProjectFilterKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
onSelectedProjectKeysChange(Array.from(next));
onSelectedProjectFilterKeysChange(Array.from(next));
},
[onSelectedProjectKeysChange, selectedProjectKeys]
[onSelectedProjectFilterKeysChange, selectedProjectFilterKeys]
);
const handleClearProjectFilter = useCallback(() => {
onSelectedProjectKeysChange([]);
}, [onSelectedProjectKeysChange]);
onSelectedProjectFilterKeysChange([]);
}, [onSelectedProjectFilterKeysChange]);
const handleAgentPress = useCallback(
(entry: SidebarAgentListEntry) => {
@@ -755,7 +755,7 @@ export function SidebarAgentList({
ref={projectFilterAnchorRef}
style={({ hovered = false, pressed }) => [
styles.filterTrigger,
(selectedProjectKeys.length > 0 || hovered || pressed) &&
(selectedProjectFilterKeys.length > 0 || hovered || pressed) &&
styles.filterTriggerActive,
]}
onPress={() => setIsProjectFilterOpen(true)}
@@ -763,20 +763,20 @@ export function SidebarAgentList({
{({ hovered = false, pressed }) => {
const isInteracting = hovered || pressed;
const showActiveForeground =
selectedProjectKeys.length > 0 || isInteracting;
selectedProjectFilterKeys.length > 0 || isInteracting;
return (
<>
{selectedProjectKeys.length === 1 && selectedProjectIconUri ? (
{selectedProjectFilterKeys.length === 1 && selectedProjectIconUri ? (
<Image
source={{
uri: selectedProjectIconUri,
}}
style={styles.selectedProjectIcon}
/>
) : selectedProjectKeys.length > 1 ? (
) : selectedProjectFilterKeys.length > 1 ? (
<View style={styles.projectCountBadge}>
<Text style={styles.projectCountBadgeText}>
{selectedProjectKeys.length}
{selectedProjectFilterKeys.length}
</Text>
</View>
) : null}
@@ -802,7 +802,7 @@ export function SidebarAgentList({
}}
</Pressable>
{selectedProjectKeys.length > 0 ? (
{selectedProjectFilterKeys.length > 0 ? (
<Pressable style={styles.clearFilterButton} onPress={handleClearProjectFilter}>
<Text style={styles.clearFilterText}>Clear</Text>
</Pressable>
@@ -821,14 +821,14 @@ export function SidebarAgentList({
anchorRef={projectFilterAnchorRef}
>
<View style={styles.filterOptionsList}>
{projectOptions.length === 0 ? (
{projectFilterOptions.length === 0 ? (
<Text style={styles.filterEmptyText}>No projects</Text>
) : (
projectOptions.map((option) => (
projectFilterOptions.map((option) => (
<ProjectFilterOptionRow
key={option.projectKey}
option={option}
selected={selectedProjectKeys.includes(option.projectKey)}
selected={selectedProjectFilterKeys.includes(option.projectKey)}
iconDataUri={projectIconByProjectKey.get(option.projectKey) ?? null}
displayName={deriveProjectDisplayName({
projectKey: option.projectKey,
@@ -1,212 +1,93 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import {
getHostRuntimeStore,
type HostRuntimeAgentDirectoryStatus,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" };
export type ConnectionState =
| { status: "idle"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
| { status: "connecting"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "online"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
| { status: "offline"; activeConnection: ActiveConnection | null; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
| { status: "error"; activeConnection: ActiveConnection | null; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
export type ConnectionStatus = ConnectionState["status"];
export type ConnectionStateUpdate =
| { status: "idle" }
| { status: "connecting"; activeConnection?: ActiveConnection | null; lastOnlineAt?: string | null }
| { status: "online"; activeConnection?: ActiveConnection | null; lastOnlineAt: string }
| { status: "offline"; activeConnection?: ActiveConnection | null; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; activeConnection?: ActiveConnection | null; lastError: string; lastOnlineAt?: string | null };
export type ConnectionStatus = HostRuntimeConnectionStatus;
export type DaemonConnectionRecord = {
daemon: HostProfile;
} & ConnectionState;
status: HostRuntimeConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
lastOnlineAt: string | null;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
agentDirectoryError: string | null;
hasEverLoadedAgentDirectory: boolean;
};
interface DaemonConnectionsContextValue {
connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean;
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
markAgentListReady: (serverId: string, ready: boolean) => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
function createDefaultConnectionState(): ConnectionState {
return {
status: "idle",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
agentListReady: false,
hasEverReceivedAgentList: false,
};
}
function buildConnectionStates(input: {
daemons: HostProfile[];
runtime: ReturnType<typeof getHostRuntimeStore>;
}): Map<string, DaemonConnectionRecord> {
const { daemons, runtime } = input;
const next = new Map<string, DaemonConnectionRecord>();
function resolveNextConnectionState(
existing: ConnectionState,
update: ConnectionStateUpdate
): ConnectionState {
switch (update.status) {
case "idle":
return {
status: "idle",
activeConnection: existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: false,
};
case "connecting":
return {
status: "connecting",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "online":
const currentAgentListReady =
existing.status === "online" ? existing.agentListReady : false;
return {
status: "online",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: null,
lastOnlineAt: update.lastOnlineAt,
agentListReady: currentAgentListReady,
hasEverReceivedAgentList:
currentAgentListReady || existing.hasEverReceivedAgentList || false,
};
case "offline":
return {
status: "offline",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError ?? null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
case "error":
return {
status: "error",
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
lastError: update.lastError,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
agentListReady: false,
hasEverReceivedAgentList: existing.hasEverReceivedAgentList ?? false,
};
for (const daemon of daemons) {
const snapshot = runtime.getSnapshot(daemon.serverId);
next.set(daemon.serverId, {
daemon,
status: snapshot?.connectionStatus ?? "connecting",
activeConnection: snapshot?.activeConnection ?? null,
lastError: snapshot?.lastError ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
agentDirectoryStatus: snapshot?.agentDirectoryStatus ?? "initial_loading",
agentDirectoryError: snapshot?.agentDirectoryError ?? null,
hasEverLoadedAgentDirectory:
snapshot?.hasEverLoadedAgentDirectory ?? false,
});
}
}
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) {
const severity: "info" | "warn" = next.status === "error" ? "warn" : "info";
const logger = severity === "warn" ? console.warn : console.info;
const logPayload = {
event: "daemon_connection_state",
serverId: daemon.serverId,
label: daemon.label,
from: previous.status,
to: next.status,
lastError: next.lastError ?? null,
lastOnlineAt: next.lastOnlineAt ?? null,
timestamp: new Date().toISOString(),
severity,
};
logger("[DaemonConnection]", logPayload);
return next;
}
export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext);
if (!ctx) {
throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider");
throw new Error(
"useDaemonConnections must be used within DaemonConnectionsProvider"
);
}
return ctx;
}
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const runtime = getHostRuntimeStore();
// Ensure connection states stay in sync with registry entries
useEffect(() => {
setConnectionStates((prev) => {
const next = new Map<string, DaemonConnectionRecord>();
for (const daemon of daemons) {
const existing = prev.get(daemon.serverId);
next.set(daemon.serverId, {
daemon,
...(existing ?? createDefaultConnectionState()),
});
}
return next;
});
}, [daemons]);
const updateConnectionStatus = useCallback(
(serverId: string, update: ConnectionStateUpdate) => {
setConnectionStates((prev) => {
const existing = prev.get(serverId);
if (!existing) {
return prev;
}
const nextState = resolveNextConnectionState(existing, update);
const hasChanged =
existing.status !== nextState.status ||
existing.lastError !== nextState.lastError ||
existing.lastOnlineAt !== nextState.lastOnlineAt;
if (hasChanged) {
logConnectionLifecycle(existing.daemon, existing, nextState);
}
const next = new Map(prev);
next.set(serverId, { daemon: existing.daemon, ...nextState });
return next;
});
},
[]
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion()
);
const markAgentListReady = useCallback((serverId: string, ready: boolean) => {
setConnectionStates((prev) => {
const existing = prev.get(serverId);
if (!existing) {
return prev;
}
if (existing.status !== "online") {
return prev;
}
if (
existing.agentListReady === ready &&
(ready ? existing.hasEverReceivedAgentList : true)
) {
return prev;
}
const next = new Map(prev);
next.set(serverId, {
...existing,
agentListReady: ready,
hasEverReceivedAgentList:
ready || existing.hasEverReceivedAgentList,
});
return next;
});
}, []);
const connectionStates = useMemo(
() => buildConnectionStates({ daemons, runtime }),
[daemons, runtime, runtimeVersion]
);
const value: DaemonConnectionsContextValue = {
connectionStates,
isLoading: registryLoading,
updateConnectionStatus,
markAgentListReady,
};
const value = useMemo<DaemonConnectionsContextValue>(
() => ({
connectionStates,
isLoading: registryLoading,
}),
[connectionStates, registryLoading]
);
return (
<DaemonConnectionsContext.Provider value={value}>
+37 -219
View File
@@ -1,7 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { AppState, Platform } from "react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -15,7 +14,6 @@ import {
} from "@/types/stream";
import type {
ActivityLogPayload,
AgentSnapshotPayload,
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
@@ -26,9 +24,9 @@ import {
type NotificationPermissionRequest,
} from "@server/shared/agent-attention-notification";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
useSessionStore,
type Agent,
@@ -39,6 +37,11 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -53,32 +56,8 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
const derivePendingPermissionKey = (
agentId: string,
request: NotificationPermissionRequest
) => {
const fallbackId =
request.id ||
(typeof request.metadata?.id === "string"
? request.metadata.id
: undefined) ||
request.name ||
request.title ||
`${request.kind}:${JSON.stringify(
request.input ?? request.metadata ?? {}
)}`;
return `${agentId}:${fallbackId}`;
};
const HISTORY_STALE_AFTER_MS = 60_000;
type SessionConnectionSnapshot = {
isConnected: boolean;
isConnecting: boolean;
lastError: string | null;
};
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -89,15 +68,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
return null;
};
const mapConnectionState = (
state: ConnectionState,
lastError: string | null
): SessionConnectionSnapshot => ({
isConnected: state.status === "connected",
isConnecting: state.status === "connecting",
lastError: state.status === "disconnected" ? state.reason ?? lastError : null,
});
const getLatestPermissionRequest = (
session: SessionState | undefined,
agentId: string
@@ -142,51 +112,6 @@ type AgentUpdatePayload = Extract<
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id;
function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string
) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt
? new Date(snapshot.lastUserMessageAt)
: null;
const attentionTimestamp = snapshot.attentionTimestamp
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt
? new Date(snapshot.archivedAt)
: null;
return {
serverId,
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt,
capabilities: snapshot.capabilities,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes ?? [],
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
labels: snapshot.labels,
projectPlacement: null,
};
}
const createExplorerState = () => ({
directories: new Map(),
files: new Map(),
@@ -213,32 +138,11 @@ interface SessionProviderSharedProps {
serverId: string;
}
interface SessionProviderUrlProps extends SessionProviderSharedProps {
serverUrl: string;
daemonPublicKeyB64?: string;
}
interface SessionProviderClientProps extends SessionProviderSharedProps {
client: DaemonClient;
}
export type SessionProviderProps = SessionProviderUrlProps | SessionProviderClientProps;
function SessionProviderWithUrl({
children,
serverId,
serverUrl,
daemonPublicKeyB64,
}: SessionProviderUrlProps) {
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
return (
<SessionProviderInternal
children={children}
serverId={serverId}
client={client}
/>
);
}
export type SessionProviderProps = SessionProviderClientProps;
function SessionProviderWithClient({
children,
@@ -256,10 +160,7 @@ function SessionProviderWithClient({
// SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider(props: SessionProviderProps) {
if ("client" in props) {
return <SessionProviderWithClient {...props} />;
}
return <SessionProviderWithUrl {...props} />;
return <SessionProviderWithClient {...props} />;
}
function SessionProviderInternal({
@@ -268,11 +169,7 @@ function SessionProviderInternal({
client,
}: SessionProviderClientProps) {
const queryClient = useQueryClient();
const [connectionSnapshot, setConnectionSnapshot] =
useState<SessionConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { markAgentListReady } = useDaemonConnections();
const { isConnected } = useHostRuntimeSession(serverId);
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -323,6 +220,9 @@ function SessionProviderInternal({
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const sessionAgents = useSessionStore(
(state) => state.sessions[serverId]?.agents
);
const handleAppResumed = useCallback(
(awayMs: number) => {
@@ -359,8 +259,6 @@ function SessionProviderInternal({
) => Promise<void>)
| null
>(null);
const hasBootstrappedAgentUpdatesRef = useRef(false);
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
@@ -380,6 +278,18 @@ function SessionProviderInternal({
};
}, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const notifyAgentAttention = useCallback(
(params: {
agentId: string;
@@ -456,22 +366,6 @@ function SessionProviderInternal({
}
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
useEffect(() => {
const unsubscribe = client.subscribeConnectionStatus((state) => {
setConnectionSnapshot(mapConnectionState(state, client.lastError));
});
return unsubscribe;
}, [client]);
const wasConnectedRef = useRef(client.isConnected);
useEffect(() => {
const wasConnected = wasConnectedRef.current;
if (!wasConnected && connectionSnapshot.isConnected) {
bumpHistorySyncGeneration(serverId);
}
wasConnectedRef.current = connectionSnapshot.isConnected;
}, [serverId, connectionSnapshot.isConnected, bumpHistorySyncGeneration]);
// Initialize session in store
useEffect(() => {
initializeSession(serverId, client, audioPlayer);
@@ -483,11 +377,11 @@ function SessionProviderInternal({
// If the client drops mid-initialization, clear pending flags
useEffect(() => {
if (!connectionSnapshot.isConnected) {
if (!isConnected) {
pendingAgentUpdatesRef.current.clear();
setInitializingAgents(serverId, new Map());
}
}, [serverId, connectionSnapshot.isConnected, setInitializingAgents]);
}, [serverId, isConnected, setInitializingAgents]);
const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => {
@@ -542,9 +436,13 @@ function SessionProviderInternal({
return;
}
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = {
...normalizeAgentSnapshot(update.agent, serverId),
projectPlacement: update.project,
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
};
console.log("[Session] Agent update:", agent.id, agent.status);
@@ -796,91 +694,11 @@ function SessionProviderInternal({
);
useEffect(() => {
if (!connectionSnapshot.isConnected) {
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
if (isConnected) {
return;
}
if (hasBootstrappedAgentUpdatesRef.current) {
return;
}
hasBootstrappedAgentUpdatesRef.current = true;
let cancelled = false;
const requestedSubscriptionId = `app:${serverId}`;
const bootstrapAgentDirectory = async () => {
try {
const payload = await client.fetchAgents({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: requestedSubscriptionId },
});
if (cancelled) {
return;
}
agentUpdatesSubscriptionIdRef.current =
payload.subscriptionId ?? requestedSubscriptionId;
const nextAgents = new Map<string, Agent>();
const nextPendingPermissions = new Map<
string,
{ key: string; agentId: string; request: NotificationPermissionRequest }
>();
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const entry of payload.entries) {
const agent = {
...normalizeAgentSnapshot(entry.agent, serverId),
projectPlacement: entry.project,
};
nextAgents.set(agent.id, agent);
nextStatuses.set(agent.id, agent.status);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
nextPendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
previousAgentStatusRef.current = nextStatuses;
pendingAgentUpdatesRef.current.clear();
setAgents(serverId, nextAgents);
for (const agent of nextAgents.values()) {
setAgentLastActivity(agent.id, agent.lastActivityAt);
}
setPendingPermissions(serverId, nextPendingPermissions);
setInitializingAgents(serverId, new Map());
setHasHydratedAgents(serverId, true);
markAgentListReady(serverId, true);
} catch (err) {
if (cancelled) {
return;
}
hasBootstrappedAgentUpdatesRef.current = false;
pendingAgentUpdatesRef.current.clear();
agentUpdatesSubscriptionIdRef.current = null;
console.error("[Session] fetchAgents bootstrap failed", { serverId, err });
}
};
void bootstrapAgentDirectory();
return () => {
cancelled = true;
};
}, [
connectionSnapshot.isConnected,
client,
serverId,
setAgentLastActivity,
setAgents,
setHasHydratedAgents,
setInitializingAgents,
setPendingPermissions,
markAgentListReady,
]);
pendingAgentUpdatesRef.current.clear();
}, [isConnected]);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
@@ -4,8 +4,13 @@ import {
deriveAgentScreenViewState,
type AgentScreenMachineInput,
type AgentScreenMachineMemory,
type AgentScreenViewState,
} from "./use-agent-screen-state-machine";
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
return {
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
isHistorySyncing: false,
needsAuthoritativeSync: false,
shouldUseOptimisticStream: false,
hasHydratedHistoryBefore: false,
};
}
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
): AgentScreenMachineMemory {
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
}
function expectReadyState(state: AgentScreenViewState): ReadyState {
expect(state.tag).toBe("ready");
if (state.tag !== "ready") {
throw new Error("expected ready state");
}
return state;
}
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
expect(state.sync.status).toBe("catching_up");
if (state.sync.status !== "catching_up") {
throw new Error("expected catching_up sync state");
}
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
});
it("stays ready after first paint even if agent is temporarily missing", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("idle");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.sync.status).toBe("idle");
expect(ready.agent.id).toBe("agent-1");
});
it("shows reconnecting sync status without blocking after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
isConnected: false,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("reconnecting");
expect(ready.sync.status).toBe("reconnecting");
});
it("shows non-blocking catching-up state after first paint", () => {
const memory: AgentScreenMachineMemory = {
it("shows overlay catching-up state for first open while loading history", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("catching_up");
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const errorInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const errorResult = deriveAgentScreenViewState({
input: errorInput,
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "idle" },
};
const retryResult = deriveAgentScreenViewState({
input: retryInput,
memory: errorResult.memory,
});
const retryReady = expectReadyState(retryResult.state);
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
it("keeps ready with sync_error when refresh fails after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("sync_error");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
});
it("returns not_found when resolver confirms missing agent", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "not_found", message: "agent missing" },
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
});
it("promotes optimistic source while placeholder is used", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("optimistic");
expect(ready.source).toBe("optimistic");
expect(ready.sync.status).toBe("idle");
});
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const initialMemory = createBaseMemory();
const optimisticInput: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
input: optimisticInput,
memory: initialMemory,
});
expect(optimistic.state.tag).toBe("ready");
if (optimistic.state.tag !== "ready") {
throw new Error("expected optimistic ready state");
}
expect(optimistic.state.source).toBe("optimistic");
const optimisticReady = expectReadyState(optimistic.state);
expect(optimisticReady.source).toBe("optimistic");
const handoffInput: AgentScreenMachineInput = {
...createBaseInput(),
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
input: handoffInput,
memory: optimistic.memory,
});
const handoffReady = expectReadyState(handoff.state);
expect(handoff.state.tag).toBe("ready");
if (handoff.state.tag !== "ready") {
throw new Error("expected handoff ready state");
}
expect(handoff.state.source).toBe("stale");
expect(handoff.state.agent.id).toBe("draft-agent");
expect(handoffReady.source).toBe("stale");
expect(handoffReady.agent.id).toBe("draft-agent");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
hadInitialSyncFailure: true,
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});
@@ -16,18 +16,35 @@ export interface AgentScreenMachineInput {
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
shouldUseOptimisticStream: boolean;
hasHydratedHistoryBefore: boolean;
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
export type AgentScreenSyncStatus =
| "idle"
| "catching_up"
| "reconnecting"
| "sync_error";
export type AgentScreenReadySyncState =
| { status: "idle" }
| { status: "reconnecting" }
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
export type AgentScreenViewState =
| {
@@ -47,7 +64,7 @@ export type AgentScreenViewState =
tag: "ready";
agent: Agent;
source: "authoritative" | "optimistic" | "stale";
syncStatus: AgentScreenSyncStatus;
sync: AgentScreenReadySyncState;
isArchiving: boolean;
};
@@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
if (input.hasHydratedHistoryBefore) {
nextMemory.hadInitialSyncFailure = false;
}
if (
input.missingAgentState.kind === "error" &&
!input.hasHydratedHistoryBefore
) {
nextMemory.hadInitialSyncFailure = true;
}
const candidateAgent = input.agent ?? input.placeholderAgent;
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
@@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({
? "optimistic"
: "stale";
let syncStatus: AgentScreenSyncStatus = "idle";
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
syncStatus = "reconnecting";
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
syncStatus = "sync_error";
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
syncStatus = "catching_up";
let ui: "overlay" | "toast" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast =
memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
return {
@@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({
tag: "ready",
agent: displayAgent,
source,
syncStatus,
sync,
isArchiving: input.isArchivingCurrentAgent,
},
memory: nextMemory,
@@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
if (routeKeyRef.current !== routeKey) {
@@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}
+12 -76
View File
@@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -21,6 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const runtime = getHostRuntimeStore();
const sessionAgents = useSessionStore(
useShallow((state) => {
@@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
})
);
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
}
return result;
})
);
const refreshAll = useCallback(() => {
for (const [serverId, client] of Object.entries(sessionClients)) {
if (!client) {
continue;
}
void (async () => {
try {
const agentsList = await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
const agents = new Map();
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const { agent: snapshot } of agentsList.entries) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
const store = useSessionStore.getState();
store.setAgents(serverId, agents);
for (const [agentId, timestamp] of agentLastActivity.entries()) {
store.setAgentLastActivity(agentId, timestamp);
}
store.setPendingPermissions(serverId, pendingPermissions);
} catch (error) {
console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error });
}
})();
}
}, [sessionClients]);
runtime.refreshAllAgentDirectories();
}, [runtime]);
const result = useMemo(() => {
const allAgents: AggregatedAgent[] = [];
@@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
// Check if we have any cached data
const hasAnyData = allAgents.length > 0;
// Check if any connection is currently loading
const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => {
// First-time connection (never received agent list)
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.hasEverReceivedAgentList) {
return true;
}
// Reconnecting (have received agent list before)
if (c.status === 'connecting' && c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) {
return true;
}
return false;
});
// isInitialLoad: Loading for the first time (no cached data)
const isInitialLoad = isConnecting && !hasAnyData;
// isRevalidating: Loading but we have cached data (reconnecting)
const isRevalidating = isConnecting && hasAnyData;
// isLoading: Generic loading flag (either initial or revalidating)
const isLoading = isConnecting;
// Align list loading with the runtime directory-sync machine.
const isLoading = Array.from(connectionStates.values()).some(
(connection) =>
connection.agentDirectoryStatus === "initial_loading" ||
connection.agentDirectoryStatus === "revalidating"
);
const isInitialLoad = isLoading && !hasAnyData;
const isRevalidating = isLoading && hasAnyData;
return {
agents: allAgents,
+27 -43
View File
@@ -1,15 +1,18 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const ALL_AGENTS_STALE_TIME = 60_000;
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type {
AggregatedAgent,
AggregatedAgentsResult,
} from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
@@ -35,7 +38,8 @@ export function useAllAgentsList(options?: {
serverId?: string | null;
}): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
@@ -46,54 +50,35 @@ export function useAllAgentsList(options?: {
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const agentsQuery = useQuery({
queryKey: ["allAgents", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
refetchOnMount: "always" as const,
});
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["allAgents", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const agents = useMemo(() => {
if (!serverId) {
if (!serverId || !liveAgents) {
return [];
}
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (const entry of data) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
for (const agent of liveAgents.values()) {
const aggregated = toAggregatedAgent({
source: live ?? normalized,
source: agent,
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
if (aggregated.labels.ui !== "true") {
continue;
}
list.push(aggregated);
}
@@ -110,16 +95,15 @@ export function useAllAgentsList(options?: {
});
return list;
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
}, [connectionStates, liveAgents, serverId]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && agents.length === 0;
const isRevalidating = isFetching && agents.length > 0;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
@@ -1,62 +0,0 @@
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import { DaemonClient } from "@server/client/daemon-client";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
void promise.catch((error) => {
console.warn(`[DaemonClient] ${label} failed`, error);
});
}
type DaemonClientOptions = {
daemonPublicKeyB64?: string;
};
export function useDaemonClient(
url: string,
options: DaemonClientOptions = {}
): DaemonClient {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClient({
url,
suppressSendErrors: true,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
e2ee: options.daemonPublicKeyB64
? {
enabled: true,
daemonPublicKeyB64: options.daemonPublicKeyB64,
}
: undefined,
});
},
[options.daemonPublicKeyB64, url]
);
useEffect(() => {
runDaemonRequest("connect", client.connect());
return () => {
runDaemonRequest("close", client.close());
};
}, [client]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
return;
}
client.ensureConnected();
});
return () => {
subscription.remove();
};
}, [client]);
return client;
}
@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
deriveSidebarStateBucket,
isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { resolveProjectPlacement } from "@/utils/project-placement";
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
const SIDEBAR_DONE_FILL_TARGET = 50;
export interface SidebarProjectOption {
export interface SidebarProjectFilterOption {
projectKey: string;
projectName: string;
activeCount: number;
@@ -29,9 +30,9 @@ export interface SidebarAgentListEntry {
project: ProjectPlacementPayload;
}
export interface SidebarAgentsGroupedResult {
export interface SidebarAgentsListResult {
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
projectFilterOptions: SidebarProjectFilterOption[];
hasMoreEntries: boolean;
isLoading: boolean;
isInitialLoad: boolean;
@@ -60,7 +61,6 @@ function compareByTitleAsc(
return titleCmp;
}
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true,
sensitivity: "base",
@@ -121,7 +121,7 @@ function applySidebarDefaultOrdering(
}
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent & { createdAt: Date } {
@@ -144,63 +144,41 @@ function toAggregatedAgent(params: {
};
}
export function useSidebarAgentsGrouped(options?: {
export function useSidebarAgentsList(options?: {
isOpen?: boolean;
serverId?: string | null;
selectedProjectKeys?: string[];
}): SidebarAgentsGroupedResult {
selectedProjectFilterKeys?: string[];
}): SidebarAgentsListResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const isOpen = options?.isOpen ?? true;
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
const selectedProjectKeys = useMemo(
const selectedProjectFilterKeys = useMemo(
() =>
new Set(
(options?.selectedProjectKeys ?? [])
(options?.selectedProjectFilterKeys ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
),
[options?.selectedProjectKeys]
[options?.selectedProjectFilterKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const agentsQuery = useQuery({
queryKey: ["sidebarAgentsList", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "updated_at", direction: "desc" },
],
});
},
enabled: canFetch,
staleTime: SIDEBAR_AGENTS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId) {
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId || !liveAgents) {
return {
entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[],
projectFilterOptions: [] as SidebarProjectFilterOption[],
hasAnyData: false,
hasMoreEntries: false,
};
@@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: {
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>();
const byProject = new Map<string, SidebarProjectFilterOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
const pushEntry = (entry: SidebarAgentListEntry): void => {
@@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: {
});
};
const fetchedEntries = agentsQuery.data?.entries ?? [];
for (const fetchedEntry of fetchedEntries) {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
const project = resolveProjectPlacement({
projectPlacement: live.projectPlacement ?? null,
cwd: live.cwd,
});
const agent = toAggregatedAgent({
source: live ?? normalized,
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project });
}
if (liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
if (!live.projectPlacement) {
continue;
}
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project: live.projectPlacement });
}
}
const filteredEntries =
selectedProjectKeys.size > 0
selectedProjectFilterKeys.size > 0
? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey)
selectedProjectFilterKeys.has(entry.project.projectKey)
)
: mergedEntries;
@@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: {
return {
entries: ordered.entries,
projectOptions: options,
projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
}, [
agentsQuery.data?.entries,
connectionStates,
liveAgents,
selectedProjectKeys,
serverId,
]);
}, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]);
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && !hasAnyData;
const isRevalidating = isDirectoryLoading && hasAnyData;
return {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import type { HostRuntimeSnapshot } from "./host-runtime";
import { toDaemonConnectionUpdateFromRuntime } from "./host-runtime-bridge";
const DIRECT_CONNECTION = {
type: "direct" as const,
endpoint: "lan:6767",
display: "lan:6767",
};
function makeSnapshot(
input: Partial<HostRuntimeSnapshot>
): HostRuntimeSnapshot {
return {
serverId: "srv_test",
activeConnectionId: "direct:lan:6767",
activeConnection: DIRECT_CONNECTION,
connectionStatus: "idle",
lastError: null,
lastOnlineAt: null,
probeByConnectionId: new Map(),
clientGeneration: 0,
...input,
};
}
describe("toDaemonConnectionUpdateFromRuntime", () => {
it("maps online snapshots to online updates with the same active connection", () => {
const snapshot = makeSnapshot({
connectionStatus: "online",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
expect(update).toEqual({
status: "online",
activeConnection: DIRECT_CONNECTION,
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
});
it("maps error snapshots to error updates with the same error message", () => {
const snapshot = makeSnapshot({
connectionStatus: "error",
lastError: "transport closed",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
expect(update).toEqual({
status: "error",
activeConnection: DIRECT_CONNECTION,
lastError: "transport closed",
lastOnlineAt: "2026-02-19T20:00:00.000Z",
});
});
it("maps missing runtime snapshots to offline updates", () => {
const update = toDaemonConnectionUpdateFromRuntime(null);
expect(update).toEqual({
status: "offline",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
});
});
});
@@ -1,47 +0,0 @@
import type { ConnectionStateUpdate } from "@/contexts/daemon-connections-context";
import type { HostRuntimeSnapshot } from "./host-runtime";
export function toDaemonConnectionUpdateFromRuntime(
snapshot: HostRuntimeSnapshot | null
): ConnectionStateUpdate {
if (!snapshot) {
return {
status: "offline",
activeConnection: null,
lastError: null,
lastOnlineAt: null,
};
}
const activeConnection = snapshot.activeConnection;
switch (snapshot.connectionStatus) {
case "idle":
return { status: "idle" };
case "connecting":
return {
status: "connecting",
activeConnection,
lastOnlineAt: snapshot.lastOnlineAt,
};
case "online":
return {
status: "online",
activeConnection,
lastOnlineAt: snapshot.lastOnlineAt ?? new Date().toISOString(),
};
case "offline":
return {
status: "offline",
activeConnection,
lastError: null,
lastOnlineAt: snapshot.lastOnlineAt,
};
case "error":
return {
status: "error",
activeConnection,
lastError: snapshot.lastError ?? "Connection error",
lastOnlineAt: snapshot.lastOnlineAt,
};
}
}
+539 -2
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { describe, expect, it, vi } from "vitest";
import type {
DaemonClient,
ConnectionState,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
import {
HostRuntimeController,
HostRuntimeStore,
type HostRuntimeControllerDeps,
} from "./host-runtime";
@@ -13,6 +18,7 @@ class FakeDaemonClient {
public connectCalls = 0;
public closeCalls = 0;
public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
async connect(): Promise<void> {
this.connectCalls += 1;
@@ -49,6 +55,31 @@ class FakeDaemonClient {
return this.error;
}
async fetchAgents(
options?: FetchAgentsOptions
): Promise<{
entries: [];
pageInfo: {
hasMoreBefore: false;
hasMoreAfter: false;
beforeCursor: null;
afterCursor: null;
};
subscriptionId: string | null;
}> {
this.fetchAgentsCalls.push(options ?? {});
return {
entries: [],
pageInfo: {
hasMoreBefore: false,
hasMoreAfter: false,
beforeCursor: null,
afterCursor: null,
},
subscriptionId: options?.subscribe?.subscriptionId ?? null,
};
}
setConnectionState(next: ConnectionState): void {
this.state = next;
if (next.status === "disconnected") {
@@ -103,10 +134,90 @@ function makeDeps(
}
return value;
},
getClientSessionKey: async () => "clsk_test_runtime",
};
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
let reject: ((reason?: unknown) => void) | null = null;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: (value: T | PromiseLike<T>) => resolve?.(value),
reject: (reason?: unknown) => reject?.(reason),
};
}
describe("HostRuntimeController", () => {
it("keeps known hosts in connecting when client reports idle during connect", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const idleClient = new FakeDaemonClient();
const deps: HostRuntimeControllerDeps = {
createClient: () => idleClient as unknown as DaemonClient,
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
idleClient.connect = async () => {
idleClient.connectCalls += 1;
// Intentionally do not emit a connected state; stay in idle.
};
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
});
it("passes resolved client session key into created active clients", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const seenSessionKeys: string[] = [];
const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({
host,
deps: {
createClient: ({ clientSessionKey }) => {
seenSessionKeys.push(clientSessionKey);
return fakeClient as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_runtime_stable",
},
});
await controller.start({ autoProbe: false });
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
it("selects the lowest-latency connection on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
@@ -250,4 +361,430 @@ describe("HostRuntimeController", () => {
expect(latest?.lastError).toBe("transport closed");
unsubscribe();
});
it("logs typed reason codes for connection transitions", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
try {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: makeDeps(
{
"direct:lan:6767": 12,
},
clients
),
});
await controller.start({ autoProbe: false });
clients[0]?.setConnectionState({
status: "disconnected",
reason: "transport closed",
});
const transitionPayloads = infoSpy.mock.calls
.filter((call) => call[0] === "[HostRuntimeTransition]")
.map((call) => call[1] as { reasonCode?: string | null });
const lastTransition =
transitionPayloads[transitionPayloads.length - 1] ?? null;
expect(lastTransition?.reasonCode).toBe("transport_error");
} finally {
infoSpy.mockRestore();
}
});
it("marks directory loading on first connection before any directory sync succeeds", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(false);
expect(snapshot.agentDirectoryStatus).toBe("initial_loading");
});
it("keeps directory ready through reconnects after the first successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true);
clients[0]?.setConnectionState({
status: "disconnected",
reason: "client_closed",
});
expect(controller.getSnapshot().connectionStatus).toBe("offline");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
clients[0]?.setConnectionState({ status: "connected" });
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
});
it("stores directory sync errors as non-blocking after a successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
controller.markAgentDirectorySyncError("bootstrap failed");
const snapshot = controller.getSnapshot();
expect(snapshot.agentDirectoryStatus).toBe("error_after_ready");
expect(snapshot.agentDirectoryError).toBe("bootstrap failed");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(true);
});
it("keeps online snapshots coupled to a live client reference", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
const unsubscribe = controller.subscribe(() => {
observed.push(controller.getSnapshot());
});
await controller.start({ autoProbe: false });
for (const snapshot of observed) {
if (snapshot.connectionStatus === "online") {
expect(snapshot.client).toBeTruthy();
}
}
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().client).toBeTruthy();
unsubscribe();
});
it("ignores stale switch failures after a newer connection is already online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
{
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
},
],
});
const firstConnectGate = createDeferred<void>();
const createdClients: FakeDaemonClient[] = [];
const deps: HostRuntimeControllerDeps = {
createClient: ({ connection }) => {
const client = new FakeDaemonClient();
if (connection.id === "direct:lan:6767") {
client.connect = async () => {
client.connectCalls += 1;
await firstConnectGate.promise;
throw new Error("stale direct connect failed");
};
}
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
const waitUntil = async (
predicate: () => boolean,
timeoutMs = 200
): Promise<void> => {
const timeoutAt = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= timeoutAt) {
throw new Error("timed out waiting for predicate");
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
const switchDirect = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
createdClients.length === 1 &&
snapshot.activeConnectionId === "direct:lan:6767" &&
snapshot.connectionStatus === "connecting"
);
});
const switchRelay = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "relay:relay.paseo.sh:443" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
snapshot.activeConnectionId === "relay:relay.paseo.sh:443" &&
snapshot.connectionStatus === "online"
);
});
firstConnectGate.resolve();
await Promise.allSettled([switchDirect, switchRelay]);
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.lastError).toBeNull();
expect(createdClients).toHaveLength(2);
expect(createdClients[0]?.closeCalls).toBe(1);
});
it("ignores stale probe results when overlapping probe cycles finish out of order", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
measureLatency: async () => {
probeCalls += 1;
if (probeCalls === 1) {
return await slowProbe.promise;
}
if (probeCalls === 2) {
return await fastProbe.promise;
}
throw new Error("unexpected probe call");
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
const first = controller.runProbeCycleNow();
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
slowProbe.resolve(900);
await first;
const probeAfterFirstSettles = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
status: "available",
latencyMs: 12,
});
});
it("keeps active client generation stable while overlapping probes run", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
const createdClients: FakeDaemonClient[] = [];
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
const client = new FakeDaemonClient();
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;
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
await controller.start({ autoProbe: false });
const activeClientBeforeProbes = controller.getSnapshot().client;
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
const first = controller.runProbeCycleNow();
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
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);
});
});
describe("HostRuntimeStore", () => {
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientSessionKey: async () => "clsk_test_runtime",
},
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: "app:srv_test" },
});
const snapshot = store.getSnapshot(host.serverId);
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]);
});
it("surfaces startup failures as error instead of leaving host idle", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const store = new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("create client failed");
},
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
},
});
store.syncHosts([host]);
let snapshot = store.getSnapshot(host.serverId);
const timeoutAt = Date.now() + 100;
while (snapshot?.connectionStatus !== "error" && 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");
});
});
File diff suppressed because it is too large Load Diff
@@ -76,9 +76,10 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import {
DropdownMenu,
@@ -128,7 +129,7 @@ export function AgentReadyScreen({
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus: HostRuntimeConnectionStatus =
runtimeSnapshot?.connectionStatus ??
(isUnknownDaemon ? "offline" : "idle");
(isUnknownDaemon ? "offline" : "connecting");
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
@@ -278,7 +279,6 @@ function AgentScreenContent({
const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback(
(params: {
agentId?: string | null;
cwd?: string | null;
projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean;
@@ -287,19 +287,6 @@ function AgentScreenContent({
return params.projectPlacementIsGit;
}
const agentId = params.agentId?.trim();
if (agentId) {
const sidebarAgents = queryClient.getQueryData<{
entries: FetchAgentsEntry[];
}>(["sidebarAgentsList", serverId]);
const sidebarIsGit = sidebarAgents?.entries.find(
(entry) => entry.agent.id === agentId
)?.project?.checkout?.isGit;
if (typeof sidebarIsGit === "boolean") {
return sidebarIsGit;
}
}
const cwd = params.cwd?.trim();
if (!cwd) {
return null;
@@ -327,7 +314,6 @@ function AgentScreenContent({
?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -394,7 +380,6 @@ function AgentScreenContent({
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -458,6 +443,7 @@ function AgentScreenContent({
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
: -1
);
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
@@ -517,6 +503,38 @@ function AgentScreenContent({
};
});
const handleHistorySyncFailure = useCallback(
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
if (resolvedAgentId) {
console.warn("[AgentScreen] history sync failed", {
origin,
agentId: resolvedAgentId,
error,
});
}
const message = toErrorMessage(error);
setMissingAgentState((prev) => {
if (prev.kind === "error" && prev.message === message) {
return prev;
}
return { kind: "error", message };
});
},
[resolvedAgentId]
);
const ensureInitializedWithSyncErrorHandling = useCallback(
(origin: "focus" | "entry") => {
if (!resolvedAgentId) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId]
);
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
@@ -534,25 +552,18 @@ function AgentScreenContent({
}
}, [connectionStatus, toast]);
useEffect(() => {
if (missingAgentState.kind !== "error") {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [missingAgentState.kind, toast]);
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !isConnected || !hasSession) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] focus sync failed", {
agentId: resolvedAgentId,
error,
});
});
}, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId])
ensureInitializedWithSyncErrorHandling("focus");
}, [
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
resolvedAgentId,
])
);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
@@ -688,6 +699,7 @@ function AgentScreenContent({
isHistorySyncing,
needsAuthoritativeSync,
shouldUseOptimisticStream,
hasHydratedHistoryBefore,
},
});
@@ -769,15 +781,10 @@ function AgentScreenContent({
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
ensureInitializedWithSyncErrorHandling("entry");
}, [
resolvedAgentId,
ensureAgentIsInitialized,
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
needsAuthoritativeSync,
@@ -831,21 +838,28 @@ function AgentScreenContent({
return;
}
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const hydrated = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: null,
cwd: normalized.cwd,
}),
};
setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(normalized.id, normalized);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of next.entries()) {
if (pending.agentId === normalized.id) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of normalized.pendingPermissions) {
const key = derivePendingPermissionKey(normalized.id, request);
next.set(key, { key, agentId: normalized.id, request });
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
@@ -940,14 +954,31 @@ function AgentScreenContent({
[theme.colors.primary, toast]
);
const syncBannerMessage =
viewState.tag !== "ready"
? null
: viewState.syncStatus === "catching_up"
? "Refreshing agent history..."
: viewState.syncStatus === "sync_error"
? "Refresh delayed. Retrying in background."
: null;
const shouldEmitHistoryRefreshToast =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.shouldEmitHistoryRefreshToast;
const shouldEmitSyncErrorToast =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.shouldEmitSyncErrorToast;
useEffect(() => {
if (!shouldEmitHistoryRefreshToast) {
return;
}
toast.show("Refreshing agent history...", {
durationMs: 2200,
testID: "agent-history-refresh-toast",
});
}, [shouldEmitHistoryRefreshToast, toast]);
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [shouldEmitSyncErrorToast, toast]);
if (viewState.tag === "not_found") {
return (
@@ -1188,12 +1219,6 @@ function AgentScreenContent({
}
/>
{syncBannerMessage ? (
<View style={styles.syncBanner} testID="agent-sync-banner">
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
</View>
) : null}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
@@ -1221,6 +1246,14 @@ function AgentScreenContent({
/>
)}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</FileDropZone>
@@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
overflow: "hidden",
},
syncBanner: {
marginHorizontal: theme.spacing[4],
marginTop: theme.spacing[1],
marginBottom: theme.spacing[2],
borderRadius: theme.borderRadius.full,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
syncBannerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
},
historySyncOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
zIndex: 40,
},
archivingOverlay: {
position: "absolute",
top: 0,
+33 -59
View File
@@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
@@ -457,7 +458,6 @@ export default function SettingsScreen() {
removeHost,
removeConnection,
} = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -723,17 +723,10 @@ export default function SettingsScreen() {
</View>
) : (
daemons.map((daemon) => {
const connection = connectionStates.get(daemon.serverId);
const connectionStatus = connection?.status ?? "idle";
const activeConnection = connection?.activeConnection ?? null;
const lastConnectionError = connection?.lastError ?? null;
return (
<DaemonCard
key={daemon.serverId}
daemon={daemon}
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onOpenSettings={handleEditDaemon}
/>
);
@@ -865,9 +858,6 @@ export default function SettingsScreen() {
<HostDetailModal
visible={Boolean(editingDaemonLive)}
host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)}
@@ -983,9 +973,6 @@ export default function SettingsScreen() {
interface HostDetailModalProps {
visible: boolean;
host: HostProfile | null;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
isSaving: boolean;
onClose: () => void;
onSave: (label: string) => void;
@@ -1000,9 +987,6 @@ interface HostDetailModalProps {
function HostDetailModal({
visible,
host,
connectionStatus,
activeConnection,
lastError,
isSaving,
onClose,
onSave,
@@ -1018,45 +1002,37 @@ function HostDetailModal({
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
// Latency probes for each connection
// Read per-connection probes from host runtime snapshots.
const connections = host?.connections ?? [];
const latencyQueries = useQueries({
queries: connections.map((conn) => ({
queryKey: ["connection-latency", conn.id],
queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }),
enabled: visible,
refetchInterval: 5_000,
staleTime: 4_000,
gcTime: 60_000,
retry: 1,
})),
});
const latencyByConnectionId = new Map(
connections.map((conn, i) => [conn.id, latencyQueries[i]] as const)
);
// Restart logic (moved from DaemonCard)
const { client: runtimeClient, isConnected } = useHostRuntimeSession(
const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
host?.serverId ?? ""
);
const runtime = getHostRuntimeStore();
const daemonClient = runtimeClient;
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
const isConnectedRef = useRef(isConnected);
const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
const lastError = runtimeSnapshot?.lastError ?? null;
const [isRestarting, setIsRestarting] = useState(false);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
const isHostConnected = useCallback(() => {
if (!host) {
return false;
}
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
}, [host, runtime]);
const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000;
if (isConnectedRef.current) {
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs);
if (isHostConnected()) {
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
}
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isScreenMountedRef.current) {
setIsRestarting(false);
@@ -1067,12 +1043,12 @@ function HostDetailModal({
);
}
}
}, [host, isScreenMountedRef, waitForCondition]);
}, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!daemonClient || !host) return;
if (!isConnectedRef.current) {
if (!isHostConnected()) {
Alert.alert(
"Host offline",
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
@@ -1094,7 +1070,7 @@ function HostDetailModal({
});
void waitForDaemonRestart();
}, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]);
}, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!daemonClient || !host) {
@@ -1225,14 +1201,14 @@ function HostDetailModal({
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.connections.map((conn) => {
const latency = latencyByConnectionId.get(conn.id);
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
latencyMs={latency?.data ?? undefined}
latencyLoading={latency?.isLoading ?? false}
latencyError={latency?.isError ?? false}
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title =
conn.type === "relay"
@@ -1272,7 +1248,7 @@ function HostDetailModal({
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
disabled={!daemonClient || !isConnected}
>
Restart daemon
</DropdownMenuItem>
@@ -1475,20 +1451,18 @@ function ConnectionRow({
interface DaemonCardProps {
daemon: HostProfile;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({
daemon,
connectionStatus,
activeConnection,
lastError,
onOpenSettings,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const { snapshot } = useHostRuntimeSession(daemon.serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
@@ -0,0 +1,60 @@
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
type PendingPermissionEntry = {
key: string;
agentId: string;
request: Agent["pendingPermissions"][number];
};
export function buildAgentDirectoryState(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): {
agents: Map<string, Agent>;
pendingPermissions: Map<string, PendingPermissionEntry>;
} {
const agents = new Map<string, Agent>();
const pendingPermissions = new Map<string, PendingPermissionEntry>();
for (const entry of input.entries) {
const normalized = normalizeAgentSnapshot(entry.agent, input.serverId);
const projectPlacement = resolveProjectPlacement({
projectPlacement: entry.project,
cwd: normalized.cwd,
});
const agent: Agent = {
...normalized,
projectPlacement,
};
agents.set(agent.id, agent);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
return { agents, pendingPermissions };
}
export function applyFetchedAgentDirectory(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): { agents: Map<string, Agent> } {
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
const store = useSessionStore.getState();
store.setAgents(input.serverId, agents);
for (const agent of agents.values()) {
store.setAgentLastActivity(agent.id, agent.lastActivityAt);
}
store.setPendingPermissions(input.serverId, pendingPermissions);
store.setInitializingAgents(input.serverId, new Map());
store.setHasHydratedAgents(input.serverId, true);
return { agents };
}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("client-session-key", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("returns stored client session key when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("clsk_existing");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_existing");
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("creates and persists a client session key when missing", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-session-key-v1",
"clsk_123456781234123412341234567890ab"
);
});
});
@@ -0,0 +1,54 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1";
let cachedClientSessionKey: string | null = null;
let inFlightClientSessionKey: Promise<string> | null = null;
function generateClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_${randomUuid}`;
}
function normalizeStoredClientSessionKey(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function getOrCreateClientSessionKey(): Promise<string> {
if (cachedClientSessionKey) {
return cachedClientSessionKey;
}
if (inFlightClientSessionKey) {
return inFlightClientSessionKey;
}
inFlightClientSessionKey = (async () => {
const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY);
const existing = normalizeStoredClientSessionKey(storedValue);
if (existing) {
cachedClientSessionKey = existing;
return existing;
}
const nextValue = generateClientSessionKey();
await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue);
cachedClientSessionKey = nextValue;
return nextValue;
})();
try {
return await inFlightClientSessionKey;
} finally {
inFlightClientSessionKey = null;
}
}
+5 -1
View File
@@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
clientSessionKey?: string;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
deriveProjectPlacementFromCwd,
resolveProjectPlacement,
} from "./project-placement";
describe("project-placement", () => {
it("derives fallback placement from cwd", () => {
const placement = deriveProjectPlacementFromCwd("/Users/test/repo");
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe("/Users/test/repo");
expect(placement.checkout.isGit).toBe(false);
});
it("normalizes paseo worktree paths into the parent repo key", () => {
const placement = deriveProjectPlacementFromCwd(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
});
it("prefers an existing placement when present", () => {
const existing = {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "/Users/test/repo",
isGit: true as const,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
},
};
const resolved = resolveProjectPlacement({
projectPlacement: existing,
cwd: "/Users/test/repo",
});
expect(resolved).toBe(existing);
});
});
@@ -0,0 +1,32 @@
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { deriveProjectKey, deriveProjectName } from "@/utils/agent-grouping";
function normalizeWorkingDirectory(cwd: string): string {
const trimmed = cwd.trim();
return trimmed.length > 0 ? trimmed : ".";
}
export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayload {
const normalizedCwd = normalizeWorkingDirectory(cwd);
const projectKey = deriveProjectKey(normalizedCwd);
return {
projectKey,
projectName: deriveProjectName(projectKey),
checkout: {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
};
}
export function resolveProjectPlacement(input: {
projectPlacement: ProjectPlacementPayload | null | undefined;
cwd: string;
}): ProjectPlacementPayload {
return input.projectPlacement ?? deriveProjectPlacementFromCwd(input.cwd);
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = [];
class MockDaemonClient {
private statusHandlers = new Set<
(
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
>();
public lastError: string | null = null;
constructor(config: { clientSessionKey?: string; url?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(
event: "status",
handler: (
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
): () => void {
if (event === "status") {
this.statusHandlers.add(handler);
}
return () => {
this.statusHandlers.delete(handler);
};
}
async connect(): Promise<void> {
const message = {
type: "status" as const,
payload: {
status: "server_info",
serverId: "srv_probe_test",
hostname: "probe-host",
},
};
for (const handler of this.statusHandlers) {
handler(message);
}
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
}
}
return {
MockDaemonClient,
createdConfigs,
};
});
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
describe("test-daemon-connection probe client identity", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
});
it("uses isolated probe clientSessionKey values for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey);
});
});
@@ -4,6 +4,16 @@ import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -41,15 +51,23 @@ export class DaemonConnectionTestError extends Error {
}
}
function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig {
async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientSessionKey,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
};
if (connection.type === "direct") {
return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) };
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
};
}
if (!serverId) {
@@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo
return {
...base,
url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }),
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId,
clientSessionKey,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
};
}
@@ -142,7 +164,7 @@ export async function probeConnection(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ serverId: string; hostname: string | null }> {
const config = buildClientConfig(connection, options?.serverId);
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 };
@@ -152,7 +174,7 @@ export async function measureConnectionLatency(
connection: HostConnection,
options?: ProbeOptions,
): Promise<number> {
const config = buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId);
const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
try {
const { rttMs } = await client.ping({ timeoutMs: 5000 });
@@ -0,0 +1,47 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-session-key"
);
let cachedClientSessionKey: string | null = null;
function normalizeClientSessionKey(value: string): string | null {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function generateClientSessionKey(): string {
return `clsk_${randomUUID().replace(/-/g, "")}`;
}
export async function getOrCreateCliClientSessionKey(): Promise<string> {
if (cachedClientSessionKey) {
return cachedClientSessionKey;
}
try {
const existing = normalizeClientSessionKey(
await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
);
if (existing) {
cachedClientSessionKey = existing;
return existing;
}
} catch (error) {
const code = (error as { code?: string } | null)?.code;
if (code !== "ENOENT") {
throw error;
}
}
const nextValue = generateClientSessionKey();
await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true });
await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
cachedClientSessionKey = nextValue;
return nextValue;
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { DaemonClient } from '@getpaseo/server'
import WebSocket from 'ws'
import { getOrCreateCliClientSessionKey } from './client-session-key.js'
export interface ConnectOptions {
host?: string
@@ -39,7 +40,9 @@ function createNodeWebSocketFactory() {
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const url = `ws://${host}/ws`
const clientSessionKey = await getOrCreateCliClientSessionKey()
const encodedSessionKey = encodeURIComponent(clientSessionKey)
const url = `ws://${host}/ws?clientSessionKey=${encodedSessionKey}`
const client = new DaemonClient({
url,
+167 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RelayDurableObject } from "./cloudflare-adapter.js";
import relayWorker, { RelayDurableObject } from "./cloudflare-adapter.js";
type MockSocket = WebSocket & {
send: ReturnType<typeof vi.fn>;
@@ -42,6 +42,57 @@ function createMockState() {
};
}
async function withMockWebSocketPair(
run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise<void> | void
): Promise<void> {
const serverWs = createMockSocket();
const clientWs = createMockSocket();
const WebSocketPairMock = class {
[index: number]: WebSocket;
constructor() {
this[0] = clientWs as unknown as WebSocket;
this[1] = serverWs as unknown as WebSocket;
}
};
const previousPair = (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair;
(globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = WebSocketPairMock;
try {
await run({ clientWs, serverWs });
} finally {
if (previousPair === undefined) {
delete (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair;
} else {
(globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = previousPair;
}
}
}
describe("RelayDurableObject versioning", () => {
it("accepts legacy v1 client sockets without clientId", async () => {
const { state } = createMockState();
await withMockWebSocketPair(async () => {
const relay = new RelayDurableObject(state as any);
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=1", {
headers: {
Upgrade: "websocket",
},
});
await relay.fetch(req).catch(() => undefined);
expect(state.acceptWebSocket).toHaveBeenCalled();
});
});
it("rejects v2 client sockets when clientId is missing", async () => {
const { state } = createMockState();
const relay = new RelayDurableObject(state as any);
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2");
const response = await relay.fetch(req);
expect(response.status).toBe(400);
await expect(response.text()).resolves.toBe("Missing clientId parameter");
});
});
describe("RelayDurableObject control nudge/reset behavior", () => {
afterEach(() => {
vi.useRealTimers();
@@ -93,4 +144,119 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
vi.advanceTimersByTime(5_000);
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive");
});
it("does not replace existing client sockets for the same clientId", async () => {
const existingClient = createMockSocket({
version: "2",
role: "client",
clientId: "clt_same_session",
serverId: "srv_test",
createdAt: Date.now(),
});
const { state, setTagSockets } = createMockState();
setTagSockets("client:clt_same_session", [existingClient]);
setTagSockets("client", [existingClient]);
await withMockWebSocketPair(async () => {
const relay = new RelayDurableObject(state as any);
const req = new Request(
"https://relay.test/ws?role=client&serverId=srv_test&clientId=clt_same_session&v=2",
{
headers: {
Upgrade: "websocket",
},
}
);
await relay.fetch(req).catch(() => undefined);
expect(existingClient.close).not.toHaveBeenCalled();
});
});
it("keeps server data socket alive while at least one client socket remains", () => {
const clientId = "clt_multi";
const disconnectedClient = createMockSocket({
version: "2",
role: "client",
clientId,
serverId: "srv_test",
createdAt: Date.now(),
});
const stillConnectedClient = createMockSocket({
version: "2",
role: "client",
clientId,
serverId: "srv_test",
createdAt: Date.now(),
});
const serverData = createMockSocket();
const control = createMockSocket();
const { state, setTagSockets } = createMockState();
setTagSockets("server-control", [control]);
setTagSockets(`server:${clientId}`, [serverData]);
setTagSockets("client", [stillConnectedClient]);
setTagSockets(`client:${clientId}`, [stillConnectedClient]);
const relay = new RelayDurableObject(state as any);
relay.webSocketClose(
disconnectedClient as unknown as WebSocket,
1001,
"Client disconnected",
true
);
expect(serverData.close).not.toHaveBeenCalled();
expect(control.send).not.toHaveBeenCalledWith(
JSON.stringify({ type: "client_disconnected", clientId })
);
});
});
describe("relay worker endpoint routing", () => {
it("routes missing v to legacy v1 isolated DO ids", async () => {
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
const get = vi.fn(() => ({ fetch }));
const idFromName = vi.fn(() => ({ toString: () => "id" }));
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server"),
{ RELAY: { idFromName, get } } as any
);
expect(idFromName).toHaveBeenCalledWith("relay-v1:srv_test");
expect(fetch).toHaveBeenCalledTimes(1);
await expect(response.text()).resolves.toBe("ok:1");
});
it("routes v=2 to v2 isolated DO ids", async () => {
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
const get = vi.fn(() => ({ fetch }));
const idFromName = vi.fn(() => ({ toString: () => "id" }));
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=2"),
{ RELAY: { idFromName, get } } as any
);
expect(idFromName).toHaveBeenCalledWith("relay-v2:srv_test");
expect(fetch).toHaveBeenCalledTimes(1);
await expect(response.text()).resolves.toBe("ok:2");
});
it("rejects invalid v values", async () => {
const fetch = vi.fn();
const get = vi.fn(() => ({ fetch }));
const idFromName = vi.fn(() => ({ toString: () => "id" }));
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=nope"),
{ RELAY: { idFromName, get } } as any
);
expect(response.status).toBe(400);
await expect(response.text()).resolves.toBe("Invalid v parameter (expected 1 or 2)");
expect(idFromName).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
});
+144 -42
View File
@@ -19,6 +19,21 @@
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
type RelayProtocolVersion = "1" | "2";
const LEGACY_RELAY_VERSION: RelayProtocolVersion = "1";
const CURRENT_RELAY_VERSION: RelayProtocolVersion = "2";
function resolveRelayVersion(rawValue: string | null): RelayProtocolVersion | null {
if (rawValue == null) return LEGACY_RELAY_VERSION;
const value = rawValue.trim();
if (!value) return LEGACY_RELAY_VERSION;
if (value === LEGACY_RELAY_VERSION || value === CURRENT_RELAY_VERSION) {
return value;
}
return null;
}
type WebSocketPair = {
0: WebSocket;
1: WebSocket;
@@ -54,13 +69,14 @@ interface DurableObjectStub {
/**
* Durable Object that handles WebSocket relay for a single session.
*
* WebSockets connect to this DO in three shapes:
* v1 WebSockets connect in two shapes:
* - role=server: daemon socket
* - role=client: app/client socket
*
* v2 WebSockets connect in three shapes:
* - role=server (no clientId): daemon control socket (one per serverId)
* - role=server&clientId=...: daemon per-client data socket (one per clientId)
* - role=client&clientId=...: app/client socket (one per clientId)
*
* Messages are forwarded between the per-client data sockets and their matching
* client sockets. The DO hibernates when idle.
* - role=client&clientId=...: app/client socket (many per clientId)
*/
interface CFResponseInit extends ResponseInit {
webSocket?: WebSocket;
@@ -72,7 +88,26 @@ export class RelayDurableObject {
constructor(state: DurableObjectState) {
this.state = state;
}
private createWebSocketPair(): [WebSocket, WebSocket] {
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
return [pair[0], pair[1]];
}
private requireWebSocketUpgrade(request: Request): Response | null {
const upgradeHeader = request.headers.get("Upgrade");
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
return new Response("Expected WebSocket upgrade", { status: 426 });
}
return null;
}
private asSwitchingProtocolsResponse(client: WebSocket): Response {
return new Response(null, {
status: 101,
webSocket: client,
} as CFResponseInit);
}
private hasServerDataSocket(clientId: string): boolean {
@@ -180,39 +215,53 @@ export class RelayDurableObject {
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const role = url.searchParams.get("role") as ConnectionRole | null;
const serverId = url.searchParams.get("serverId");
const clientIdRaw = url.searchParams.get("clientId");
const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : "";
private fetchV1(request: Request, role: ConnectionRole, serverId: string): Response {
const upgradeError = this.requireWebSocketUpgrade(request);
if (upgradeError) return upgradeError;
if (!role || (role !== "server" && role !== "client")) {
return new Response("Missing or invalid role parameter", { status: 400 });
for (const ws of this.state.getWebSockets(role)) {
ws.close(1008, "Replaced by new connection");
}
if (!serverId) {
return new Response("Missing serverId parameter", { status: 400 });
}
const [client, server] = this.createWebSocketPair();
this.state.acceptWebSocket(server, [role]);
const attachment: RelaySessionAttachment = {
serverId,
role,
version: LEGACY_RELAY_VERSION,
clientId: null,
createdAt: Date.now(),
};
(server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log(`[Relay DO] v1:${role} connected to session ${serverId}`);
return this.asSwitchingProtocolsResponse(client);
}
private fetchV2(
request: Request,
role: ConnectionRole,
serverId: string,
clientId: string
): Response {
// Clients must provide a clientId so the daemon can create an independent
// E2EE channel per client connection.
if (role === "client" && !clientId) {
return new Response("Missing clientId parameter", { status: 400 });
}
const upgradeHeader = request.headers.get("Upgrade");
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
return new Response("Expected WebSocket upgrade", { status: 426 });
}
const upgradeError = this.requireWebSocketUpgrade(request);
if (upgradeError) return upgradeError;
const isServerControl = role === "server" && !clientId;
const isServerData = role === "server" && !!clientId;
// Close any existing connection with the same identity.
// Close any existing server-side connection with the same identity.
// - server-control: single per serverId
// - server-data: single per clientId
// - client: single per clientId
// - client: many sockets per clientId are allowed
if (isServerControl) {
for (const ws of this.state.getWebSockets("server-control")) {
ws.close(1008, "Replaced by new connection");
@@ -221,15 +270,9 @@ export class RelayDurableObject {
for (const ws of this.state.getWebSockets(`server:${clientId}`)) {
ws.close(1008, "Replaced by new connection");
}
} else {
for (const ws of this.state.getWebSockets(`client:${clientId}`)) {
ws.close(1008, "Replaced by new connection");
}
}
// Create WebSocket pair
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
const [client, server] = [pair[0], pair[1]];
const [client, server] = this.createWebSocketPair();
const tags: string[] = [];
if (role === "client") {
@@ -240,20 +283,19 @@ export class RelayDurableObject {
tags.push("server", `server:${clientId}`);
}
// Accept with hibernation support, tagged for lookup.
this.state.acceptWebSocket(server, tags);
// Store attachment for hibernation recovery
const attachment: RelaySessionAttachment = {
serverId,
role,
version: CURRENT_RELAY_VERSION,
clientId: clientId || null,
createdAt: Date.now(),
};
(server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log(
`[Relay DO] ${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}`
`[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}`
);
if (role === "client") {
@@ -274,10 +316,34 @@ export class RelayDurableObject {
this.flushClientFrames(clientId, server);
}
return new Response(null, {
status: 101,
webSocket: client,
} as CFResponseInit);
return this.asSwitchingProtocolsResponse(client);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const role = url.searchParams.get("role") as ConnectionRole | null;
const serverId = url.searchParams.get("serverId");
const clientIdRaw = url.searchParams.get("clientId");
const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : "";
const version = resolveRelayVersion(url.searchParams.get("v"));
if (!role || (role !== "server" && role !== "client")) {
return new Response("Missing or invalid role parameter", { status: 400 });
}
if (!serverId) {
return new Response("Missing serverId parameter", { status: 400 });
}
if (!version) {
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
}
if (version === LEGACY_RELAY_VERSION) {
return this.fetchV1(request, role, serverId);
}
return this.fetchV2(request, role, serverId, clientId);
}
/**
@@ -290,12 +356,27 @@ export class RelayDurableObject {
return;
}
const version = attachment.version ?? LEGACY_RELAY_VERSION;
if (version === LEGACY_RELAY_VERSION) {
const targetRole = attachment.role === "server" ? "client" : "server";
const targets = this.state.getWebSockets(targetRole);
for (const target of targets) {
try {
target.send(message);
} catch (error) {
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
}
}
return;
}
const { role, clientId } = attachment;
if (!clientId) {
// Control channel: support simple app-level keepalive.
if (typeof message === "string") {
try {
const parsed = JSON.parse(message) as any;
const parsed = JSON.parse(message) as unknown as { type?: unknown };
if (parsed?.type === "ping") {
try {
ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
@@ -349,13 +430,25 @@ export class RelayDurableObject {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
if (!attachment) return;
const version = attachment.version ?? LEGACY_RELAY_VERSION;
console.log(
`[Relay DO] ${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
`[Relay DO] v${version}:${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
);
if (version === LEGACY_RELAY_VERSION) {
return;
}
if (attachment.role === "client" && attachment.clientId) {
const remainingClientSockets = this.state
.getWebSockets(`client:${attachment.clientId}`)
.some((socket) => socket !== ws);
if (remainingClientSockets) {
return;
}
this.pendingClientFrames.delete(attachment.clientId);
// Close the matching server-data socket so the daemon can clean up quickly.
// Last socket for this session closed: now clean up matching server-data socket.
for (const serverWs of this.state.getWebSockets(`server:${attachment.clientId}`)) {
try {
serverWs.close(1001, "Client disconnected");
@@ -412,10 +505,19 @@ export default {
return new Response("Missing serverId parameter", { status: 400 });
}
// Route to Durable Object instance for this session
const id = env.RELAY.idFromName(serverId);
const version = resolveRelayVersion(url.searchParams.get("v"));
if (!version) {
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
}
// Route to a version-isolated Durable Object instance.
const id = env.RELAY.idFromName(`relay-v${version}:${serverId}`);
const stub = env.RELAY.get(id);
return stub.fetch(request);
const normalizedUrl = new URL(request.url);
normalizedUrl.searchParams.set("v", version);
const normalizedRequest = new Request(normalizedUrl.toString(), request);
return stub.fetch(normalizedRequest);
}
return new Response("Not found", { status: 404 });
+7 -7
View File
@@ -53,7 +53,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const start = Date.now();
while (Date.now() - start < timeout) {
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server`;
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server&v=2`;
const opened = await new Promise<boolean>((resolve) => {
const ws = new WebSocket(probeUrl);
const timer = setTimeout(() => {
@@ -144,7 +144,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
// Daemon connects to relay as "server" control role
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
);
await new Promise<void>((resolve, reject) => {
@@ -199,7 +199,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
// Client connects to relay as "client" role (must include clientId)
const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2`
);
await new Promise<void>((resolve, reject) => {
@@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2`
);
await new Promise<void>((resolve, reject) => {
daemonWs.on("open", resolve);
@@ -322,7 +322,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
);
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
);
await new Promise<void>((r) => daemonControlWs.on("open", r));
@@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2`
);
await new Promise<void>((r) => clientWs.on("open", r));
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2`
);
await new Promise<void>((r) => daemonWs.on("open", r));
+3 -3
View File
@@ -37,13 +37,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
async () => {
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const clientId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`;
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`;
const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
)}&role=server&clientId=${encodeURIComponent(clientId)}`;
)}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
)}&role=client&clientId=${encodeURIComponent(clientId)}`;
)}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`;
// === Key setup ===
const daemonKeyPair = await generateKeyPair();
+6
View File
@@ -13,6 +13,12 @@ export type ConnectionRole = "server" | "client";
export interface RelaySessionAttachment {
serverId: string;
role: ConnectionRole;
/**
* Relay protocol version carried by this socket.
* v1: single server/client socket pair
* v2: control + per-client data sockets
*/
version?: "1" | "2";
/**
* Unique id for the client connection. Allows the daemon to create an
* independent socket + E2EE channel per connected client.
+207 -33
View File
@@ -83,7 +83,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -166,12 +166,177 @@ describe('DaemonClient', () => {
})
})
test('does not reconnect after close when ensureConnected is called', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
})
clients.push(client)
const connectPromise = client.connect()
mock.triggerOpen()
await connectPromise
expect(client.getConnectionState().status).toBe('connected')
await client.close()
expect(client.getConnectionState().status).toBe('disposed')
client.ensureConnected()
expect(client.getConnectionState().status).toBe('disposed')
})
test('transitions out of connecting when connect timeout elapses', async () => {
vi.useFakeTimers()
try {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
connectTimeoutMs: 100,
transportFactory: () => mock.transport,
})
clients.push(client)
const pendingConnect = client.connect().then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error })
)
expect(client.getConnectionState().status).toBe('connecting')
await vi.advanceTimersByTimeAsync(120)
const result = await pendingConnect
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error).toBeInstanceOf(Error)
expect((result.error as Error).message).toContain('Connection timed out')
}
expect(client.getConnectionState().status).toBe('disconnected')
} finally {
vi.useRealTimers()
}
})
test('reconnects after relay close with replaced-by-new-connection reason', async () => {
vi.useFakeTimers()
try {
const logger = createMockLogger()
const first = createMockTransport()
const second = createMockTransport()
const transports = [first, second]
let transportIndex = 0
const client = new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2',
logger,
reconnect: {
enabled: true,
baseDelayMs: 5,
maxDelayMs: 5,
},
transportFactory: () => {
const next = transports[Math.min(transportIndex, transports.length - 1)]
transportIndex += 1
return next.transport
},
})
clients.push(client)
const connectPromise = client.connect()
first.triggerOpen()
await connectPromise
expect(client.getConnectionState().status).toBe('connected')
first.triggerClose({ code: 1008, reason: 'Replaced by new connection' })
expect(client.getConnectionState().status).toBe('disconnected')
await vi.advanceTimersByTimeAsync(10)
expect(client.getConnectionState().status).toBe('connecting')
second.triggerOpen()
expect(client.getConnectionState().status).toBe('connected')
} finally {
vi.useRealTimers()
}
})
test('requires explicit relay session identity when URL clientId is missing', () => {
expect(() => {
new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
reconnect: { enabled: false },
})
}).toThrow('Relay client requires clientSessionKey or URL clientId')
})
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => {
expect(() => {
new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
clientSessionKey: 'clsk_b',
reconnect: { enabled: false },
})
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
})
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
expect(() => {
new DaemonClient({
url: 'ws://127.0.0.1:6767/ws',
reconnect: { enabled: false },
})
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
})
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
expect(() => {
new DaemonClient({
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
clientSessionKey: 'clsk_b',
reconnect: { enabled: false },
})
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
})
test('logs configured runtime generation in connection transition events', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
runtimeGeneration: 7,
transportFactory: () => mock.transport,
})
clients.push(client)
const connectPromise = client.connect()
mock.triggerOpen()
await connectPromise
const transitionPayloads = logger.info.mock.calls
.filter(([, message]) => message === 'DaemonClientTransition')
.map(([payload]) => payload as { generation?: number | null })
expect(transitionPayloads.length).toBeGreaterThan(0)
for (const payload of transitionPayloads) {
expect(payload.generation).toBe(7)
}
})
test('subscribes to checkout diff updates via RPC handshake', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -234,7 +399,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -302,7 +467,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -361,7 +526,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -432,7 +597,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -494,7 +659,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -540,7 +705,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -621,7 +786,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -664,7 +829,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -716,7 +881,7 @@ describe('DaemonClient', () => {
})
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory,
@@ -743,7 +908,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -797,7 +962,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -872,7 +1037,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -928,7 +1093,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -974,7 +1139,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1016,7 +1181,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1037,7 +1202,12 @@ describe('DaemonClient', () => {
})
)
expect(mock.sent).toHaveLength(0)
expect(mock.sent).toHaveLength(1)
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(bufferedAck?.streamId).toBe(19)
expect(bufferedAck?.offset).toBe(8)
mock.sent.length = 0
const seen: string[] = []
client.onTerminalStreamData(19, (chunk) => {
@@ -1045,11 +1215,7 @@ describe('DaemonClient', () => {
})
expect(seen.join('')).toContain('buffered')
expect(mock.sent).toHaveLength(1)
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(ackFrame?.streamId).toBe(19)
expect(ackFrame?.offset).toBe(8)
expect(mock.sent).toHaveLength(0)
})
test('stops delivering and acking after terminal_stream_exit', async () => {
@@ -1057,7 +1223,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1106,7 +1272,11 @@ describe('DaemonClient', () => {
)
expect(seen).toEqual(['before-exit'])
expect(mock.sent).toHaveLength(0)
expect(mock.sent).toHaveLength(1)
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(postExitAck?.streamId).toBe(23)
expect(postExitAck?.offset).toBe(22)
unsubscribe()
})
@@ -1115,7 +1285,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1165,7 +1335,11 @@ describe('DaemonClient', () => {
)
expect(seen).toEqual([])
expect(mock.sent).toHaveLength(0)
expect(mock.sent).toHaveLength(1)
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(detachedAck?.streamId).toBe(31)
expect(detachedAck?.offset).toBe(12)
})
test('parses canonical agent_stream tool_call payloads without crashing', async () => {
@@ -1173,7 +1347,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1244,7 +1418,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1296,7 +1470,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1388,7 +1562,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1458,7 +1632,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
@@ -1494,7 +1668,7 @@ describe('DaemonClient', () => {
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
url: 'ws://test?clientSessionKey=clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
+222 -69
View File
@@ -77,7 +77,6 @@ import {
describeTransportClose,
describeTransportError,
encodeUtf8String,
safeRandomId,
type DaemonTransport,
type DaemonTransportFactory,
type WebSocketFactory,
@@ -110,6 +109,7 @@ export type ConnectionState =
| { status: 'connecting'; attempt: number }
| { status: 'connected' }
| { status: 'disconnected'; reason?: string }
| { status: 'disposed' }
export type DaemonEvent =
| {
@@ -144,11 +144,14 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
export type DaemonClientConfig = {
url: string
clientSessionKey?: string
runtimeGeneration?: number | null
authHeader?: string
suppressSendErrors?: boolean
transportFactory?: DaemonTransportFactory
webSocketFactory?: WebSocketFactory
logger?: Logger
connectTimeoutMs?: number
e2ee?: {
enabled?: boolean
daemonPublicKeyB64?: string
@@ -297,6 +300,7 @@ class DaemonRpcError extends Error {
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
/** Default timeout for waiting for connection before sending queued messages */
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000
@@ -308,6 +312,45 @@ function isWaiterTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith('Timeout waiting for message')
}
function normalizeClientSessionKey(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function hashForLog(value: string): string {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) | 0
}
return `h_${Math.abs(hash).toString(16)}`
}
function toReasonCode(reason: string | null | undefined): string | null {
if (!reason) {
return null
}
const normalized = reason.toLowerCase()
if (normalized.includes('timed out')) {
return 'connect_timeout'
}
if (normalized.includes('disposed')) {
return 'disposed'
}
if (normalized.includes('client closed')) {
return 'client_closed'
}
if (normalized.includes('transport')) {
return 'transport_error'
}
if (normalized.includes('failed to connect')) {
return 'connect_failed'
}
return 'unknown'
}
interface PendingSend {
message: SessionInboundMessage
resolve: () => void
@@ -328,6 +371,7 @@ export class DaemonClient {
private checkoutStatusInFlight: Map<string, Promise<CheckoutStatusPayload>> = new Map()
private connectionListeners: Set<(status: ConnectionState) => void> = new Set()
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private connectTimeout: ReturnType<typeof setTimeout> | null = null
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private shouldReconnect = true
@@ -343,11 +387,30 @@ export class DaemonClient {
private terminalDirectorySubscriptions = new Set<string>()
private logger: Logger
private pendingSendQueue: PendingSend[] = []
private relayClientId: string | null = null
private terminalStreams: TerminalStreamManager
private readonly logConnectionPath: 'direct' | 'relay'
private readonly logServerId: string | null
private readonly logClientSessionKeyHash: string
private readonly logGeneration: number | null
constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger
this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? 'relay' : 'direct'
let parsedUrlForLog: URL | null = null
try {
parsedUrlForLog = new URL(this.config.url)
} catch {
parsedUrlForLog = null
}
const parsedServerIdForLog = normalizeClientSessionKey(
parsedUrlForLog?.searchParams.get('serverId')
)
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null
this.logClientSessionKeyHash = 'h_unresolved'
this.logGeneration =
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration)
? this.config.runtimeGeneration
: null
this.terminalStreams = new TerminalStreamManager({
sendAck: (ack) => {
this.sendBinaryFrame({
@@ -359,20 +422,47 @@ export class DaemonClient {
})
},
})
const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey)
let parsed: URL | null = null
try {
parsed = new URL(this.config.url)
} catch {
// ignore - invalid URL will be handled on connect
}
// Relay requires a clientId so the daemon can create an independent
// socket + E2EE channel per connected client. Generate one per DaemonClient
// instance (stable across reconnects in this tab/app session).
// socket + E2EE channel per connected client.
if (isRelayClientWebSocketUrl(this.config.url)) {
try {
const parsed = new URL(this.config.url)
if (!parsed.searchParams.get('clientId')) {
this.relayClientId = `clt_${safeRandomId()}`
parsed.searchParams.set('clientId', this.relayClientId)
this.config.url = parsed.toString()
}
} catch {
// ignore - invalid URL will be handled on connect
const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
throw new Error('Relay clientId and clientSessionKey must match when both are provided')
}
const resolvedClientSessionKey = configClientSessionKey ?? urlClientId
if (!resolvedClientSessionKey) {
throw new Error('Relay client requires clientSessionKey or URL clientId')
}
this.config.clientSessionKey = resolvedClientSessionKey
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
if (parsed && !urlClientId) {
parsed.searchParams.set('clientId', resolvedClientSessionKey)
this.config.url = parsed.toString()
}
return
}
const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey'))
if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) {
throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
}
const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey
if (!resolvedClientSessionKey) {
throw new Error('Direct client requires clientSessionKey or URL clientSessionKey')
}
this.config.clientSessionKey = resolvedClientSessionKey
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
if (parsed && !urlClientSessionKey) {
parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey)
this.config.url = parsed.toString()
}
}
@@ -381,6 +471,9 @@ export class DaemonClient {
// ============================================================================
async connect(): Promise<void> {
if (this.connectionState.status === 'disposed') {
throw new Error('Daemon client is disposed')
}
if (this.connectionState.status === 'connected') {
return
}
@@ -399,6 +492,10 @@ export class DaemonClient {
}
private attemptConnect(): void {
if (this.connectionState.status === 'disposed') {
this.rejectConnect(new Error('Daemon client is disposed'))
return
}
if (!this.shouldReconnect) {
this.rejectConnect(new Error('Daemon client is closed'))
return
@@ -414,10 +511,8 @@ export class DaemonClient {
}
try {
// If we reconnect while the previous socket is still open (common in browsers
// where `onerror` may fire before `onclose`), we can end up with multiple
// concurrent relay sockets. Cloudflare then closes the old one with
// "Replaced by new connection", causing a disconnect loop.
// Reconnect can overlap with browser close/error delivery ordering.
// Always dispose previous transport before constructing the next one.
this.disposeTransport()
const baseTransportFactory =
this.config.transportFactory ??
@@ -443,62 +538,55 @@ export class DaemonClient {
this.updateConnectionState({
status: 'connecting',
attempt: this.reconnectAttempt,
})
}, { event: 'CONNECT_REQUEST' })
this.resetConnectTimeout()
const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
this.connectTimeout = setTimeout(() => {
if (this.connectionState.status !== 'connecting') {
return
}
this.lastErrorValue = 'Connection timed out'
this.disposeTransport(1001, 'Connection timed out')
this.scheduleReconnect({
reason: 'Connection timed out',
event: 'CONNECT_TIMEOUT',
reasonCode: 'connect_timeout',
})
}, timeoutMs)
this.transportCleanup = [
transport.onOpen(() => {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null
}
this.lastErrorValue = null
this.reconnectAttempt = 0
this.updateConnectionState({ status: 'connected' })
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue()
this.resolveConnect()
}),
transport.onClose((event) => {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null
}
const closeRecord = event as { code?: unknown; reason?: unknown } | null
const closeCode =
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.code === 'number'
? closeRecord.code
: null
const closeReason =
closeRecord && typeof closeRecord === 'object' && typeof closeRecord.reason === 'string'
? closeRecord.reason
: null
const reason = describeTransportClose(event)
if (reason) {
this.lastErrorValue = reason
}
this.updateConnectionState({
status: 'disconnected',
...(reason ? { reason } : {}),
this.scheduleReconnect({
reason,
event: 'TRANSPORT_CLOSE',
reasonCode: 'transport_closed',
})
// When connecting over the relay, only one client connection is allowed at a time.
// If another device/tab takes over, we should not auto-reconnect and "fight" the new
// connection (which causes flapping where both sides repeatedly replace each other).
if (
isRelayClientWebSocketUrl(this.config.url) &&
closeCode === 1008 &&
(closeReason ?? reason) === 'Replaced by new connection'
) {
this.shouldReconnect = false
this.clearWaiters(new Error(reason ?? 'Replaced by new connection'))
this.rejectPendingSendQueue(new Error(reason ?? 'Replaced by new connection'))
this.rejectConnect(new Error(reason ?? 'Replaced by new connection'))
return
}
this.scheduleReconnect(reason)
}),
transport.onError((event) => {
this.resetConnectTimeout()
const reason = describeTransportError(event)
const isGeneric = reason === 'Transport error'
// Browser WebSocket.onerror often provides no useful details and is followed
@@ -514,8 +602,11 @@ export class DaemonClient {
this.connectionState.status === 'connecting'
) {
this.lastErrorValue = reason
this.updateConnectionState({ status: 'disconnected', reason })
this.scheduleReconnect(reason)
this.scheduleReconnect({
reason,
event: 'TRANSPORT_ERROR',
reasonCode: 'transport_error',
})
}
}, 250)
}
@@ -527,15 +618,23 @@ export class DaemonClient {
this.pendingGenericTransportErrorTimeout = null
}
this.lastErrorValue = reason
this.updateConnectionState({ status: 'disconnected', reason })
this.scheduleReconnect(reason)
this.scheduleReconnect({
reason,
event: 'TRANSPORT_ERROR',
reasonCode: 'transport_error',
})
}),
transport.onMessage((data) => this.handleTransportMessage(data)),
]
} catch (error) {
this.resetConnectTimeout()
const message = error instanceof Error ? error.message : 'Failed to connect'
this.lastErrorValue = message
this.scheduleReconnect(message)
this.scheduleReconnect({
reason: message,
event: 'CONNECT_FAILED',
reasonCode: 'connect_failed',
})
this.rejectConnect(error instanceof Error ? error : new Error(message))
}
}
@@ -559,6 +658,9 @@ export class DaemonClient {
}
async close(): Promise<void> {
if (this.connectionState.status === 'disposed') {
return
}
this.shouldReconnect = false
this.connectPromise = null
this.connectResolve = null
@@ -567,16 +669,21 @@ export class DaemonClient {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
this.resetConnectTimeout()
this.disposeTransport(1000, 'Client closed')
this.clearWaiters(new Error('Daemon client closed'))
this.rejectPendingSendQueue(new Error('Daemon client closed'))
this.terminalStreams.clearAll()
this.updateConnectionState({
status: 'disconnected',
reason: 'client_closed',
})
this.updateConnectionState(
{ status: 'disposed' },
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
)
}
ensureConnected(): void {
if (this.connectionState.status === 'disposed') {
return
}
if (!this.shouldReconnect) {
this.shouldReconnect = true
}
@@ -2505,6 +2612,7 @@ export class DaemonClient {
}
private cleanupTransport(): void {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null
@@ -2519,6 +2627,14 @@ export class DaemonClient {
this.transportCleanup = []
}
private resetConnectTimeout(): void {
if (!this.connectTimeout) {
return
}
clearTimeout(this.connectTimeout)
this.connectTimeout = null
}
private handleTransportMessage(data: unknown): void {
const rawData =
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
@@ -2573,8 +2689,32 @@ export class DaemonClient {
}
}
private updateConnectionState(next: ConnectionState): void {
private updateConnectionState(
next: ConnectionState,
metadata?: { event: string; reason?: string; reasonCode?: string }
): void {
const previous = this.connectionState
this.connectionState = next
const reasonFromNext =
next.status === 'disconnected' && typeof next.reason === 'string'
? next.reason
: null
const reason = metadata?.reason ?? reasonFromNext
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason)
this.logger.info(
{
serverId: this.logServerId,
clientSessionKeyHash: this.logClientSessionKeyHash,
from: previous.status,
to: next.status,
event: metadata?.event ?? 'STATE_UPDATE',
connectionPath: this.logConnectionPath,
generation: this.logGeneration,
reasonCode,
reason,
},
'DaemonClientTransition'
)
for (const listener of this.connectionListeners) {
try {
listener(next)
@@ -2584,22 +2724,17 @@ export class DaemonClient {
}
}
private scheduleReconnect(reason?: string): void {
private scheduleReconnect(input?: {
reason?: string
event?: string
reasonCode?: string
}): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
return
}
const attempt = this.reconnectAttempt
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
this.reconnectAttempt = attempt + 1
const wasDisposed = this.connectionState.status === 'disposed'
const reason = input?.reason
if (typeof reason === 'string' && reason.trim().length > 0) {
this.lastErrorValue = reason.trim()
@@ -2611,10 +2746,28 @@ export class DaemonClient {
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
this.terminalStreams.clearAll()
if (wasDisposed) {
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
return
}
this.updateConnectionState({
status: 'disconnected',
...(reason ? { reason } : {}),
}, {
event: input?.event ?? 'TRANSPORT_CLOSE',
...(reason ? { reason } : {}),
...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}),
})
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
this.rejectConnect(new Error(reason ?? 'Transport disconnected before connect'))
return
}
const attempt = this.reconnectAttempt
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
this.reconnectAttempt = attempt + 1
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
if (!this.shouldReconnect) {
@@ -0,0 +1,242 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
firstQuery: null as QueryMock | null,
secondQuery: null as QueryMock | null,
releaseOldAssistant: null as (() => void) | null,
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
type QueryMock = {
next: ReturnType<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function buildUsage() {
return {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
};
}
function buildFirstQueryMock(
allowOldAssistant: Promise<void>
): QueryMock {
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "interrupt-regression-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
await allowOldAssistant;
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "OLD_TURN_RESPONSE",
},
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
}),
interrupt: vi.fn(async () => {
throw new Error("simulated interrupt failure");
}),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
};
}
function buildSecondQueryMock(): QueryMock {
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "interrupt-regression-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "NEW_TURN_RESPONSE",
},
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
};
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of stream) {
events.push(event);
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
break;
}
}
return events;
}
function collectAssistantText(events: AgentStreamEvent[]): string {
return events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
)
.map((event) => event.item.text)
.join("");
}
describe("ClaudeAgentSession interrupt restart regression", () => {
beforeEach(() => {
const allowOldAssistant = deferred<void>();
let queryCreateCount = 0;
sdkMocks.query.mockImplementation(() => {
queryCreateCount += 1;
if (queryCreateCount === 1) {
const mock = buildFirstQueryMock(allowOldAssistant.promise);
sdkMocks.firstQuery = mock;
return mock;
}
const mock = buildSecondQueryMock();
sdkMocks.secondQuery = mock;
return mock;
});
sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve();
});
afterEach(() => {
sdkMocks.query.mockReset();
sdkMocks.firstQuery = null;
sdkMocks.secondQuery = null;
sdkMocks.releaseOldAssistant = null;
});
test("starts a fresh query after interrupt failure to avoid stale old-turn response", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = session.stream("first prompt");
await firstTurn.next();
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt"));
await Promise.resolve();
sdkMocks.releaseOldAssistant?.();
const secondTurnEvents = await secondTurnPromise;
const secondAssistantText = collectAssistantText(secondTurnEvents);
expect(sdkMocks.query).toHaveBeenCalledTimes(2);
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");
await firstTurn.return?.();
await session.close();
});
});
@@ -1607,6 +1607,9 @@ class ClaudeAgentSession implements AgentSession {
this.queryRestartNeeded = false;
} catch (error) {
this.logger.warn({ err: error }, "Failed to interrupt active turn");
// If interrupt fails, the SDK iterator may remain in an indeterminate state.
// Force a teardown/recreate path so the next turn cannot reuse stale query state.
this.queryRestartNeeded = true;
}
}
@@ -32,13 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
const CLIENT_SESSION_KEY = "clsk_checkout_debug";
async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClient({
url: DAEMON_URL,
url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`,
clientSessionKey: CLIENT_SESSION_KEY,
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false },
});
@@ -216,7 +216,7 @@ describe("relay-transport control lifecycle", () => {
dataSocket,
{
transport: "relay",
externalSessionKey: "relay:clt_test",
externalSessionKey: "session:clt_test",
}
);
});
@@ -324,7 +324,7 @@ export function startRelayTransport({
attached = true;
const externalMetadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: `relay:${clientId}`,
externalSessionKey: `session:${clientId}`,
};
if (daemonKeyPair) {
void attachEncryptedSocket(
@@ -16,10 +16,19 @@ export type DaemonClientConfig = Omit<
export type CreateAgentOptions = CreateAgentRequestOptions;
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
let testClientCounter = 0;
function nextTestClientSessionKey(): string {
testClientCounter += 1;
return `clsk_test_client_${testClientCounter}`;
}
export class DaemonClient extends SharedDaemonClient {
constructor(config: DaemonClientConfig) {
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey();
super({
...config,
clientSessionKey,
webSocketFactory: (url, options) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
});
@@ -256,7 +256,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-1",
externalSessionKey: "session:client-1",
};
const socket1 = new MockSocket();
@@ -278,11 +278,158 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("rejects direct socket attach when clientSessionKey is missing", async () => {
const server = createServer();
const request = {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: "/ws",
};
const socket = new MockSocket();
let closeCode: number | null = null;
let closeReason = "";
socket.on("close", (code: unknown, reason: unknown) => {
closeCode = typeof code === "number" ? code : null;
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
});
await (server as any).attachSocket(socket, request);
expect(closeCode).toBe(1008);
expect(closeReason).toBe("Missing clientSessionKey");
expect(sessionMock.instances).toHaveLength(0);
await server.close();
});
test("attaches multiple sockets to the same relay external session key", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-multi",
};
const socket1 = new MockSocket();
await server.attachExternalSocket(socket1, metadata);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!;
const socket2 = new MockSocket();
await server.attachExternalSocket(socket2, metadata);
expect(sessionMock.instances).toHaveLength(1);
const onMessage = session.args.onMessage as
| ((msg: { type: "status"; payload: { status: string } }) => void)
| undefined;
expect(onMessage).toBeTypeOf("function");
onMessage?.({
type: "status",
payload: { status: "ok" },
});
expect(socket1.sent.length).toBeGreaterThan(1);
expect(socket2.sent.length).toBeGreaterThan(1);
await server.close();
});
test("reuses direct session when clientSessionKey reconnects within grace window", async () => {
const server = createServer();
const request = {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: "/ws?clientSessionKey=clsk_direct_reconnect",
};
const socket1 = new MockSocket();
await (server as any).attachSocket(socket1, request);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!;
socket1.emit("close", 1006, "");
await vi.advanceTimersByTimeAsync(1_000);
expect(session.cleanup).not.toHaveBeenCalled();
const socket2 = new MockSocket();
await (server as any).attachSocket(socket2, request);
expect(sessionMock.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000);
expect(session.cleanup).not.toHaveBeenCalled();
await server.close();
});
test("reuses one session when switching from direct to relay with the same session key", async () => {
const server = createServer();
const clientSessionKey = "clsk_switch_path";
const directRequest = {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: `/ws?clientSessionKey=${clientSessionKey}`,
};
const relayMetadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: `session:${clientSessionKey}`,
};
const directSocket = new MockSocket();
await (server as any).attachSocket(directSocket, directRequest);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!;
const relaySocket = new MockSocket();
await server.attachExternalSocket(relaySocket, relayMetadata);
expect(sessionMock.instances).toHaveLength(1);
const onMessage = session.args.onMessage as
| ((msg: { type: "status"; payload: { status: string } }) => void)
| undefined;
expect(onMessage).toBeTypeOf("function");
onMessage?.({
type: "status",
payload: { status: "ok" },
});
expect(directSocket.sent.length).toBeGreaterThan(1);
expect(relaySocket.sent.length).toBeGreaterThan(1);
directSocket.emit("close", 1006, "");
await vi.advanceTimersByTimeAsync(1_000);
expect(session.cleanup).not.toHaveBeenCalled();
relaySocket.emit("close", 1006, "");
await vi.advanceTimersByTimeAsync(90_000);
expect(session.cleanup).toHaveBeenCalledTimes(1);
await server.close();
});
test("cleans up relay session when reconnect grace expires", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-2",
externalSessionKey: "session:client-2",
};
const socket1 = new MockSocket();
@@ -302,7 +449,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer({ speechReadiness });
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-server-info-capabilities",
externalSessionKey: "session:client-server-info-capabilities",
};
const socket = new MockSocket();
@@ -339,7 +486,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-server-info-broadcast",
externalSessionKey: "session:client-server-info-broadcast",
};
const socket = new MockSocket();
@@ -368,7 +515,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-server-info-download-guidance",
externalSessionKey: "session:client-server-info-download-guidance",
};
const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata);
@@ -397,7 +544,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-binary-inbound",
externalSessionKey: "session:client-binary-inbound",
};
const socket = new MockSocket();
@@ -438,7 +585,7 @@ describe("relay external socket reconnect behavior", () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-binary-outbound",
externalSessionKey: "session:client-binary-outbound",
};
const socket = new MockSocket();
+106 -33
View File
@@ -151,7 +151,7 @@ type SessionConnection = {
session: Session;
clientId: string;
connectionLogger: pino.Logger;
socketRef: { current: WebSocketLike };
sockets: Set<WebSocketLike>;
externalSessionKey: string | null;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
};
@@ -357,19 +357,20 @@ export class VoiceAssistantWebSocketServer {
connection.externalDisconnectCleanupTimeout = null;
}
const ws = connection.socketRef.current;
cleanupPromises.push(connection.session.cleanup());
cleanupPromises.push(
new Promise<void>((resolve) => {
// WebSocket.CLOSED = 3
if (ws.readyState === 3) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
for (const ws of connection.sockets) {
cleanupPromises.push(
new Promise<void>((resolve) => {
// WebSocket.CLOSED = 3
if (ws.readyState === 3) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
}
}
await Promise.all(cleanupPromises);
this.sessions.clear();
@@ -394,15 +395,54 @@ export class VoiceAssistantWebSocketServer {
ws.send(encodeBinaryMuxFrame(frame));
}
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
for (const ws of connection.sockets) {
this.sendToClient(ws, message);
}
}
private sendBinaryToConnection(
connection: SessionConnection,
frame: Parameters<typeof encodeBinaryMuxFrame>[0]
): void {
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
}
}
private async attachSocket(
ws: WebSocketLike,
request?: unknown,
metadata?: ExternalSocketMetadata
): Promise<void> {
const externalSessionKey =
const requestMetadata = extractSocketRequestMetadata(request);
const relayExternalSessionKey =
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
? metadata.externalSessionKey
: null;
const directExternalSessionKey =
typeof requestMetadata.clientSessionKey === "string" &&
requestMetadata.clientSessionKey.trim().length > 0
? `session:${requestMetadata.clientSessionKey.trim()}`
: null;
const externalSessionKey = relayExternalSessionKey ?? directExternalSessionKey;
if (metadata?.transport !== "relay" && !directExternalSessionKey) {
this.logger.warn(
{
host: requestMetadata.host,
origin: requestMetadata.origin,
remoteAddress: requestMetadata.remoteAddress,
},
"Rejected direct connection without clientSessionKey"
);
try {
ws.close(1008, "Missing clientSessionKey");
} catch {
// ignore close errors
}
return;
}
if (externalSessionKey) {
const existing = this.externalSessionsByKey.get(externalSessionKey);
@@ -412,12 +452,7 @@ export class VoiceAssistantWebSocketServer {
existing.externalDisconnectCleanupTimeout = null;
}
const previousSocket = existing.socketRef.current;
if (previousSocket !== ws) {
this.sessions.delete(previousSocket);
existing.socketRef.current = ws;
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendServerInfo(ws);
existing.connectionLogger.trace(
@@ -434,10 +469,9 @@ export class VoiceAssistantWebSocketServer {
}
const clientId = `client-${++this.clientIdCounter}`;
const requestMetadata = extractSocketRequestMetadata(request);
const connectionLoggerFields: Record<string, string> = {
clientId,
transport: externalSessionKey ? "relay" : "direct",
transport: metadata?.transport === "relay" ? "relay" : "direct",
};
if (requestMetadata.host) {
connectionLoggerFields.host = requestMetadata.host;
@@ -452,15 +486,21 @@ export class VoiceAssistantWebSocketServer {
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
}
const connectionLogger = this.logger.child(connectionLoggerFields);
const socketRef = { current: ws };
let connection: SessionConnection | null = null;
const session = new Session({
clientId,
onMessage: (msg) => {
this.sendToClient(socketRef.current, wrapSessionMessage(msg));
if (!connection) {
return;
}
this.sendToConnection(connection, wrapSessionMessage(msg));
},
onBinaryMessage: (frame) => {
this.sendBinaryToClient(socketRef.current, frame);
if (!connection) {
return;
}
this.sendBinaryToConnection(connection, frame);
},
logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore,
@@ -493,11 +533,11 @@ export class VoiceAssistantWebSocketServer {
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
});
const connection: SessionConnection = {
connection = {
session,
clientId,
connectionLogger,
socketRef,
sockets: new Set([ws]),
externalSessionKey,
externalDisconnectCleanupTimeout: null,
};
@@ -593,11 +633,9 @@ export class VoiceAssistantWebSocketServer {
const activeConnection = this.sessions.get(ws);
if (activeConnection !== connection) return;
this.sessions.delete(ws);
connection.sockets.delete(ws);
if (
connection.externalSessionKey &&
connection.socketRef.current === ws
) {
if (connection.externalSessionKey && connection.sockets.size === 0) {
if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
}
@@ -623,6 +661,19 @@ export class VoiceAssistantWebSocketServer {
return;
}
if (connection.sockets.size > 0) {
connection.connectionLogger.trace(
{
clientId: connection.clientId,
remainingSockets: connection.sockets.size,
code: details.code,
reason: stringifyCloseReason(details.reason),
},
"Client socket disconnected; session remains attached"
);
return;
}
await this.cleanupConnection(connection, "Client disconnected");
}
@@ -635,8 +686,10 @@ export class VoiceAssistantWebSocketServer {
connection.externalDisconnectCleanupTimeout = null;
}
const currentSocket = connection.socketRef.current;
this.sessions.delete(currentSocket);
for (const socket of connection.sockets) {
this.sessions.delete(socket);
}
connection.sockets.clear();
if (connection.externalSessionKey) {
const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
if (existing === connection) {
@@ -899,6 +952,7 @@ type SocketRequestMetadata = {
origin?: string;
userAgent?: string;
remoteAddress?: string;
clientSessionKey?: string;
};
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
@@ -912,6 +966,7 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
origin?: unknown;
"user-agent"?: unknown;
};
url?: unknown;
socket?: {
remoteAddress?: unknown;
};
@@ -928,12 +983,30 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
typeof record.socket?.remoteAddress === "string"
? record.socket.remoteAddress
: undefined;
const rawUrl = typeof record.url === "string" ? record.url : null;
const clientSessionKey = (() => {
if (!rawUrl) {
return undefined;
}
try {
const parsed = new URL(rawUrl, "http://localhost");
const value = parsed.searchParams.get("clientSessionKey");
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
})();
return {
...(host ? { host } : {}),
...(origin ? { origin } : {}),
...(userAgent ? { userAgent } : {}),
...(remoteAddress ? { remoteAddress } : {}),
...(clientSessionKey ? { clientSessionKey } : {}),
};
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import {
buildRelayWebSocketUrl,
CURRENT_RELAY_PROTOCOL_VERSION,
normalizeRelayProtocolVersion,
} from "./daemon-endpoints.js";
describe("relay websocket URL versioning", () => {
test("defaults relay URLs to v2", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
serverId: "srv_test",
role: "client",
clientSessionKey: "clsk_test",
})
);
expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION);
expect(url.searchParams.get("clientId")).toBe("clsk_test");
});
test("allows explicitly requesting v1 relay URLs", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
serverId: "srv_test",
role: "server",
version: "1",
})
);
expect(url.searchParams.get("v")).toBe("1");
});
test("normalizes numeric relay versions", () => {
expect(normalizeRelayProtocolVersion(2)).toBe("2");
expect(normalizeRelayProtocolVersion(1)).toBe("1");
});
test("rejects unsupported relay versions", () => {
expect(() => normalizeRelayProtocolVersion("3")).toThrow(
'Relay version must be "1" or "2"'
);
});
});
+47 -4
View File
@@ -5,6 +5,28 @@ export type HostPortParts = {
};
export type RelayRole = "server" | "client";
export type RelayProtocolVersion = "1" | "2";
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
export function normalizeRelayProtocolVersion(
value: unknown,
fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION
): RelayProtocolVersion {
if (value == null) {
return fallback;
}
const normalized =
typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : "";
if (!normalized) {
return fallback;
}
if (normalized === "1" || normalized === "2") {
return normalized;
}
throw new Error('Relay version must be "1" or "2"');
}
function parsePort(portStr: string, context: string): number {
const port = Number(portStr);
@@ -60,11 +82,21 @@ function shouldUseSecureWebSocket(port: number): boolean {
return port === 443;
}
export function buildDaemonWebSocketUrl(endpoint: string): string {
export function buildDaemonWebSocketUrl(
endpoint: string,
params?: { clientSessionKey?: string }
): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
return `${protocol}://${hostPart}:${port}/ws`;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
if (
typeof params?.clientSessionKey === "string" &&
params.clientSessionKey.trim().length > 0
) {
url.searchParams.set("clientSessionKey", params.clientSessionKey.trim());
}
return url.toString();
}
export function buildRelayWebSocketUrl(params: {
@@ -72,6 +104,8 @@ export function buildRelayWebSocketUrl(params: {
serverId: string;
role: RelayRole;
clientId?: string;
clientSessionKey?: string;
version?: RelayProtocolVersion | 1 | 2;
}): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
@@ -79,8 +113,17 @@ export function buildRelayWebSocketUrl(params: {
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("serverId", params.serverId);
url.searchParams.set("role", params.role);
if (params.clientId) {
url.searchParams.set("clientId", params.clientId);
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
if (
params.clientId &&
params.clientSessionKey &&
params.clientId !== params.clientSessionKey
) {
throw new Error("clientId and clientSessionKey must match when both are provided");
}
const resolvedClientId = params.clientId ?? params.clientSessionKey;
if (resolvedClientId) {
url.searchParams.set("clientId", resolvedClientId);
}
return url.toString();
}