refactor(app): unified panel state machine for mobile sidebars

Replace separate sidebar stores with a unified panel store that uses a
discriminated union state machine on mobile. This makes it impossible
for both the agent list and file explorer sidebars to be open at the
same time on mobile.

Mobile state machine (mobileView):
- 'agent': Main agent view (no overlay panel)
- 'agent-list': Agent list sidebar (left overlay)
- 'file-explorer': File explorer sidebar (right overlay)

Desktop retains independent boolean toggles since sidebars sit
alongside content rather than overlaying it.

Key changes:
- Created stores/panel-store.ts with unified state
- Deleted stores/sidebar-store.ts and explorer-sidebar-store.ts
- Updated all components to use new panel store
- Animation contexts derive isOpen from unified state
This commit is contained in:
Mohamed Boudra
2026-01-16 16:19:49 +07:00
parent b8f056060f
commit ed50c168ad
58 changed files with 1579 additions and 893 deletions
+9 -2
View File
@@ -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();
}
+3 -7
View File
@@ -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();
@@ -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);
});
+13 -8
View File
@@ -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 = (
@@ -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={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleExplorer} style={styles.menuButton}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
{isMobile ? (
<Folder
size={16}
+26 -60
View File
@@ -28,7 +28,6 @@ import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-ag
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { generateMessageId } from "@/types/stream";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type {
AgentProvider,
@@ -379,7 +378,6 @@ export default function HomeScreen() {
}
}, [isNonGitDirectory, useWorktree]);
const pendingRequestIdRef = useRef<string | null>(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 (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
@@ -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 (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
}
export function DropdownSheet({
title,
visible,
onClose,
children,
}: DropdownSheetProps): ReactElement {
const bottomSheetRef = useRef<BottomSheetModal>(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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[]
);
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={DropdownSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.dropdownSheetOverlay}>
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
<View style={styles.dropdownSheetContainer}>
<View style={styles.dropdownSheetHandle} />
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<ScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
</View>
</Modal>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
@@ -205,6 +258,8 @@ export function AdaptiveSelect({
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[]
);
if (isMobile) {
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={DropdownSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.dropdownSheetOverlay}>
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
<View style={styles.dropdownSheetContainer}>
<View style={styles.dropdownSheetHandle} />
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<ScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
</View>
</Modal>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
@@ -332,7 +420,6 @@ export function ComboSelect({
}: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const inputRef = useRef<TextInput | null>(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}
>
<TextInput
ref={inputRef}
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
{Platform.OS === "web" ? (
<TextInput
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
) : (
<BottomSheetTextInput
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
)}
{showCustomOption ? (
<View style={styles.dropdownSheetList}>
<Pressable
@@ -876,7 +975,6 @@ export function WorkingDirectoryDropdown({
}: WorkingDirectoryDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const inputRef = useRef<TextInput | null>(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}
>
<TextInput
ref={inputRef}
style={styles.dropdownSearchInput}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
{Platform.OS === "web" ? (
<TextInput
style={styles.dropdownSearchInput}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
) : (
<BottomSheetTextInput
style={styles.dropdownSearchInput}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
)}
{!hasSuggestedPaths && !showCustomOption ? (
<Text style={styles.helperText}>
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],
@@ -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(() => {
@@ -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,
]
);
@@ -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"
>
<SidebarContent
activeTab={activeTab}
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
serverId={serverId}
agentId={agentId}
fileViewMode={viewMode}
onFileViewModeChange={setViewMode}
fileViewMode={explorerViewMode}
onFileViewModeChange={setExplorerViewMode}
isMobile={isMobile}
/>
</Animated.View>
@@ -201,13 +211,13 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
</GestureDetector>
<SidebarContent
activeTab={activeTab}
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleClose}
serverId={serverId}
agentId={agentId}
fileViewMode={viewMode}
onFileViewModeChange={setViewMode}
fileViewMode={explorerViewMode}
onFileViewModeChange={setExplorerViewMode}
isMobile={false}
/>
</Animated.View>
@@ -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<string | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0);
@@ -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) {
<ScreenHeader
left={
<>
<Pressable onPress={toggle} style={styles.menuButton}>
<Pressable onPress={toggleAgentList} style={styles.menuButton}>
<MenuIcon size={16} color={menuIconColor} />
</Pressable>
{title && (
@@ -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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
// Cmd+B or Ctrl+B: toggle sidebar
if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") {
event.preventDefault();
toggleSidebar();
toggleAgentList();
return;
}
+1
View File
@@ -649,6 +649,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
+20 -15
View File
@@ -11,7 +11,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Plus, Settings } from "lucide-react-native";
import { router } from "expo-router";
import { useSidebarStore } from "@/stores/sidebar-store";
import { usePanelStore } from "@/stores/panel-store";
import { AgentList } from "./agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -26,7 +26,15 @@ interface SlidingSidebarProps {
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { isOpen, close } = useSidebarStore();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => 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()
@@ -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<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const { isOpen } = useExplorerSidebarStore();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => 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);
+10 -15
View File
@@ -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<unknown>;
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]
);
@@ -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<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const { isOpen } = useSidebarStore();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => 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);
+10 -4
View File
@@ -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();
@@ -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();
@@ -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<ExplorerSidebarState>()(
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();
}
+219
View File
@@ -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<PanelState>()(
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,
};
}
+1 -1
View File
@@ -196,7 +196,7 @@ export interface SessionState {
git?: any;
worktreeName?: string;
requestId?: string;
}) => Promise<void>;
}) => Promise<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: any) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
-33
View File
@@ -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<SidebarState>()(
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();
}
+17 -3
View File
@@ -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<string, AgentSnapshotPayload> = 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;
}
@@ -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<string, AgentLifecycleStatus>();
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"
);
@@ -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<void>((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<void>((resolve) => {
daemon.httpServer.listen(port, () => resolve());
});
@@ -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<StoredAgentRecord[]> | 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<StoredAgentRecord[]> {
@@ -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;
+23 -19
View File
@@ -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<string, string> = {
@@ -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<string | null> {
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<McpServer> {
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 {
@@ -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();
});
});
@@ -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"
);
@@ -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<AgentModelDefinition[]>;
}
const claudeClient = new ClaudeAgentClient();
const codexClient = new CodexMcpAgentClient();
const opencodeClient = new OpenCodeAgentClient();
export function buildProviderRegistry(logger: Logger): Record<AgentProvider, ProviderDefinition> {
const claudeClient = new ClaudeAgentClient({ logger });
const codexClient = new CodexMcpAgentClient(logger);
const opencodeClient = new OpenCodeAgentClient(logger);
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = {
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<AgentProvider, ProviderDefinition> = null as any;
export function createAllClients(logger: Logger): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger);
return {
claude: registry.claude.createClient(logger),
codex: registry.codex.createClient(logger),
opencode: registry.opencode.createClient(logger),
};
}
export function createAllClients(): Record<AgentProvider, AgentClient> {
const clients: Partial<Record<AgentProvider, AgentClient>> = {};
for (const [id, definition] of Object.entries(PROVIDER_REGISTRY)) {
clients[id as AgentProvider] = definition.createClient();
}
return clients as Record<AgentProvider, AgentClient>;
}
export async function fetchProviderModels(
provider: AgentProvider,
options?: { cwd?: string }
): Promise<AgentModelDefinition[]> {
const definition = getProviderDefinition(provider);
return definition.fetchModels(options);
}
export async function shutdownProviders(): Promise<void> {
await OpenCodeServerManager.getInstance().shutdown();
export async function shutdownProviders(logger: Logger): Promise<void> {
await OpenCodeServerManager.getInstance(logger).shutdown();
}
@@ -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<string, AgentDefinition> };
logger: Logger;
};
type ClaudeAgentSessionOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
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<string, AgentDefinition> };
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<AgentSession> {
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<string, AgentDefinition> };
private readonly logger: Logger;
private query: Query | null = null;
private input: Pushable<SDKUserMessage> | 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;
@@ -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<str
return filePath;
}
async function replaceInlineImageData(promptText: string): Promise<string> {
async function replaceInlineImageData(promptText: string, logger: Logger): Promise<string> {
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<string> {
return output;
}
async function toPromptText(prompt: AgentPromptInput): Promise<string> {
async function toPromptText(prompt: AgentPromptInput, logger: Logger): Promise<string> {
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<string> {
}
}
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<AgentSession> {
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<AgentTimelineItem[]> {
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"
);
@@ -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<AgentSession> {
const openCodeConfig = this.assertConfig(config);
@@ -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<string | null> {
if (!debugDir) {
return null;
+23 -13
View File
@@ -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<SessionTranscriptionResult> {
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) {
+84 -110
View File
@@ -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<TranscriptionResult> {
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<TranscriptionResult> {
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;
}
@@ -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<string, PendingPlayback> = 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<void> {
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");
+56 -65
View File
@@ -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<SpeechStreamResult> {
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<SpeechStreamResult> {
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;
}
@@ -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<string, Set<(reason?: string) => 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"
);
+53 -37
View File
@@ -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<PaseoDaemon> {
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<void>((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<void>((resolve) => {
httpServer.close(() => resolve());
@@ -469,8 +483,10 @@ export async function createPaseoDaemon(
};
}
async function closeAllAgents(agentManager: AgentManager): Promise<void> {
const logger = getRootLogger().child({ module: "bootstrap" });
async function closeAllAgents(
logger: Logger,
agentManager: AgentManager
): Promise<void> {
const agents = agentManager.listAgents();
for (const agent of agents) {
try {
+1 -1
View File
@@ -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();
+3 -14
View File
@@ -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 });
}
+18 -15
View File
@@ -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<typeof PersistedConfigSchema>;
const CONFIG_FILENAME = "config.json";
type LoggerLike = {
child(bindings: Record<string, unknown>): 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}`);
@@ -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<ManagedAgent>,
"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);
@@ -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<string, unknown>): LoggerLike;
error(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentRegistryPersistence = Pick<AgentRegistry, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
@@ -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");
});
});
+27 -13
View File
@@ -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<string, unknown>): 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<void> {
* Save conversation to disk
*/
export async function saveConversation(
logger: LoggerLike,
conversationId: string,
messages: ModelMessage[]
): Promise<void> {
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<ModelMessage[] | null> {
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<ConversationMetadata[]> {
export async function listConversations(logger: LoggerLike): Promise<ConversationMetadata[]> {
const log = getLogger(logger);
try {
await ensureConversationsDir();
@@ -126,7 +139,7 @@ export async function listConversations(): Promise<ConversationMetadata[]> {
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<ConversationMetadata[]> {
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<ConversationMetadata[]> {
/**
* Delete conversation from disk
*/
export async function deleteConversation(conversationId: string): Promise<void> {
export async function deleteConversation(logger: LoggerLike, conversationId: string): Promise<void> {
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"
);
@@ -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"
);
@@ -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<string> = 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");
}
}
+36 -18
View File
@@ -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<typeof logger.child>;
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<typeof buildProviderRegistry>;
private agentTitleCache: Map<string, string | null> = 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<void> {
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<void> {
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<void> {
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 },
@@ -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();
+41 -27
View File
@@ -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<WebSocket, Session> = new Map();
private conversationIdToWs: Map<string, WebSocket> = 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<void> {
// 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 ?? "<unreadable>";
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",
@@ -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<typeof createOpenAI> | 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<string> {
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";
}
}
+106 -45
View File
@@ -82,12 +82,14 @@ Body vs Notes:
program
.command("create <title>")
.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);
+170 -4
View File
@@ -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", () => {
+36 -9
View File
@@ -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" });
}
+3
View File
@@ -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>;