Complete Zustand migration: fix component access to session APIs

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 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-02 17:42:49 +00:00
parent 473e11d25f
commit 5ddaea7352
12 changed files with 483 additions and 195 deletions

View File

@@ -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.**

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ coverage/
# Misc
*.pem
.vercel
# Local Claude configuration
CLAUDE.local.md

163
CLAUDE.md Normal file
View File

@@ -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.**

View File

@@ -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",

View File

@@ -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<void>((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<SessionContextValue["ws"] | null>(
const daemonWs = useSessionForServer<UseWebSocketReturn | null>(
daemon.id,
useCallback((session) => session?.ws ?? null, [])
);
const restartServerFn = useSessionForServer<SessionContextValue["restartServer"] | null>(
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";

View File

@@ -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<void>;
agents: Map<string, any>;
providerModels: Map<any, any>;
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
};
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
@@ -251,26 +260,33 @@ function AgentFlowModal({
return exists ? serverId : null;
}, [serverId, daemonEntries]);
const [selectedServerId, setSelectedServerId] = useState<string | null>(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<CreateAgentSessionSlice | null>(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<CreateAgentSessionSlice | null>(() => {
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<SessionContextValue, "providerModels" | "requestProviderModels"> | 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();

View File

@@ -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<SessionContextValue | null>(null);
const realtimeSessionRef = useRef<SessionState | null>(null);
const [isRealtimeMode, setIsRealtimeMode] = useState(false);
const bargeInPlaybackStopRef = useRef<number | null>(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: {

View File

@@ -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 (
<SessionContext.Provider value={value}>
{children}

View File

@@ -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<Set<string>>(new Set());
const loggedDaemonsRef = useRef<Set<string>>(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<DaemonSession | null>(() => {
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);
}

View File

@@ -158,6 +158,42 @@ export interface SessionState {
// Audio player (immutable reference)
audioPlayer: ReturnType<typeof useAudioPlayer> | 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<void>;
sendAgentAudio: (
agentId: string,
audioBlob: Blob,
requestId?: string,
options?: { mode?: "transcribe_only" | "auto_run" }
) => Promise<void>;
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<SessionStore>()(
});
},
// 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];

View File

@@ -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());
},

View File

@@ -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) => {