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