Rename daemon UI copy to host

This commit is contained in:
Mohamed Boudra
2025-11-26 17:26:25 +01:00
parent 59967aad87
commit 04d51fbe10
10 changed files with 82 additions and 143 deletions

View File

@@ -102,7 +102,7 @@ function MissingDaemonView() {
textAlign: "center",
}}
>
No daemon configured. Open Settings to add a server URL.
No host configured. Open Settings to add a server URL.
</Text>
</View>
);

View File

@@ -104,7 +104,7 @@ export default function AgentScreen() {
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon";
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected host";
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus = connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
const connectionStatusLabel = formatConnectionStatus(connectionStatus);

View File

@@ -56,7 +56,7 @@ export default function FileExplorerScreen() {
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected daemon";
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected host";
const connectionStatus = connection?.status ?? "idle";
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastError = connection?.lastError ?? null;

View File

@@ -65,7 +65,7 @@ export default function GitDiffScreen() {
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Active daemon";
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Active host";
const connectionStatus = connection?.status ?? "idle";
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastError = connection?.lastError ?? null;

View File

@@ -357,9 +357,9 @@ export default function SettingsScreen() {
? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${
isServerConfigLocked ? " - Session unavailable" : ""
}`
: "No active daemon selected.";
: "No active host selected.";
const serverHelperText = isServerConfigLocked
? `Connect to ${activeDaemon?.label ?? "this daemon"} to edit its server URL.`
? `Connect to ${activeDaemon?.label ?? "this host"} to edit its server URL.`
: "Must be a valid WebSocket URL (ws:// or wss://)";
useEffect(() => {
@@ -459,11 +459,11 @@ export default function SettingsScreen() {
const handleSubmitDaemonForm = useCallback(async () => {
if (!daemonForm.label.trim()) {
Alert.alert("Label required", "Please enter a label for the daemon.");
Alert.alert("Label required", "Please enter a label for the host.");
return;
}
if (!validateServerUrl(daemonForm.wsUrl)) {
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
return;
}
@@ -484,7 +484,7 @@ export default function SettingsScreen() {
handleCloseDaemonForm();
} catch (error) {
console.error("[Settings] Failed to save daemon", error);
Alert.alert("Error", "Unable to save daemon");
Alert.alert("Error", "Unable to save host");
} finally {
setIsSavingDaemon(false);
}
@@ -493,7 +493,7 @@ export default function SettingsScreen() {
const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => {
Alert.alert(
"Remove Daemon",
"Remove Host",
`Remove ${profile.label}?`,
[
{ text: "Cancel", style: "cancel" },
@@ -505,7 +505,7 @@ export default function SettingsScreen() {
await removeDaemon(profile.id);
} catch (error) {
console.error("[Settings] Failed to remove daemon", error);
Alert.alert("Error", "Unable to remove daemon");
Alert.alert("Error", "Unable to remove host");
}
},
},
@@ -541,7 +541,7 @@ export default function SettingsScreen() {
async (profile: DaemonProfile) => {
const url = profile.wsUrl;
if (!validateServerUrl(url)) {
Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://");
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
return;
}
updateDaemonTestState(profile.id, { status: "testing" });
@@ -592,9 +592,9 @@ export default function SettingsScreen() {
function deriveDaemonLabel(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname || "Daemon";
return parsed.hostname || "Host";
} catch {
return "Daemon";
return "Host";
}
}
@@ -685,7 +685,7 @@ export default function SettingsScreen() {
if (isServerConfigLocked) {
Alert.alert(
"Session unavailable",
`${activeDaemon?.label ?? "This daemon"} is not connected. Connect to it before testing the URL.`
`${activeDaemon?.label ?? "This host"} is not connected. Connect to it before testing the URL.`
);
return;
}
@@ -803,13 +803,13 @@ export default function SettingsScreen() {
)}
</View>
{/* Daemon Management */}
{/* Host Management */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Daemons</Text>
<Text style={styles.sectionTitle}>Hosts</Text>
{daemons.length === 0 ? (
<View style={styles.settingCard}>
<Text style={styles.settingDescription}>No daemons configured.</Text>
<Text style={styles.settingDescription}>No hosts configured.</Text>
</View>
) : (
daemons.map((daemon) => {
@@ -841,7 +841,7 @@ export default function SettingsScreen() {
{isDaemonFormVisible ? (
<View style={styles.settingCard}>
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Daemon" : "Add Daemon"}</Text>
<Text style={styles.settingTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
@@ -886,14 +886,14 @@ export default function SettingsScreen() {
disabled={isSavingDaemon}
>
<Text style={[styles.daemonActionText, styles.daemonActionPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Daemon" : "Add Daemon"}
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Host" : "Add Host"}
</Text>
</Pressable>
</View>
</View>
) : (
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
<Text style={styles.addButtonText}>Add Daemon</Text>
<Text style={styles.addButtonText}>Add Host</Text>
</Pressable>
)}
</View>
@@ -1103,7 +1103,7 @@ function DaemonCard({
const beginServerRestart = useCallback(() => {
if (!daemonSession) {
Alert.alert(
"Daemon unavailable",
"Host unavailable",
`${daemon.label} is not connected. Select it to connect before restarting.`
);
return;
@@ -1133,7 +1133,7 @@ function DaemonCard({
const handleRestartPress = useCallback(() => {
if (!daemonSession) {
Alert.alert(
"Daemon unavailable",
"Host unavailable",
`${daemon.label} is not connected. Select it to connect before restarting.`
);
return;

View File

@@ -146,7 +146,7 @@ export function AgentList({ agentGroups }: AgentListProps) {
</Text>
<Text style={styles.sheetSubtitle}>
{isActionDaemonUnavailable
? "Connect this daemon before managing its agents."
? "Connect this host before managing its agents."
: "Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project."}
</Text>
<Pressable
@@ -160,7 +160,7 @@ export function AgentList({ agentGroups }: AgentListProps) {
(!deleteAgent || isActionDaemonUnavailable) && styles.sheetDeleteTextDisabled,
]}
>
{isActionDaemonUnavailable ? "Daemon offline" : "Delete agent"}
{isActionDaemonUnavailable ? "Host offline" : "Delete agent"}
</Text>
</Pressable>
<Pressable

View File

@@ -326,7 +326,7 @@ function AgentFlowModal({
selectedDaemonConnection?.daemon.label ??
selectedDaemonConnection?.daemon.wsUrl ??
selectedDaemonId ??
"Selected daemon";
"Selected host";
const selectedDaemonStatusLabel = formatConnectionStatus(selectedDaemonStatus);
const selectedDaemonIsUnavailable =
!session || selectedDaemonStatus !== "online" || !ws?.isConnected;
@@ -987,7 +987,7 @@ function AgentFlowModal({
logOfflineDaemonAction("dictation");
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before using dictation."
"Connect to the selected host before using dictation."
);
return;
}
@@ -1060,8 +1060,8 @@ function AgentFlowModal({
setIsImportLoading(false);
logOfflineDaemonAction("import_list");
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon to load import candidates."
daemonAvailabilityError ??
"Connect to the selected host to load import candidates."
);
return;
}
@@ -1313,7 +1313,7 @@ function AgentFlowModal({
const trimmedWorkingDir = workingDir.trim();
const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0;
const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected)
? daemonAvailabilityError ?? "Connect to the selected daemon to inspect the repository."
? daemonAvailabilityError ?? "Connect to the selected host to inspect the repository."
: null;
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
? "idle"
@@ -1392,7 +1392,7 @@ function AgentFlowModal({
logOfflineDaemonAction("create");
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before creating an agent."
"Connect to the selected host before creating an agent."
);
return;
}
@@ -1494,8 +1494,8 @@ function AgentFlowModal({
if (!resumeAgent || !isTargetDaemonReady) {
logOfflineDaemonAction("resume");
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon before importing an agent."
daemonAvailabilityError ??
"Connect to the selected host before importing an agent."
);
return;
}
@@ -1861,9 +1861,9 @@ function AgentFlowModal({
showsVerticalScrollIndicator={false}
>
<View style={styles.daemonSelectorSection}>
<Text style={styles.daemonSelectorLabel}>Target Daemon</Text>
<Text style={styles.daemonSelectorLabel}>Target Host</Text>
{daemonEntries.length === 0 ? (
<Text style={styles.daemonChipText}>No daemons available</Text>
<Text style={styles.daemonChipText}>No hosts available</Text>
) : (
<ScrollView
horizontal

View File

@@ -108,7 +108,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const removeDaemon = useCallback(async (id: string) => {
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
const normalized = normalizeDefaults(remaining);
await persist(normalized.length > 0 ? normalized : [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)]);
await persist(normalized.length > 0 ? normalized : [createProfile("Local Host", FALLBACK_DAEMON_URL, true)]);
}, [persist, readDaemons]);
const setDefaultDaemon = useCallback(async (id: string) => {
@@ -154,9 +154,9 @@ function generateDaemonId(): string {
function deriveLabelFromUrl(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname || "Unnamed Daemon";
return parsed.hostname || "Unnamed Host";
} catch {
return "Unnamed Daemon";
return "Unnamed Host";
}
}
@@ -202,13 +202,13 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
if (legacyUrl) {
const migrated = [createProfile("Primary Daemon", legacyUrl, true)];
const migrated = [createProfile("Primary Host", legacyUrl, true)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
return migrated;
}
}
const fallback = [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)];
const fallback = [createProfile("Local Host", FALLBACK_DAEMON_URL, true)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
return fallback;
} catch (error) {

View File

@@ -9,7 +9,7 @@ export class DaemonSessionUnavailableError extends Error {
serverId: string;
constructor(serverId: string) {
super(`Daemon session "${serverId}" is unavailable`);
super(`Host session "${serverId}" is unavailable`);
this.name = "DaemonSessionUnavailableError";
this.serverId = serverId;
}
@@ -66,7 +66,7 @@ export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOp
if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) {
alertedDaemonsRef.current.add(targetServerId);
Alert.alert("Daemon unavailable", message.trim());
Alert.alert("Host unavailable", message.trim());
}
if (!loggedDaemonsRef.current.has(targetServerId)) {

141
plan.md
View File

@@ -1,111 +1,50 @@
# Multi-Daemon Production Rollout Plan
# Host Management UX Overhaul
## Completed Work (Summary)
The multi-daemon infrastructure is in place: session directory with daemon-scoped subscriptions, aggregated agent views, daemon-aware routing for agent/diff/file screens, React Query for connection state persistence, background reconnection with exponential backoff, `useDaemonRequest` for consistent async flows, structured logging, and architecture docs in `docs/multi-daemon.md`.
## Guiding Principles
- The app always reflects the union of every connected daemon—no manual switching to “see” data.
- Actions (create/resume agent, browse files, realtime tools) automatically route to the correct daemon based on the agent/workspace context.
- Connection health, loading states, and mutations are observable and resilient (React Query or equivalent).
## Workstreams & Tasks
- Hosts are always connected when added—no manual "connect" action, no toggles.
- A stopped host is not an error—it's just a state. Errors only surface when the user tries to interact with an agent whose host is stopped.
- No "active" or "primary" concepts—actions that need a host require the user to choose explicitly.
- The home screen shows a flat list of agents with host name as metadata, not grouped by host.
### 1. Session Directory & Data Consistency
- [x] Rebuild `useSessionDirectory` so it stays in sync with realtime session state (agents, permissions, stream updates) instead of caching an accessor forever.
- Added session-level subscriptions so the directory re-renders on daemon updates and ran `npm run typecheck` to verify.
- [x] Expose a lightweight subscription/API for background `SessionProvider`s so any change invalidates aggregated consumers without forcing rerenders of the main tree.
- Added central session-directory listeners in the daemon connections context, had SessionProviders emit invalidations, updated `useSessionDirectory` to use the new API, and ran `npm run typecheck`.
- [x] Audit every consumer that still reaches for `useSession()` directly (AgentStreamView, AgentInputArea, file explorers, realtime, etc.) and ensure they receive the session instance that corresponds to the agent/daemon theyre operating on.
- Agent detail UI now pulls daemon-scoped sessions via `useDaemonSession`, so sending messages, toggling modes, and inline file explorers all operate against the routes `serverId` (see `packages/app/src/components/agent-input-area.tsx:72`, `packages/app/src/components/agent-stream-view.tsx:31`, `packages/app/src/components/agent-status-bar.tsx:6`, `packages/app/src/app/agent/[serverId]/[agentId].tsx:256`) and `npm run typecheck` passes.
## Tasks
### 2. Aggregated Agent Experience
- [x] Replace the home screens `agents.size` gate with `useAggregatedAgents`, render the merged list (grouped/sorted by daemon), and remove the daemon picker UI from the header.
- Home now builds grouped daemon sections via `useAggregatedAgents`, `AgentList` renders each section with the correct serverId routing, the header exposes dedicated import/create buttons without manual daemon switching, and `npm run typecheck` passes.
- When closing the agent action sheet we now clear the stored `serverId` so `useDaemonSession` falls back to the active daemon and the Home screen no longer crashes if that background daemon disconnects after a long-press.
- [x] Ensure all agent rows carry their `serverId` through navigation (Agent screen, diff viewer, file explorer, orchestrator) so every deep link includes `/agent/[serverId]/[agentId]`.
- Annotated every agent snapshot with its daemon `serverId`, updated shared row components (`AgentList`, `AgentSidebar`, `ActiveProcesses`) to read it when navigating/deleting, and re-ran `npm run typecheck`.
- [x] Update stream detail routes (git diff, file explorer) to validate the daemon session from params and gracefully show status/loading/error states if the background session is unavailable.
- Wrapped both routes in session guards that render connection-aware placeholders when the target daemon is offline/unavailable, moved the existing logic into gated child components, and re-ran `npm run typecheck` to verify.
- [x] Guard the main agent route when the requested daemon session is unavailable so deep links or quick daemon switches don't crash the screen.
- Added a connection-aware guard around `/agent/[serverId]/[agentId]` that renders a friendly placeholder when the session is offline/unavailable and re-ran `npm run typecheck`.
- [x] Fix the agent screen dropdown positioning so we don't double-apply the safe-area offset and remove the leaked debug logging.
- `packages/app/src/app/agent/[serverId]/[agentId].tsx:320-339` adds `insets.top` to `measureInWindow` coordinates (which already include the status bar) and emits `[Menu]` console logs on every open, so the action menu renders ~40px too low on notched devices and spams the JS console.
- Removed the extra `insets.top` offset, cleaned up the `[Menu]` debug logs, and re-ran `npm run typecheck`.
- [x] Make inline file path navigation pick up the correct daemon id even when two daemons generate the same `agentId`.
- Added `resolvedServerId` to the `handleInlinePathPress` dependency list in `packages/app/src/components/agent-stream-view.tsx:83-116`, so navigating after switching daemons now routes to the correct file explorer target; re-ran `npm run typecheck`.
- [x] Sync the active daemon context with the agent route so realtime/audio flows hit the same websocket as the rendered agent, even when arriving from a deep link.
- `AgentScreen` looks up the requested session via `useDaemonSession` but never calls `setActiveDaemonId`, so opening `/agent/[serverB]/[agentId]` while daemon A is active leaves the global `SessionProvider`/`RealtimeProvider` pointed at A (see `packages/app/src/app/agent/[serverId]/[agentId].tsx:74-138`).
- `AgentInputArea` forwards realtime/voice interactions through `useRealtime()` and the active `ws` (`packages/app/src/components/agent-input-area.tsx:656-744`), so with the mismatch above those commands get sent to daemon A instead of the daemon hosting the open agent.
- Added a route-aware effect in `packages/app/src/app/agent/[serverId]/[agentId].tsx` that synchronizes `setActiveDaemonId` with the screen's `serverId` param so the root Session/Realtime providers follow deep links, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Restore the legacy `/agent/[id]` route as a compatibility shim so old deep links (without a daemon id) keep working while we transition the UI.
- Added `packages/app/src/app/agent/[id].tsx`, which scans the session directory for the requested agent, auto-redirects when theres a single match, and lets the user choose when multiple daemons share that id; registered the screen in `_layout.tsx` and re-ran `npm run typecheck --workspace=@paseo/app`.
- [x] Keep background agent actions from swapping the active daemon just to open the action sheet or delete.
- `AgentList` still called `setActiveDaemonId` on long-press and before deletion (`packages/app/src/components/agent-list.tsx:19-63`), so managing a background daemons agent on Home would tear down the active websocket/realtime session. Removed those calls and rely on `useDaemonSession` so the aggregated view no longer hijacks the global session for contextual actions.
- [x] Allow guarded screens to opt out of the `useDaemonSession` alert so offline placeholders dont trigger duplicate system popups.
- Added a `suppressUnavailableAlert` option to `useDaemonSession`, had Agent, Git diff, and File Explorer guards opt in so they only render their inline placeholders, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Force the root `SessionProvider` to remount whenever the active daemon changes so session state never leaks between servers.
- Added `key={activeDaemon.id}` in `_layout.tsx`, ensuring each daemon gets a fresh session tree so cached agents/permissions dont show up under the wrong daemon while switching routes.
### 1. Rename "Daemon" to "Host" in UI
- [x] Rename all user-facing labels, messages, and UI text from "daemon" to "host" throughout the app.
- Settings screen, connection banners, error messages, modals, etc.
- Internal code can keep "daemon" terminology; this is UI-only.
- Updated every user-facing string (settings, modals, placeholders, defaults) to say "host" and spot-checked via targeted searches; no automated tests were run.
### 3. Agent Creation & Lifecycle Actions
- [x] Remove the server selector from the home header; inside the Create/Import modal replace the current “session swap” approach with an explicit `serverId` prop that simply determines which daemon receives the mutation.
- Create/Import modals now accept a `serverId` prop, stop mutating the active daemon, and every caller (home screen, footer, agent view) passes the daemon id theyre operating against; ran `npm run typecheck`.
- [x] Prevent `useDaemonSession` from throwing during modal render when a daemon is offline—surface that state inside the UI (disabled create button + inline error) and keep the chip selection responsive.
- Currently selecting a daemon chip whose session isnt connected (e.g., auto-connect disabled or still initializing) crashes `CreateAgentModal` because `useDaemonSession` rethrows immediately; we need to gate the selection and show an inline “connect first” state instead of exploding.
- Confirmed this is still happening: `CreateAgentModal` passes the selected `serverId` straight into `useDaemonSession` (`packages/app/src/components/create-agent-modal.tsx:233`), so tapping an offline daemon chip kills the modal before we can render error UI.
- Updated `CreateAgentModal` to read sessions from the directory, block websocket actions while the target daemon is offline, surface a daemon availability warning, and disable create/import flows until the daemon connects; ran `npm run typecheck`.
- [x] When creating/resuming/cloning agents, route follow-up navigation and queued requests to the daemon returned in the success payload rather than assuming the active daemon changed.
- `CreateAgentModal` now calls `setActiveDaemonId` with the server from the success payload before pushing the agent route, so the global `SessionProvider`/`RealtimeProvider` swaps to the correct daemon and realtime controls no longer stay bound to the previous server (see `packages/app/src/components/create-agent-modal.tsx:210-220` and `packages/app/src/components/create-agent-modal.tsx:845-864`); re-ran `npm run typecheck`.
- [x] Block Create/Import repo + snapshot fetches when the selected daemon is offline so we surface the availability error instead of spinning forever.
- Guarded `requestRepoInfo`/`requestImportCandidates` with the daemon availability signal so offline sessions now surface `daemonAvailabilityError` immediately instead of issuing `ws.send`, then added the missing `sheetDeleteTextDisabled` style in `packages/app/src/components/agent-list.tsx` to clear the lingering `npm run typecheck --workspace=@paseo/app` failure.
- [x] Restore the Import Agent flow (modal entry, mutation wiring, navigation) without undoing the multi-daemon routing work from previous steps.
- After reworking Home/Header and the modals, the import trigger disappeared and existing deep links no longer reach a functioning flow. Bring the Import CTA back (Home, footer, agent screen), ensure it accepts a daemon id, and verify the import mutation routes to the selected daemon without regressing the new server-aware navigation.
- Rewired the import buttons across Home (header + empty state with deep-link auto open), the global footer, and the agent action menu so every trigger passes the correct daemon id into `ImportAgentModal`, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Guard Create/Resume/Dictation actions when the active daemon is offline and no explicit daemon is selected.
- The offline gate only ran for an explicitly chosen `serverId`, so opening the modal on an offline active daemon still fired create/resume/dictation websocket sends; `packages/app/src/components/create-agent-modal.tsx` now derives availability from the effective daemon id and requires an online websocket before enabling those flows.
### 2. Remove Active/Primary/Auto-Connect Concepts
- [ ] Remove the concept of "active daemon" from the UI and simplify to just "hosts".
- [ ] Remove the concept of "primary daemon"—no default host for actions.
- [ ] Remove the "auto-connect" toggle from host settings—hosts always auto-connect when added.
- [ ] Clean up any related state/UI that exposes these concepts to users.
### 4. Connection State & Persistence
- [x] Stop the Settings daemon list from firing “Daemon unavailable” alerts when background daemons are offline by reading session snapshots via `useSessionForServer` and only performing restart/test flows when a session is actually mounted.
- Updated `packages/app/src/app/settings.tsx` to rely on `useSessionForServer` for both the active daemon and each `DaemonCard`, so offline entries no longer call `useDaemonSession` (which showed alerts) and `npm run typecheck` still passes.
- [x] Introduce React Query (or a similar observable store) around AsyncStorage-backed registries (`DaemonRegistryProvider`, `DaemonConnectionsProvider`, app settings) so callers get loading/error states without bespoke hooks.
- Added `@tanstack/react-query` with a root provider, refactored the daemon registry, connections, and app settings to load/persist via cached queries (surfacing shared loading/error states) and verified everything with `npm run typecheck --workspace=@paseo/app`.
- [x] Add background reconnection + exponential backoff per daemon; surface “connecting/offline/last error” indicators in settings and home.
- WebSocket sessions now retry with exponential backoff and feed precise status/error metadata into the daemon connection store, the home screen shows a connection health banner, settings display colored status badges plus last errors for every daemon, and `npm run typecheck --workspace=@paseo/app` passes.
- [x] Persist the last successful session snapshot per daemon so the UI can hydrate agent lists immediately while a websocket reconnects.
- SessionProviders now hydrate agents/permissions/commands from the last stored `session_state` snapshot, persist new snapshots to AsyncStorage per daemon, and `npm run typecheck --workspace=@paseo/app` passes.
- [x] Standardize request/response handling behind a shared hook (React-Query style states for idle/loading/success/error, request dedupe, retries, timeouts) and document how daemon-facing components consume it.
- Added `useDaemonRequest` with deduped execution, timeout/retry controls, and React Query-style metadata plus wrote `docs/daemon-request-hook.md` describing how daemon clients consume it; ran `npm run typecheck --workspace=@paseo/app`.
- [x] Replace ad-hoc websocket request flows (git info, permission responses, diff/file fetches, etc.) with the new hook so every async action exposes consistent status + cancellation semantics.
- Adopted `useDaemonRequest` for repo-inspection modals, permission cards, git diff, and file explorer interactions (with inline loading/error states) and re-ran `npm run typecheck --workspace=@paseo/app`.
### 3. Simplify Settings Screen
- [ ] Remove the standalone "Test Connection" form/URL input at the top of settings.
- [ ] Keep the per-host "Test" button in each host row (already exists).
### 5. Performance & UX Polish
- [x] Ensure agent image attachments preserve MIME metadata and are base64 encoded before hitting the daemon.
- `packages/app/src/contexts/session-context.tsx:1198` now accepts `{ uri, mimeType }` attachments and reads them via `expo-file-system`, and `packages/app/src/components/agent-input-area.tsx:140` forwards the stored metadata so queued sends no longer drop screenshots (`npm run typecheck` passes).
- [x] Profile the Create Agent modal—debounce expensive effects (e.g., provider model fetches) per server and prefetch metadata when daemons are idle to eliminate the visible lag when switching targets.
- Added per-server debounce + cleanup around provider model requests, scheduled idle-time prefetch for online daemons via the session directory, and verified with `npm run typecheck --workspace=@paseo/app`.
- [x] Audit websocket usage so background `SessionProvider`s never duplicate connections for the active daemon (one live connection per daemon id).
- Tracked session accessors by role, had background hosts register as `background`, filtered out daemons that already have a primary session before spinning up background connections, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Enforce “impossible states are impossible” across UI/data models (strict typing, discriminated unions, exhaustive switches) so complex flows remain clean without relying on Expo E2E tests.
- Tightened daemon connection state to a discriminated union with exhaustiveness checks, added an `assertUnreachable` helper for status switches, and re-ran `npm run typecheck --workspace=@paseo/app`.
### 4. Transparent Connection Management
- [ ] When a host is added, the app auto-connects and keeps the connection alive.
- [ ] Users should never need to manually "connect" to a host—the app handles it.
- [ ] Remove any UI that asks users to "connect" before performing actions.
### 6. Observability & Tooling
- [x] Add structured logging for daemon connection lifecycle (connect, error, auto-connect skip) so we can diagnose “multi daemon” issues from device logs.
- Added connection state transition logs with daemon ids/labels plus auto-connect skip logs for disabled daemons, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Emit analytics when users create/resume agents on background daemons, attempt actions while those daemons are offline, or switch default daemons—helps prioritize reconnection UX.
- Added a centralized analytics helper, instrumented active-daemon switches plus background create/resume flows (including offline/blocked actions like dictation and import refresh), and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Document the architecture in `docs/multi-daemon.md` (registry, sessions, routing rules) so future contributors understand how to extend it.
- Added a multi-daemon architecture guide detailing the registry, connection/session hosts, session directory access, routing rules, and extension guidelines (docs-only change; no tests needed).
- [x] Land the accumulated multi-daemon changes in source control with a clean commit (linted, type-checked, plan updated).
- Fixed lint violations around guarded daemon screens, added an opt-in nullable `useDaemonSession`, reran `npm run lint --workspace=@paseo/app` (warnings only) and `npm run typecheck --workspace=@paseo/app`, and prepared the tree for a clean commit.
### 5. Fix Error Philosophy
- [ ] A stopped/disconnected host is NOT an error—don't show error states on home screen just because a host is offline.
- [ ] Only show errors when the user tries to interact with an agent whose host is stopped.
- [ ] Update connection banners/indicators to show neutral "offline" state instead of error styling.
### Review
- [x] 2025-11-26 Reviewer sanity check for the recent multi-daemon rollout work.
- Confirmed the new `useDaemonSession` hook, guarded Agent/Git Diff/File Explorer screens, and server-aware Create/Import flows align with the documented fixes; no regressions or missing follow-ups spotted, so no additional tasks were opened.
- [x] 2025-11-26 Reviewer follow-up on session isolation across daemons.
- Found that the active `SessionProvider` kept its React state when switching daemons, so stale agents/permissions could leak between server contexts; fixed by keying the provider in `_layout.tsx` so the tree remounts on each daemon change.
- [x] 2025-11-26 Reviewer pass on provider model prefetch/perf work in CreateAgentModal.
- Idle provider-model prefetch timers could still fire after a daemon was removed; cleared stale timers before scheduling background fetches so requests don't target deleted entries (`packages/app/src/components/create-agent-modal.tsx:1198`) and re-ran `npm run typecheck --workspace=@paseo/app`.
- [x] 2025-11-26 Reviewer pass on connection-state/SessionProvider role refactor.
- Checked the new discriminated `ConnectionState`, role-aware session accessor registry, and `MultiDaemonSessionHost` filters to avoid duplicate websocket sessions; reran `npm run typecheck --workspace=@paseo/app` and didn't spot regressions or new follow-ups to open.
- [x] 2025-11-26 Reviewer pass on agent route daemon selection.
- Deep links with unregistered daemon ids repeatedly forced `setActiveDaemonId`, remounting the root `SessionProvider` and flashing the missing-daemon screen; `/agent/[serverId]/[agentId]` now ignores unknown daemons and shows a clear unavailable state instead.
- [x] 2025-11-27 Reviewer pass on connection status UX.
- Offline daemons were rendered with a muted tone via `getConnectionStatusTone`, hiding them in the connection banners; mapped offline to `warning` so settings/home surface disconnected daemons in amber (`packages/app/src/utils/daemons.ts`).
- [x] 2025-11-27 Reviewer fix for provider model fetching on the active daemon.
- `CreateAgentModal` skipped provider model requests whenever no daemon was explicitly selected (active daemon fallback), so the model list never refreshed after changing the working directory. The effect now targets the effective daemon id instead of requiring `selectedServerId`, and `npm run typecheck --workspace=@paseo/app` passes (`packages/app/src/components/create-agent-modal.tsx`).
### 6. Agent Creation Flow
- [ ] Remove reliance on "primary" or "active" daemon for agent creation.
- [ ] Require explicit host selection when creating an agent—user must choose where to deploy.
- [ ] Fix the contradictory error message "Daemon is online, connect to it before creating"—this should never appear.
### 7. Home Screen Agent List
- [ ] Remove grouping of agents by host—show a single flat list.
- [ ] Each agent row displays its host name as metadata (badge, subtitle, etc.).
- [ ] Sort agents by recent activity or alphabetically (not by host).