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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -649,6 +649,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="assistant-message"
|
||||
style={[
|
||||
assistantMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user