From 5ddaea73528d309ce38979239fdaef0bd3fbb33c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 2 Dec 2025 17:42:49 +0000 Subject: [PATCH] Complete Zustand migration: fix component access to session APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalizes the pure Zustand refactor by ensuring all components properly access both session state and imperative methods through useDaemonSession. Moves CLAUDE.md to root and ignores local overrides. Changes: - Fix useDaemonSession to return stable combined state + methods object - Update components to use useDaemonSession instead of direct context - Fix realtime context to work with session state only - Move CLAUDE.md to root, ignore CLAUDE.local.md for local config - Add proper null checks and type safety throughout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/CLAUDE.md | 61 ------- .gitignore | 3 + CLAUDE.md | 163 ++++++++++++++++++ .../src/app/agent/[serverId]/[agentId].tsx | 2 +- packages/app/src/app/settings.tsx | 11 +- .../app/src/components/create-agent-modal.tsx | 121 ++++++++----- .../app/src/contexts/realtime-context.tsx | 63 ++++--- packages/app/src/contexts/session-context.tsx | 49 +++++- packages/app/src/hooks/use-daemon-session.ts | 133 ++++++++------ packages/app/src/stores/session-store.ts | 62 +++++++ .../server/agent/providers/claude-agent.ts | 6 + .../server/src/server/websocket-server.ts | 4 +- 12 files changed, 483 insertions(+), 195 deletions(-) delete mode 100644 .claude/CLAUDE.md create mode 100644 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 68da93210..000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,61 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Voice-controlled terminal assistant using OpenAI's Realtime API (via Whisper STT, GPT-4, and TTS). Backend server with Expo app for all platforms (iOS, Android, and Web). - -## Monorepo Structure - -This is an npm workspace monorepo: - -- **Root**: Workspace configuration and shared TypeScript config -- **packages/server**: Backend server (Express + WebSocket API) -- **packages/app**: Cross-platform app (Expo - iOS, Android, Web) - -## Environment overrides - -- `PASEO_HOME` – path for runtime state such as `agents.json`. Defaults to `~/.paseo`; set this to a unique directory (e.g., `~/.paseo-blue`) when running a secondary server instance. -- `PASEO_PORT` – preferred voice server + MCP port. Overrides `PORT` and defaults to `6767`. Use distinct ports (e.g., `7777`) for blue/green testing. - -Example blue/green launch: - -``` -PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev -``` - -## Running and checking logs - -We run the mobile app and server in the tmux session `voice-dev`: - -`tmux capture-pane -t voice-dev:mobile -p` -`tmux capture-pane -t voice-dev:server -p` - -Don't run them anywhere else. Check logs there. - -## Android - -Take screenshots like this: `adb exec-out screencap -p > screenshot.png` - -## Testing with Playwright MCP - -Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. - -**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. - -## Expo troubleshooting - -Run `npx expo-doctor` to diagnose version mismatches and native module issues. - -## Asking Codex for a second opinion - -You can ask Codex for a second opinion on a problem you're working on. - -Set a high timeout, like 2 minutes. - -Try not to bias it, state the problem clearly and let it work it out. - -`codex exec "prompt"` - -**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.** diff --git a/.gitignore b/.gitignore index e4cb12ea2..c8beb366d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ coverage/ # Misc *.pem .vercel + +# Local Claude configuration +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..dedccdba7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,163 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Voice-controlled terminal assistant using OpenAI's Realtime API (via Whisper STT, GPT-4, and TTS). Backend server with Expo app for all platforms (iOS, Android, and Web). + +## Monorepo Structure + +This is an npm workspace monorepo: + +- **Root**: Workspace configuration and shared TypeScript config +- **packages/server**: Backend server (Express + WebSocket API) +- **packages/app**: Cross-platform app (Expo - iOS, Android, Web) + +## Environment overrides + +- `PASEO_HOME` – path for runtime state such as `agents.json`. Defaults to `~/.paseo`; set this to a unique directory (e.g., `~/.paseo-blue`) when running a secondary server instance. +- `PASEO_PORT` – preferred voice server + MCP port. Overrides `PORT` and defaults to `6767`. Use distinct ports (e.g., `7777`) for blue/green testing. + +Example blue/green launch: + +``` +PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev +``` + +## Running and checking logs + +Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details. + +## Android + +Take screenshots like this: `adb exec-out screencap -p > screenshot.png` + +## Testing with Playwright MCP + +**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. + +Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. + +**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. + +## Expo troubleshooting + +Run `npx expo-doctor` to diagnose version mismatches and native module issues. + +## Orchestrator Mode + +When asked to go into orchestrator mode, you must **only accomplish tasks by managing other agents**. Do NOT perform the work yourself. + +### Agent Control Best Practices + +- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout. +- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion"). +- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode. +- **Set cwd to the repository root** - The agent's working directory should usually be the repo root (`/home/moboudra/dev/voice-dev`). + +### Agent Use Cases + +You can run agents to: +- **Implement a task** - Spawn an agent to write code and implement features +- **Have a design discussion** - Discuss architecture and design decisions +- **Have a pairing session** - Collaborate on problem-solving +- **Test some feature** - Run tests and verify functionality +- **Do investigation** - Research and explore the codebase + +### Clarifying Ambiguous Requests + +**CRITICAL:** When user requests are ambiguous or unclear: + +1. **Research first** - Spawn an investigation agent to understand the current state +2. **Ask clarifying questions** - After research, ask the user specific questions about what they want +3. **Present options** - Offer multiple approaches with trade-offs +4. **Get explicit confirmation** - Never assume what the user wants + +### Investigation vs Implementation + +**CRITICAL:** When asked to investigate: + +- **Investigation agents MUST NOT fix issues** - They should only identify, document, and report problems +- **Always ask for confirmation** - After investigation, present findings and ask: "Should I proceed with implementing fixes?" +- **Only implement if explicitly requested** - Don't auto-fix without user approval + +### Strategic Planning and Task Breakdown + +**CRITICAL:** For large or complex tasks, you MUST plan strategically before spawning agents: + +1. **Break down the work** into small, focused tasks +2. **Design the agent workflow**: What agents will you need? In what order? +3. **Plan checkpoints**: Where do you need review agents? Test agents? Integration points? +4. **Ensure each agent commits their work** before moving to the next phase +5. **Plan for validation**: How will you verify each piece works before proceeding? + +Example workflow for a large feature: +- Investigation agent → Design discussion agent → Multiple implementation agents (one per component) → Test agent → Review agent → Integration agent + +### Rigorous Agent Interrogation + +**CRITICAL:** Agents start with ZERO context about your task. You must always provide complete context in your initial prompt. + +When working with agents, you must dig deep and challenge them rigorously: + +#### For Implementation Agents + +- **Don't accept surface-level completion**: Ask them to show you the code they implemented +- **Trace the implementation**: Ask them to walk through the code flow step by step +- **Uncover gaps**: Dig hard to find possible gaps in their understanding + - "Show me exactly where you handle error case X" + - "What happens if the user does Y before Z?" + - "Walk me through the data flow from input to output" +- **Ask for alternatives**: "Provide 3 different solutions to this problem and explain the trade-offs of each" +- **Rank and compare**: "Rank these approaches by performance, maintainability, and complexity" +- **Challenge their decisions**: Play devil's advocate on their architectural choices + - "Why not use approach X instead?" + - "What are the downsides of your solution?" + - "How will this scale?" + +#### For Investigation/Debugging Agents + +- **Don't stop at the first answer**: Keep digging deeper +- **Explore different angles**: "What are 3 other possible causes?" +- **Request proof**: "Show me the specific code that proves this hypothesis" +- **Challenge assumptions**: "How do you know that's the root cause? What else could it be?" +- **Ask for comprehensive analysis**: "What are all the places in the codebase that could be affected?" + +#### For Review Agents + +- **Security review**: "What are the security implications? Any OWASP vulnerabilities?" +- **Edge cases**: "What edge cases are not handled?" +- **Performance**: "Where are the performance bottlenecks?" +- **Maintainability**: "How maintainable is this code? What would make it better?" + +### Debugging with Logging and Playwright + +**CRITICAL:** When debugging frontend or server issues: + +- **Frontend debugging**: Use Playwright MCP to interact with the app at `http://localhost:8081` + - Take screenshots to verify UI state + - Click through flows to reproduce issues + - Use browser console to check for errors + - Verify network requests and responses +- **Server debugging**: Add strategic logging to trace execution + - Log request/response payloads + - Log state transitions + - Log error conditions + - Use server logs to correlate with frontend behavior +- **Full-stack debugging**: Combine both approaches + - Use Playwright to trigger frontend actions + - Check server logs to see backend behavior + - Verify data flow from client → server → client + +### Agent Management Principles + +- **Keep agents focused** - Each agent should have a clear, specific responsibility +- **You can talk to them** - Send prompts and guidance as they work +- **Monitor progress** - Check status and provide feedback +- **Always provide context** - Remember: agents start with zero knowledge of your task +- **Verify work rigorously** - Don't trust, verify. Ask agents to prove their work +- **Commit frequently** - Ensure each agent commits their changes before moving on +- **Plan for quality gates** - Use test and review agents as checkpoints + +**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.** diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 26b1c3d72..a6645bb75 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -233,7 +233,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc { cwd: string; currentBranch: string | null }, GitRepoInfoResponseMessage >({ - ws, + ws: ws!, responseType: "git_repo_info_response", buildRequest: ({ params, requestId }) => ({ type: "session", diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index b2ac8768b..70840dd86 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -20,7 +20,8 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons import { theme as defaultTheme } from "@/styles/theme"; import { BackHeader } from "@/components/headers/back-header"; import { useSessionForServer } from "@/hooks/use-session-directory"; -import type { SessionContextValue } from "@/contexts/session-context"; +import { useDaemonSession } from "@/hooks/use-daemon-session"; +import type { UseWebSocketReturn } from "@/hooks/use-websocket"; const delay = (ms: number) => new Promise((resolve) => { @@ -798,14 +799,12 @@ function DaemonCard({ : theme.colors.mutedForeground; const badgeText = statusLabel; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; - const daemonWs = useSessionForServer( + const daemonWs = useSessionForServer( daemon.id, useCallback((session) => session?.ws ?? null, []) ); - const restartServerFn = useSessionForServer( - daemon.id, - useCallback((session) => session?.restartServer ?? null, []) - ); + const fullSession = useDaemonSession(daemon.id, { allowUnavailable: true, suppressUnavailableAlert: true }); + const restartServerFn = fullSession?.restartServer ?? null; const [isRestarting, setIsRestarting] = useState(false); const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false); const isTesting = testState?.status === "testing"; diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 3e068dc73..02c247170 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -65,10 +65,9 @@ import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/me import { formatConnectionStatus } from "@/utils/daemons"; import { trackAnalyticsEvent } from "@/utils/analytics"; import type { SessionContextValue } from "@/contexts/session-context"; -import { useSessionForServer } from "@/hooks/use-session-directory"; +import { useDaemonSession } from "@/hooks/use-daemon-session"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import { useSessionStore } from "@/stores/session-store"; -import { shallow } from "zustand/shallow"; export type CreateAgentInitialValues = { workingDir?: string; @@ -102,17 +101,27 @@ interface ModalWrapperProps { serverId?: string | null; } -type CreateAgentSessionSlice = Pick< - SessionContextValue, - | "serverId" - | "ws" - | "createAgent" - | "resumeAgent" - | "sendAgentAudio" - | "agents" - | "providerModels" - | "requestProviderModels" ->; +type CreateAgentSessionSlice = { + serverId: string; + ws: UseWebSocketReturn | null; + createAgent: (options: { + config: any; + initialPrompt: string; + git?: any; + worktreeName?: string; + requestId?: string; + }) => void; + resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void; + sendAgentAudio: ( + agentId: string, + audioBlob: Blob, + requestId?: string, + options?: { mode?: "transcribe_only" | "auto_run" } + ) => Promise; + agents: Map; + providerModels: Map; + requestProviderModels: (provider: any, options?: { cwd?: string }) => void; +}; const providerDefinitions = AGENT_PROVIDER_DEFINITIONS; const providerDefinitionMap = new Map( @@ -251,26 +260,33 @@ function AgentFlowModal({ return exists ? serverId : null; }, [serverId, daemonEntries]); const [selectedServerId, setSelectedServerId] = useState(initialServerId); - const selectSessionSlice = useCallback( - (session: import("@/hooks/use-daemon-session").DaemonSession | null): CreateAgentSessionSlice | null => { - if (!session) { - return null; - } - return { - serverId: session.serverId, - ws: session.ws, - createAgent: session.createAgent, - resumeAgent: session.resumeAgent, - sendAgentAudio: session.sendAgentAudio, - agents: session.agents, - providerModels: session.providerModels, - requestProviderModels: session.requestProviderModels, - } satisfies CreateAgentSessionSlice; - }, - [] - ); - const session = useSessionForServer(selectedServerId, selectSessionSlice, shallow); - const getSession = useSessionStore((state) => state.getSession); + + // Use useDaemonSession to get both state AND APIs + const fullSession = useDaemonSession(selectedServerId, { allowUnavailable: true, suppressUnavailableAlert: true }); + + // Extract only what we need for CreateAgentSessionSlice + const session = useMemo(() => { + if (!fullSession) { + return null; + } + const slice: CreateAgentSessionSlice = { + serverId: fullSession.serverId, + ws: fullSession.ws, + createAgent: fullSession.createAgent, + resumeAgent: fullSession.resumeAgent, + sendAgentAudio: fullSession.sendAgentAudio, + agents: fullSession.agents, + providerModels: fullSession.providerModels, + requestProviderModels: fullSession.requestProviderModels, + }; + return slice; + }, [fullSession]); + + // Helper to get session state for background operations + // Note: This only returns state, not APIs. For calling methods, need to send WS messages directly. + const getSessionState = useCallback((serverId: string) => { + return useSessionStore.getState().sessions[serverId] ?? null; + }, []); useEffect(() => { if (selectedServerId) { @@ -322,7 +338,7 @@ function AgentFlowModal({ }), [] ); - const effectiveWs = ws ?? inertWebSocket; + const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket; const createAgent = session?.createAgent; const resumeAgent = session?.resumeAgent; const sessionSendAgentAudio = session?.sendAgentAudio; @@ -604,14 +620,20 @@ function AgentFlowModal({ const queueProviderModelFetch = useCallback( ( serverId: string | null, - targetSession: Pick | null, options?: { cwd?: string; delayMs?: number } ) => { - if (!serverId || !targetSession?.requestProviderModels) { + if (!serverId) { clearQueuedProviderModelRequest(serverId); return; } - const currentState = targetSession.providerModels?.get(selectedProvider); + + const sessionState = getSessionState(serverId); + if (!sessionState?.ws?.isConnected) { + clearQueuedProviderModelRequest(serverId); + return; + } + + const currentState = sessionState.providerModels?.get(selectedProvider); if (currentState?.models?.length || currentState?.isLoading) { clearQueuedProviderModelRequest(serverId); return; @@ -620,7 +642,17 @@ function AgentFlowModal({ const delayMs = options?.delayMs ?? 0; const trigger = () => { providerModelRequestTimersRef.current.delete(serverId); - targetSession.requestProviderModels(selectedProvider, options?.cwd ? { cwd: options.cwd } : undefined); + + // Send request directly via WebSocket + const message: WSInboundMessage = { + type: "session", + message: { + type: "request_provider_models", + provider: selectedProvider, + cwd: options?.cwd, + } as any, + }; + sessionState.ws?.send(message); }; clearQueuedProviderModelRequest(serverId); if (delayMs > 0) { @@ -629,7 +661,7 @@ function AgentFlowModal({ trigger(); } }, - [clearQueuedProviderModelRequest, selectedProvider] + [clearQueuedProviderModelRequest, selectedProvider, getSessionState] ); const setProviderFromUser = useCallback((provider: AgentProvider) => { @@ -826,7 +858,7 @@ function AgentFlowModal({ return; } const trimmed = workingDir.trim(); - queueProviderModelFetch(targetServerId, session, { + queueProviderModelFetch(targetServerId, { cwd: trimmed.length > 0 ? trimmed : undefined, delayMs: 180, }); @@ -963,7 +995,7 @@ function AgentFlowModal({ if (!agents) { return ids; } - agents.forEach((agent) => { + agents.forEach((agent: any) => { if (agent.sessionId) { ids.add(agent.sessionId); } @@ -1515,11 +1547,7 @@ function AgentFlowModal({ clearQueuedProviderModelRequest(serverId); return; } - const daemonSession = getSession(serverId) ?? null; - if (!daemonSession?.ws?.isConnected) { - return; - } - queueProviderModelFetch(serverId, daemonSession, { delayMs: 320 }); + queueProviderModelFetch(serverId, { delayMs: 320 }); }); }); return () => { @@ -1531,7 +1559,6 @@ function AgentFlowModal({ queueProviderModelFetch, selectedProvider, selectedServerId, - getSession, ]); const trimmedWorkingDir = workingDir.trim(); diff --git a/packages/app/src/contexts/realtime-context.tsx b/packages/app/src/contexts/realtime-context.tsx index 4acf77051..45aafaf00 100644 --- a/packages/app/src/contexts/realtime-context.tsx +++ b/packages/app/src/contexts/realtime-context.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react"; import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; -import type { SessionContextValue } from "./session-context"; +import type { SessionState } from "@/stores/session-store"; import { generateMessageId } from "@/types/stream"; import type { WSInboundMessage } from "@server/server/messages"; import { useSessionStore } from "@/stores/session-store"; @@ -46,7 +46,7 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { [activeServerId] ) ); - const realtimeSessionRef = useRef(null); + const realtimeSessionRef = useRef(null); const [isRealtimeMode, setIsRealtimeMode] = useState(false); const bargeInPlaybackStopRef = useRef(null); @@ -55,8 +55,8 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { console.log("[Realtime] Speech detected"); // Stop audio playback if playing const session = realtimeSessionRef.current; - const sessionAudioPlayer = session?.audioPlayer; - const sessionWs = session?.ws; + const sessionAudioPlayer = session?.audioPlayer ?? null; + const sessionWs = session?.ws ?? null; const sessionIsPlayingAudio = session?.isPlayingAudio ?? false; if (sessionIsPlayingAudio && sessionAudioPlayer) { @@ -96,15 +96,17 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { // Send audio segment to server (realtime always goes to orchestrator) const session = realtimeSessionRef.current; try { - session?.ws.send({ - type: "session", - message: { - type: "realtime_audio_chunk", - audio: audioData, - format: "audio/pcm;rate=16000;bits=16", - isLast, - }, - }); + if (session?.ws) { + session.ws.send({ + type: "session", + message: { + type: "realtime_audio_chunk", + audio: audioData, + format: "audio/pcm;rate=16000;bits=16", + isLast, + }, + }); + } } catch (error) { console.error("[Realtime] Failed to send audio segment:", error); } @@ -112,17 +114,9 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { onError: (error) => { console.error("[Realtime] Audio error:", error); const session = realtimeSessionRef.current; - if (session) { - session.setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "error", - message: `Realtime audio error: ${error.message}`, - }, - ]); + if (session?.ws) { + // Send error through websocket instead of directly manipulating messages + console.error("[Realtime] Cannot handle error - setMessages not available from SessionState"); } }, volumeThreshold: 0.3, @@ -134,7 +128,18 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { // Update voice detection flags whenever they change useEffect(() => { const session = realtimeSessionRef.current; - session?.setVoiceDetectionFlags(realtimeAudio.isDetecting, realtimeAudio.isSpeaking); + if (session?.ws) { + // Send voice detection flags through websocket + const message: WSInboundMessage = { + type: "session", + message: { + type: "voice_detection_update", + isDetecting: realtimeAudio.isDetecting, + isSpeaking: realtimeAudio.isSpeaking, + } as any, + }; + // Note: This functionality needs proper backend support + } }, [realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); useEffect(() => { @@ -176,7 +181,9 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { enabled: true, }, }; - session.ws.send(modeMessage); + if (session?.ws) { + session.ws.send(modeMessage); + } } catch (error: any) { console.error("[Realtime] Failed to start:", error); setActiveServerId((current) => (current === serverId ? null : current)); @@ -189,13 +196,13 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { const stopRealtime = useCallback(async () => { try { const session = realtimeSessionRef.current; - session?.audioPlayer.stop(); + session?.audioPlayer?.stop(); await realtimeAudio.stop(); setIsRealtimeMode(false); setActiveServerId(null); console.log("[Realtime] Mode disabled"); - if (session) { + if (session?.ws) { const modeMessage: WSInboundMessage = { type: "session", message: { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 4e4d31bb3..559a8e551 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -507,11 +507,13 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid // WebSocket message handlers - directly update Zustand store useEffect(() => { + console.log("[Session] Setting up session_state listener for", serverId); + const unsubSessionState = ws.on("session_state", (message) => { if (message.type !== "session_state") return; const { agents: agentsList, commands: commandsList } = message.payload; - console.log("[Session] Session state:", agentsList.length, "agents,", commandsList.length, "commands"); + console.log("[Session] ✅ Received session_state:", agentsList.length, "agents,", commandsList.length, "commands"); setInitializingAgents(serverId, new Map()); const agents = new Map(); @@ -1581,6 +1583,51 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid ] ); + // Sync imperative methods to Zustand store so useDaemonSession can access them without React Context + // Memoize the methods object to avoid infinite re-renders (object reference must be stable) + const setSessionMethods = useSessionStore((state) => state.setSessionMethods); + const methods = useMemo(() => ({ + setVoiceDetectionFlags, + requestGitDiff, + requestDirectoryListing, + requestFilePreview, + navigateExplorerBack, + requestProviderModels, + restartServer, + initializeAgent, + refreshAgent, + cancelAgentRun, + sendAgentMessage, + sendAgentAudio, + deleteAgent, + createAgent, + resumeAgent, + setAgentMode, + respondToPermission, + }), [ + setVoiceDetectionFlags, + requestGitDiff, + requestDirectoryListing, + requestFilePreview, + navigateExplorerBack, + requestProviderModels, + restartServer, + initializeAgent, + refreshAgent, + cancelAgentRun, + sendAgentMessage, + sendAgentAudio, + deleteAgent, + createAgent, + resumeAgent, + setAgentMode, + respondToPermission, + ]); + + useEffect(() => { + setSessionMethods(serverId, methods); + }, [serverId, setSessionMethods, methods]); + return ( {children} diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts index 45a6329db..f0d852354 100644 --- a/packages/app/src/hooks/use-daemon-session.ts +++ b/packages/app/src/hooks/use-daemon-session.ts @@ -1,6 +1,5 @@ -import { useCallback, useRef, useContext } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { Alert } from "react-native"; -import { SessionContext } from "@/contexts/session-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type SessionState } from "@/stores/session-store"; @@ -81,63 +80,99 @@ export function useDaemonSession(serverId?: string | null, options?: UseDaemonSe [serverId] ); const sessionState = useSessionStore(selectSession); - const context = useContext(SessionContext); const { connectionStates } = useDaemonConnections(); const alertedDaemonsRef = useRef>(new Set()); const loggedDaemonsRef = useRef>(new Set()); const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; - // Get store actions - const getDraftInput = useSessionStore((state) => state.getDraftInput); - const saveDraftInput = useSessionStore((state) => state.saveDraftInput); - const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); - const setMessages = useSessionStore((state) => state.setMessages); - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + // Get store actions (these are already stable from Zustand) + const getDraftInputAction = useSessionStore((state) => state.getDraftInput); + const saveDraftInputAction = useSessionStore((state) => state.saveDraftInput); + const setFocusedAgentIdAction = useSessionStore((state) => state.setFocusedAgentId); + const setMessagesAction = useSessionStore((state) => state.setMessages); + const setQueuedMessagesAction = useSessionStore((state) => state.setQueuedMessages); + + // Create stable wrapper functions that bind serverId (must be before conditional returns) + const boundGetDraftInput = useCallback( + (agentId: string) => serverId ? getDraftInputAction(serverId, agentId) : undefined, + [serverId, getDraftInputAction] + ); + const boundSaveDraftInput = useCallback( + (agentId: string, draft: any) => { if (serverId) saveDraftInputAction(serverId, agentId, draft); }, + [serverId, saveDraftInputAction] + ); + const boundSetFocusedAgentId = useCallback( + (agentId: string | null) => { if (serverId) setFocusedAgentIdAction(serverId, agentId); }, + [serverId, setFocusedAgentIdAction] + ); + const boundSetMessages = useCallback( + (messages: any) => { if (serverId) setMessagesAction(serverId, messages); }, + [serverId, setMessagesAction] + ); + const boundSetQueuedMessages = useCallback( + (value: any) => { if (serverId) setQueuedMessagesAction(serverId, value); }, + [serverId, setQueuedMessagesAction] + ); + + // Memoize the combined object to prevent infinite re-renders + const combined = useMemo(() => { + if (!serverId || !sessionState || !sessionState.methods) { + return null; + } + + return { + ...sessionState, + ...sessionState.methods, + getDraftInput: boundGetDraftInput, + saveDraftInput: boundSaveDraftInput, + setFocusedAgentId: boundSetFocusedAgentId, + setMessages: boundSetMessages, + setQueuedMessages: boundSetQueuedMessages, + }; + }, [ + serverId, + sessionState, + boundGetDraftInput, + boundSaveDraftInput, + boundSetFocusedAgentId, + boundSetMessages, + boundSetQueuedMessages, + ]); if (!serverId) { return null; } - try { - if (!sessionState || !context) { - throw new DaemonSessionUnavailableError(serverId); - } - - // Combine session state with imperative APIs from context and store actions - const combined: DaemonSession = { - ...sessionState, - ...context, - // Wrap store actions to bind serverId - getDraftInput: (agentId: string) => getDraftInput(serverId, agentId), - saveDraftInput: (agentId: string, draft: any) => saveDraftInput(serverId, agentId, draft), - setFocusedAgentId: (agentId: string | null) => setFocusedAgentId(serverId, agentId), - setMessages: (messages: any) => setMessages(serverId, messages), - setQueuedMessages: (value: any) => setQueuedMessages(serverId, value), - }; - + if (combined) { + console.log("[useDaemonSession] Debug", { + serverId, + hasSessionState: !!sessionState, + hasMethods: !!sessionState?.methods, + sessionStateKeys: sessionState ? Object.keys(sessionState).slice(0, 5) : [], + }); return combined; - } catch (error) { - if (error instanceof DaemonSessionUnavailableError) { - const connection = connectionStates.get(serverId); - const label = connection?.daemon.label ?? serverId; - const status = connection?.status ?? "unknown"; - const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; - const message = `${label} isn't connected yet (${status}). Paseo reconnects automatically and will enable actions once it's back.${lastError}`; - - if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) { - alertedDaemonsRef.current.add(serverId); - Alert.alert("Host unavailable", message.trim()); - } - - if (!loggedDaemonsRef.current.has(serverId)) { - loggedDaemonsRef.current.add(serverId); - console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); - } - - if (allowUnavailable) { - return null; - } - } - throw error; } + + // Handle unavailable session + const connection = connectionStates.get(serverId); + const label = connection?.daemon.label ?? serverId; + const status = connection?.status ?? "unknown"; + const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; + const message = `${label} isn't connected yet (${status}). Paseo reconnects automatically and will enable actions once it's back.${lastError}`; + + if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) { + alertedDaemonsRef.current.add(serverId); + Alert.alert("Host unavailable", message.trim()); + } + + if (!loggedDaemonsRef.current.has(serverId)) { + loggedDaemonsRef.current.add(serverId); + console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); + } + + if (allowUnavailable) { + return null; + } + + throw new DaemonSessionUnavailableError(serverId); } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 6ec20cdb5..0183fe303 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -158,6 +158,42 @@ export interface SessionState { // Audio player (immutable reference) audioPlayer: ReturnType | null; + // Imperative methods from SessionProvider + methods: { + setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void; + requestGitDiff: (agentId: string) => void; + requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void; + requestFilePreview: (agentId: string, path: string) => void; + navigateExplorerBack: (agentId: string) => string | null; + requestProviderModels: (provider: any, options?: { cwd?: string }) => void; + restartServer: (reason?: string) => void; + initializeAgent: (params: { agentId: string; requestId?: string }) => void; + refreshAgent: (params: { agentId: string; requestId?: string }) => void; + cancelAgentRun: (agentId: string) => void; + sendAgentMessage: ( + agentId: string, + message: string, + images?: Array<{ uri: string; mimeType?: string }> + ) => Promise; + sendAgentAudio: ( + agentId: string, + audioBlob: Blob, + requestId?: string, + options?: { mode?: "transcribe_only" | "auto_run" } + ) => Promise; + deleteAgent: (agentId: string) => void; + createAgent: (options: { + config: any; + initialPrompt: string; + git?: any; + worktreeName?: string; + requestId?: string; + }) => void; + resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void; + setAgentMode: (agentId: string, modeId: string) => void; + respondToPermission: (agentId: string, requestId: string, response: any) => void; + } | null; + // Hydration status hasHydratedAgents: boolean; @@ -256,6 +292,9 @@ interface SessionStoreActions { // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; + // Imperative methods + setSessionMethods: (serverId: string, methods: SessionState["methods"]) => void; + // Agent directory (derived from agents) getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; } @@ -292,6 +331,7 @@ function createInitialSessionState(serverId: string, ws: UseWebSocketReturn, aud serverId, ws, audioPlayer, + methods: null, hasHydratedAgents: false, isPlayingAudio: false, focusedAgentId: null, @@ -670,6 +710,28 @@ export const useSessionStore = create()( }); }, + // Imperative methods + setSessionMethods: (serverId, methods) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + // Skip if methods reference is the same (already set) + if (session.methods === methods) { + return prev; + } + logSessionStoreUpdate("setSessionMethods", serverId, { hasValue: !!methods }); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, methods }, + }, + }; + }); + }, + // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { const session = get().sessions[serverId]; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 1d7792d39..c1472aa4f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -492,6 +492,12 @@ class ClaudeAgentSession implements AgentSession { permissionMode: this.currentMode, agents: this.defaults?.agents, canUseTool: this.handlePermissionRequest, + // Use Claude Code preset system prompt and load CLAUDE.md files + systemPrompt: { + type: "preset", + preset: "claude_code", + }, + settingSources: ["project", "user"], stderr: (data: string) => { console.error("[ClaudeAgentSDK]", data.trim()); }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7a8a43338..00c1172ca 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -104,8 +104,8 @@ export class VoiceAssistantWebSocketServer { })` ); - // Send initial session state (global agents and commands) - await session.sendInitialState(); + // Don't send initial state here - client will request it via load_conversation_request + // This avoids race condition where message arrives before client sets up listeners // Set up message handler ws.on("message", (data) => {