diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts
index 8357ac481..8e37e6a6f 100644
--- a/packages/app/e2e/global-setup.ts
+++ b/packages/app/e2e/global-setup.ts
@@ -118,6 +118,7 @@ export default async function globalSetup() {
env: {
...process.env,
PASEO_HOME: paseoHome,
+ PASEO_SERVER_ID: 'e2e-test-daemon',
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts
index 20027d341..b024ed314 100644
--- a/packages/app/e2e/host-selection.spec.ts
+++ b/packages/app/e2e/host-selection.spec.ts
@@ -71,3 +71,119 @@ test('new agent respects serverId in the URL', async ({ page }) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
+
+test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
+ const daemonPort = process.env.E2E_DAEMON_PORT;
+ if (!daemonPort) {
+ throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
+ }
+
+ const nowIso = new Date().toISOString();
+ const testDaemon = {
+ id: 'e2e-test-daemon',
+ label: 'localhost',
+ endpoints: [`127.0.0.1:${daemonPort}`],
+ relay: null,
+ metadata: null,
+ createdAt: nowIso,
+ updatedAt: nowIso,
+ };
+
+ await page.goto('/');
+ await page.evaluate(
+ ({ daemon }) => {
+ const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
+ localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
+ localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
+ localStorage.removeItem('@paseo:create-agent-preferences');
+ localStorage.removeItem('@paseo:settings');
+ },
+ { daemon: testDaemon }
+ );
+
+ await page.reload();
+ await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
+
+ // Host should be auto-selected (no manual selection required).
+ await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
+ const input = page.getByRole('textbox', { name: 'Message agent...' });
+ await expect(input).toBeEditable({ timeout: 30000 });
+});
+
+test('adopts daemon-provided serverId for legacy host ids', async ({ page }) => {
+ const daemonPort = process.env.E2E_DAEMON_PORT;
+ if (!daemonPort) {
+ throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
+ }
+
+ const nowIso = new Date().toISOString();
+ const legacyId = 'legacy-daemon-id';
+ const daemon = {
+ id: legacyId,
+ label: 'localhost',
+ endpoints: [`127.0.0.1:${daemonPort}`],
+ relay: null,
+ metadata: null,
+ createdAt: nowIso,
+ updatedAt: nowIso,
+ };
+
+ await page.goto('/settings');
+ await page.evaluate(
+ ({ daemon, legacyId }) => {
+ const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
+ localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
+ localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
+ localStorage.setItem(
+ '@paseo:create-agent-preferences',
+ JSON.stringify({
+ serverId: legacyId,
+ provider: 'claude',
+ providerPreferences: {
+ claude: { model: 'haiku' },
+ codex: { model: 'gpt-5.1-codex-mini' },
+ },
+ })
+ );
+ localStorage.removeItem('@paseo:settings');
+ },
+ { daemon, legacyId }
+ );
+
+ await page.reload();
+ await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
+
+ // Wait for server_info handshake to rekey registry + preferences.
+ await page.waitForFunction(
+ () => {
+ const raw = localStorage.getItem('@paseo:daemon-registry');
+ const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
+ if (!raw || !prefsRaw) return false;
+ const registry = JSON.parse(raw);
+ const prefs = JSON.parse(prefsRaw);
+ if (!Array.isArray(registry) || registry.length !== 1) return false;
+ if (registry[0]?.id !== 'e2e-test-daemon') return false;
+ if (prefs?.serverId !== 'e2e-test-daemon') return false;
+ const legacyIds = registry[0]?.metadata?.legacyIds;
+ return Array.isArray(legacyIds) && legacyIds.includes('legacy-daemon-id');
+ },
+ { timeout: 20000 }
+ );
+
+ const snapshot = await page.evaluate(() => {
+ const raw = localStorage.getItem('@paseo:daemon-registry');
+ const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
+ return { raw, prefsRaw };
+ });
+ if (!snapshot.raw || !snapshot.prefsRaw) {
+ throw new Error('Expected registry + preferences to be present after rekey.');
+ }
+
+ const registry = JSON.parse(snapshot.raw) as any[];
+ expect(registry[0].id).toBe('e2e-test-daemon');
+ expect(registry[0].metadata.legacyIds).toContain('legacy-daemon-id');
+
+ const prefs = JSON.parse(snapshot.prefsRaw) as any;
+ expect(prefs.serverId).toBe('e2e-test-daemon');
+});
diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx
index 0fe2f7282..489a6c228 100644
--- a/packages/app/src/app/agent/[serverId]/[agentId].tsx
+++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx
@@ -1,12 +1,23 @@
-import { useLocalSearchParams } from "expo-router";
+import { useEffect } from "react";
+import { useLocalSearchParams, useRouter } from "expo-router";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
+import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
export default function AgentReadyRoute() {
+ const router = useRouter();
+ const { daemons } = useDaemonRegistry();
const { serverId, agentId } = useLocalSearchParams<{
serverId?: string;
agentId?: string;
}>();
+ useEffect(() => {
+ if (typeof serverId !== "string" || typeof agentId !== "string") return;
+ const canonical = resolveCanonicalDaemonId(daemons, serverId);
+ if (!canonical || canonical === serverId) return;
+ router.replace(`/agent/${canonical}/${agentId}` as any);
+ }, [agentId, daemons, router, serverId]);
+
return (
);
}
-
diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx
index f07d1be23..9ec0ccc05 100644
--- a/packages/app/src/components/ui/dropdown-menu.tsx
+++ b/packages/app/src/components/ui/dropdown-menu.tsx
@@ -210,7 +210,7 @@ export function DropdownMenuTrigger({
onPress={handlePress}
style={({ pressed, hovered }) => {
if (typeof style === "function") {
- return style({ pressed, hovered, open: ctx.open });
+ return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx
index d26e1d09a..bae50952c 100644
--- a/packages/app/src/contexts/daemon-registry-context.tsx
+++ b/packages/app/src/contexts/daemon-registry-context.tsx
@@ -59,6 +59,7 @@ interface DaemonRegistryContextValue {
removeDaemon: (id: string) => Promise;
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise;
+ adoptDaemonServerId: (currentId: string, serverId: string) => Promise;
}
const DaemonRegistryContext = createContext(null);
@@ -129,6 +130,72 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
await persist(remaining);
}, [persist, readDaemons]);
+ const adoptDaemonServerId = useCallback(
+ async (currentId: string, serverId: string) => {
+ const trimmed = serverId.trim();
+ if (!trimmed) {
+ throw new Error("serverId is required");
+ }
+ if (currentId === trimmed) {
+ return trimmed;
+ }
+
+ const existing = readDaemons();
+ const now = new Date().toISOString();
+ const idx = existing.findIndex((daemon) => daemon.id === currentId);
+ if (idx === -1) {
+ return trimmed;
+ }
+
+ const current = existing[idx];
+ const otherIdx = existing.findIndex((daemon) => daemon.id === trimmed);
+
+ const addLegacyId = (metadata: Record | null | undefined, legacyId: string) => {
+ const prev = (metadata ?? {}) as Record;
+ const legacyIdsRaw = prev.legacyIds;
+ const legacyIds = Array.isArray(legacyIdsRaw)
+ ? legacyIdsRaw.filter((value) => typeof value === "string")
+ : [];
+ if (!legacyIds.includes(legacyId)) {
+ legacyIds.push(legacyId);
+ }
+ return { ...prev, legacyIds };
+ };
+
+ // Merge into existing canonical entry if it exists.
+ if (otherIdx !== -1) {
+ const canonical = existing[otherIdx];
+ const merged: HostProfile = {
+ ...canonical,
+ label: canonical.label?.trim() ? canonical.label : current.label,
+ endpoints: Array.from(new Set([...(canonical.endpoints ?? []), ...(current.endpoints ?? [])])),
+ daemonPublicKeyB64: canonical.daemonPublicKeyB64 ?? current.daemonPublicKeyB64,
+ relay: canonical.relay ?? current.relay ?? null,
+ createdAt: canonical.createdAt ?? current.createdAt,
+ updatedAt: now,
+ metadata: addLegacyId(canonical.metadata, currentId),
+ };
+ const next = existing.filter((daemon) => daemon.id !== currentId);
+ next[otherIdx > idx ? otherIdx - 1 : otherIdx] = merged;
+ await persist(next);
+ return trimmed;
+ }
+
+ const updated: HostProfile = {
+ ...current,
+ id: trimmed,
+ updatedAt: now,
+ metadata: addLegacyId(current.metadata, currentId),
+ };
+
+ const next = [...existing];
+ next[idx] = updated;
+ await persist(next);
+ return trimmed;
+ },
+ [persist, readDaemons]
+ );
+
const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOfferV1) => {
const existing = readDaemons();
@@ -194,6 +261,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
addDaemon,
updateDaemon,
removeDaemon,
+ adoptDaemonServerId,
upsertDaemonFromOffer,
upsertDaemonFromOfferUrl,
};
@@ -389,3 +457,16 @@ export function buildDirectDaemonWsUrl(profile: HostProfile): string {
}
return buildDaemonWebSocketUrl(endpoint);
}
+
+export function resolveCanonicalDaemonId(daemons: HostProfile[], id: string): string | null {
+ const direct = daemons.find((daemon) => daemon.id === id);
+ if (direct) return direct.id;
+
+ for (const daemon of daemons) {
+ const legacyIds = (daemon.metadata as any)?.legacyIds;
+ if (Array.isArray(legacyIds) && legacyIds.some((value) => value === id)) {
+ return daemon.id;
+ }
+ }
+ return null;
+}
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index dc6c6d82f..520cdf21e 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -1,7 +1,7 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useMutation } from "@tanstack/react-query";
+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";
@@ -23,6 +23,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
+import { useDaemonRegistry } from "./daemon-registry-context";
import {
useSessionStore,
type SessionState,
@@ -259,18 +260,23 @@ interface SessionProviderProps {
serverId: string;
}
+const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
+const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
+
// SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider({
children,
serverUrl,
serverId,
}: SessionProviderProps) {
+ const queryClient = useQueryClient();
const client = useDaemonClient(serverUrl);
const [connectionSnapshot, setConnectionSnapshot] =
useState(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { updateConnectionStatus } = useDaemonConnections();
+ const { adoptDaemonServerId } = useDaemonRegistry();
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -349,6 +355,7 @@ export function SessionProvider({
);
const attentionNotifiedRef = useRef