import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { get } from "svelte/store"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; import { configStore } from "$lib/stores/config"; import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import { initAchievementsListener } from "$lib/stores/achievements"; import type { ConnectionStatus, PermissionPromptEvent, UserQuestionEvent, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents"; import { agentStore } from "$lib/stores/agents"; import { todos } from "$lib/stores/todos"; import { initializeNotificationRules, cleanupNotificationRules, handleConnectionStatusChange, handleNewUserMessage, } from "$lib/notifications/rules"; import { notificationManager } from "$lib/notifications/notificationManager"; interface StateChangePayload { state: CharacterState; tool_name: string | null; conversation_id?: string; } const connectedConversations = new Set(); const greetingPendingConversations = new Set(); let unlisteners: Array<() => void> = []; let skipNextGreeting = false; export function setSkipNextGreeting(skip: boolean) { skipNextGreeting = skip; } function getTimeOfDay(): string { const hour = new Date().getHours(); if (hour >= 5 && hour < 12) { return "morning"; } else if (hour >= 12 && hour < 17) { return "afternoon"; } else if (hour >= 17 && hour < 21) { return "evening"; } else { return "late night"; } } function generateGreetingPrompt(): string { const timeOfDay = getTimeOfDay(); return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; } async function sendGreeting(conversationId: string): Promise { // Check if we should skip this greeting if (skipNextGreeting) { skipNextGreeting = false; // Reset the flag return false; } const config = configStore.getConfig(); if (!config.greeting_enabled) { return false; } const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt(); // Don't show the system prompt in the UI - just trigger Claude to respond characterState.setState("thinking"); // Reset notification state for greeting handleNewUserMessage(); try { await invoke("send_prompt", { conversationId, message: greetingPrompt, }); return true; } catch (error) { console.error("Failed to send greeting:", error); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`); characterState.setTemporaryState("error", 3000); return false; } } interface OutputPayload { line_type: string; content: string; tool_name: string | null; conversation_id?: string; cost?: { input_tokens: number; output_tokens: number; cost_usd: number; }; parent_tool_use_id?: string; } interface ConnectionPayload { status: ConnectionStatus; conversation_id?: string; } interface SessionPayload { session_id: string; conversation_id?: string; } interface WorkingDirectoryPayload { directory: string; conversation_id?: string; } export async function cleanupConversationTracking(conversationId: string) { connectedConversations.delete(conversationId); greetingPendingConversations.delete(conversationId); // Clean up any temp files associated with this conversation try { await invoke("cleanup_temp_files", { conversationId }); } catch (error) { console.error("Failed to cleanup temp files for conversation:", error); } } export async function initializeTauriListeners() { // Cleanup any existing listeners first cleanupTauriListeners(); // Initialize notification rules initializeNotificationRules(); // Initialize stats listener await initStatsListener(); // Initialize achievements listener await initAchievementsListener(); const connectionUnlisten = await listen("claude:connection", async (event) => { const { status, conversation_id } = event.payload; // Update connection status for the specific conversation if (conversation_id) { claudeStore.setConnectionStatusForConversation(conversation_id, status); } else { // Fallback to active conversation if no conversation_id claudeStore.setConnectionStatus(status); } // Handle notification for connection status handleConnectionStatusChange(status); if (status === "connected") { // Get the actual conversation ID (fallback to active if not provided) const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { // Add system message to the correct conversation claudeStore.addLineToConversation( targetConversationId, "system", "Connected to Claude Code" ); // Update character state for this conversation claudeStore.setCharacterStateForConversation(targetConversationId, "idle"); // Check if this specific conversation has connected before if (!connectedConversations.has(targetConversationId)) { connectedConversations.add(targetConversationId); resetSessionStats(); // Reset session stats on new connection // Immediately hold the tab at yellow while we wait for the greeting response. // This avoids a brief green flash before the greeting is even sent. greetingPendingConversations.add(targetConversationId); claudeStore.setConnectionStatusForConversation( targetConversationId, "connecting" as ConnectionStatus ); const greetingSent = await sendGreeting(targetConversationId); if (!greetingSent) { // Greeting was disabled or failed — flip straight to connected. greetingPendingConversations.delete(targetConversationId); claudeStore.setConnectionStatusForConversation( targetConversationId, "connected" as ConnectionStatus ); } } } } else if (status === "disconnected") { const targetConversationId = conversation_id || get(claudeStore.activeConversationId); // Mark all running agents as errored on disconnect, but not during reconnects // (permission prompts trigger reconnects and agents may complete before reconnect) if (!skipNextGreeting && targetConversationId) { agentStore.markAllErrored(targetConversationId); // Clear the conversation's agents from the store on real disconnect // This prevents agents from persisting across sessions agentStore.clearConversation(targetConversationId); } // Only remove from connected set if we're not about to reconnect if (!skipNextGreeting && targetConversationId) { connectedConversations.delete(targetConversationId); greetingPendingConversations.delete(targetConversationId); } // Don't add system message if we're about to reconnect if (!skipNextGreeting && targetConversationId) { claudeStore.addLineToConversation( targetConversationId, "system", "Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system" ); // Clear todos on real disconnect (not on reconnects for permissions) todos.clear(); } // Update the tab's connection status on real disconnects if (!skipNextGreeting && targetConversationId) { claudeStore.setConnectionStatusForConversation( targetConversationId, "disconnected" as ConnectionStatus ); } // Update character state and processing state for this conversation if (targetConversationId) { claudeStore.setCharacterStateForConversation(targetConversationId, "idle"); claudeStore.setProcessingForConversation(targetConversationId, false); } } else if (status === "error") { const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { connectedConversations.delete(targetConversationId); greetingPendingConversations.delete(targetConversationId); claudeStore.addLineToConversation(targetConversationId, "error", "Connection error"); } characterState.setTemporaryState("error", 3000); } }); unlisteners.push(connectionUnlisten); const stateUnlisten = await listen("claude:state", (event) => { const { state, conversation_id } = event.payload; const stateMap: Record = { idle: "idle", thinking: "thinking", typing: "typing", searching: "searching", coding: "coding", mcp: "mcp", permission: "permission", success: "success", error: "error", }; const mappedState = stateMap[state.toLowerCase()] || "idle"; // Per-conversation sound tracking — fires for any tab (active or background). // All sounds are driven from state-change events rather than a global store // subscription, so background tabs receive their sounds correctly and // switching tabs never replays a sound that has already fired. const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null; if (resolvedConversationId) { const conv = get(claudeStore.conversations).get(resolvedConversationId); if (conv) { const previousState = conv.characterState; // New response starting — clear all per-task sound flags. // Only reset when entering from a clean-slate state, not mid-task. // Transitioning from coding/searching/mcp/typing → thinking means we're // still within the same task (between tool calls), so the sound must not replay. const cleanSlateStates: CharacterState[] = ["idle", "success", "error"]; if (mappedState === "thinking" && cleanSlateStates.includes(previousState)) { claudeStore.resetSoundState(resolvedConversationId); } // Record when a long-running phase begins (used for the 2-second // minimum duration check before playing the completion sound). if ( (mappedState === "coding" || mappedState === "searching") && previousState !== "coding" && previousState !== "searching" ) { claudeStore.setTaskStartTime(resolvedConversationId, Date.now()); } // Task-start sound — fires once when work enters a long-running phase. if ( (mappedState === "coding" || mappedState === "searching") && previousState !== "coding" && previousState !== "searching" && !conv.taskStartSoundFired ) { notificationManager.notifyTaskStart(); claudeStore.markTaskStartSoundFired(resolvedConversationId); } // Error sound — fires each time a new error state is entered. if (mappedState === "error" && previousState !== "error") { notificationManager.notifyError(); } // Permission sound — fires each time a permission request arrives. if (mappedState === "permission") { notificationManager.notifyPermission(); } // Completion sound — fires once per task after sufficient duration. if (mappedState === "success" && !conv.successSoundFired) { const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0; if (duration > 2000) { notificationManager.notifySuccess(); } claudeStore.markSuccessSoundFired(resolvedConversationId); } } } // Always update the conversation's state const isTerminalState = mappedState === "idle" || mappedState === "success" || mappedState === "error"; if (conversation_id) { claudeStore.setCharacterStateForConversation(conversation_id, mappedState); if (isTerminalState) { claudeStore.setProcessingForConversation(conversation_id, false); } } else { // Fallback to active conversation if no conversation_id const activeConversationId = get(claudeStore.activeConversationId); if (activeConversationId) { claudeStore.setCharacterStateForConversation(activeConversationId, mappedState); if (isTerminalState) { claudeStore.setProcessingForConversation(activeConversationId, false); } } } // Only update global character state for active conversation const activeConversationId = get(claudeStore.activeConversationId); if (!conversation_id || conversation_id === activeConversationId) { if (mappedState === "success" || mappedState === "error") { characterState.setTemporaryState(mappedState, 3000); } else { characterState.setState(mappedState); } } }); unlisteners.push(stateUnlisten); const outputUnlisten = await listen("claude:output", (event) => { const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } = event.payload; // Convert snake_case cost to camelCase for TypeScript const costData = cost ? { inputTokens: cost.input_tokens, outputTokens: cost.output_tokens, costUsd: cost.cost_usd, } : undefined; // Flip to connected when first assistant message arrives after greeting if ( conversation_id && line_type === "assistant" && greetingPendingConversations.has(conversation_id) ) { greetingPendingConversations.delete(conversation_id); claudeStore.setConnectionStatusForConversation( conversation_id, "connected" as ConnectionStatus ); } // Always store the output to the correct conversation if (conversation_id) { claudeStore.addLineToConversation( conversation_id, line_type as | "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit" | "compact-prompt" | "worktree" | "config-change", content, tool_name || undefined, costData, parent_tool_use_id ); } else { // Fallback to active conversation if no conversation_id provided claudeStore.addLine( line_type as | "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit" | "compact-prompt" | "worktree" | "config-change", content, tool_name || undefined, costData, parent_tool_use_id ); } }); unlisteners.push(outputUnlisten); const streamUnlisten = await listen("claude:stream", () => { // no-op }); unlisteners.push(streamUnlisten); const sessionUnlisten = await listen("claude:session", (event) => { const { session_id, conversation_id } = event.payload; // Store session ID for the correct conversation if (conversation_id) { claudeStore.setSessionIdForConversation(conversation_id, session_id); claudeStore.addLineToConversation( conversation_id, "system", `Session: ${session_id.substring(0, 8)}...` ); } else { // Fallback to active conversation if no conversation_id claudeStore.setSessionId(session_id); claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`); } }); unlisteners.push(sessionUnlisten); const cwdUnlisten = await listen("claude:cwd", (event) => { const { directory, conversation_id } = event.payload; // Store working directory for the correct conversation if (conversation_id) { claudeStore.setWorkingDirectoryForConversation(conversation_id, directory); } else { // Fallback to active conversation if no conversation_id claudeStore.setWorkingDirectory(directory); } }); unlisteners.push(cwdUnlisten); console.log("[Tauri Listener] Setting up claude:permission listener"); const permissionUnlisten = await listen("claude:permission", (event) => { const { permissions, conversation_id } = event.payload; console.log( `[Permission] Event received: ${permissions.length} permission(s) for conversation ${conversation_id || "active"}`, { permissions, conversation_id } ); // Store each permission request for the specific conversation for (const permission of permissions) { const { id, tool_name, tool_input, description } = permission; if (conversation_id) { claudeStore.requestPermissionForConversation(conversation_id, { id, tool: tool_name, description, input: tool_input, }); claudeStore.addLineToConversation( conversation_id, "system", `Permission requested for: ${tool_name}` ); } else { // Fallback to active conversation if no conversation_id claudeStore.requestPermission({ id, tool: tool_name, description, input: tool_input, }); claudeStore.addLine("system", `Permission requested for: ${tool_name}`); } } }); unlisteners.push(permissionUnlisten); const agentStartUnlisten = await listen("claude:agent-start", (event) => { const { tool_use_id, agent_id, description, subagent_type, model, started_at, conversation_id, parent_tool_use_id, } = event.payload; const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { agentStore.addAgent(targetConversationId, { toolUseId: tool_use_id, agentId: agent_id, description, subagentType: subagent_type, model, startedAt: started_at, status: "running", parentToolUseId: parent_tool_use_id, }); } }); unlisteners.push(agentStartUnlisten); const agentUpdateUnlisten = await listen<{ conversationId: string; toolUseId: string; agentId: string; agentType?: string; }>("claude:agent-update", (event) => { const { conversationId, toolUseId, agentId, agentType } = event.payload; agentStore.updateAgentId(conversationId, toolUseId, agentId, agentType); }); unlisteners.push(agentUpdateUnlisten); const agentEndUnlisten = await listen("claude:agent-end", (event) => { const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } = event.payload; const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { agentStore.endAgent( targetConversationId, tool_use_id, ended_at, is_error, last_assistant_message ); } }); unlisteners.push(agentEndUnlisten); const questionUnlisten = await listen("claude:question", (event) => { const questionEvent = event.payload; // Store question request for the specific conversation if (questionEvent.conversation_id) { claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent); claudeStore.addLineToConversation( questionEvent.conversation_id, "system", `Question: ${questionEvent.question}` ); } else { // Fallback to active conversation if no conversation_id claudeStore.requestQuestion(questionEvent); claudeStore.addLine("system", `Question: ${questionEvent.question}`); } }); unlisteners.push(questionUnlisten); } export function cleanupTauriListeners() { // Cleanup all event listeners unlisteners.forEach((unlisten) => unlisten()); unlisteners = []; // Cleanup notification rules cleanupNotificationRules(); } export async function initializeDiscordRpc() { const config = configStore.getConfig(); if (config.discord_rpc_enabled) { try { const startedAt = new Date(); const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000); const model = config.model || "claude"; console.log("Initializing Discord RPC with initial activity:", { session_name: "Idle", model, started_at: startedAtUnixSeconds, }); await invoke("init_discord_rpc", { sessionName: "Idle", model, startedAt: startedAtUnixSeconds, }); console.log("Discord RPC initialized successfully with initial presence"); } catch (error) { console.error("Failed to initialize Discord RPC:", error); console.warn("Discord RPC will be unavailable. Make sure Discord is running."); } } else { console.log("Discord RPC is disabled in config, skipping initialization"); } } export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) { const config = configStore.getConfig(); if (!config.discord_rpc_enabled) { return; } try { const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000); await invoke("update_discord_rpc", { sessionName: sessionName, model, startedAt: startedAtUnixSeconds, }); } catch (error) { console.error("Failed to update Discord RPC:", error); } } export async function stopDiscordRpc() { try { await invoke("stop_discord_rpc"); } catch (error) { console.error("Failed to stop Discord RPC:", error); } }