diff --git a/packages/app/e2e/create-agent.spec.ts b/packages/app/e2e/create-agent.spec.ts index 652fd0ea2..b5ed821ba 100644 --- a/packages/app/e2e/create-agent.spec.ts +++ b/packages/app/e2e/create-agent.spec.ts @@ -4,13 +4,20 @@ import { createTempGitRepo } from './helpers/workspace'; test('create agent in a temp repo', async ({ page }) => { const repo = await createTempGitRepo(); - const message = `E2E create agent ${Date.now()}`; + const prompt = "Respond with exactly: Hello"; try { await gotoHome(page); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); - await createAgent(page, message); + await createAgent(page, prompt); + + // Verify user message is shown in the stream + await expect(page.getByText(prompt, { exact: true })).toBeVisible(); + + // Wait for agent response containing "Hello" within an assistant message + const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' }); + await expect(assistantMessage).toBeVisible({ timeout: 30000 }); } finally { await repo.cleanup(); } diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 938c2b4b2..89288ffec 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -41,14 +41,10 @@ export const ensureHostSelected = async (page: Page) => { if (await selectHost.isVisible()) { await selectHost.click(); + // Wait for the host option to appear and click it const hostOption = page.getByText('localhost', { exact: true }).first(); - if (await hostOption.isVisible()) { - await hostOption.click(); - } else { - const fallbackOption = page.getByText('Local Host', { exact: true }).first(); - await expect(fallbackOption).toBeVisible(); - await fallbackOption.click(); - } + await expect(hostOption).toBeVisible(); + await hostOption.click(); } await expect(input).toBeEditable(); diff --git a/packages/app/e2e/preserve-prompt-on-error.spec.ts b/packages/app/e2e/preserve-prompt-on-error.spec.ts new file mode 100644 index 000000000..50b736688 --- /dev/null +++ b/packages/app/e2e/preserve-prompt-on-error.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from './fixtures'; +import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app'; + +test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => { + const nonExistentDir = '/non/existent/directory/that/does/not/exist'; + const promptText = `Test prompt that should be preserved ${Date.now()}`; + + await gotoHome(page); + await ensureHostSelected(page); + await setWorkingDirectory(page, nonExistentDir); + + // Enter prompt text + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); + await input.fill(promptText); + + // Try to submit - this should fail with an error about the directory not existing + await input.press('Enter'); + + // Verify error message is displayed (error includes the path) + await expect(page.getByText(/Working directory does not exist/)).toBeVisible(); + + // Verify the prompt text is still preserved in the input + await expect(input).toHaveValue(promptText); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 366393607..094efe08b 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -17,7 +17,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, useEffect, type ReactNode, useMemo } from "react"; import { Platform } from "react-native"; import { SlidingSidebar } from "@/components/sliding-sidebar"; -import { useSidebarStore } from "@/stores/sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { SidebarAnimationProvider, @@ -54,21 +54,28 @@ interface AppContainerProps { function AppContainer({ children, selectedAgentId }: AppContainerProps) { const { theme } = useUnistyles(); - const { isOpen, open, toggle } = useSidebarStore(); + const mobileView = usePanelStore((state) => state.mobileView); + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const openAgentList = usePanelStore((state) => state.openAgentList); + const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const horizontalScroll = useHorizontalScrollOptional(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; + // Cmd+B to toggle sidebar (web only) useEffect(() => { if (Platform.OS !== "web") return; function handleKeyDown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key === "b") { event.preventDefault(); - toggle(); + toggleAgentList(); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggle]); + }, [toggleAgentList]); const { translateX, backdropOpacity, @@ -77,8 +84,6 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { animateToClose, isGesturing, } = useSidebarAnimation(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; // Track initial touch position for manual activation const touchStartX = useSharedValue(0); @@ -135,7 +140,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { animateToOpen(); - runOnJS(open)(); + runOnJS(openAgentList)(); } else { animateToClose(); } @@ -143,7 +148,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { .onFinalize(() => { isGesturing.value = false; }), - [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX] + [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, openAgentList, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX] ); const content = ( diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 93e474397..041154201 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -46,7 +46,7 @@ import { ExplorerSidebarAnimationProvider, useExplorerSidebarAnimation, } from "@/contexts/explorer-sidebar-animation-context"; -import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -188,7 +188,19 @@ function AgentScreenContent({ addImagesRef.current = addImages; }, []); - const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + + // Derive isExplorerOpen from the unified panel state + const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + const { translateX: explorerTranslateX, backdropOpacity: explorerBackdropOpacity, @@ -197,8 +209,6 @@ function AgentScreenContent({ animateToClose: animateExplorerToClose, isGesturing: isExplorerGesturing, } = useExplorerSidebarAnimation(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; useEffect(() => { if (Platform.OS !== "web") { @@ -239,7 +249,7 @@ function AgentScreenContent({ const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; if (shouldOpen) { animateExplorerToOpen(); - runOnJS(openExplorer)(); + runOnJS(openFileExplorer)(); } else { animateExplorerToClose(); } @@ -255,7 +265,7 @@ function AgentScreenContent({ explorerBackdropOpacity, animateExplorerToOpen, animateExplorerToClose, - openExplorer, + openFileExplorer, isExplorerGesturing, ] ); @@ -266,14 +276,14 @@ function AgentScreenContent({ const handler = BackHandler.addEventListener("hardwareBackPress", () => { if (isExplorerOpen) { - closeExplorer(); + closeToAgent(); return true; // Prevent default back navigation } return false; // Let default back navigation happen }); return () => handler.remove(); - }, [isExplorerOpen, closeExplorer]); + }, [isExplorerOpen, closeToAgent]); const resolvedAgentId = agentId; @@ -565,14 +575,14 @@ function AgentScreenContent({ const handleViewChanges = useCallback(() => { handleCloseMenu(); setExplorerTab("changes"); - openExplorer(); - }, [handleCloseMenu, setExplorerTab, openExplorer]); + openFileExplorer(); + }, [handleCloseMenu, setExplorerTab, openFileExplorer]); const handleBrowseFiles = useCallback(() => { handleCloseMenu(); setExplorerTab("files"); - openExplorer(); - }, [handleCloseMenu, setExplorerTab, openExplorer]); + openFileExplorer(); + }, [handleCloseMenu, setExplorerTab, openFileExplorer]); const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId || !refreshAgent) { @@ -637,7 +647,7 @@ function AgentScreenContent({ title={agent.title || "Agent"} rightContent={ - + {isMobile ? ( (null); const sessionMethods = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.methods : undefined ); @@ -446,17 +444,32 @@ export default function HomeScreen() { : undefined; void persistFormPreferences(); - - const requestId = generateMessageId(); - pendingRequestIdRef.current = requestId; setIsLoading(true); - createAgent({ - config, - initialPrompt: trimmedPrompt, - images, - git: gitOptions, - requestId, - }); + + try { + const result = await createAgent({ + config, + initialPrompt: trimmedPrompt, + images, + git: gitOptions, + }); + + // Success - clear prompt and navigate to agent + setPromptText(""); + const agentId = (result as { id?: string })?.id; + if (agentId && selectedServerId) { + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { serverId: selectedServerId, agentId }, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create agent"; + setErrorMessage(message); + throw error; // Re-throw so AgentInputArea knows it failed + } finally { + setIsLoading(false); + } }, [ useWorktree, @@ -470,6 +483,7 @@ export default function HomeScreen() { isNonGitDirectory, modeOptions, persistFormPreferences, + router, selectedMode, selectedModel, selectedProvider, @@ -479,54 +493,6 @@ export default function HomeScreen() { ] ); - useEffect(() => { - if (!sessionClient) { - return; - } - const unsubscribe = sessionClient.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status: string; - agentId?: string; - requestId?: string; - error?: string; - }; - const expectedRequestId = pendingRequestIdRef.current; - if (!expectedRequestId || payload.requestId !== expectedRequestId) { - return; - } - if (payload.status === "agent_create_failed") { - pendingRequestIdRef.current = null; - setIsLoading(false); - setErrorMessage(payload.error ?? "Failed to create agent"); - return; - } - if (payload.status !== "agent_created" || !payload.agentId) { - return; - } - if (!selectedServerId) { - pendingRequestIdRef.current = null; - setIsLoading(false); - return; - } - pendingRequestIdRef.current = null; - setIsLoading(false); - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: selectedServerId, - agentId: payload.agentId, - }, - }); - }); - - return () => { - unsubscribe(); - }; - }, [router, selectedServerId, sessionClient]); - return ( diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 967b09f76..a44fe1d57 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -4,13 +4,22 @@ import { View, Text, Pressable, - ScrollView, Modal, TextInput, ActivityIndicator, useWindowDimensions, + ScrollView, + Platform, } from "react-native"; import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { + BottomSheetModal, + BottomSheetScrollView, + BottomSheetBackdrop, + BottomSheetTextInput, + BottomSheetBackgroundProps, +} from "@gorhom/bottom-sheet"; +import Animated from "react-native-reanimated"; import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import type { @@ -156,34 +165,78 @@ interface DropdownSheetProps { children: ReactNode; } +function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { + return ( + + ); +} + export function DropdownSheet({ title, visible, onClose, children, }: DropdownSheetProps): ReactElement { + const bottomSheetRef = useRef(null); + const snapPoints = useMemo(() => ["60%", "90%"], []); + + useEffect(() => { + if (visible) { + bottomSheetRef.current?.present(); + } else { + bottomSheetRef.current?.dismiss(); + } + }, [visible]); + + const handleSheetChange = useCallback( + (index: number) => { + if (index === -1) { + onClose(); + } + }, + [onClose] + ); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [] + ); + return ( - - - - - - {title} - - {children} - - + + {title} - + + {children} + + ); } @@ -205,6 +258,8 @@ export function AdaptiveSelect({ const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const bottomSheetRef = useRef(null); + const snapPoints = useMemo(() => ["60%", "90%"], []); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, @@ -240,29 +295,62 @@ export function AdaptiveSelect({ }); }, [visible, isMobile, anchorRef, windowWidth, windowHeight]); + useEffect(() => { + if (!isMobile) return; + if (visible) { + bottomSheetRef.current?.present(); + } else { + bottomSheetRef.current?.dismiss(); + } + }, [visible, isMobile]); + + const handleSheetChange = useCallback( + (index: number) => { + if (index === -1) { + onClose(); + } + }, + [onClose] + ); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [] + ); + if (isMobile) { return ( - - - - - - {title} - - {children} - - + + {title} - + + {children} + + ); } @@ -332,7 +420,6 @@ export function ComboSelect({ }: ComboSelectProps): ReactElement { const [isOpen, setIsOpen] = useState(false); const anchorRef = useRef(null); - const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const selectedOption = options.find((opt) => opt.id === value); @@ -347,7 +434,6 @@ export function ComboSelect({ useEffect(() => { if (isOpen) { setSearchQuery(""); - inputRef.current?.focus(); } }, [isOpen]); @@ -400,16 +486,29 @@ export function ComboSelect({ onClose={handleClose} anchorRef={anchorRef} > - + {Platform.OS === "web" ? ( + + ) : ( + + )} {showCustomOption ? ( (null); - const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const handleOpen = useCallback(() => setIsOpen(true), []); @@ -885,7 +983,6 @@ export function WorkingDirectoryDropdown({ useEffect(() => { if (isOpen) { setSearchQuery(""); - inputRef.current?.focus(); } }, [isOpen]); @@ -929,16 +1026,29 @@ export function WorkingDirectoryDropdown({ onClose={handleClose} anchorRef={anchorRef} > - + {Platform.OS === "web" ? ( + + ) : ( + + )} {!hasSuggestedPaths && !showCustomOption ? ( We'll suggest directories from agents on this host once they exist. @@ -1208,6 +1318,18 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], color: theme.colors.foreground, }, + bottomSheetBackground: { + backgroundColor: theme.colors.surface2, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + bottomSheetHandle: { + backgroundColor: theme.colors.palette.zinc[600], + }, + bottomSheetHeader: { + paddingHorizontal: theme.spacing[6], + paddingBottom: theme.spacing[2], + }, dropdownSheetOverlay: { flex: 1, justifyContent: "flex-end", @@ -1244,7 +1366,6 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, textAlign: "center", - marginBottom: theme.spacing[4], }, dropdownSheetScrollContent: { paddingBottom: theme.spacing[8], diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 96450802e..8856717e4 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -231,14 +231,19 @@ export function AgentInputArea({ return; } - const isControlled = value !== undefined; + const isControlledLocal = value !== undefined; setSelectedImages([]); setIsProcessing(true); try { await submitMessage(trimmedMessage, imageAttachments); // Clear input only after successful submission - if (isControlled) { + // For controlled inputs with onSubmitMessage, the parent handles clearing + // because agent creation is async (WebSocket) and errors come back later + if (onSubmitMessageRef.current) { + // Parent manages input state - don't clear here + // Parent will clear on success via onChangeText + } else if (isControlledLocal) { onChangeText?.(""); } else { setUserInput(""); @@ -284,8 +289,13 @@ export function AgentInputArea({ } }, [isAgentRunning, isConnected]); - // Hydrate draft only when switching agents + // Hydrate draft only when switching agents (uncontrolled mode only) + const isControlled = value !== undefined; useEffect(() => { + // Skip draft hydration for controlled inputs - parent manages state + if (isControlled) { + return; + } const draft = getDraftInput(agentId); if (!draft) { setUserInput(""); @@ -295,7 +305,7 @@ export function AgentInputArea({ setUserInput(draft.text); setSelectedImages(draft.images as ImageAttachment[]); - }, [agentId, getDraftInput]); + }, [agentId, getDraftInput, isControlled]); // Persist drafts into the shared session store with change detection to avoid redundant work useEffect(() => { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 17b68c2f7..bcf1b6fee 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -29,7 +29,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { ChevronDown } from "lucide-react-native"; -import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; import { AssistantMessage, UserMessage, @@ -87,8 +87,8 @@ export function AgentStreamView({ const hasAutoScrolledOnce = useRef(false); const isNearBottomRef = useRef(true); const streamItemCountRef = useRef(0); - const { open: openExplorer, setActiveTab: setExplorerTab } = - useExplorerSidebarStore(); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const setExplorerTab = usePanelStore((state) => state.setExplorerTab); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; @@ -142,7 +142,7 @@ export function AgentStreamView({ } setExplorerTab("files"); - openExplorer(); + openFileExplorer(); }, [ agent.cwd, @@ -150,7 +150,7 @@ export function AgentStreamView({ requestDirectoryListingOrInert, requestFilePreviewOrInert, setExplorerTab, - openExplorer, + openFileExplorer, ] ); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 6f2d97f60..e16d9acaf 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -10,11 +10,11 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { X, GitBranch, Folder, LayoutGrid, List as ListIcon } from "lucide-react-native"; import { - useExplorerSidebarStore, + usePanelStore, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, type ViewMode, -} from "@/stores/explorer-sidebar-store"; +} from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "./git-diff-pane"; @@ -30,8 +30,21 @@ interface ExplorerSidebarProps { export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const { isOpen, activeTab, width, viewMode, close, setActiveTab, setWidth, setViewMode } = - useExplorerSidebarStore(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + const explorerTab = usePanelStore((state) => state.explorerTab); + const explorerWidth = usePanelStore((state) => state.explorerWidth); + const explorerViewMode = usePanelStore((state) => state.explorerViewMode); + const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); + const setExplorerViewMode = usePanelStore((state) => state.setExplorerViewMode); + + // Derive isOpen from the unified panel state + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + const { translateX, backdropOpacity, @@ -42,22 +55,19 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { closeGestureRef, } = useExplorerSidebarAnimation(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - // For resize drag, track the starting width - const startWidthRef = useRef(width); - const resizeWidth = useSharedValue(width); + const startWidthRef = useRef(explorerWidth); + const resizeWidth = useSharedValue(explorerWidth); const handleClose = useCallback(() => { - close(); - }, [close]); + closeToAgent(); + }, [closeToAgent]); const handleTabPress = useCallback( (tab: ExplorerTab) => { - setActiveTab(tab); + setExplorerTab(tab); }, - [setActiveTab] + [setExplorerTab] ); // Swipe gesture to close (swipe right on mobile) @@ -116,8 +126,8 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { .enabled(!isMobile) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = width; - resizeWidth.value = width; + startWidthRef.current = explorerWidth; + resizeWidth.value = explorerWidth; }) .onUpdate((event) => { // Dragging left (negative translationX) increases width @@ -129,9 +139,9 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { resizeWidth.value = clampedWidth; }) .onEnd(() => { - runOnJS(setWidth)(resizeWidth.value); + runOnJS(setExplorerWidth)(resizeWidth.value); }), - [isMobile, width, resizeWidth, setWidth] + [isMobile, explorerWidth, resizeWidth, setExplorerWidth] ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ @@ -168,13 +178,13 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { pointerEvents="auto" > @@ -201,13 +211,13 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 550edcf92..95a1406ca 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -46,9 +46,9 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { DaemonProfile } from "@/contexts/daemon-registry-context"; import { useSessionStore } from "@/stores/session-store"; import { - useExplorerSidebarStore, + usePanelStore, type SortOption, -} from "@/stores/explorer-sidebar-store"; +} from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; const MAX_CONCURRENT_THUMBNAILS = 2; @@ -90,7 +90,9 @@ export function FileExplorerPane({ const requestFilePreview = methods?.requestFilePreview; const requestFileDownloadToken = methods?.requestFileDownloadToken; const navigateExplorerBack = methods?.navigateExplorerBack; - const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore(); + const viewMode = usePanelStore((state) => state.explorerViewMode); + const sortOption = usePanelStore((state) => state.explorerSortOption); + const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const [selectedEntryPath, setSelectedEntryPath] = useState(null); const listScrollRef = useRef | null>(null); const listScrollOffsetRef = useRef(0); diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index fa7330878..c211e7072 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -3,7 +3,7 @@ import { Pressable, Text } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Menu, PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; -import { useSidebarStore } from "@/stores/sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; interface MenuHeaderProps { title?: string; @@ -12,10 +12,13 @@ interface MenuHeaderProps { export function MenuHeader({ title, rightContent }: MenuHeaderProps) { const { theme } = useUnistyles(); - const { isOpen, toggle } = useSidebarStore(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const toggleAgentList = usePanelStore((state) => state.toggleAgentList); + const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const MenuIcon = isMobile ? Menu : PanelLeft; const menuIconColor = !isMobile && isOpen ? theme.colors.foreground @@ -25,7 +28,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) { - + {title && ( diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 15387a1d2..6a837d91f 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -28,7 +28,7 @@ import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "./dictation-controls"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { SessionContextValue } from "@/contexts/session-context"; -import { useSidebarStore } from "@/stores/sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; export interface ImageAttachment { uri: string; @@ -117,7 +117,7 @@ export const MessageInput = forwardRef( ref ) { const { theme } = useUnistyles(); - const toggleSidebar = useSidebarStore((state) => state.toggle); + const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const textInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null @@ -397,7 +397,7 @@ export const MessageInput = forwardRef( // Cmd+B or Ctrl+B: toggle sidebar if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") { event.preventDefault(); - toggleSidebar(); + toggleAgentList(); return; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d0c974fc7..f936f08b8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -649,6 +649,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( state.mobileView); + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + + // Derive isOpen from the unified panel state + const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; + const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); const { translateX, @@ -52,9 +60,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { } }, [isRevalidating, isManualRefresh]); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const sortedAgents = useMemo(() => { return [...agents].sort((a, b) => { if (a.requiresAttention && !b.requiresAttention) return -1; @@ -70,15 +75,15 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const hasMore = agents.length > SIDEBAR_AGENT_LIMIT; const handleClose = useCallback(() => { - close(); - }, [close]); + closeToAgent(); + }, [closeToAgent]); // Mobile: close sidebar and navigate const handleCreateAgentMobile = useCallback(() => { - close(); + closeToAgent(); router.push("/"); - }, [close]); + }, [closeToAgent]); // Desktop: just navigate, don't close const handleCreateAgentDesktop = useCallback(() => { @@ -87,9 +92,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { - close(); + closeToAgent(); router.push("/settings"); - }, [close]); + }, [closeToAgent]); // Desktop: just navigate, don't close const handleSettingsDesktop = useCallback(() => { @@ -101,17 +106,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const handleAgentSelectMobile = useCallback(() => { translateX.value = -windowWidth; backdropOpacity.value = 0; - close(); - }, [close, translateX, backdropOpacity, windowWidth]); + closeToAgent(); + }, [closeToAgent, translateX, backdropOpacity, windowWidth]); const handleViewMore = useCallback(() => { if (isMobile) { translateX.value = -windowWidth; backdropOpacity.value = 0; } - close(); + closeToAgent(); router.push("/agents"); - }, [backdropOpacity, close, isMobile, translateX, windowWidth]); + }, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]); // Close gesture (swipe left to close when sidebar is open) const closeGesture = Gesture.Pan() diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 9de3773d9..9b9e04192 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -7,7 +7,8 @@ import { type SharedValue, } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; -import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; +import { UnistylesRuntime } from "react-native-unistyles"; +import { usePanelStore } from "@/stores/panel-store"; const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); @@ -26,7 +27,13 @@ const ExplorerSidebarAnimationContext = createContext state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + + // Derive isOpen from the unified panel state + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; // Right sidebar: closed = +windowWidth (off-screen right), open = 0 const translateX = useSharedValue(isOpen ? 0 : windowWidth); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 7912c5f40..5ace09f1d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -362,10 +362,11 @@ export interface SessionContextValue { createAgent: (options: { config: any; initialPrompt: string; + images?: Array<{ uri: string; mimeType?: string }>; git?: any; worktreeName?: string; requestId?: string; - }) => void; + }) => Promise; setAgentMode: (agentId: string, modeId: string) => void; respondToPermission: ( agentId: string, @@ -2077,20 +2078,14 @@ export function SessionProvider({ error ); } - void client - .createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 - ? { images: imagesData } - : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }) - .catch((error) => { - console.error("[Session] Failed to create agent:", error); - }); + return client.createAgent({ + config, + ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(git ? { git } : {}), + ...(worktreeName ? { worktreeName } : {}), + ...(requestId ? { requestId } : {}), + }); }, [encodeImages, client] ); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index f257c3e14..2eb42e2dc 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -6,7 +6,8 @@ import { Easing, type SharedValue, } from "react-native-reanimated"; -import { useSidebarStore } from "@/stores/sidebar-store"; +import { UnistylesRuntime } from "react-native-unistyles"; +import { usePanelStore } from "@/stores/panel-store"; const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); @@ -24,7 +25,13 @@ const SidebarAnimationContext = createContext state.mobileView); + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + + // Derive isOpen from the unified panel state + const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; // Initialize based on current state const translateX = useSharedValue(isOpen ? 0 : -windowWidth); diff --git a/packages/app/src/hooks/use-git-diff-query.ts b/packages/app/src/hooks/use-git-diff-query.ts index 9e42bb351..3495beeb6 100644 --- a/packages/app/src/hooks/use-git-diff-query.ts +++ b/packages/app/src/hooks/use-git-diff-query.ts @@ -1,7 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; +import { UnistylesRuntime } from "react-native-unistyles"; import { useSessionStore } from "@/stores/session-store"; -import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; const GIT_DIFF_STALE_TIME = 30_000; @@ -22,7 +23,12 @@ export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) { const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); - const { isOpen, activeTab } = useExplorerSidebarStore(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const explorerTab = usePanelStore((state) => state.explorerTab); + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const query = useQuery({ queryKey: gitDiffQueryKey(serverId, agentId), @@ -40,14 +46,14 @@ export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) { // Revalidate when sidebar opens with "changes" tab active useEffect(() => { - if (!isOpen || activeTab !== "changes" || !agentId) { + if (!isOpen || explorerTab !== "changes" || !agentId) { return; } // Invalidate to trigger background refetch (shows stale data while fetching) queryClient.invalidateQueries({ queryKey: gitDiffQueryKey(serverId, agentId), }); - }, [isOpen, activeTab, serverId, agentId, queryClient]); + }, [isOpen, explorerTab, serverId, agentId, queryClient]); const refresh = useCallback(() => { return query.refetch(); diff --git a/packages/app/src/hooks/use-highlighted-diff-query.ts b/packages/app/src/hooks/use-highlighted-diff-query.ts index 7e29a6447..a010616f6 100644 --- a/packages/app/src/hooks/use-highlighted-diff-query.ts +++ b/packages/app/src/hooks/use-highlighted-diff-query.ts @@ -1,7 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; +import { UnistylesRuntime } from "react-native-unistyles"; import { useSessionStore } from "@/stores/session-store"; -import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; +import { usePanelStore } from "@/stores/panel-store"; import type { HighlightedDiffResponse } from "@server/shared/messages"; import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; @@ -30,7 +31,12 @@ export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDif const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); - const { isOpen, activeTab } = useExplorerSidebarStore(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const explorerTab = usePanelStore((state) => state.explorerTab); + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const query = useQuery({ queryKey: highlightedDiffQueryKey(serverId, agentId), @@ -80,14 +86,14 @@ export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDif // Revalidate when sidebar opens with "changes" tab active useEffect(() => { - if (!isOpen || activeTab !== "changes" || !agentId) { + if (!isOpen || explorerTab !== "changes" || !agentId) { return; } // Invalidate to trigger background refetch (shows stale data while fetching) queryClient.invalidateQueries({ queryKey: highlightedDiffQueryKey(serverId, agentId), }); - }, [isOpen, activeTab, serverId, agentId, queryClient]); + }, [isOpen, explorerTab, serverId, agentId, queryClient]); const refresh = useCallback(() => { return query.refetch(); diff --git a/packages/app/src/stores/explorer-sidebar-store.ts b/packages/app/src/stores/explorer-sidebar-store.ts deleted file mode 100644 index 098c074f0..000000000 --- a/packages/app/src/stores/explorer-sidebar-store.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Platform } from "react-native"; - -type ExplorerTab = "changes" | "files"; -export type ViewMode = "list" | "grid"; -export type SortOption = "name" | "modified" | "size"; - -export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; -export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; -export const MAX_EXPLORER_SIDEBAR_WIDTH = 800; - -const DEFAULT_OPEN = Platform.OS === "web"; - -interface ExplorerSidebarState { - isOpen: boolean; - activeTab: ExplorerTab; - width: number; - viewMode: ViewMode; - sortOption: SortOption; - toggle: () => void; - open: () => void; - close: () => void; - setActiveTab: (tab: ExplorerTab) => void; - setWidth: (width: number) => void; - setViewMode: (mode: ViewMode) => void; - setSortOption: (option: SortOption) => void; -} - -function clampWidth(width: number): number { - return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width)); -} - -export const useExplorerSidebarStore = create()( - persist( - (set) => ({ - isOpen: DEFAULT_OPEN, - activeTab: "changes", - width: DEFAULT_EXPLORER_SIDEBAR_WIDTH, - viewMode: "list", - sortOption: "name", - toggle: () => set((state) => ({ isOpen: !state.isOpen })), - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), - setActiveTab: (tab) => set({ activeTab: tab }), - setWidth: (width) => set({ width: clampWidth(width) }), - setViewMode: (mode) => set({ viewMode: mode }), - setSortOption: (option) => set({ sortOption: option }), - }), - { - name: "explorer-sidebar-state", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - isOpen: state.isOpen, - activeTab: state.activeTab, - width: state.width, - viewMode: state.viewMode, - sortOption: state.sortOption, - }), - } - ) -); - -export function useExplorerSidebar() { - return useExplorerSidebarStore(); -} diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts new file mode 100644 index 000000000..3f073ec85 --- /dev/null +++ b/packages/app/src/stores/panel-store.ts @@ -0,0 +1,219 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Platform } from "react-native"; + +/** + * Mobile panel state machine. + * + * On mobile, exactly one panel can be visible at a time: + * - 'agent': Main agent view (no overlay panel) + * - 'agent-list': Agent list sidebar (left overlay) + * - 'file-explorer': File explorer sidebar (right overlay) + * + * This makes impossible states unrepresentable - you cannot have both + * sidebars open at the same time on mobile. + */ +type MobilePanelView = "agent" | "agent-list" | "file-explorer"; + +/** + * Desktop sidebar state. + * + * On desktop, sidebars are independent toggleable panels that don't overlay + * the main content - they sit alongside it. Both can be open simultaneously. + */ +interface DesktopSidebarState { + agentListOpen: boolean; + fileExplorerOpen: boolean; +} + +type ExplorerTab = "changes" | "files"; +export type ViewMode = "list" | "grid"; +export type SortOption = "name" | "modified" | "size"; + +export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; +export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; +export const MAX_EXPLORER_SIDEBAR_WIDTH = 800; + +interface PanelState { + // Mobile: which panel is currently shown + mobileView: MobilePanelView; + + // Desktop: independent sidebar toggles + desktop: DesktopSidebarState; + + // File explorer settings (shared between mobile/desktop) + explorerTab: ExplorerTab; + explorerWidth: number; + explorerViewMode: ViewMode; + explorerSortOption: SortOption; + + // Actions + openAgentList: () => void; + openFileExplorer: () => void; + closeToAgent: () => void; + toggleAgentList: () => void; + toggleFileExplorer: () => void; + + // File explorer settings actions + setExplorerTab: (tab: ExplorerTab) => void; + setExplorerWidth: (width: number) => void; + setExplorerViewMode: (mode: ViewMode) => void; + setExplorerSortOption: (option: SortOption) => void; +} + +function clampWidth(width: number): number { + return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width)); +} + +const DEFAULT_DESKTOP_OPEN = Platform.OS === "web"; + +export const usePanelStore = create()( + persist( + (set) => ({ + // Mobile always starts at agent view + mobileView: "agent", + + // Desktop defaults based on platform + desktop: { + agentListOpen: DEFAULT_DESKTOP_OPEN, + fileExplorerOpen: DEFAULT_DESKTOP_OPEN, + }, + + // File explorer defaults + explorerTab: "changes", + explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, + explorerViewMode: "list", + explorerSortOption: "name", + + openAgentList: () => + set((state) => ({ + mobileView: "agent-list", + desktop: { ...state.desktop, agentListOpen: true }, + })), + + openFileExplorer: () => + set((state) => ({ + mobileView: "file-explorer", + desktop: { ...state.desktop, fileExplorerOpen: true }, + })), + + closeToAgent: () => + set((state) => ({ + mobileView: "agent", + // On desktop, closing depends on which panel triggered it + // This is called when closing via gesture/backdrop, so we close the currently active mobile panel + desktop: { + agentListOpen: + state.mobileView === "agent-list" ? false : state.desktop.agentListOpen, + fileExplorerOpen: + state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen, + }, + })), + + toggleAgentList: () => + set((state) => { + // Mobile: toggle between agent and agent-list + const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list"; + return { + mobileView: newMobileView, + desktop: { + ...state.desktop, + agentListOpen: !state.desktop.agentListOpen, + }, + }; + }), + + toggleFileExplorer: () => + set((state) => { + // Mobile: toggle between agent and file-explorer + const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer"; + return { + mobileView: newMobileView, + desktop: { + ...state.desktop, + fileExplorerOpen: !state.desktop.fileExplorerOpen, + }, + }; + }), + + setExplorerTab: (tab) => set({ explorerTab: tab }), + setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), + setExplorerViewMode: (mode) => set({ explorerViewMode: mode }), + setExplorerSortOption: (option) => set({ explorerSortOption: option }), + }), + { + name: "panel-state", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + mobileView: state.mobileView, + desktop: state.desktop, + explorerTab: state.explorerTab, + explorerWidth: state.explorerWidth, + explorerViewMode: state.explorerViewMode, + explorerSortOption: state.explorerSortOption, + }), + } + ) +); + +/** + * Hook that provides platform-aware panel state. + * + * On mobile, uses the state machine (mobileView). + * On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen). + * + * @param isMobile - Whether the current breakpoint is mobile + */ +export function usePanelState(isMobile: boolean) { + const store = usePanelStore(); + + if (isMobile) { + return { + isAgentListOpen: store.mobileView === "agent-list", + isFileExplorerOpen: store.mobileView === "file-explorer", + openAgentList: store.openAgentList, + openFileExplorer: store.openFileExplorer, + closeAgentList: store.closeToAgent, + closeFileExplorer: store.closeToAgent, + toggleAgentList: store.toggleAgentList, + toggleFileExplorer: store.toggleFileExplorer, + // Explorer settings + explorerTab: store.explorerTab, + explorerWidth: store.explorerWidth, + explorerViewMode: store.explorerViewMode, + explorerSortOption: store.explorerSortOption, + setExplorerTab: store.setExplorerTab, + setExplorerWidth: store.setExplorerWidth, + setExplorerViewMode: store.setExplorerViewMode, + setExplorerSortOption: store.setExplorerSortOption, + }; + } + + // Desktop: independent toggles + return { + isAgentListOpen: store.desktop.agentListOpen, + isFileExplorerOpen: store.desktop.fileExplorerOpen, + openAgentList: store.openAgentList, + openFileExplorer: store.openFileExplorer, + closeAgentList: () => + usePanelStore.setState((state) => ({ + desktop: { ...state.desktop, agentListOpen: false }, + })), + closeFileExplorer: () => + usePanelStore.setState((state) => ({ + desktop: { ...state.desktop, fileExplorerOpen: false }, + })), + toggleAgentList: store.toggleAgentList, + toggleFileExplorer: store.toggleFileExplorer, + // Explorer settings + explorerTab: store.explorerTab, + explorerWidth: store.explorerWidth, + explorerViewMode: store.explorerViewMode, + explorerSortOption: store.explorerSortOption, + setExplorerTab: store.setExplorerTab, + setExplorerWidth: store.setExplorerWidth, + setExplorerViewMode: store.setExplorerViewMode, + setExplorerSortOption: store.setExplorerSortOption, + }; +} diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index d2966e4ef..d3e815bc8 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -196,7 +196,7 @@ export interface SessionState { git?: any; worktreeName?: string; requestId?: string; - }) => Promise; + }) => Promise; setAgentMode: (agentId: string, modeId: string) => void; respondToPermission: (agentId: string, requestId: string, response: any) => void; ensureAgentIsInitialized: (agentId: string) => Promise; diff --git a/packages/app/src/stores/sidebar-store.ts b/packages/app/src/stores/sidebar-store.ts deleted file mode 100644 index bb926f386..000000000 --- a/packages/app/src/stores/sidebar-store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Platform } from "react-native"; - -interface SidebarState { - isOpen: boolean; - toggle: () => void; - open: () => void; - close: () => void; -} - -const DEFAULT_OPEN = Platform.OS === "web"; - -export const useSidebarStore = create()( - persist( - (set) => ({ - isOpen: DEFAULT_OPEN, - toggle: () => set((state) => ({ isOpen: !state.isOpen })), - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), - }), - { - name: "sidebar-state", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ isOpen: state.isOpen }), - } - ) -); - -export function useSidebar() { - return useSidebarStore(); -} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index f02ca4d70..042e4c0c4 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -8,7 +8,6 @@ import { SessionInboundMessageSchema, WSOutboundMessageSchema, } from "../shared/messages.js"; -import { getRootLogger } from "../server/logger.js"; import type { AgentStreamEventPayload, AgentSnapshotPayload, @@ -40,7 +39,19 @@ import type { } from "../server/agent/agent-sdk-types.js"; import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js"; -const logger = getRootLogger().child({ module: "daemon-client" }); +export interface Logger { + debug(obj: object, msg?: string): void; + info(obj: object, msg?: string): void; + warn(obj: object, msg?: string): void; + error(obj: object, msg?: string): void; +} + +const consoleLogger: Logger = { + debug: (obj, msg) => console.debug(msg, obj), + info: (obj, msg) => console.info(msg, obj), + warn: (obj, msg) => console.warn(msg, obj), + error: (obj, msg) => console.error(msg, obj), +}; export type DaemonTransport = { send: (data: string) => void; @@ -115,6 +126,7 @@ export type DaemonClientV2Config = { suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; + logger?: Logger; reconnect?: { enabled?: boolean; baseDelayMs?: number; @@ -197,6 +209,7 @@ export class DaemonClientV2 { private connectionState: ConnectionState = { status: "idle" }; private messageQueueLimit: number | null; private agentIndex: Map = new Map(); + private logger: Logger; constructor(private config: DaemonClientV2Config) { this.messageQueueLimit = @@ -204,6 +217,7 @@ export class DaemonClientV2 { ? DEFAULT_MESSAGE_QUEUE_LIMIT : config.messageQueueLimit; this.conversationId = config.conversationId ?? null; + this.logger = config.logger ?? consoleLogger; } // ============================================================================ @@ -1412,7 +1426,7 @@ export class DaemonClientV2 { const parsed = WSOutboundMessageSchema.safeParse(parsedJson); if (!parsed.success) { const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? "unknown"; - logger.warn({ msgType, error: parsed.error.message }, "Message validation failed"); + this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed"); return; } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 49d1dd0e7..2b48b4a3f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4,9 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus, } from "../../shared/agent-lifecycle.js"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "agent-manager" }); +import type { Logger } from "pino"; import type { AgentCapabilityFlags, @@ -61,6 +59,7 @@ export type AgentManagerOptions = { registry?: AgentRegistry; agentControlMcp?: AgentControlMcpConfig; onAgentAttention?: AgentAttentionCallback; + logger: Logger; }; export type WaitForAgentOptions = { @@ -187,14 +186,16 @@ export class AgentManager { private readonly previousStatuses = new Map(); private readonly agentControlMcp?: AgentControlMcpConfig; private onAgentAttention?: AgentAttentionCallback; + private logger: Logger; - constructor(options?: AgentManagerOptions) { + constructor(options: AgentManagerOptions) { this.maxTimelineItems = options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.agentControlMcp = options?.agentControlMcp; this.onAgentAttention = options?.onAgentAttention; + this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { for (const [provider, client] of Object.entries(options.clients)) { if (client) { @@ -271,7 +272,7 @@ export class AgentManager { }); descriptors.push(...entries); } catch (error) { - logger.warn( + this.logger.warn( { err: error, provider }, "Failed to list persisted agents for provider" ); @@ -358,7 +359,7 @@ export class AgentManager { try { await existing.session.close(); } catch (error) { - logger.warn( + this.logger.warn( { err: error, agentId }, "Failed to close previous session during refresh" ); @@ -549,7 +550,7 @@ export class AgentManager { try { await agent.session.interrupt(); } catch (error) { - logger.error( + this.logger.error( { err: error, agentId }, "Failed to interrupt session" ); @@ -561,7 +562,7 @@ export class AgentManager { await pendingRun.return(undefined as unknown as AgentStreamEvent); return true; } catch (error) { - logger.error( + this.logger.error( { err: error, agentId }, "Failed to cancel run" ); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index aaca4c479..e9b2cf00c 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -6,6 +6,7 @@ import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { describe, expect, test } from "vitest"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import pino from "pino"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; @@ -168,7 +169,7 @@ describe("agent MCP end-to-end", () => { await copyClaudeCredentials(sourceClaudeConfigDir, claudeConfigDir); process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; - const daemon = await createPaseoDaemon(daemonConfig); + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); await new Promise((resolve) => { daemon.httpServer.listen(port, () => resolve()); }); @@ -382,7 +383,7 @@ describe("agent MCP end-to-end", () => { await copyClaudeCredentials(sourceClaudeConfigDir, claudeConfigDir); process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; - const daemon = await createPaseoDaemon(daemonConfig); + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); await new Promise((resolve) => { daemon.httpServer.listen(port, () => resolve()); }); diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index a7ba97583..8e33b2678 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -2,9 +2,7 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { z } from "zod"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "agent-registry" }); +import type { Logger } from "pino"; import { AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; @@ -70,9 +68,11 @@ export class AgentRegistry { private loaded = false; private filePath: string; private loadPromise: Promise | null = null; + private logger: Logger; - constructor(filePath: string) { + constructor(filePath: string, logger: Logger) { this.filePath = filePath; + this.logger = logger.child({ module: "agent", component: "agent-registry" }); } async load(): Promise { @@ -99,7 +99,7 @@ export class AgentRegistry { this.cache.clear(); return []; } - logger.error({ err: error }, "Failed to load agents"); + this.logger.error({ err: error }, "Failed to load agents"); this.loaded = true; this.cache.clear(); return []; @@ -185,7 +185,7 @@ export class AgentRegistry { records.push(record); this.cache.set(record.id, record); } catch (error) { - logger.error({ err: error }, "Skipping invalid record"); + this.logger.error({ err: error }, "Skipping invalid record"); } } return records; @@ -203,7 +203,7 @@ export class AgentRegistry { try { this.cache.clear(); const records = this.parseRecords(candidate); - logger.warn("Recovered corrupted agents.json payload; rewrote sanitized copy"); + this.logger.warn("Recovered corrupted agents.json payload; rewrote sanitized copy"); const sanitizedPayload = JSON.stringify(records, null, 2); await writeFileAtomically(this.filePath, sanitizedPayload); return records; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index fae4cac8e..8584fb659 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -3,9 +3,7 @@ import { z } from "zod"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { ensureValidJson } from "../json-utils.js"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "mcp-server" }); +import type { Logger } from "pino"; import type { AgentPromptInput, @@ -38,6 +36,7 @@ export interface AgentMcpServerOptions { * When set, create_agent will auto-inject this as parentAgentId. */ callerAgentId?: string; + logger: Logger; } const CLAUDE_TO_CODEX_MODE: Record = { @@ -179,7 +178,8 @@ async function waitForAgentWithTimeout( function startAgentRun( agentManager: AgentManager, agentId: string, - prompt: AgentPromptInput + prompt: AgentPromptInput, + logger: Logger ): void { const iterator = agentManager.streamAgent(agentId, prompt); void (async () => { @@ -223,7 +223,8 @@ function sanitizePermissionRequest( async function resolveAgentTitle( agentRegistry: AgentRegistry, - agentId: string + agentId: string, + logger: Logger ): Promise { try { const record = await agentRegistry.get(agentId); @@ -239,17 +240,19 @@ async function resolveAgentTitle( async function serializeSnapshotWithMetadata( agentRegistry: AgentRegistry, - snapshot: ManagedAgent + snapshot: ManagedAgent, + logger: Logger ) { - const title = await resolveAgentTitle(agentRegistry, snapshot.id); + const title = await resolveAgentTitle(agentRegistry, snapshot.id, logger); return serializeAgentSnapshot(snapshot, { title }); } export async function createAgentMcpServer( options: AgentMcpServerOptions ): Promise { - const { agentManager, agentRegistry, callerAgentId } = options; - const waitTracker = new WaitForAgentTracker(); + const { agentManager, agentRegistry, callerAgentId, logger } = options; + const childLogger = logger.child({ module: "agent", component: "mcp-server" }); + const waitTracker = new WaitForAgentTracker(logger); const server = new McpServer({ name: "agent-mcp", @@ -439,14 +442,14 @@ export async function createAgentMcpServer( try { agentManager.recordUserMessage(snapshot.id, initialPrompt); } catch (error) { - logger.error( + childLogger.error( { err: error, agentId: snapshot.id }, "Failed to record initial prompt" ); } try { - startAgentRun(agentManager, snapshot.id, initialPrompt); + startAgentRun(agentManager, snapshot.id, initialPrompt, childLogger); // If not running in background, wait for completion if (!background) { @@ -475,7 +478,7 @@ export async function createAgentMcpServer( return response; } } catch (error) { - logger.error( + childLogger.error( { err: error, agentId: snapshot.id }, "Failed to run initial prompt" ); @@ -620,14 +623,14 @@ export async function createAgentMcpServer( } if (snapshot.lifecycle === "running" || snapshot.pendingRun) { - logger.debug( + childLogger.debug( { agentId }, "Interrupting active run before sending new prompt" ); try { const cancelled = await agentManager.cancelAgentRun(agentId); if (!cancelled) { - logger.warn( + childLogger.warn( { agentId }, "Agent reported running but no active run was cancelled" ); @@ -653,7 +656,7 @@ export async function createAgentMcpServer( await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } } catch (error) { - logger.error( + childLogger.error( { err: error, agentId }, "Failed to interrupt agent" ); @@ -668,13 +671,13 @@ export async function createAgentMcpServer( try { agentManager.recordUserMessage(agentId, prompt); } catch (error) { - logger.error( + childLogger.error( { err: error, agentId }, "Failed to record user message" ); } - startAgentRun(agentManager, agentId, prompt); + startAgentRun(agentManager, agentId, prompt, childLogger); // If not running in background, wait for completion if (!background) { @@ -740,7 +743,8 @@ export async function createAgentMcpServer( const structuredSnapshot = await serializeSnapshotWithMetadata( agentRegistry, - snapshot + snapshot, + childLogger ); return { content: [], @@ -766,7 +770,7 @@ export async function createAgentMcpServer( const snapshots = agentManager.listAgents(); const agents = await Promise.all( snapshots.map((snapshot) => - serializeSnapshotWithMetadata(agentRegistry, snapshot) + serializeSnapshotWithMetadata(agentRegistry, snapshot, childLogger) ) ); return { diff --git a/packages/server/src/server/agent/model-resolver.test.ts b/packages/server/src/server/agent/model-resolver.test.ts index aa2f27c46..ead4d0151 100644 --- a/packages/server/src/server/agent/model-resolver.test.ts +++ b/packages/server/src/server/agent/model-resolver.test.ts @@ -3,16 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAgentModel } from "./model-resolver.js"; vi.mock("./provider-registry.js", () => ({ - fetchProviderModels: vi.fn(), + buildProviderRegistry: vi.fn(), })); -import { fetchProviderModels } from "./provider-registry.js"; +import { buildProviderRegistry } from "./provider-registry.js"; -const mockedFetch = vi.mocked(fetchProviderModels); +const mockedBuildProviderRegistry = vi.mocked(buildProviderRegistry); +const testLogger = { warn: vi.fn() } as any; describe("resolveAgentModel", () => { beforeEach(() => { - mockedFetch.mockReset(); + mockedBuildProviderRegistry.mockReset(); + testLogger.warn.mockClear(); }); it("returns the trimmed requested model when provided", async () => { @@ -20,42 +22,59 @@ describe("resolveAgentModel", () => { provider: "codex", requestedModel: " gpt-5.1 ", cwd: "/tmp", + logger: testLogger, }); expect(result).toBe("gpt-5.1"); - expect(mockedFetch).not.toHaveBeenCalled(); + expect(mockedBuildProviderRegistry).not.toHaveBeenCalled(); }); it("uses the default model from the provider catalog when no model specified", async () => { - mockedFetch.mockResolvedValue([ - { id: "claude-3.5-haiku", isDefault: false } as any, - { id: "claude-3.5-sonnet", isDefault: true } as any, + const fetchModels = vi.fn().mockResolvedValue([ + { id: "claude-3.5-haiku", isDefault: false }, + { id: "claude-3.5-sonnet", isDefault: true }, ]); + mockedBuildProviderRegistry.mockReturnValue({ + claude: { fetchModels }, + codex: { fetchModels: vi.fn() }, + opencode: { fetchModels: vi.fn() }, + } as any); - const result = await resolveAgentModel({ provider: "claude", cwd: "~/repo" }); + const result = await resolveAgentModel({ provider: "claude", cwd: "~/repo", logger: testLogger }); expect(result).toBe("claude-3.5-sonnet"); - expect(mockedFetch).toHaveBeenCalledWith("claude", { + expect(fetchModels).toHaveBeenCalledWith({ cwd: expect.stringMatching(/repo$/), }); }); it("falls back to the first model when none are flagged as default", async () => { - mockedFetch.mockResolvedValue([ - { id: "model-a", isDefault: false } as any, - { id: "model-b", isDefault: false } as any, + const fetchModels = vi.fn().mockResolvedValue([ + { id: "model-a", isDefault: false }, + { id: "model-b", isDefault: false }, ]); + mockedBuildProviderRegistry.mockReturnValue({ + claude: { fetchModels: vi.fn() }, + codex: { fetchModels }, + opencode: { fetchModels: vi.fn() }, + } as any); - const result = await resolveAgentModel({ provider: "codex" }); + const result = await resolveAgentModel({ provider: "codex", logger: testLogger }); expect(result).toBe("model-a"); }); it("returns undefined when the catalog lookup fails", async () => { - mockedFetch.mockRejectedValue(new Error("boom")); + const fetchModels = vi.fn().mockRejectedValue(new Error("boom")); + mockedBuildProviderRegistry.mockReturnValue({ + claude: { fetchModels: vi.fn() }, + codex: { fetchModels }, + opencode: { fetchModels: vi.fn() }, + } as any); - const result = await resolveAgentModel({ provider: "codex" }); + const result = await resolveAgentModel({ provider: "codex", logger: testLogger }); expect(result).toBeUndefined(); + expect(testLogger.warn).toHaveBeenCalled(); }); }); diff --git a/packages/server/src/server/agent/model-resolver.ts b/packages/server/src/server/agent/model-resolver.ts index 0f46d9dc5..81016ea6e 100644 --- a/packages/server/src/server/agent/model-resolver.ts +++ b/packages/server/src/server/agent/model-resolver.ts @@ -1,14 +1,13 @@ -import { fetchProviderModels } from "./provider-registry.js"; +import { buildProviderRegistry } from "./provider-registry.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { expandTilde } from "../../utils/path.js"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "model-resolver" }); +import type { Logger } from "pino"; type ResolveAgentModelOptions = { provider: AgentProvider; requestedModel?: string | null; cwd?: string; + logger: Logger; }; export async function resolveAgentModel( @@ -20,13 +19,14 @@ export async function resolveAgentModel( } try { - const models = await fetchProviderModels(options.provider, { + const providerRegistry = buildProviderRegistry(options.logger); + const models = await providerRegistry[options.provider].fetchModels({ cwd: options.cwd ? expandTilde(options.cwd) : undefined, }); const preferred = models.find((model) => model.isDefault) ?? models[0]; return preferred?.id; } catch (error) { - logger.warn( + options.logger.warn( { err: error, provider: options.provider }, "Failed to resolve default model" ); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 38d34858a..733e51ba2 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -4,6 +4,7 @@ import type { AgentProvider, ListModelsOptions, } from "./agent-sdk-types.js"; +import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js"; @@ -25,60 +26,46 @@ export { }; export interface ProviderDefinition extends AgentProviderDefinition { - createClient: () => AgentClient; + createClient: (logger: Logger) => AgentClient; fetchModels: (options?: ListModelsOptions) => Promise; } -const claudeClient = new ClaudeAgentClient(); -const codexClient = new CodexMcpAgentClient(); -const opencodeClient = new OpenCodeAgentClient(); +export function buildProviderRegistry(logger: Logger): Record { + const claudeClient = new ClaudeAgentClient({ logger }); + const codexClient = new CodexMcpAgentClient(logger); + const opencodeClient = new OpenCodeAgentClient(logger); -export const PROVIDER_REGISTRY: Record = { - claude: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!, - createClient: () => new ClaudeAgentClient(), - fetchModels: (options) => claudeClient.listModels(options), - }, - codex: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!, - createClient: () => new CodexMcpAgentClient(), - fetchModels: (options) => codexClient.listModels(options), - }, - opencode: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, - createClient: () => new OpenCodeAgentClient(), - fetchModels: (options) => opencodeClient.listModels(options), - }, -}; - -export function getProviderDefinition(provider: AgentProvider): ProviderDefinition { - const definition = PROVIDER_REGISTRY[provider]; - if (!definition) { - throw new Error(`Unknown agent provider: ${provider}`); - } - return definition; + return { + claude: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!, + createClient: (logger: Logger) => new ClaudeAgentClient({ logger }), + fetchModels: (options) => claudeClient.listModels(options), + }, + codex: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!, + createClient: (logger: Logger) => new CodexMcpAgentClient(logger), + fetchModels: (options) => codexClient.listModels(options), + }, + opencode: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, + createClient: (logger: Logger) => new OpenCodeAgentClient(logger), + fetchModels: (options) => opencodeClient.listModels(options), + }, + }; } -export function getAllProviderDefinitions(): ProviderDefinition[] { - return Object.values(PROVIDER_REGISTRY); +// Deprecated: Use buildProviderRegistry instead +export const PROVIDER_REGISTRY: Record = null as any; + +export function createAllClients(logger: Logger): Record { + const registry = buildProviderRegistry(logger); + return { + claude: registry.claude.createClient(logger), + codex: registry.codex.createClient(logger), + opencode: registry.opencode.createClient(logger), + }; } -export function createAllClients(): Record { - const clients: Partial> = {}; - for (const [id, definition] of Object.entries(PROVIDER_REGISTRY)) { - clients[id as AgentProvider] = definition.createClient(); - } - return clients as Record; -} - -export async function fetchProviderModels( - provider: AgentProvider, - options?: { cwd?: string } -): Promise { - const definition = getProviderDefinition(provider); - return definition.fetchModels(options); -} - -export async function shutdownProviders(): Promise { - await OpenCodeServerManager.getInstance().shutdown(); +export async function shutdownProviders(logger: Logger): Promise { + await OpenCodeServerManager.getInstance(logger).shutdown(); } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index fb4b79c68..602968ec3 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -20,9 +20,7 @@ import { type SDKSystemMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import { getRootLogger } from "../../logger.js"; - -const logger = getRootLogger().child({ module: "agent", provider: "claude" }); +import type { Logger } from "pino"; import type { AgentCapabilityFlags, @@ -109,11 +107,13 @@ type ClaudeOptions = Options; type ClaudeAgentClientOptions = { defaults?: { agents?: Record }; + logger: Logger; }; type ClaudeAgentSessionOptions = { defaults?: { agents?: Record }; handle?: AgentPersistenceHandle; + logger: Logger; }; function appendCallerAgentId(url: string, agentId: string): string { @@ -303,15 +303,18 @@ export class ClaudeAgentClient implements AgentClient { readonly capabilities = CLAUDE_CAPABILITIES; private readonly defaults?: { agents?: Record }; + private readonly logger: Logger; - constructor(options?: ClaudeAgentClientOptions) { - this.defaults = options?.defaults; + constructor(options: ClaudeAgentClientOptions) { + this.defaults = options.defaults; + this.logger = options.logger.child({ module: "agent", provider: "claude" }); } async createSession(config: AgentSessionConfig): Promise { const claudeConfig = this.assertConfig(config); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, + logger: this.logger, }); } @@ -329,6 +332,7 @@ export class ClaudeAgentClient implements AgentClient { return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, handle, + logger: this.logger, }); } @@ -400,6 +404,7 @@ class ClaudeAgentSession implements AgentSession { private readonly config: ClaudeAgentConfig; private readonly defaults?: { agents?: Record }; + private readonly logger: Logger; private query: Query | null = null; private input: Pushable | null = null; private claudeSessionId: string | null; @@ -431,11 +436,12 @@ class ClaudeAgentSession implements AgentSession { constructor( config: ClaudeAgentConfig, - options?: ClaudeAgentSessionOptions + options: ClaudeAgentSessionOptions ) { this.config = config; - this.defaults = options?.defaults; - const handle = options?.handle; + this.defaults = options.defaults; + this.logger = options.logger; + const handle = options.handle; if (handle) { if (!handle.sessionId) { @@ -552,7 +558,7 @@ class ClaudeAgentSession implements AgentSession { this.turnCancelRequested = true; // Store the interrupt promise so processPrompt can await it before calling query.next() this.pendingInterruptPromise = this.interruptActiveTurn().catch((error) => { - logger.warn({ err: error }, "Failed to interrupt during cancel"); + this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); // Push turn_canceled before ending the queue so consumers get proper lifecycle signals queue.push({ @@ -575,7 +581,7 @@ class ClaudeAgentSession implements AgentSession { const forwardPromise = this.forwardPromptEvents(sdkMessage, queue, turnId); this.activeTurnPromise = forwardPromise; forwardPromise.catch((error) => { - logger.error({ err: error }, "Unexpected error in forwardPromptEvents"); + this.logger.error({ err: error }, "Unexpected error in forwardPromptEvents"); }); try { @@ -810,7 +816,7 @@ class ClaudeAgentSession implements AgentSession { }, settingSources: ["user", "project"], stderr: (data: string) => { - logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr"); + this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr"); }, env: { ...process.env, @@ -1022,7 +1028,7 @@ class ClaudeAgentSession implements AgentSession { this.query = null; this.input = null; } catch (error) { - logger.warn({ err: error }, "Failed to interrupt active turn"); + this.logger.warn({ err: error }, "Failed to interrupt active turn"); } } @@ -1094,13 +1100,38 @@ class ClaudeAgentSession implements AgentSession { if (message.subtype !== "init") { return; } - this.claudeSessionId = message.session_id; + + const newSessionId = message.session_id; + const existingSessionId = this.claudeSessionId; + + if (existingSessionId === null) { + // First time setting session ID (empty → filled) - this is expected + this.claudeSessionId = newSessionId; + this.logger.debug( + { sessionId: newSessionId }, + "Claude session ID set for the first time" + ); + } else if (existingSessionId === newSessionId) { + // Same session ID - no-op, but log for visibility + this.logger.debug( + { sessionId: newSessionId }, + "Claude session ID unchanged (same value)" + ); + } else { + // CRITICAL: Session ID is being overwritten with a different value + // This should NEVER happen and indicates a serious bug + throw new Error( + `CRITICAL: Claude session ID overwrite detected! ` + + `Existing: ${existingSessionId}, New: ${newSessionId}. ` + + `This indicates a session identity corruption bug.` + ); + } this.availableModes = DEFAULT_MODES; this.currentMode = message.permissionMode; this.persistence = null; // Capture actual model from SDK init message (not just the configured model) if (message.model) { - logger.debug({ model: message.model }, "Captured model from SDK init"); + this.logger.debug({ model: message.model }, "Captured model from SDK init"); this.lastOptionsModel = message.model; // Invalidate cached runtime info so it picks up the new model this.cachedRuntimeInfo = null; diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index b7db225c1..c33779fd3 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -14,9 +14,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { ElicitRequestSchema, type ElicitResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { getRootLogger } from "../../logger.js"; - -const logger = getRootLogger().child({ module: "agent", provider: "codex" }); +import type { Logger } from "pino"; import type { AgentCapabilityFlags, @@ -2373,7 +2371,7 @@ async function writeImageAttachment(mimeType: string, data: string): Promise { +async function replaceInlineImageData(promptText: string, logger: Logger): Promise { const dataUrlRegex = /data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)/g; const matches = Array.from(promptText.matchAll(dataUrlRegex)); @@ -2406,9 +2404,9 @@ async function replaceInlineImageData(promptText: string): Promise { return output; } -async function toPromptText(prompt: AgentPromptInput): Promise { +async function toPromptText(prompt: AgentPromptInput, logger: Logger): Promise { if (typeof prompt === "string") { - return await replaceInlineImageData(prompt); + return await replaceInlineImageData(prompt, logger); } const parts: string[] = []; for (const chunk of prompt) { @@ -2427,7 +2425,7 @@ async function toPromptText(prompt: AgentPromptInput): Promise { } } const joined = parts.join("\n\n"); - return await replaceInlineImageData(joined); + return await replaceInlineImageData(joined, logger); } function getCodexMcpCommand(): string { @@ -2752,6 +2750,7 @@ class CodexMcpAgentSession implements AgentSession { readonly capabilities = CODEX_MCP_CAPABILITIES; private readonly client: Client; + private readonly logger: Logger; private transport: StdioClientTransport | null = null; private connected = false; private config: AgentSessionConfig; @@ -2778,7 +2777,8 @@ class CodexMcpAgentSession implements AgentSession { private resumeHandle: AgentPersistenceHandle | null = null; private pendingResumeFile: string | null = null; - constructor(config: CodexMcpAgentConfig, resumeHandle?: AgentPersistenceHandle) { + constructor(config: CodexMcpAgentConfig, resumeHandle: AgentPersistenceHandle | undefined, logger: Logger) { + this.logger = logger; if (config.modeId === undefined) { throw new Error("Codex agent requires modeId to be specified"); } @@ -2889,7 +2889,7 @@ class CodexMcpAgentSession implements AgentSession { const timeline = await loadCodexPersistedTimeline(historyId, { rolloutPath: resolveCodexRolloutPath(metadata), sessionRoot: resolveCodexSessionRootFromMetadata(metadata), - }); + }, this.logger); if (timeline.length > 0) { this.persistedHistory = timeline; this.historyPending = true; @@ -2956,7 +2956,7 @@ class CodexMcpAgentSession implements AgentSession { const abortController = new AbortController(); this.currentAbortController = abortController; - const promptText = await toPromptText(prompt); + const promptText = await toPromptText(prompt, this.logger); // NOTE: user_message is NOT emitted here because the agent-manager's // recordUserMessage() already handles emitting the user message timeline // event before calling stream(). Emitting here would cause duplicates. @@ -4203,12 +4203,18 @@ export class CodexMcpAgentClient implements AgentClient { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_MCP_CAPABILITIES; + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger.child({ module: "agent", provider: "codex" }); + } + async createSession(config: AgentSessionConfig): Promise { const sessionConfig: CodexMcpAgentConfig = { ...config, provider: CODEX_PROVIDER, }; - const session = new CodexMcpAgentSession(sessionConfig); + const session = new CodexMcpAgentSession(sessionConfig, undefined, this.logger); await session.connect(); return session; } @@ -4233,7 +4239,7 @@ export class CodexMcpAgentClient implements AgentClient { ...merged, provider: CODEX_PROVIDER, }; - const session = new CodexMcpAgentSession(sessionConfig, handle); + const session = new CodexMcpAgentSession(sessionConfig, handle, this.logger); await session.connect(); return session; } @@ -4274,7 +4280,7 @@ export class CodexMcpAgentClient implements AgentClient { const timeline = await loadCodexPersistedTimeline(sessionId, { sessionRoot: root, rolloutPath: candidate.path, - }); + }, this.logger); descriptors.push({ provider: CODEX_PROVIDER, sessionId, @@ -4808,7 +4814,8 @@ type CodexPersistedTimelineOptions = { async function loadCodexPersistedTimeline( sessionId: string, - options?: CodexPersistedTimelineOptions + options?: CodexPersistedTimelineOptions, + logger?: Logger ): Promise { const rolloutPath = options?.rolloutPath ?? null; if (rolloutPath) { @@ -4847,7 +4854,7 @@ async function loadCodexPersistedTimeline( const timeline = await parseRolloutFile(rolloutFile); return timeline.slice(0, PERSISTED_TIMELINE_LIMIT); } catch (error) { - logger.warn( + logger?.warn( { err: error, sessionId }, "Failed to load persisted timeline" ); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8c343ae9d..07f733117 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,9 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; import net from "node:net"; -import { getRootLogger } from "../../logger.js"; - -const logger = getRootLogger().child({ module: "agent", provider: "opencode" }); +import type { Logger } from "pino"; import type { AgentCapabilityFlags, @@ -69,10 +67,15 @@ export class OpenCodeServerManager { private server: ChildProcess | null = null; private port: number | null = null; private startPromise: Promise<{ port: number; url: string }> | null = null; + private readonly logger: Logger; - static getInstance(): OpenCodeServerManager { + private constructor(logger: Logger) { + this.logger = logger; + } + + static getInstance(logger: Logger): OpenCodeServerManager { if (!OpenCodeServerManager.instance) { - OpenCodeServerManager.instance = new OpenCodeServerManager(); + OpenCodeServerManager.instance = new OpenCodeServerManager(logger); OpenCodeServerManager.registerExitHandler(); } return OpenCodeServerManager.instance; @@ -141,7 +144,7 @@ export class OpenCodeServerManager { }); this.server.stderr?.on("data", (data: Buffer) => { - logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr"); + this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr"); }); this.server.on("error", (error) => { @@ -183,7 +186,13 @@ export class OpenCodeAgentClient implements AgentClient { readonly provider: "opencode" = "opencode"; readonly capabilities = OPENCODE_CAPABILITIES; - private serverManager = OpenCodeServerManager.getInstance(); + private readonly serverManager: OpenCodeServerManager; + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger.child({ module: "agent", provider: "opencode" }); + this.serverManager = OpenCodeServerManager.getInstance(this.logger); + } async createSession(config: AgentSessionConfig): Promise { const openCodeConfig = this.assertConfig(config); diff --git a/packages/server/src/server/agent/stt-debug.ts b/packages/server/src/server/agent/stt-debug.ts index 18a5cf68d..a3c50bac9 100644 --- a/packages/server/src/server/agent/stt-debug.ts +++ b/packages/server/src/server/agent/stt-debug.ts @@ -1,9 +1,7 @@ +import type pino from "pino"; import { mkdir, writeFile } from "fs/promises"; import { join, resolve } from "path"; import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "stt-debug" }); const debugDir = process.env.STT_DEBUG_AUDIO_DIR ? resolve(process.env.STT_DEBUG_AUDIO_DIR) @@ -20,7 +18,8 @@ export interface DebugAudioMetadata { export async function maybePersistDebugAudio( audio: Buffer, - metadata: DebugAudioMetadata + metadata: DebugAudioMetadata, + logger: pino.Logger ): Promise { if (!debugDir) { return null; diff --git a/packages/server/src/server/agent/stt-manager.ts b/packages/server/src/server/agent/stt-manager.ts index b1a61f59e..4abb6ea15 100644 --- a/packages/server/src/server/agent/stt-manager.ts +++ b/packages/server/src/server/agent/stt-manager.ts @@ -1,6 +1,6 @@ -import { transcribeAudio, type TranscriptionResult } from "./stt-openai.js"; +import type pino from "pino"; +import type { OpenAISTT, TranscriptionResult } from "./stt-openai.js"; import { maybePersistDebugAudio } from "./stt-debug.js"; -import { getRootLogger } from "../logger.js"; interface TranscriptionMetadata { agentId?: string; @@ -20,11 +20,13 @@ export interface SessionTranscriptionResult extends TranscriptionResult { */ export class STTManager { private readonly sessionId: string; - private readonly logger; + private readonly logger: pino.Logger; + private readonly stt: OpenAISTT | null; - constructor(sessionId: string) { + constructor(sessionId: string, logger: pino.Logger, stt: OpenAISTT | null) { this.sessionId = sessionId; - this.logger = getRootLogger().child({ module: "agent", component: "stt-manager", sessionId }); + this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId }); + this.stt = stt; } /** @@ -35,6 +37,10 @@ export class STTManager { format: string, metadata?: TranscriptionMetadata ): Promise { + if (!this.stt) { + throw new Error("STT not configured"); + } + this.logger.debug( { bytes: audio.length, format, label: metadata?.label }, "Transcribing audio" @@ -42,18 +48,22 @@ export class STTManager { let debugRecordingPath: string | null = null; try { - debugRecordingPath = await maybePersistDebugAudio(audio, { - sessionId: this.sessionId, - agentId: metadata?.agentId, - requestId: metadata?.requestId, - label: metadata?.label, - format, - }); + debugRecordingPath = await maybePersistDebugAudio( + audio, + { + sessionId: this.sessionId, + agentId: metadata?.agentId, + requestId: metadata?.requestId, + label: metadata?.label, + format, + }, + this.logger + ); } catch (error) { this.logger.warn({ err: error }, "Failed to persist debug audio"); } - const result = await transcribeAudio(audio, format); + const result = await this.stt.transcribeAudio(audio, format); // Filter out low-confidence transcriptions (non-speech sounds) if (result.isLowConfidence) { diff --git a/packages/server/src/server/agent/stt-openai.ts b/packages/server/src/server/agent/stt-openai.ts index 910b84bb4..5e70c6a81 100644 --- a/packages/server/src/server/agent/stt-openai.ts +++ b/packages/server/src/server/agent/stt-openai.ts @@ -1,12 +1,10 @@ +import type pino from "pino"; import OpenAI from "openai"; import { writeFile, unlink } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; import { v4 } from "uuid"; import { inferAudioExtension } from "./audio-utils.js"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", provider: "openai", component: "stt" }); export interface STTConfig { apiKey: string; @@ -53,125 +51,101 @@ function isLogprobTokenArray(value: unknown): value is LogprobToken[] { return Array.isArray(value) && value.every((entry) => isLogprobToken(entry)); } -let openaiClient: OpenAI | null = null; -let config: STTConfig | null = null; +export class OpenAISTT { + private readonly openaiClient: OpenAI; + private readonly config: STTConfig; + private readonly logger: pino.Logger; -export function initializeSTT(sttConfig: STTConfig): void { - config = sttConfig; - openaiClient = new OpenAI({ - apiKey: sttConfig.apiKey, - }); - logger.info({ model: sttConfig.model || "whisper-1" }, "STT (OpenAI Whisper) initialized"); -} - -export async function transcribeAudio( - audioBuffer: Buffer, - format: string -): Promise { - if (!openaiClient || !config) { - throw new Error("STT not initialized. Call initializeSTT() first."); - } - - const startTime = Date.now(); - let tempFilePath: string | null = null; - - try { - // Map format to file extension - const ext = inferAudioExtension(format); - - // Write audio buffer to temporary file - // OpenAI API requires file upload, not raw buffer - tempFilePath = join(tmpdir(), `audio-${v4()}.${ext}`); - await writeFile(tempFilePath, audioBuffer); - - logger.debug( - { tempFilePath, bytes: audioBuffer.length }, - "Transcribing audio file" - ); - - // Call OpenAI Whisper API - const modelToUse = config.model ?? "whisper-1"; - const supportsLogprobs = - modelToUse === "gpt-4o-transcribe" || modelToUse === "gpt-4o-mini-transcribe"; - const includeLogprobs: ["logprobs"] = ["logprobs"]; - - const response = await openaiClient.audio.transcriptions.create({ - file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)), - language: "en", - model: modelToUse, - ...(supportsLogprobs ? { include: includeLogprobs } : {}), - response_format: "json", // Get language and duration info + constructor(sttConfig: STTConfig, parentLogger: pino.Logger) { + this.config = sttConfig; + this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "stt" }); + this.openaiClient = new OpenAI({ + apiKey: sttConfig.apiKey, }); + this.logger.info({ model: sttConfig.model || "whisper-1" }, "STT (OpenAI Whisper) initialized"); + } - const duration = Date.now() - startTime; + public async transcribeAudio(audioBuffer: Buffer, format: string): Promise { + const startTime = Date.now(); + let tempFilePath: string | null = null; - // Get confidence threshold (default: -3.0) - const confidenceThreshold = config.confidenceThreshold ?? -3.0; + try { + const ext = inferAudioExtension(format); + tempFilePath = join(tmpdir(), `audio-${v4()}.${ext}`); + await writeFile(tempFilePath, audioBuffer); - // Analyze logprobs if available - let avgLogprob: number | undefined; - let isLowConfidence = false; - const logprobs = - supportsLogprobs && - isObject(response) && - isLogprobTokenArray(response.logprobs) - ? response.logprobs - : undefined; - - if (logprobs && logprobs.length > 0) { - // Calculate average logprob - const totalLogprob = logprobs.reduce( - (sum, token) => sum + token.logprob, - 0 + this.logger.debug( + { tempFilePath, bytes: audioBuffer.length }, + "Transcribing audio file" ); - avgLogprob = totalLogprob / logprobs.length; - // Check if transcription is low confidence - isLowConfidence = avgLogprob < confidenceThreshold; + const modelToUse = this.config.model ?? "whisper-1"; + const supportsLogprobs = + modelToUse === "gpt-4o-transcribe" || modelToUse === "gpt-4o-mini-transcribe"; + const includeLogprobs: ["logprobs"] = ["logprobs"]; - if (isLowConfidence) { - logger.debug( - { - avgLogprob, - threshold: confidenceThreshold, - text: response.text, - tokenLogprobs: logprobs.map((t) => `${t.token}:${t.logprob.toFixed(2)}`).join(", ") - }, - "Low confidence transcription detected" - ); + const response = await this.openaiClient.audio.transcriptions.create({ + file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)), + language: "en", + model: modelToUse, + ...(supportsLogprobs ? { include: includeLogprobs } : {}), + response_format: "json", + }); + + const duration = Date.now() - startTime; + const confidenceThreshold = this.config.confidenceThreshold ?? -3.0; + + let avgLogprob: number | undefined; + let isLowConfidence = false; + const logprobs = + supportsLogprobs && + isObject(response) && + isLogprobTokenArray(response.logprobs) + ? response.logprobs + : undefined; + + if (logprobs && logprobs.length > 0) { + const totalLogprob = logprobs.reduce((sum, token) => sum + token.logprob, 0); + avgLogprob = totalLogprob / logprobs.length; + isLowConfidence = avgLogprob < confidenceThreshold; + + if (isLowConfidence) { + this.logger.debug( + { + avgLogprob, + threshold: confidenceThreshold, + text: response.text, + tokenLogprobs: logprobs.map((t) => `${t.token}:${t.logprob.toFixed(2)}`).join(", "), + }, + "Low confidence transcription detected" + ); + } } - } - logger.debug( - { duration, text: response.text, avgLogprob }, - "Transcription complete" - ); + this.logger.debug({ duration, text: response.text, avgLogprob }, "Transcription complete"); - return { - text: response.text, - duration: duration, - logprobs: logprobs, - avgLogprob: avgLogprob, - isLowConfidence: isLowConfidence, - language: - isObject(response) && typeof response.language === "string" - ? response.language - : undefined, - }; - } catch (error: any) { - logger.error({ err: error }, "Transcription error"); - throw new Error(`STT transcription failed: ${error.message}`); - } finally { - // Clean up temporary file - if (tempFilePath) { - try { - await unlink(tempFilePath); - } catch (cleanupError) { - logger.warn({ tempFilePath }, "Failed to clean up temp file"); + return { + text: response.text, + duration: duration, + logprobs: logprobs, + avgLogprob: avgLogprob, + isLowConfidence: isLowConfidence, + language: + isObject(response) && typeof response.language === "string" + ? response.language + : undefined, + }; + } catch (error: any) { + this.logger.error({ err: error }, "Transcription error"); + throw new Error(`STT transcription failed: ${error.message}`); + } finally { + if (tempFilePath) { + try { + await unlink(tempFilePath); + } catch (cleanupError) { + this.logger.warn({ tempFilePath }, "Failed to clean up temp file"); + } } } } } -export function isSTTInitialized(): boolean { - return openaiClient !== null && config !== null; -} diff --git a/packages/server/src/server/agent/tts-manager.ts b/packages/server/src/server/agent/tts-manager.ts index 402d9fafe..f12b69519 100644 --- a/packages/server/src/server/agent/tts-manager.ts +++ b/packages/server/src/server/agent/tts-manager.ts @@ -1,7 +1,7 @@ +import type pino from "pino"; import { v4 as uuidv4 } from "uuid"; -import { synthesizeSpeech } from "./tts-openai.js"; +import type { OpenAITTS } from "./tts-openai.js"; import type { SessionOutboundMessage } from "../messages.js"; -import { getRootLogger } from "../logger.js"; interface PendingPlayback { resolve: () => void; @@ -16,10 +16,12 @@ interface PendingPlayback { */ export class TTSManager { private pendingPlaybacks: Map = new Map(); - private readonly logger; + private readonly logger: pino.Logger; + private readonly tts: OpenAITTS | null; - constructor(sessionId: string) { - this.logger = getRootLogger().child({ module: "agent", component: "tts-manager", sessionId }); + constructor(sessionId: string, logger: pino.Logger, tts: OpenAITTS | null) { + this.logger = logger.child({ module: "agent", component: "tts-manager", sessionId }); + this.tts = tts; } /** @@ -32,13 +34,17 @@ export class TTSManager { abortSignal: AbortSignal, isRealtimeMode: boolean ): Promise { + if (!this.tts) { + throw new Error("TTS not configured"); + } + if (abortSignal.aborted) { this.logger.debug("Aborted before generating audio"); return; } // Generate TTS audio stream - const { stream, format } = await synthesizeSpeech(text); + const { stream, format } = await this.tts.synthesizeSpeech(text); if (abortSignal.aborted) { this.logger.debug("Aborted after generating audio"); diff --git a/packages/server/src/server/agent/tts-openai.ts b/packages/server/src/server/agent/tts-openai.ts index b66af3ee5..efe4e4ca0 100644 --- a/packages/server/src/server/agent/tts-openai.ts +++ b/packages/server/src/server/agent/tts-openai.ts @@ -1,8 +1,6 @@ +import type pino from "pino"; import OpenAI from "openai"; import { Readable } from "stream"; -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", provider: "openai", component: "tts" }); export interface TTSConfig { apiKey: string; @@ -16,72 +14,65 @@ export interface SpeechStreamResult { format: string; } -let openaiClient: OpenAI | null = null; -let config: TTSConfig | null = null; +export class OpenAITTS { + private readonly openaiClient: OpenAI; + private readonly config: TTSConfig; + private readonly logger: pino.Logger; -export function initializeTTS(ttsConfig: TTSConfig): void { - config = { - model: "tts-1", - voice: "alloy", - responseFormat: "pcm", - ...ttsConfig, - }; - openaiClient = new OpenAI({ - apiKey: ttsConfig.apiKey, - }); - logger.info( - { voice: config.voice, model: config.model, format: config.responseFormat }, - "TTS (OpenAI) initialized" - ); -} - -export async function synthesizeSpeech( - text: string -): Promise { - if (!openaiClient || !config) { - throw new Error("TTS not initialized. Call initializeTTS() first."); - } - - if (!text || text.trim().length === 0) { - throw new Error("Cannot synthesize empty text"); - } - - const startTime = Date.now(); - - try { - logger.debug( - { textLength: text.length, preview: text.substring(0, 50) }, - "Synthesizing speech" - ); - - // Call OpenAI TTS API with streaming - const response = await openaiClient.audio.speech.create({ - model: config.model!, - voice: config.voice!, - input: text, - // speed: 1.2, - response_format: config.responseFormat as "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm", + constructor(ttsConfig: TTSConfig, parentLogger: pino.Logger) { + this.config = { + model: "tts-1", + voice: "alloy", + responseFormat: "pcm", + ...ttsConfig, + }; + this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "tts" }); + this.openaiClient = new OpenAI({ + apiKey: ttsConfig.apiKey, }); - const audioStream = response.body as unknown as Readable; + this.logger.info( + { voice: this.config.voice, model: this.config.model, format: this.config.responseFormat }, + "TTS (OpenAI) initialized" + ); + } - const duration = Date.now() - startTime; - logger.debug({ duration }, "Speech synthesis stream ready"); + public getConfig(): TTSConfig { + return this.config; + } - return { - stream: audioStream, - format: config.responseFormat || "mp3", - }; - } catch (error: any) { - logger.error({ err: error }, "Speech synthesis error"); - throw new Error(`TTS synthesis failed: ${error.message}`); + public async synthesizeSpeech(text: string): Promise { + if (!text || text.trim().length === 0) { + throw new Error("Cannot synthesize empty text"); + } + + const startTime = Date.now(); + + try { + this.logger.debug( + { textLength: text.length, preview: text.substring(0, 50) }, + "Synthesizing speech" + ); + + const response = await this.openaiClient.audio.speech.create({ + model: this.config.model!, + voice: this.config.voice!, + input: text, + response_format: this.config.responseFormat as "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm", + }); + + const audioStream = response.body as unknown as Readable; + + const duration = Date.now() - startTime; + this.logger.debug({ duration }, "Speech synthesis stream ready"); + + return { + stream: audioStream, + format: this.config.responseFormat || "mp3", + }; + } catch (error: any) { + this.logger.error({ err: error }, "Speech synthesis error"); + throw new Error(`TTS synthesis failed: ${error.message}`); + } } } - -export function isTTSInitialized(): boolean { - return openaiClient !== null && config !== null; -} - -export function getTTSConfig(): TTSConfig | null { - return config; -} diff --git a/packages/server/src/server/agent/wait-for-agent-tracker.ts b/packages/server/src/server/agent/wait-for-agent-tracker.ts index cea4848ca..80a4a1073 100644 --- a/packages/server/src/server/agent/wait-for-agent-tracker.ts +++ b/packages/server/src/server/agent/wait-for-agent-tracker.ts @@ -1,6 +1,4 @@ -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "agent", component: "wait-for-agent-tracker" }); +import type { Logger } from "pino"; export type WaitForAgentCanceler = (agentId: string, reason?: string) => boolean; @@ -10,6 +8,11 @@ export type WaitForAgentCanceler = (agentId: string, reason?: string) => boolean */ export class WaitForAgentTracker { private waiters = new Map void>>(); + private logger: Logger; + + constructor(logger: Logger) { + this.logger = logger.child({ module: "agent", component: "wait-for-agent-tracker" }); + } register(agentId: string, cancel: (reason?: string) => void): () => void { if (!this.waiters.has(agentId)) { @@ -41,7 +44,7 @@ export class WaitForAgentTracker { try { cancel(reason); } catch (error) { - logger.warn( + this.logger.warn( { err: error, agentId }, "Cancel callback failed" ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index d5e94fe9d..07e498135 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -6,7 +6,7 @@ import { stat } from "fs/promises"; import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { getRootLogger } from "./logger.js"; +import type { Logger } from "pino"; type ListenTarget = | { type: "tcp"; host: string; port: number } @@ -40,8 +40,8 @@ function parseListenString(listen: string): ListenTarget { import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; -import { initializeSTT, type STTConfig } from "./agent/stt-openai.js"; -import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js"; +import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js"; +import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js"; import { listConversations, deleteConversation } from "./persistence.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentRegistry } from "./agent/agent-registry.js"; @@ -95,9 +95,10 @@ export interface PaseoDaemon { } export async function createPaseoDaemon( - config: PaseoDaemonConfig + config: PaseoDaemonConfig, + rootLogger: Logger ): Promise { - const logger = getRootLogger().child({ module: "bootstrap" }); + const logger = rootLogger.child({ module: "bootstrap" }); const agentMcpRoute = config.agentMcpRoute; const basicAuthUsers = config.auth.basicUsers; @@ -177,7 +178,7 @@ export async function createPaseoDaemon( // Conversation management endpoints app.get("/api/conversations", async (_req, res) => { try { - const conversations = await listConversations(); + const conversations = await listConversations(logger); res.json(conversations); } catch (err) { logger.error({ err }, "Failed to list conversations"); @@ -188,7 +189,7 @@ export async function createPaseoDaemon( app.delete("/api/conversations/:id", async (req, res) => { try { const { id } = req.params; - await deleteConversation(id); + await deleteConversation(logger, id); res.json({ success: true }); } catch (err) { logger.error({ err }, "Failed to delete conversation"); @@ -248,17 +249,18 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); - const agentRegistry = new AgentRegistry(config.agentRegistryPath); + const agentRegistry = new AgentRegistry(config.agentRegistryPath, logger); const agentManager = new AgentManager({ clients: { - ...createAllClients(), + ...createAllClients(logger), ...config.agentClients, }, registry: agentRegistry, agentControlMcp: config.agentControlMcp, + logger, }); - attachAgentRegistryPersistence(agentManager, agentRegistry); + attachAgentRegistryPersistence(logger, agentManager, agentRegistry); const persistedRecords = await agentRegistry.list(); logger.info( `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` @@ -272,6 +274,7 @@ export async function createPaseoDaemon( agentManager, agentRegistry, callerAgentId, + logger, }); const transport = new StreamableHTTPServerTransport({ @@ -372,17 +375,8 @@ export async function createPaseoDaemon( app.delete(agentMcpRoute, handleAgentMcpRequest); logger.info({ route: agentMcpRoute }, "Agent MCP server mounted"); - const wsServer = new VoiceAssistantWebSocketServer( - httpServer, - agentManager, - agentRegistry, - downloadTokenStore, - { - agentMcpUrl: config.agentControlMcp.url, - agentMcpHeaders: config.agentControlMcp.headers, - }, - { allowedOrigins } - ); + let sttService: OpenAISTT | null = null; + let ttsService: OpenAITTS | null = null; const openaiApiKey = config.openai?.apiKey; if (openaiApiKey) { @@ -391,29 +385,49 @@ export async function createPaseoDaemon( const sttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey; if (sttApiKey) { const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {}; - initializeSTT({ - apiKey: sttApiKey, - ...sttConfig, - }); + sttService = new OpenAISTT( + { + apiKey: sttApiKey, + ...sttConfig, + }, + logger + ); } const ttsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey; if (ttsApiKey) { const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {}; - initializeTTS({ - apiKey: ttsApiKey, - voice: "alloy", - model: "tts-1", - responseFormat: "pcm", - ...ttsConfig, - }); + ttsService = new OpenAITTS( + { + apiKey: ttsApiKey, + voice: "alloy", + model: "tts-1", + responseFormat: "pcm", + ...ttsConfig, + }, + logger + ); } - initializeTitleGenerator(openaiApiKey); + initializeTitleGenerator(logger.child({ module: "agent-title-generator" }), openaiApiKey); } else { logger.warn("OPENAI_API_KEY not set - LLM, STT, and TTS features will not work"); } + const wsServer = new VoiceAssistantWebSocketServer( + httpServer, + logger, + agentManager, + agentRegistry, + downloadTokenStore, + { + agentMcpUrl: config.agentControlMcp.url, + agentMcpHeaders: config.agentControlMcp.headers, + }, + { allowedOrigins }, + { stt: sttService, tts: ttsService } + ); + const start = async () => { await new Promise((resolve, reject) => { const onError = (err: Error) => { @@ -448,8 +462,8 @@ export async function createPaseoDaemon( }; const stop = async () => { - await closeAllAgents(agentManager); - await shutdownProviders(); + await closeAllAgents(logger, agentManager); + await shutdownProviders(logger); await wsServer.close(); await new Promise((resolve) => { httpServer.close(() => resolve()); @@ -469,8 +483,10 @@ export async function createPaseoDaemon( }; } -async function closeAllAgents(agentManager: AgentManager): Promise { - const logger = getRootLogger().child({ module: "bootstrap" }); +async function closeAllAgents( + logger: Logger, + agentManager: AgentManager +): Promise { const agents = agentManager.listAgents(); for (const agent of agents) { try { diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 070ca7913..596be907a 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -15,7 +15,7 @@ async function main() { const persistedConfig = loadPersistedConfig(paseoHome); const logger = createRootLogger(persistedConfig); const config = loadConfig(paseoHome); - const daemon = await createPaseoDaemon(config); + const daemon = await createPaseoDaemon(config, logger); await daemon.start(); diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index a9f9f20ee..92865f543 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -23,8 +23,6 @@ export function resolveLogConfig( return { level, format }; } -let rootLogger: pino.Logger | undefined; - export function createRootLogger( persistedConfig: PersistedConfig | undefined ): pino.Logger { @@ -42,21 +40,12 @@ export function createRootLogger( } : undefined; - rootLogger = pino({ + return pino({ level: config.level, transport, }); - - return rootLogger; } -export function getRootLogger(): pino.Logger { - if (!rootLogger) { - throw new Error("Root logger not initialized. Call createRootLogger first."); - } - return rootLogger; -} - -export function createChildLogger(name: string): pino.Logger { - return getRootLogger().child({ name }); +export function createChildLogger(parent: pino.Logger, name: string): pino.Logger { + return parent.child({ name }); } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 088d08810..ded1c6332 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,7 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; -import { getRootLogger } from "./logger.js"; const PersistedConfigSchema = z.object({ listen: z.string().optional(), @@ -24,25 +23,28 @@ export type PersistedConfig = z.infer; const CONFIG_FILENAME = "config.json"; +type LoggerLike = { + child(bindings: Record): LoggerLike; + info(...args: any[]): void; +}; + function getConfigPath(paseoHome: string): string { return path.join(paseoHome, CONFIG_FILENAME); } -function getLogger() { - try { - return getRootLogger().child({ module: "config" }); - } catch { - // Root logger not initialized yet, return null - return null; - } +function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { + return logger?.child({ module: "config" }); } -export function loadPersistedConfig(paseoHome: string): PersistedConfig { - const logger = getLogger(); +export function loadPersistedConfig( + paseoHome: string, + logger?: LoggerLike +): PersistedConfig { + const log = getLogger(logger); const configPath = getConfigPath(paseoHome); if (!existsSync(configPath)) { - logger?.info(`No config file at ${configPath}, using defaults`); + log?.info(`No config file at ${configPath}, using defaults`); return PersistedConfigSchema.parse({}); } @@ -70,15 +72,16 @@ export function loadPersistedConfig(paseoHome: string): PersistedConfig { throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`); } - logger?.info(`Loaded from ${configPath}`); + log?.info(`Loaded from ${configPath}`); return result.data; } export function savePersistedConfig( paseoHome: string, - config: PersistedConfig + config: PersistedConfig, + logger?: LoggerLike ): void { - const logger = getLogger(); + const log = getLogger(logger); const configPath = getConfigPath(paseoHome); const result = PersistedConfigSchema.safeParse(config); @@ -91,7 +94,7 @@ export function savePersistedConfig( try { writeFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); - logger?.info(`Saved to ${configPath}`); + log?.info(`Saved to ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`[Config] Failed to write ${configPath}: ${message}`); diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 4291c5748..4e0abd0ca 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -11,6 +11,11 @@ import type { AgentSessionConfig, } from "./agent/agent-sdk-types.js"; +const testLogger = { + child: () => testLogger, + error: vi.fn(), +} as any; + type ManagedAgentOverrides = Omit< Partial, "config" | "pendingPermissions" | "session" | "pendingRun" @@ -118,7 +123,7 @@ describe("persistence hooks", () => { }; }), }; - attachAgentRegistryPersistence(agentManager as any, { + attachAgentRegistryPersistence(testLogger, agentManager as any, { applySnapshot, list: vi.fn(), } as any); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 0d73064ec..e3ebcfe10 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -7,9 +7,15 @@ import type { AgentRegistry, StoredAgentRecord, } from "./agent/agent-registry.js"; -import { getRootLogger } from "./logger.js"; -const logger = getRootLogger().child({ module: "persistence" }); +type LoggerLike = { + child(bindings: Record): LoggerLike; + error(...args: any[]): void; +}; + +function getLogger(logger: LoggerLike): LoggerLike { + return logger.child({ module: "persistence" }); +} type AgentRegistryPersistence = Pick; type AgentManagerStateSource = Pick; @@ -23,15 +29,17 @@ function isKnownProvider(provider: string): provider is AgentProvider { * agent_state snapshot is flushed to disk. */ export function attachAgentRegistryPersistence( + logger: LoggerLike, agentManager: AgentManagerStateSource, registry: AgentRegistryPersistence ): () => void { + const log = getLogger(logger); const unsubscribe = agentManager.subscribe((event) => { if (event.type !== "agent_state") { return; } void registry.applySnapshot(event.agent).catch((error) => { - logger.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot"); + log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot"); }); }); diff --git a/packages/server/src/server/persistence.ts b/packages/server/src/server/persistence.ts index d19d1f771..6eff575ac 100644 --- a/packages/server/src/server/persistence.ts +++ b/packages/server/src/server/persistence.ts @@ -2,9 +2,17 @@ import { readFile, writeFile, readdir, unlink, mkdir, stat } from "fs/promises"; import { join } from "path"; import type { ModelMessage } from "@ai-sdk/provider-utils"; import { standardizePrompt } from "ai/internal"; -import { getRootLogger } from "./logger.js"; -const logger = getRootLogger().child({ module: "persistence" }); +type LoggerLike = { + child(bindings: Record): LoggerLike; + info(...args: any[]): void; + debug(...args: any[]): void; + error(...args: any[]): void; +}; + +function getLogger(logger: LoggerLike): LoggerLike { + return logger.child({ module: "persistence" }); +} const CONVERSATIONS_DIR = join(process.cwd(), "conversations"); @@ -32,9 +40,11 @@ async function ensureConversationsDir(): Promise { * Save conversation to disk */ export async function saveConversation( + logger: LoggerLike, conversationId: string, messages: ModelMessage[] ): Promise { + const log = getLogger(logger); try { await ensureConversationsDir(); @@ -47,12 +57,12 @@ export async function saveConversation( }; await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8"); - logger.info( + log.info( { conversationId, messageCount: messages.length }, "Saved conversation" ); } catch (error) { - logger.error( + log.error( { err: error, conversationId }, "Failed to save conversation" ); @@ -65,8 +75,10 @@ export async function saveConversation( * Returns null if conversation doesn't exist or fails to parse */ export async function loadConversation( + logger: LoggerLike, conversationId: string ): Promise { + const log = getLogger(logger); try { const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`); @@ -74,7 +86,7 @@ export async function loadConversation( try { await stat(filepath); } catch { - logger.debug({ conversationId }, "Conversation not found"); + log.debug({ conversationId }, "Conversation not found"); return null; } @@ -87,14 +99,14 @@ export async function loadConversation( prompt: data.messages, }); - logger.info( + log.info( { conversationId, messageCount: data.messageCount }, "Loaded conversation" ); return result.messages as ModelMessage[]; } catch (error) { - logger.error( + log.error( { err: error, conversationId }, "Failed to load conversation" ); @@ -105,7 +117,8 @@ export async function loadConversation( /** * List all conversations with metadata */ -export async function listConversations(): Promise { +export async function listConversations(logger: LoggerLike): Promise { + const log = getLogger(logger); try { await ensureConversationsDir(); @@ -126,7 +139,7 @@ export async function listConversations(): Promise { messageCount: data.messageCount, }); } catch (error) { - logger.error({ err: error, file }, "Failed to read conversation"); + log.error({ err: error, file }, "Failed to read conversation"); // Skip invalid files } } @@ -136,7 +149,7 @@ export async function listConversations(): Promise { return conversations; } catch (error) { - logger.error({ err: error }, "Failed to list conversations"); + log.error({ err: error }, "Failed to list conversations"); return []; } } @@ -144,13 +157,14 @@ export async function listConversations(): Promise { /** * Delete conversation from disk */ -export async function deleteConversation(conversationId: string): Promise { +export async function deleteConversation(logger: LoggerLike, conversationId: string): Promise { + const log = getLogger(logger); try { const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`); await unlink(filepath); - logger.info({ conversationId }, "Deleted conversation"); + log.info({ conversationId }, "Deleted conversation"); } catch (error) { - logger.error( + log.error( { err: error, conversationId }, "Failed to delete conversation" ); diff --git a/packages/server/src/server/push/push-service.ts b/packages/server/src/server/push/push-service.ts index b1d726dd2..d391b5ab6 100644 --- a/packages/server/src/server/push/push-service.ts +++ b/packages/server/src/server/push/push-service.ts @@ -1,7 +1,5 @@ -import { getRootLogger } from "../logger.js"; import type { PushTokenStore } from "./token-store.js"; - -const logger = getRootLogger().child({ module: "push", component: "push-service" }); +import type pino from "pino"; interface PushPayload { title: string; @@ -32,9 +30,11 @@ const MAX_BATCH_SIZE = 100; * Handles batching and invalid token removal. */ export class PushService { - private tokenStore: PushTokenStore; + private readonly logger: pino.Logger; + private readonly tokenStore: PushTokenStore; - constructor(tokenStore: PushTokenStore) { + constructor(logger: pino.Logger, tokenStore: PushTokenStore) { + this.logger = logger.child({ component: "push-service" }); this.tokenStore = tokenStore; } @@ -74,7 +74,7 @@ export class PushService { }); if (!response.ok) { - logger.error( + this.logger.error( { status: response.status, statusText: response.statusText }, "Expo push API error" ); @@ -84,7 +84,7 @@ export class PushService { const result = (await response.json()) as { data: ExpoPushTicket[] }; this.handleTickets(messages, result.data); } catch (error) { - logger.error({ err: error }, "Failed to send push notifications"); + this.logger.error({ err: error }, "Failed to send push notifications"); } } @@ -97,7 +97,7 @@ export class PushService { const message = messages[i]; if (ticket.status === "error") { - logger.error( + this.logger.error( { token: message.to, message: ticket.message, details: ticket.details }, "Push failed for token" ); diff --git a/packages/server/src/server/push/token-store.ts b/packages/server/src/server/push/token-store.ts index 0b2395cab..de3c5358b 100644 --- a/packages/server/src/server/push/token-store.ts +++ b/packages/server/src/server/push/token-store.ts @@ -1,23 +1,26 @@ -import { getRootLogger } from "../logger.js"; - -const logger = getRootLogger().child({ module: "push", component: "token-store" }); +import type pino from "pino"; /** * Simple in-memory store for Expo push tokens. * Tokens are used to send push notifications when all clients are stale. */ export class PushTokenStore { + private readonly logger: pino.Logger; private tokens: Set = new Set(); + constructor(logger: pino.Logger) { + this.logger = logger.child({ component: "token-store" }); + } + addToken(token: string): void { this.tokens.add(token); - logger.debug({ total: this.tokens.size }, "Added token"); + this.logger.debug({ total: this.tokens.size }, "Added token"); } removeToken(token: string): void { const deleted = this.tokens.delete(token); if (deleted) { - logger.debug({ total: this.tokens.size }, "Removed token"); + this.logger.debug({ total: this.tokens.size }, "Removed token"); } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6fb00459e..f6c7d55e8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -25,6 +25,8 @@ import { getSystemPrompt } from "./agent/system-prompt.js"; import { getAllTools } from "./agent/llm-openai.js"; import { TTSManager } from "./agent/tts-manager.js"; import { STTManager } from "./agent/stt-manager.js"; +import type { OpenAISTT } from "./agent/stt-openai.js"; +import type { OpenAITTS } from "./agent/tts-openai.js"; import { saveConversation, listConversations, @@ -36,7 +38,7 @@ import { } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { fetchProviderModels } from "./agent/provider-registry.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; @@ -69,9 +71,7 @@ import { validateBranchSlug, } from "../utils/worktree.js"; import { expandTilde } from "../utils/path.js"; -import { getRootLogger } from "./logger.js"; - -const logger = getRootLogger().child({ module: "session" }); +import type pino from "pino"; type AgentMcpClientConfig = { agentMcpUrl: string; @@ -158,7 +158,11 @@ function convertPCMToWavBuffer( return wavBuffer; } -function coerceAgentProvider(value: string, agentId?: string): AgentProvider { +function coerceAgentProvider( + logger: pino.Logger, + value: string, + agentId?: string +): AgentProvider { if (isValidAgentProvider(value)) { return value; } @@ -170,6 +174,7 @@ function coerceAgentProvider(value: string, agentId?: string): AgentProvider { } function toAgentPersistenceHandle( + logger: pino.Logger, handle: StoredAgentRecord["persistence"] ): AgentPersistenceHandle | null { if (!handle) { @@ -204,7 +209,7 @@ export class Session { private readonly clientId: string; private readonly conversationId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; - private readonly sessionLogger: ReturnType; + private readonly sessionLogger: pino.Logger; // State machine private abortController: AbortController; @@ -238,6 +243,7 @@ export class Session { private readonly agentMcpConfig: AgentMcpClientConfig; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; + private readonly providerRegistry: ReturnType; private agentTitleCache: Map = new Map(); private unsubscribeAgentEvents: (() => void) | null = null; private clientActivity: { @@ -250,11 +256,14 @@ export class Session { constructor( clientId: string, onMessage: (msg: SessionOutboundMessage) => void, + logger: pino.Logger, downloadTokenStore: DownloadTokenStore, pushTokenStore: PushTokenStore, agentManager: AgentManager, agentRegistry: AgentRegistry, agentMcpConfig: AgentMcpClientConfig, + stt: OpenAISTT | null, + tts: OpenAITTS | null, options?: { conversationId?: string; initialMessages?: ModelMessage[]; @@ -269,7 +278,12 @@ export class Session { this.agentRegistry = agentRegistry; this.agentMcpConfig = agentMcpConfig; this.abortController = new AbortController(); - this.sessionLogger = logger.child({ clientId: this.clientId, conversationId: this.conversationId }); + this.sessionLogger = logger.child({ + module: "session", + clientId: this.clientId, + conversationId: this.conversationId, + }); + this.providerRegistry = buildProviderRegistry(this.sessionLogger); // Initialize conversation history if (options?.initialMessages) { @@ -281,8 +295,8 @@ export class Session { } // Initialize per-session managers - this.ttsManager = new TTSManager(this.conversationId); - this.sttManager = new STTManager(this.conversationId); + this.ttsManager = new TTSManager(this.conversationId, this.sessionLogger, tts); + this.sttManager = new STTManager(this.conversationId, this.sessionLogger, stt); // Initialize agent MCP client asynchronously void this.initializeAgentMcp(); @@ -559,7 +573,7 @@ export class Session { ? new Date(record.lastUserMessageAt) : null; - const provider = coerceAgentProvider(record.provider, record.id); + const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id); return { id: record.id, provider, @@ -573,7 +587,7 @@ export class Session { currentModeId: record.lastModeId ?? null, availableModes: [], pendingPermissions: [], - persistence: toAgentPersistenceHandle(record.persistence), + persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence), lastUsage: undefined, lastError: undefined, title: record.title ?? null, @@ -597,7 +611,7 @@ export class Session { throw new Error(`Agent not found: ${agentId}`); } - const handle = toAgentPersistenceHandle(record.persistence); + const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); let snapshot: ManagedAgent; if (handle) { snapshot = await this.agentManager.resumeAgent( @@ -694,7 +708,11 @@ export class Session { { agentId }, `Generating title for agent ${agentId}` ); - const title = await generateAgentTitle(timeline, snapshot.cwd); + const title = await generateAgentTitle( + this.sessionLogger.child({ module: "agent-title-generator" }), + timeline, + snapshot.cwd + ); await this.agentRegistry.setTitle(agentId, title); this.setCachedTitle(agentId, title); const latest = this.agentManager.getAgent(agentId) ?? snapshot; @@ -880,7 +898,7 @@ export class Session { */ public async handleListConversations(requestId: string): Promise { try { - const conversations = await listConversations(); + const conversations = await listConversations(this.sessionLogger); this.emit({ type: "list_conversations_response", payload: { @@ -917,7 +935,7 @@ export class Session { requestId: string ): Promise { try { - await deleteConversation(conversationId); + await deleteConversation(this.sessionLogger, conversationId); this.emit({ type: "delete_conversation_response", payload: { @@ -1467,7 +1485,7 @@ export class Session { if (!record) { throw new Error(`Agent not found: ${agentId}`); } - const handle = toAgentPersistenceHandle(record.persistence); + const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); if (!handle) { throw new Error( `Agent ${agentId} cannot be refreshed because it lacks persistence` @@ -1653,7 +1671,7 @@ export class Session { ): Promise { const fetchedAt = new Date().toISOString(); try { - const models = await fetchProviderModels(msg.provider, { + const models = await this.providerRegistry[msg.provider].fetchModels({ cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, }); this.emit({ @@ -2943,7 +2961,7 @@ export class Session { // Persist conversation to disk try { - await saveConversation(this.conversationId, this.messages); + await saveConversation(this.sessionLogger, this.conversationId, this.messages); } catch (error) { this.sessionLogger.error( { err: error }, diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index f3c2c2da3..8f5249f3d 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; +import pino from "pino"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; type TestPaseoDaemonOptions = { @@ -84,7 +85,8 @@ export async function createTestPaseoDaemon( downloadTokenTtlMs: options.downloadTokenTtlMs, }; - const daemon = await createPaseoDaemon(config); + const logger = pino({ level: "silent" }); + const daemon = await createPaseoDaemon(config, logger); try { await daemon.start(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index adfcc176f..e30d351af 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -16,9 +16,9 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; -import { getRootLogger } from "./logger.js"; - -const logger = getRootLogger().child({ module: "websocket-server" }); +import type { OpenAISTT } from "./agent/stt-openai.js"; +import type { OpenAITTS } from "./agent/tts-openai.js"; +import type pino from "pino"; type AgentMcpClientConfig = { agentMcpUrl: string; @@ -34,6 +34,7 @@ type WebSocketServerConfig = { * This is a thin transport layer with no business logic. */ export class VoiceAssistantWebSocketServer { + private readonly logger: pino.Logger; private wss: WebSocketServer; private sessions: Map = new Map(); private conversationIdToWs: Map = new Map(); @@ -44,20 +45,29 @@ export class VoiceAssistantWebSocketServer { private pushTokenStore: PushTokenStore; private pushService: PushService; private readonly agentMcpConfig: AgentMcpClientConfig; + private readonly stt: OpenAISTT | null; + private readonly tts: OpenAITTS | null; constructor( server: HTTPServer, + logger: pino.Logger, agentManager: AgentManager, agentRegistry: AgentRegistry, downloadTokenStore: DownloadTokenStore, agentMcpConfig: AgentMcpClientConfig, - wsConfig: WebSocketServerConfig + wsConfig: WebSocketServerConfig, + speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null } ) { + this.logger = logger.child({ module: "websocket-server" }); this.agentManager = agentManager; this.agentRegistry = agentRegistry; this.downloadTokenStore = downloadTokenStore; - this.pushTokenStore = new PushTokenStore(); - this.pushService = new PushService(this.pushTokenStore); + this.stt = speech?.stt ?? null; + this.tts = speech?.tts ?? null; + + const pushLogger = this.logger.child({ module: "push" }); + this.pushTokenStore = new PushTokenStore(pushLogger); + this.pushService = new PushService(pushLogger, this.pushTokenStore); this.agentMcpConfig = agentMcpConfig; const { allowedOrigins } = wsConfig; @@ -71,7 +81,7 @@ export class VoiceAssistantWebSocketServer { if (!origin || allowedOrigins.has(origin)) { callback(true); } else { - logger.warn({ origin }, "Rejected connection from origin"); + this.logger.warn({ origin }, "Rejected connection from origin"); callback(false, 403, "Origin not allowed"); } }, @@ -85,7 +95,7 @@ export class VoiceAssistantWebSocketServer { this.broadcastAgentAttention(params); }); - logger.info("WebSocket server initialized on /ws"); + this.logger.info("WebSocket server initialized on /ws"); } /** @@ -94,6 +104,7 @@ export class VoiceAssistantWebSocketServer { private async handleConnection(ws: WebSocket, request: IncomingMessage): Promise { // Generate unique client ID const clientId = `client-${++this.clientIdCounter}`; + const connectionLogger = this.logger.child({ clientId }); // Extract conversation ID from URL query parameter if present const url = parseUrl(request.url || "", true); @@ -102,16 +113,16 @@ export class VoiceAssistantWebSocketServer { // Load conversation if ID provided let initialMessages = null; if (conversationId) { - logger.debug({ conversationId }, "Client requesting conversation"); - initialMessages = await loadConversation(conversationId); + connectionLogger.debug({ conversationId }, "Client requesting conversation"); + initialMessages = await loadConversation(connectionLogger, conversationId); if (initialMessages) { - logger.debug( + connectionLogger.debug( { conversationId, messageCount: initialMessages.length }, "Loaded conversation" ); } else { - logger.debug({ conversationId }, "Conversation not found, starting fresh"); + connectionLogger.debug({ conversationId }, "Conversation not found, starting fresh"); } } @@ -121,11 +132,14 @@ export class VoiceAssistantWebSocketServer { (msg) => { this.sendToClient(ws, wrapSessionMessage(msg)); }, + connectionLogger.child({ module: "session" }), this.downloadTokenStore, this.pushTokenStore, this.agentManager, this.agentRegistry, this.agentMcpConfig, + this.stt, + this.tts, { conversationId, initialMessages: initialMessages || undefined, @@ -136,7 +150,7 @@ export class VoiceAssistantWebSocketServer { this.sessions.set(ws, session); this.conversationIdToWs.set(session.getConversationId(), ws); - logger.info( + connectionLogger.info( { clientId, conversationId: session.getConversationId(), totalSessions: this.sessions.size }, "Client connected" ); @@ -154,7 +168,7 @@ export class VoiceAssistantWebSocketServer { const session = this.sessions.get(ws); if (!session) return; - logger.info( + connectionLogger.info( { clientId, totalSessions: this.sessions.size - 1 }, "Client disconnected" ); @@ -166,13 +180,13 @@ export class VoiceAssistantWebSocketServer { this.sessions.delete(ws); this.conversationIdToWs.delete(session.getConversationId()); - logger.debug({ conversationId: session.getConversationId() }, "Conversation deleted"); + connectionLogger.debug({ conversationId: session.getConversationId() }, "Conversation deleted"); }); // Set up error handler ws.on("error", async (error) => { const err = error instanceof Error ? error : new Error(String(error)); - logger.error({ err }, "Client error"); + connectionLogger.error({ err }, "Client error"); const session = this.sessions.get(ws); if (!session) return; @@ -206,7 +220,7 @@ export class VoiceAssistantWebSocketServer { sessionMessageType: message.message.type, } : {}), }; - logger.debug(messageSummary, "Received message"); + this.logger.debug(messageSummary, "Received message"); // Handle WebSocket-level messages switch (message.type) { @@ -215,7 +229,7 @@ export class VoiceAssistantWebSocketServer { return; case "recording_state": - logger.debug({ isRecording: message.isRecording }, "Recording state"); + this.logger.debug({ isRecording: message.isRecording }, "Recording state"); return; case "session": @@ -224,7 +238,7 @@ export class VoiceAssistantWebSocketServer { if (sessionMessage) { // Debug: Log create_agent_request details if (sessionMessage.type === "create_agent_request") { - logger.debug({ + this.logger.debug({ cwd: sessionMessage.config.cwd, initialMode: sessionMessage.config.modeId, worktreeName: sessionMessage.worktreeName, @@ -236,7 +250,7 @@ export class VoiceAssistantWebSocketServer { if (session) { await session.handleMessage(sessionMessage); } else { - logger.error("No session found for client"); + this.logger.error("No session found for client"); } } return; @@ -264,7 +278,7 @@ export class VoiceAssistantWebSocketServer { rawPayload = rawPayload ?? ""; parsedPayload = parsedPayload ?? rawPayload; const payloadErr = payloadError instanceof Error ? payloadError : new Error(String(payloadError)); - logger.error({ err: payloadErr }, "Failed to decode raw payload"); + this.logger.error({ err: payloadErr }, "Failed to decode raw payload"); } const trimmedRawPayload = @@ -272,7 +286,7 @@ export class VoiceAssistantWebSocketServer { ? `${rawPayload.slice(0, 2000)}... (truncated)` : rawPayload; - logger.error({ + this.logger.error({ err, rawPayload: trimmedRawPayload, parsedPayload, @@ -350,13 +364,13 @@ export class VoiceAssistantWebSocketServer { } { const activity = session.getClientActivity(); if (!activity) { - logger.debug("getClientActivityState: no activity for session"); + this.logger.debug("getClientActivityState: no activity for session"); return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false }; } const now = Date.now(); const ageMs = now - activity.lastActivityAt.getTime(); const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS; - logger.debug({ + this.logger.debug({ deviceType: activity.deviceType, focusedAgentId: activity.focusedAgentId, lastActivityAt: activity.lastActivityAt.toISOString(), @@ -475,7 +489,7 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); - logger.debug({ + this.logger.debug({ agentId: params.agentId, reason: params.reason, clientCount: clientEntries.length, @@ -484,10 +498,10 @@ export class VoiceAssistantWebSocketServer { // Check if all clients are stale - if so, send push notification const allClientsStale = allStates.every((state) => state.isStale); - logger.debug({ allClientsStale }, "Client staleness check"); + this.logger.debug({ allClientsStale }, "Client staleness check"); if (allClientsStale) { const tokens = this.pushTokenStore.getAllTokens(); - logger.info({ tokenCount: tokens.length }, "Sending push notification"); + this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); if (tokens.length > 0) { void this.pushService.sendPush(tokens, { title: "Agent needs attention", diff --git a/packages/server/src/services/agent-title-generator.ts b/packages/server/src/services/agent-title-generator.ts index 885f6a600..812586c6c 100644 --- a/packages/server/src/services/agent-title-generator.ts +++ b/packages/server/src/services/agent-title-generator.ts @@ -3,15 +3,13 @@ import { createOpenAI } from "@ai-sdk/openai"; import { z } from "zod"; import type { AgentTimelineItem } from "../server/agent/agent-sdk-types.js"; import { curateAgentActivity } from "../server/agent/activity-curator.js"; -import { getRootLogger } from "../server/logger.js"; - -const logger = getRootLogger().child({ module: "agent-title-generator" }); +import type pino from "pino"; let openai: ReturnType | null = null; -export function initializeTitleGenerator(apiKey: string): void { +export function initializeTitleGenerator(logger: pino.Logger, apiKey: string): void { openai = createOpenAI({ apiKey }); - logger.info("Agent title generator initialized"); + logger.child({ action: "initialize" }).info("Agent title generator initialized"); } export function isTitleGeneratorInitialized(): boolean { @@ -23,9 +21,11 @@ export function isTitleGeneratorInitialized(): boolean { * Returns a 3-5 word title similar to ChatGPT/Claude.ai */ export async function generateAgentTitle( + logger: pino.Logger, timeline: AgentTimelineItem[], cwd: string ): Promise { + const titleLogger = logger.child({ action: "generate" }); if (!openai) { throw new Error("Title generator not initialized"); } @@ -59,11 +59,11 @@ ${activityContext}`, temperature: 0.7, }); - logger.debug({ title: object.title }, "Generated agent title"); + titleLogger.debug({ title: object.title }, "Generated agent title"); return object.title; } catch (err) { - logger.error({ err }, "Failed to generate agent title"); + titleLogger.error({ err }, "Failed to generate agent title"); return "New Agent"; } } diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index dca56b655..17f9a3487 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -82,12 +82,14 @@ Body vs Notes: program .command("create ") + .alias("add") .description("Create a new task") .option("-b, --body <text>", "Task body (use '-' to read from stdin)") .option("--deps <ids>", "Comma-separated dependency IDs") .option("--parent <id>", "Parent task ID (for hierarchy)") .option("--assignee <agent>", "Agent to assign (claude or codex)") .option("--draft", "Create as draft (not actionable)") + .option("-p, --priority <n>", "Priority (lower number = higher priority)") .option("-a, --accept <criterion>", "Acceptance criterion (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) .action(async (title, opts) => { let body = opts.body ?? ""; @@ -104,6 +106,7 @@ program status: opts.draft ? "draft" : "open", assignee: opts.assignee as AgentType | undefined, acceptanceCriteria: opts.accept, + priority: opts.priority ? parseInt(opts.priority, 10) : undefined, }); process.stdout.write(`${task.id}\n`); @@ -129,7 +132,8 @@ program const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : ""; const assignee = t.assignee ? ` @${t.assignee}` : ""; const parent = t.parentId ? ` ^${t.parentId}` : ""; - process.stdout.write(`${t.id} [${t.status}] ${t.title}${assignee}${parent}${deps}\n`); + const priority = t.priority !== undefined ? ` !${t.priority}` : ""; + process.stdout.write(`${t.id} [${t.status}] ${t.title}${priority}${assignee}${parent}${deps}\n`); } }); @@ -164,6 +168,9 @@ program process.stdout.write(`id: ${task.id}\n`); process.stdout.write(`status: ${task.status}\n`); process.stdout.write(`created: ${task.created}\n`); + if (task.priority !== undefined) { + process.stdout.write(`priority: ${task.priority}\n`); + } if (task.assignee) { process.stdout.write(`assignee: ${task.assignee}\n`); } @@ -198,7 +205,8 @@ program const tasks = await store.getReady(opts.scope); for (const t of tasks) { const assignee = t.assignee ? ` @${t.assignee}` : ""; - process.stdout.write(`${t.id} ${t.title}${assignee}\n`); + const priority = t.priority !== undefined ? ` !${t.priority}` : ""; + process.stdout.write(`${t.id} ${t.title}${priority}${assignee}\n`); } }); @@ -241,8 +249,9 @@ program // Print a task line with optional dependency info const printTask = (task: Task, prefix: string, connector: string) => { const assignee = task.assignee ? ` @${task.assignee}` : ""; + const priority = task.priority !== undefined ? ` !${task.priority}` : ""; process.stdout.write( - `${prefix}${connector}${task.id} [${task.status}] ${task.title}${assignee}\n` + `${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}\n` ); // Print dependencies on next line with arrow if (task.deps.length > 0) { @@ -259,7 +268,8 @@ program // Print root task const rootAssignee = root.assignee ? ` @${root.assignee}` : ""; - process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootAssignee}\n`); + const rootPriority = root.priority !== undefined ? ` !${root.priority}` : ""; + process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}\n`); if (root.deps.length > 0) { const depNames = root.deps .map((depId) => { @@ -303,13 +313,19 @@ program process.stdout.write(`Removed: ${id} -> ${depId}\n`); }); +const VALID_STATUSES = ["draft", "open", "in_progress", "done", "failed"] as const; + program .command("update <id>") + .alias("edit") .description("Update task properties") .option("-t, --title <text>", "New title") .option("-b, --body <text>", "New body (use '-' to read from stdin)") .option("--assignee <agent>", "New assignee (claude or codex)") - .option("-a, --accept <criterion>", "Add acceptance criterion (repeatable, append-only)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) + .option("-p, --priority <n>", "Priority (lower number = higher priority)") + .option("-s, --status <status>", "Set status (draft, open, in_progress, done, failed)") + .option("--clear-acceptance", "Clear all acceptance criteria (combine with -a to replace)") + .option("-a, --accept <criterion>", "Add acceptance criterion (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) .action(async (id, opts) => { const task = await store.get(id); if (!task) { @@ -331,12 +347,28 @@ program changes.assignee = opts.assignee as AgentType; } - // Add new acceptance criteria (append only) - for (const criterion of opts.accept) { - await store.addAcceptanceCriteria(id, criterion); + if (opts.priority !== undefined) { + changes.priority = parseInt(opts.priority, 10); } - if (Object.keys(changes).length === 0 && opts.accept.length === 0) { + if (opts.status) { + if (!VALID_STATUSES.includes(opts.status)) { + process.stderr.write(`Invalid status: ${opts.status}. Must be one of: ${VALID_STATUSES.join(", ")}\n`); + process.exit(1); + } + changes.status = opts.status as Task["status"]; + } + + // Handle acceptance criteria: --clear-acceptance clears, -a adds + if (opts.clearAcceptance) { + changes.acceptanceCriteria = [...opts.accept]; + } else { + for (const criterion of opts.accept) { + await store.addAcceptanceCriteria(id, criterion); + } + } + + if (Object.keys(changes).length === 0 && opts.accept.length === 0 && !opts.clearAcceptance) { process.stderr.write("No changes specified\n"); process.exit(1); } @@ -389,18 +421,36 @@ program for (const child of children) { const assignee = child.assignee ? ` @${child.assignee}` : ""; - process.stdout.write(`${child.id} [${child.status}] ${child.title}${assignee}\n`); + const priority = child.priority !== undefined ? ` !${child.priority}` : ""; + process.stdout.write(`${child.id} [${child.status}] ${child.title}${priority}${assignee}\n`); } }); +program + .command("delete <id>") + .alias("rm") + .description("Delete a task") + .action(async (id) => { + await store.delete(id); + process.stdout.write(`Deleted: ${id}\n`); + }); + program .command("note <id> <content>") - .description("Add a timestamped note") + .description("Add a timestamped note (timestamp is automatic, don't include one)") .action(async (id, content) => { await store.addNote(id, content); process.stdout.write("Note added\n"); }); +program + .command("steer <id> <content>") + .description("Add a steering note to guide the agent loop (triggers replan)") + .action(async (id, content) => { + await store.addNote(id, `STEER: ${content}`); + process.stdout.write("Steering note added\n"); + }); + program .command("open <id>") .description("Mark draft as open (actionable)") @@ -477,15 +527,18 @@ function runAgentWithModel( args.push(prompt); } - const fd = openSync(logFile, "a"); const result = spawnSync(config.cli, args, { - stdio: ["inherit", fd, fd], + stdio: ["inherit", "pipe", "pipe"], cwd: process.cwd(), maxBuffer: 50 * 1024 * 1024, }); - // Read the output from the log file to check for judge verdict - const output = existsSync(logFile) ? readFileSync(logFile, "utf-8") : ""; + const stdout = result.stdout?.toString() ?? ""; + const stderr = result.stderr?.toString() ?? ""; + const output = stdout + stderr; + + // Append to log file for history + appendFileSync(logFile, output); return { success: result.status === 0, output }; } @@ -578,11 +631,14 @@ Take the task descriptions AS GIVEN and create well-organized subtasks for worke ## Your Scope You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be: + - Broken down into subtasks - Deleted if no longer relevant - Reordered/reprioritized - Updated with better acceptance criteria +User steering notes override the task body - follow them. + Always keep the top-level goal in mind when reorganizing. ## Writing Good Acceptance Criteria @@ -661,7 +717,7 @@ Current iteration: ${iteration} IMPORTANT RULES: - You CANNOT mark this task as done (task close is forbidden for you) -- You MUST add a note documenting what you did: \`task note ${task.id} "what you did"\` +- You MUST add a note documenting what you did: \`task note ${task.id} "WORKER: what you did"\` - You CAN use \`task show ${task.id}\` to see full context - You CAN use \`task children ${task.id}\` to see subtasks @@ -726,7 +782,7 @@ If ANY criterion fails: If you do not include this exact XML tag, your verdict will not be recorded and the task will retry. Then add a note to the task with your findings: -\`task note ${task.id} "Judge verdict: [DONE/NOT_DONE]. Details: ..."\` +\`task note ${task.id} "JUDGE: [DONE/NOT_DONE] - details..."\` `; } @@ -745,8 +801,10 @@ function log(logFile: string, message: string): void { } function parseJudgeVerdict(output: string): "DONE" | "NOT_DONE" | null { - const match = output.match(/<VERDICT>(DONE|NOT_DONE)<\/VERDICT>/); - return match ? (match[1] as "DONE" | "NOT_DONE") : null; + // Find all matches and return the last one (in case reasoning mentions verdict earlier) + const matches = [...output.matchAll(/<VERDICT>(DONE|NOT_DONE)<\/VERDICT>/g)]; + if (matches.length === 0) return null; + return matches[matches.length - 1][1] as "DONE" | "NOT_DONE"; } program @@ -754,30 +812,26 @@ program .description("Run agent loop on tasks with planner/worker/judge") .option("--plan", "Enable planner agent") .option("--planner <model>", "Planner model (default: gpt-5.2)", "gpt-5.2") - .option("--replan <n>", "Run planner every N completed tasks (default: 3)", "3") .option("--judge <model>", "Judge model (default: haiku)", "haiku") .option("--max-iterations <n>", "Max worker/judge iterations per task (0 = no limit)", "0") .option("-w, --watch", "Keep running and wait for new tasks") .action(async (scopeId: string | undefined, opts) => { const enablePlanner = opts.plan; const plannerModel = opts.planner as ModelName; - const replanInterval = parseInt(opts.replan, 10); const judgeModel = opts.judge as ModelName; const maxIterations = parseInt(opts.maxIterations, 10); const watchMode = opts.watch; const logFile = getLogFile(); process.stdout.write("Task Runner started (planner/worker/judge loop)\n"); - process.stdout.write(`Planner: ${enablePlanner ? `${plannerModel} (replan every ${replanInterval} tasks)` : "disabled"}\n`); + process.stdout.write(`Planner: ${enablePlanner ? plannerModel : "disabled"}\n`); process.stdout.write(`Judge: ${judgeModel}\n`); process.stdout.write(`Max iterations: ${maxIterations === 0 ? "unlimited" : maxIterations}\n`); if (scopeId) process.stdout.write(`Scope: ${scopeId}\n`); process.stdout.write(`Log: ${logFile}\n`); process.stdout.write("\n"); - log(logFile, `Started with planner=${enablePlanner ? plannerModel : "disabled"} replan=${replanInterval} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`); - - let completedSinceReplan = 0; + log(logFile, `Started with planner=${enablePlanner ? plannerModel : "disabled"} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`); const runPlanner = async (task: Task, reason: string): Promise<boolean> => { log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); @@ -849,11 +903,31 @@ program // Worker/Judge loop let iteration = 1; let taskDone = false; + let consecutiveNotDone = 0; + let lastSeenSteerCount = task.notes.filter(n => n.content.startsWith("STEER:")).length; while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) { const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`; log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`); + // Check for new steering notes before worker runs + const preTask = await store.get(task.id); + if (!preTask) break; + const currentSteerCount = preTask.notes.filter(n => n.content.startsWith("STEER:")).length; + if (enablePlanner && currentSteerCount > lastSeenSteerCount) { + const newSteers = preTask.notes.filter(n => n.content.startsWith("STEER:")).slice(lastSeenSteerCount); + log(logFile, `[STEER] New steering note detected - triggering planner`); + lastSeenSteerCount = currentSteerCount; + const shouldRestart = await runPlanner(preTask, `steering: ${newSteers.map(n => n.content).join("; ")}`); + if (shouldRestart) { + const updatedTask = await store.get(task.id); + if (updatedTask && updatedTask.status === "in_progress") { + await store.update(task.id, { status: "open" }); + } + break; + } + } + // Refresh task context (notes may have been added) const freshTask = await store.get(task.id); if (!freshTask) break; @@ -877,29 +951,16 @@ program await store.close(task.id); log(logFile, `✅ Task ${task.id} completed`); taskDone = true; - completedSinceReplan++; - - // Periodic replanning after N completions - if (enablePlanner && completedSinceReplan >= replanInterval) { - completedSinceReplan = 0; - const nextReady = await store.getReady(scopeId); - if (nextReady.length > 0) { - log(logFile, `[PLANNER] Periodic replan after ${replanInterval} completions`); - // Run planner on the scope root or next ready task to reassess - const scopeTask = scopeId ? await store.get(scopeId) : nextReady[0]; - if (scopeTask) { - await runPlanner(scopeTask, "periodic reassessment"); - log(logFile, `[DEBUG] Periodic replan finished, continuing loop`); - } - } - } + consecutiveNotDone = 0; } else { - // NOT_DONE - run planner to reassess before retry - if (enablePlanner) { - log(logFile, `[JUDGE] NOT_DONE - triggering planner reassessment`); + consecutiveNotDone++; + // Only replan after 5 consecutive NOT_DONEs + if (enablePlanner && consecutiveNotDone >= 5) { + log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE - triggering planner`); const currentTask = await store.get(task.id); if (currentTask) { - const shouldRestart = await runPlanner(currentTask, `NOT_DONE iteration ${iteration}`); + const shouldRestart = await runPlanner(currentTask, `${consecutiveNotDone} consecutive NOT_DONE`); + consecutiveNotDone = 0; if (shouldRestart) { // Planner created subtasks or marked failed, restart the main loop const updatedTask = await store.get(task.id); diff --git a/packages/server/src/tasks/task-store.test.ts b/packages/server/src/tasks/task-store.test.ts index d94b0282c..f7519a1d2 100644 --- a/packages/server/src/tasks/task-store.test.ts +++ b/packages/server/src/tasks/task-store.test.ts @@ -30,6 +30,13 @@ describe("FileTaskStore", () => { expect(task.notes).toEqual([]); expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(task.assignee).toBeUndefined(); + expect(task.priority).toBeUndefined(); + }); + + it("creates a task with priority", async () => { + const task = await store.create("High priority task", { priority: 1 }); + + expect(task.priority).toBe(1); }); it("creates a task with custom status", async () => { @@ -192,6 +199,54 @@ describe("FileTaskStore", () => { store.update("nonexistent", { title: "New" }) ).rejects.toThrow(); }); + + it("replaces acceptance criteria", async () => { + const task = await store.create("Task", { + acceptanceCriteria: ["old criterion 1", "old criterion 2"], + }); + + const updated = await store.update(task.id, { + acceptanceCriteria: ["new criterion"], + }); + + expect(updated.acceptanceCriteria).toEqual(["new criterion"]); + }); + + it("clears acceptance criteria with empty array", async () => { + const task = await store.create("Task", { + acceptanceCriteria: ["criterion 1", "criterion 2"], + }); + + const updated = await store.update(task.id, { + acceptanceCriteria: [], + }); + + expect(updated.acceptanceCriteria).toEqual([]); + }); + }); + + describe("delete", () => { + it("deletes a task", async () => { + const task = await store.create("Task to delete"); + await store.delete(task.id); + + const retrieved = await store.get(task.id); + expect(retrieved).toBeNull(); + }); + + it("removes task from list", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2"); + await store.delete(task1.id); + + const tasks = await store.list(); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe(task2.id); + }); + + it("throws for non-existent task", async () => { + await expect(store.delete("nonexistent")).rejects.toThrow(); + }); }); describe("status transitions", () => { @@ -204,10 +259,39 @@ describe("FileTaskStore", () => { expect(updated?.status).toBe("open"); }); - it("throws when task is not draft", async () => { - const task = await store.create("Open task", { status: "open" }); + it("reopens a done task", async () => { + const task = await store.create("Task"); + await store.close(task.id); + await store.open(task.id); - await expect(store.open(task.id)).rejects.toThrow(); + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("reopens a failed task", async () => { + const task = await store.create("Task"); + await store.fail(task.id); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("reopens an in_progress task", async () => { + const task = await store.create("Task"); + await store.start(task.id); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("is idempotent for already open task", async () => { + const task = await store.create("Task"); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); }); }); @@ -423,7 +507,7 @@ describe("FileTaskStore", () => { expect(ready.map((t) => t.id)).toContain(task.id); }); - it("sorts by created date (oldest first)", async () => { + it("sorts by created date (oldest first) when no priority", async () => { const task1 = await store.create("Task 1"); await new Promise((r) => setTimeout(r, 10)); const task2 = await store.create("Task 2"); @@ -435,6 +519,39 @@ describe("FileTaskStore", () => { expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]); }); + it("sorts by priority first (lower number = higher priority)", async () => { + // Create in wrong order to prove priority wins over creation time + const low = await store.create("Low priority", { priority: 10 }); + const high = await store.create("High priority", { priority: 1 }); + const medium = await store.create("Medium priority", { priority: 5 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([high.id, medium.id, low.id]); + }); + + it("tasks with priority come before tasks without", async () => { + // Create without priority first to prove priority wins + const noPriority = await store.create("No priority"); + const withPriority = await store.create("With priority", { priority: 5 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([withPriority.id, noPriority.id]); + }); + + it("sorts by created date within same priority", async () => { + const first = await store.create("First", { priority: 1 }); + await new Promise((r) => setTimeout(r, 10)); + const second = await store.create("Second", { priority: 1 }); + await new Promise((r) => setTimeout(r, 10)); + const third = await store.create("Third", { priority: 1 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([first.id, second.id, third.id]); + }); + describe("parent blocked by children", () => { it("excludes parent task when it has open children", async () => { const parent = await store.create("Parent task"); @@ -809,6 +926,15 @@ describe("FileTaskStore", () => { expect(retrieved?.parentId).toBe(parent.id); }); + + it("persists priority across store instances", async () => { + const task = await store.create("Priority task", { priority: 3 }); + + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(task.id); + + expect(retrieved?.priority).toBe(3); + }); }); describe("parent-child hierarchy", () => { @@ -884,6 +1010,46 @@ describe("FileTaskStore", () => { expect(children).toHaveLength(1); expect(children[0].id).toBe(parent.id); }); + + it("sorts by priority first (lower number = higher priority)", async () => { + const parent = await store.create("Parent"); + // Create in wrong order to prove priority wins over creation time + const low = await store.create("Low priority child", { + parentId: parent.id, + priority: 10, + }); + const high = await store.create("High priority child", { + parentId: parent.id, + priority: 1, + }); + const medium = await store.create("Medium priority child", { + parentId: parent.id, + priority: 5, + }); + + const children = await store.getChildren(parent.id); + + expect(children.map((c) => c.id)).toEqual([high.id, medium.id, low.id]); + }); + + it("children with priority come before children without", async () => { + const parent = await store.create("Parent"); + // Create without priority first to prove priority wins + const noPriority = await store.create("No priority", { + parentId: parent.id, + }); + const withPriority = await store.create("With priority", { + parentId: parent.id, + priority: 5, + }); + + const children = await store.getChildren(parent.id); + + expect(children.map((c) => c.id)).toEqual([ + withPriority.id, + noPriority.id, + ]); + }); }); describe("setParent", () => { diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index 4ed7d1f24..cecbe4744 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; +import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; import type { @@ -13,6 +13,22 @@ function generateId(): string { return randomBytes(4).toString("hex"); } +function sortByPriorityThenCreated(a: Task, b: Task): number { + // Tasks with priority come before tasks without + if (a.priority !== undefined && b.priority === undefined) return -1; + if (a.priority === undefined && b.priority !== undefined) return 1; + + // If both have priority, lower number = higher priority + if (a.priority !== undefined && b.priority !== undefined) { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + } + + // Fall back to created date (oldest first) + return a.created.localeCompare(b.created); +} + function serializeTask(task: Task): string { const frontmatterLines = [ "---", @@ -31,6 +47,10 @@ function serializeTask(task: Task): string { frontmatterLines.push(`assignee: ${task.assignee}`); } + if (task.priority !== undefined) { + frontmatterLines.push(`priority: ${task.priority}`); + } + frontmatterLines.push("---"); const frontmatter = frontmatterLines.join("\n"); @@ -120,6 +140,8 @@ function parseTask(content: string): Task { const assignee = getValue("assignee") as AgentType | ""; const parentId = getValue("parentId"); + const priorityStr = getValue("priority"); + const priority = priorityStr ? parseInt(priorityStr, 10) : undefined; return { id: getValue("id"), @@ -132,6 +154,7 @@ function parseTask(content: string): Task { notes, created: getValue("created") || new Date().toISOString(), assignee: assignee || undefined, + priority, }; } @@ -245,7 +268,7 @@ export class FileTaskStore implements TaskStore { const allTasks = await this.list(); return allTasks .filter((t) => t.parentId === id) - .sort((a, b) => a.created.localeCompare(b.created)); + .sort(sortByPriorityThenCreated); } async getDescendants(id: string): Promise<Task[]> { @@ -298,10 +321,8 @@ export class FileTaskStore implements TaskStore { return children.every((c) => c.status === "done"); }; - // Sort by created date (oldest first) for consistent ordering - return candidates.filter(isReady).sort((a, b) => { - return a.created.localeCompare(b.created); - }); + // Sort by priority first (lower = higher priority), then created date + return candidates.filter(isReady).sort(sortByPriorityThenCreated); } async getBlocked(scopeId?: string): Promise<Task[]> { @@ -365,6 +386,7 @@ export class FileTaskStore implements TaskStore { notes: [], created: new Date().toISOString(), assignee: opts?.assignee, + priority: opts?.priority, }; await this.writeTask(task); @@ -385,6 +407,14 @@ export class FileTaskStore implements TaskStore { return updated; } + async delete(id: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + await unlink(this.taskPath(id)); + } + async addDep(id: string, depId: string): Promise<void> { const task = await this.get(id); if (!task) { @@ -469,9 +499,6 @@ export class FileTaskStore implements TaskStore { if (!task) { throw new Error(`Task not found: ${id}`); } - if (task.status !== "draft") { - throw new Error(`Cannot open task with status: ${task.status}`); - } await this.update(id, { status: "open" }); } diff --git a/packages/server/src/tasks/types.ts b/packages/server/src/tasks/types.ts index fbca3883a..1a9d1c0b1 100644 --- a/packages/server/src/tasks/types.ts +++ b/packages/server/src/tasks/types.ts @@ -24,6 +24,7 @@ export interface Task { notes: Note[]; created: string; // ISO date assignee?: AgentType; // optional agent override + priority?: number; // lower number = higher priority (1 is highest), tasks with priority sort before those without } export interface CreateTaskOptions { @@ -33,6 +34,7 @@ export interface CreateTaskOptions { body?: string; acceptanceCriteria?: string[]; assignee?: AgentType; + priority?: number; } export interface TaskStore { @@ -49,6 +51,7 @@ export interface TaskStore { // Mutations create(title: string, opts?: CreateTaskOptions): Promise<Task>; update(id: string, changes: Partial<Omit<Task, "id" | "created">>): Promise<Task>; + delete(id: string): Promise<void>; addDep(id: string, depId: string): Promise<void>; removeDep(id: string, depId: string): Promise<void>; setParent(id: string, parentId: string | null): Promise<void>;